diff --git a/.agents/skills/codexbar/SKILL.md b/.agents/skills/codexbar/SKILL.md new file mode 100644 index 0000000000..abcc6e3b2d --- /dev/null +++ b/.agents/skills/codexbar/SKILL.md @@ -0,0 +1,38 @@ +--- +name: codexbar +description: "CodexBar read. Provider usage, limits, credits, config health. JSON. No writes." +--- + +# CodexBar + +Read CodexBar. Never mutate config/auth. + +## Run + +```bash +skill="${CODEX_HOME:-$HOME/.codex}/skills/codexbar" +"$skill/scripts/codexbar" doctor +"$skill/scripts/codexbar" providers +"$skill/scripts/codexbar" usage +"$skill/scripts/codexbar" usage --provider codex +"$skill/scripts/codexbar" usage --all +``` + +All stdout: JSON. Upstream CodexBar shape kept. Less drift, fewer tokens. + +## Rules + +- Start `doctor` when install/config unknown. +- `usage` reads enabled providers. Prefer this. +- `usage --provider ID` reads one provider. +- `usage --all` expensive; use only when needed. +- Identities hidden by default. `--include-identities` only when user explicitly needs them. +- Secrets always hidden. +- Helper read-only: fixed allowlist only. No config writes, auth repair, enable/disable, key storage. +- Timeout means upstream stuck. Narrow provider or raise `CODEXBAR_TIMEOUT` (default 120 seconds). + +## Binary + +Auto-find: `CODEXBAR_BIN`, PATH, app bundle, Homebrew cask. If missing: open CodexBar, Preferences > Advanced > Install CLI; or set `CODEXBAR_BIN`. + +Each stdout/stderr stream capped at 1 MiB while fully drained. Timeout kills process group. diff --git a/.agents/skills/codexbar/scripts/codexbar b/.agents/skills/codexbar/scripts/codexbar new file mode 100755 index 0000000000..d60c8d45d4 --- /dev/null +++ b/.agents/skills/codexbar/scripts/codexbar @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Read-only, bounded CodexBar CLI bridge.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Sequence + + +MAX_CAPTURE_BYTES = 1024 * 1024 +DEFAULT_TIMEOUT = 120.0 +BIN_ENV = "CODEXBAR_BIN" +TIMEOUT_ENV = "CODEXBAR_TIMEOUT" +SKIP_DISCOVERY_ENV = "CODEXBAR_SKIP_DISCOVERY" + +SECRET = "" +IDENTITY = "" +EMAIL = "" + +EMAIL_RE = re.compile(r"\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b", re.I) +BEARER_RE = re.compile(r"(?i)\bbearer\s+[A-Z0-9._~+/=\-]+") +JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b") +LABELED_SECRET_RE = re.compile( + r"(?i)\b(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|" + r"session(?:id)?|secret|password|passwd|cookie|set-cookie)(\s*[:=]\s*)([^\s,;]+)" +) +WORD_SECRET_RE = re.compile( + r"(?i)\b(token|secret|password|passwd|cookie)\s+([A-Z0-9._~+/=\-]{6,})\b" +) +SECRET_KEY_RE = re.compile( + r"(?i)(authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|" + r"session(?:id)?|secret|password|passwd|cookie)" +) +IDENTITY_KEYS = { + "accountemail", + "accountorganization", + "accountid", + "accountname", + "email", + "organization", + "userid", + "username", +} +# ProviderIdentitySnapshot.providerID is UsageProvider, not an account identifier. + + +@dataclass(frozen=True) +class Binary: + path: str + source: str + + +@dataclass(frozen=True) +class Result: + returncode: int + stdout: bytes + stderr: bytes + timed_out: bool + + +def normalized_key(value: str) -> str: + return re.sub(r"[^a-z0-9]", "", value.lower()) + + +def timeout_seconds() -> float: + try: + return max(1.0, float(os.environ.get(TIMEOUT_ENV, DEFAULT_TIMEOUT))) + except ValueError: + return DEFAULT_TIMEOUT + + +def safe_path(path: str) -> str: + home = str(Path.home()) + if path == home: + return "~" + if path.startswith(home + os.sep): + return "~" + path[len(home) :] + return re.sub(r"^/Users/[^/]+", "/Users/", path) + + +def candidates() -> list[tuple[str, Path]]: + found: list[tuple[str, Path]] = [] + override = os.environ.get(BIN_ENV) + if override: + found.append(("env", Path(override).expanduser())) + + path_hit = shutil.which("codexbar") + if path_hit: + found.append(("path", Path(path_hit))) + + if os.environ.get(SKIP_DISCOVERY_ENV) == "1": + return found + + home = Path.home() + found.extend( + [ + ("homebrew", Path("/opt/homebrew/bin/codexbar")), + ("homebrew", Path("/usr/local/bin/codexbar")), + ("app", Path("/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI")), + ("app", home / "Applications/CodexBar.app/Contents/Helpers/CodexBarCLI"), + ] + ) + for root in (Path("/opt/homebrew/Caskroom/codexbar"), Path("/usr/local/Caskroom/codexbar")): + found.extend(("cask", app / "Contents/Helpers/CodexBarCLI") for app in sorted(root.glob("*/CodexBar.app"))) + return found + + +def resolve_binary() -> Binary | None: + self_path = Path(__file__).resolve() + seen: set[Path] = set() + for source, candidate in candidates(): + try: + resolved = candidate.resolve() + if resolved in seen or resolved == self_path: + continue + seen.add(resolved) + if resolved.is_file() and os.access(resolved, os.X_OK): + return Binary(str(resolved), source) + except OSError: + continue + return None + + +def drain(pipe: Any, target: bytearray) -> None: + try: + while True: + chunk = pipe.read(65536) + if not chunk: + break + room = MAX_CAPTURE_BYTES - len(target) + if room > 0: + target.extend(chunk[:room]) + finally: + pipe.close() + + +def stop_group(process: subprocess.Popen[bytes], sig: signal.Signals) -> None: + try: + os.killpg(process.pid, sig) + except ProcessLookupError: + pass + + +def run_process(argv: Sequence[str]) -> Result: + process = subprocess.Popen( + list(argv), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + assert process.stdout is not None + assert process.stderr is not None + stdout = bytearray() + stderr = bytearray() + readers = [ + threading.Thread(target=drain, args=(process.stdout, stdout), daemon=True), + threading.Thread(target=drain, args=(process.stderr, stderr), daemon=True), + ] + for reader in readers: + reader.start() + + timed_out = False + try: + process.wait(timeout=timeout_seconds()) + except subprocess.TimeoutExpired: + timed_out = True + stop_group(process, signal.SIGTERM) + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + stop_group(process, signal.SIGKILL) + process.wait() + + for reader in readers: + reader.join(timeout=3) + return Result(124 if timed_out else process.returncode, bytes(stdout), bytes(stderr), timed_out) + + +def decode(value: bytes) -> str: + return value.decode("utf-8", errors="replace") + + +def sanitize_text(value: str, include_identities: bool) -> str: + value = JWT_RE.sub(SECRET, value) + value = BEARER_RE.sub("Bearer " + SECRET, value) + value = LABELED_SECRET_RE.sub(lambda match: match.group(1) + match.group(2) + SECRET, value) + value = WORD_SECRET_RE.sub(lambda match: match.group(1) + " " + SECRET, value) + if not include_identities: + value = EMAIL_RE.sub(EMAIL, value) + return value + + +def sanitize(value: Any, include_identities: bool, key: str | None = None) -> Any: + if key and SECRET_KEY_RE.search(key): + return SECRET + if key and normalized_key(key) in IDENTITY_KEYS and not include_identities and value is not None: + return EMAIL if "email" in normalized_key(key) else IDENTITY + if isinstance(value, dict): + return {str(child_key): sanitize(child, include_identities, str(child_key)) for child_key, child in value.items()} + if isinstance(value, list): + return [sanitize(child, include_identities) for child in value] + if isinstance(value, str): + return sanitize_text(value, include_identities) + return value + + +def print_json(value: Any) -> None: + print(json.dumps(value, indent=2, sort_keys=True)) + + +def print_error(kind: str, message: str, *, binary: Binary | None = None) -> None: + payload: dict[str, Any] = {"error": {"kind": kind, "message": sanitize_text(message, False)}} + if binary: + payload["binary"] = {"path": safe_path(binary.path), "source": binary.source} + print_json(payload) + + +def emit_stderr(result: Result, include_identities: bool) -> None: + text = sanitize_text(decode(result.stderr), include_identities).strip() + if text: + print(text, file=sys.stderr) + + +def read_json(binary: Binary, argv: Sequence[str], include_identities: bool) -> int: + result = run_process([binary.path, *argv]) + emit_stderr(result, include_identities) + if result.timed_out: + print_error("timeout", f"CodexBar exceeded {timeout_seconds():g}s and was stopped.", binary=binary) + return 124 + try: + payload = json.loads(decode(result.stdout)) + except json.JSONDecodeError as error: + preview = sanitize_text(decode(result.stdout[:400]), False) + print_error("invalid_json", f"CodexBar JSON failed: {error.msg}. stdout={preview!r}", binary=binary) + return result.returncode or 1 + print_json(sanitize(payload, include_identities)) + return result.returncode + + +def doctor(binary: Binary, include_identities: bool) -> int: + version = run_process([binary.path, "--version"]) + if version.timed_out: + print_error("timeout", f"CodexBar version exceeded {timeout_seconds():g}s.", binary=binary) + return 124 + validation = run_process([binary.path, "config", "validate", "--format", "json", "--json-only"]) + emit_stderr(version, include_identities) + emit_stderr(validation, include_identities) + if validation.timed_out: + print_error("timeout", f"CodexBar config validation exceeded {timeout_seconds():g}s.", binary=binary) + return 124 + try: + issues = json.loads(decode(validation.stdout)) + except json.JSONDecodeError as error: + print_error("invalid_json", f"CodexBar config validation failed: {error.msg}.", binary=binary) + return validation.returncode or 1 + print_json( + { + "binary": {"path": safe_path(binary.path), "source": binary.source}, + "configIssues": sanitize(issues, include_identities), + "version": sanitize_text((decode(version.stdout) or decode(version.stderr)).strip(), include_identities), + } + ) + return version.returncode or validation.returncode + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(prog="codexbar", description="CodexBar read. JSON out. No writes.") + commands = root.add_subparsers(dest="command", required=True) + for name in ("doctor", "providers"): + command = commands.add_parser(name) + command.add_argument("--include-identities", action="store_true") + usage = commands.add_parser("usage") + scope = usage.add_mutually_exclusive_group() + scope.add_argument("--all", action="store_true") + scope.add_argument("--provider") + usage.add_argument("--include-identities", action="store_true") + return root + + +def main(argv: Sequence[str] | None = None) -> int: + args = parser().parse_args(argv) + binary = resolve_binary() + if not binary: + print_error("missing", "CodexBar CLI not found. Install CLI in CodexBar Advanced settings or set CODEXBAR_BIN.") + return 1 + try: + if args.command == "doctor": + return doctor(binary, args.include_identities) + if args.command == "providers": + return read_json( + binary, + ["config", "providers", "--format", "json", "--json-only"], + args.include_identities, + ) + usage_args = ["usage", "--format", "json", "--json-only"] + if args.all: + usage_args.extend(["--provider", "all"]) + elif args.provider: + usage_args.extend(["--provider", args.provider]) + return read_json(binary, usage_args, args.include_identities) + except OSError as error: + print_error("launch", str(error), binary=binary) + return 1 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except KeyboardInterrupt: + print_error("interrupted", "Interrupted.") + raise SystemExit(130) diff --git a/.agents/skills/codexbar/scripts/test_codexbar.py b/.agents/skills/codexbar/scripts/test_codexbar.py new file mode 100644 index 0000000000..ceb9dc7450 --- /dev/null +++ b/.agents/skills/codexbar/scripts/test_codexbar.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import json +import os +import runpy +import subprocess +import sys +import tempfile +import textwrap +import time +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("codexbar") +MODULE = runpy.run_path(str(SCRIPT), run_name="codexbar_skill") +MAX_CAPTURE_BYTES = MODULE["MAX_CAPTURE_BYTES"] +run_process = MODULE["run_process"] + +FAKE = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json + import os + import subprocess + import sys + import time + + argv = sys.argv[1:] + log = os.environ.get("FAKE_LOG") + if log: + with open(log, "a", encoding="utf-8") as handle: + handle.write(json.dumps(argv) + "\\n") + + if argv == ["--version"]: + key = "VERSION" + elif argv[:2] == ["config", "validate"]: + key = "VALIDATE" + elif argv[:2] == ["config", "providers"]: + key = "PROVIDERS" + elif argv and argv[0] == "usage": + key = "USAGE" + else: + key = "OTHER" + + if os.environ.get("FAKE_SPAWN_CHILD") == "1": + marker = os.environ["FAKE_MARKER"] + subprocess.Popen([ + sys.executable, + "-c", + f"import pathlib,time; time.sleep(2); pathlib.Path({marker!r}).write_text('alive')", + ]) + time.sleep(5) + + delay = os.environ.get(f"FAKE_{key}_DELAY") + if delay: + time.sleep(float(delay)) + stdout = os.environ.get(f"FAKE_{key}_STDOUT", "") + stderr = os.environ.get(f"FAKE_{key}_STDERR", "") + sys.stdout.write(stdout) + sys.stderr.write(stderr) + raise SystemExit(int(os.environ.get(f"FAKE_{key}_EXIT", "0"))) + """ +) + + +def make_env(root: Path, *, install: bool = True) -> tuple[dict[str, str], Path]: + binary = root / "CodexBar.app" / "Contents" / "Helpers" / "CodexBarCLI" + binary.parent.mkdir(parents=True) + binary.write_text(FAKE, encoding="utf-8") + binary.chmod(0o755) + env = os.environ.copy() + env["CODEXBAR_SKIP_DISCOVERY"] = "1" + env["CODEXBAR_TIMEOUT"] = "3" + env["FAKE_LOG"] = str(root / "argv.log") + env["FAKE_VERSION_STDOUT"] = "CodexBar 1.2.3\n" + env["FAKE_VALIDATE_STDOUT"] = "[]" + env["FAKE_PROVIDERS_STDOUT"] = json.dumps( + [{"provider": "codex", "displayName": "Codex", "enabled": True}] + ) + env["FAKE_USAGE_STDOUT"] = json.dumps( + [ + { + "provider": "codex", + "source": "oauth", + "usage": { + "accountEmail": "alice@example.com", + "accountOrganization": "Example Org", + "identity": {"providerID": "codex", "accountID": "acct-123"}, + "primary": {"usedPercent": 42, "windowMinutes": 300}, + }, + } + ] + ) + if install: + env["CODEXBAR_BIN"] = str(binary) + else: + env.pop("CODEXBAR_BIN", None) + env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin" + return env, binary + + +def helper(*args: str, env: dict[str, str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + check=False, + env=env, + cwd=cwd, + ) + + +class CodexBarSkillTests(unittest.TestCase): + def test_help_is_small_and_read_only(self) -> None: + result = helper("--help", env=os.environ.copy()) + self.assertEqual(result.returncode, 0) + self.assertIn("CodexBar read. JSON out. No writes.", result.stdout) + self.assertNotIn("enable", result.stdout) + self.assertNotIn("set-api-key", result.stdout) + + def test_missing_binary_is_json(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, _ = make_env(Path(tmp), install=False) + result = helper("doctor", env=env) + self.assertEqual(result.returncode, 1) + self.assertEqual(json.loads(result.stdout)["error"]["kind"], "missing") + + def test_doctor_reports_version_and_raw_validation_shape(self) -> None: + with tempfile.TemporaryDirectory(dir=Path.home()) as tmp: + env, binary = make_env(Path(tmp)) + result = helper("doctor", env=env) + self.assertEqual(result.returncode, 0) + payload = json.loads(result.stdout) + self.assertEqual(payload["version"], "CodexBar 1.2.3") + self.assertEqual(payload["configIssues"], []) + self.assertEqual(payload["binary"]["path"], "~" + str(binary)[len(str(Path.home())) :]) + + def test_providers_passes_upstream_json_without_second_schema(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, _ = make_env(Path(tmp)) + result = helper("providers", env=env) + self.assertEqual(result.returncode, 0) + self.assertEqual( + json.loads(result.stdout), + [{"provider": "codex", "displayName": "Codex", "enabled": True}], + ) + + def test_usage_defaults_to_enabled_and_runs_from_any_cwd(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + env, _ = make_env(root) + elsewhere = root / "elsewhere" + elsewhere.mkdir() + result = helper("usage", env=env, cwd=elsewhere) + calls = [json.loads(line) for line in (root / "argv.log").read_text().splitlines()] + self.assertEqual(result.returncode, 0) + self.assertEqual(calls, [["usage", "--format", "json", "--json-only"]]) + + def test_usage_scope_maps_to_upstream_cli(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + env, _ = make_env(root) + self.assertEqual(helper("usage", "--all", env=env).returncode, 0) + self.assertEqual(helper("usage", "--provider", "zai", env=env).returncode, 0) + calls = [json.loads(line) for line in (root / "argv.log").read_text().splitlines()] + self.assertEqual(calls[0][-2:], ["--provider", "all"]) + self.assertEqual(calls[1][-2:], ["--provider", "zai"]) + + def test_usage_hides_identity_but_preserves_provider_id(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, _ = make_env(Path(tmp)) + env["FAKE_USAGE_STDERR"] = ( + "Authorization: Bearer secret-token alice@example.com; token sk-live-prose-secret\n" + ) + result = helper("usage", env=env) + payload = json.loads(result.stdout)[0] + self.assertEqual(payload["provider"], "codex") + self.assertEqual(payload["usage"]["identity"]["providerID"], "codex") + self.assertEqual(payload["usage"]["accountEmail"], "") + self.assertEqual(payload["usage"]["accountOrganization"], "") + self.assertEqual(payload["usage"]["identity"]["accountID"], "") + self.assertNotIn("secret-token", result.stderr) + self.assertNotIn("sk-live-prose-secret", result.stderr) + self.assertIn("", result.stderr) + + def test_include_identities_never_exposes_secret_keys(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + env, _ = make_env(Path(tmp)) + payload = json.loads(env["FAKE_USAGE_STDOUT"]) + payload[0]["usage"]["apiKey"] = "super-secret" + env["FAKE_USAGE_STDOUT"] = json.dumps(payload) + result = helper("usage", "--include-identities", env=env) + usage = json.loads(result.stdout)[0]["usage"] + self.assertEqual(usage["accountEmail"], "alice@example.com") + self.assertEqual(usage["apiKey"], "") + + def test_each_stream_is_capped_while_fully_drained(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + writer = root / "writer" + writer.write_text( + "#!/usr/bin/env python3\n" + "import sys\n" + "sys.stdout.buffer.write(b'o' * 2000000)\n" + "sys.stderr.buffer.write(b'e' * 2000000)\n", + encoding="utf-8", + ) + writer.chmod(0o755) + result = run_process([str(writer)]) + self.assertEqual(result.returncode, 0) + self.assertEqual(len(result.stdout), MAX_CAPTURE_BYTES) + self.assertEqual(len(result.stderr), MAX_CAPTURE_BYTES) + + def test_timeout_kills_process_group(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + env, _ = make_env(root) + marker = root / "child-survived" + env["CODEXBAR_TIMEOUT"] = "1" + env["FAKE_SPAWN_CHILD"] = "1" + env["FAKE_MARKER"] = str(marker) + result = helper("usage", env=env) + time.sleep(2.2) + self.assertEqual(result.returncode, 124) + self.assertEqual(json.loads(result.stdout)["error"]["kind"], "timeout") + self.assertFalse(marker.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/qa-test/SKILL.md b/.agents/skills/qa-test/SKILL.md new file mode 100644 index 0000000000..81c752a165 --- /dev/null +++ b/.agents/skills/qa-test/SKILL.md @@ -0,0 +1,118 @@ +--- +name: qa-test +description: "CodexBar live QA/e2e testing: run provider usage matrix checks, validate real app config, use Peekaboo for menu proof, use Browser Use/official docs for API spec or logged-in dashboard checks, and handle 1Password credentials safely." +--- + +# CodexBar Live QA + +Use for live provider testing, release smoke tests, menu verification, or debugging “provider works/fails” reports. + +## Rules + +- Work from the CodexBar repo checkout. +- Use the packaged CLI first: `CodexBar.app/Contents/Helpers/CodexBarCLI`. +- Do not use `CodexBar.app/Contents/MacOS/codexbar`; that is the app binary and may appear to hang as a CLI. +- Never run broad `env`, `set`, or secret regex dumps. +- Use `$one-password` for secrets: all `op` commands inside one persistent tmux session, service account first, no raw secret output. +- Treat browser-cookie/keychain flows as prompt-risky. Prefer CLI/API-token checks and `KeychainNoUIQuery`-safe tests unless the user explicitly requested live UI. +- For current API behavior, browse official provider docs only. + +## CLI Matrix + +Run the bundled script: + +```bash +.agents/skills/qa-test/scripts/live_provider_matrix.sh --enabled +``` + +Useful modes: + +```bash +.agents/skills/qa-test/scripts/live_provider_matrix.sh --provider all +.agents/skills/qa-test/scripts/live_provider_matrix.sh --providers openai,zai,deepseek +.agents/skills/qa-test/scripts/live_provider_matrix.sh --default +``` + +Interpretation: + +- `--enabled` asks `CodexBarCLI config providers` for enabled providers, honoring `CODEXBAR_CONFIG` and default toggles. +- `--default` runs the app-facing default command with no provider override. +- `--provider all` forces every registered provider and is expected to fail for providers without sessions/keys. +- A green app config needs `--enabled` and `--default` clean; `--provider all` is a discovery/triage tool. + +## Config QA + +Validate config: + +```bash +CodexBar.app/Contents/Helpers/CodexBarCLI config validate +stat -f '%Lp %N' "$HOME/.codexbar/config.json" +``` + +Redact config shape: + +```bash +jq '(.providers // []) |= map(.apiKey = (if .apiKey then "" else .apiKey end) | + .secretKey = (if .secretKey then "" else .secretKey end) | + .cookieHeader = (if .cookieHeader then "" else .cookieHeader end) | + (if .id == "stepfun" and has("region") then .region = "" else . end) | + .tokenAccounts = (if .tokenAccounts then (.tokenAccounts | .accounts = (.accounts | map(.token = ""))) else .tokenAccounts end))' \ + "$HOME/.codexbar/config.json" +``` + +Before editing config, make a backup: + +```bash +cp "$HOME/.codexbar/config.json" "$HOME/.codexbar/config.pre-qa-$(date +%Y%m%d%H%M%S).json" +chmod 600 "$HOME/.codexbar"/config.pre-qa-*.json +``` + +## Live Menu QA + +Use Peekaboo after CLI checks: + +```bash +pkill -x CodexBar || pkill -f 'CodexBar.app/Contents/MacOS/CodexBar' || true +open -n "$PWD/CodexBar.app" +peekaboo menu list-all --json | rg -i 'codexbar' +peekaboo menu click-extra --title codexbar-merged --json +screencapture -x /tmp/codexbar-live-menu.png +``` + +Crop top-right menu if needed: + +```bash +sips --cropToHeightWidth 900 340 --cropOffset 20 2650 /tmp/codexbar-live-menu.png \ + --out /tmp/codexbar-live-menu-crop.png >/dev/null +``` + +Verify visually with `view_image`. Confirm provider tabs/rows match enabled config and no failing provider dominates the first screen. + +## Browser Use + +Use `$browser-use` only when a logged-in dashboard, API key page, or provider docs need browser/profile state. + +Existing Chrome path: + +```bash +mcporter call chrome-devtools.list_pages --args '{}' --output text +mcporter call chrome-devtools.navigate_page --args '{"url":"https://provider.example"}' --output text +mcporter call chrome-devtools.take_snapshot --args '{}' --output text +``` + +If Browser Use is unavailable, say so and use web search for public official docs; do not substitute isolated Playwright for login/profile-dependent pages. + +## Fix Triage + +- Missing auth/session: configure key/session if available; otherwise leave provider disabled or report blocked auth. +- Wrong provider API/spec: inspect official docs, then patch fetcher/settings/tests. +- Provider key exists but live API rejects it: keep key stored if useful, disable provider if the menu would show a persistent error. +- User-facing behavior changes need `CHANGELOG.md`. +- Code fixes need focused tests, `make check`, `$autoreview`, and live CLI proof before landing. + +## Known CodexBar QA Notes + +- OpenAI Admin API key is the useful usage provider key. Project `OPENAI_API_KEY` values can fail legacy credit-balance fallback with 403. +- Deepgram usage requires a key/project with Management API permissions; transcription-only keys can return 403. +- Groq usage uses the Prometheus metrics API, not ordinary inference endpoints. +- MiniMax pay-as-you-go API keys and Token Plan/Coding Plan keys are different; wrong key kind can leave usage unavailable. diff --git a/.agents/skills/qa-test/agents/openai.yaml b/.agents/skills/qa-test/agents/openai.yaml new file mode 100644 index 0000000000..3bf7a7b278 --- /dev/null +++ b/.agents/skills/qa-test/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CodexBar QA Test" + short_description: "Run live CodexBar CLI and menu QA safely." + default_prompt: "Run CodexBar live QA with CLI, Peekaboo, browser docs, and 1Password-safe credential checks." diff --git a/.agents/skills/qa-test/references/api-specs.md b/.agents/skills/qa-test/references/api-specs.md new file mode 100644 index 0000000000..8bb7298c6f --- /dev/null +++ b/.agents/skills/qa-test/references/api-specs.md @@ -0,0 +1,10 @@ +# API Spec Pointers + +Use current official docs for provider API behavior. Prefer these searches/pages before patching fetchers: + +- MiniMax: `https://platform.minimax.io/docs/llms.txt`; key types differ between pay-as-you-go API keys and Token Plan/Coding Plan keys. +- Deepgram: `https://developers.deepgram.com/llms.txt`; usage/project APIs require Management permissions and project-scoped keys. +- Groq: `https://console.groq.com/docs/prometheus-metrics`; usage metrics use `https://api.groq.com/v1/metrics/prometheus`. +- LLM Proxy/LiteLLM: `https://docs.litellm.ai/`; CodexBar expects an LLM-API-Key-Proxy compatible `/v1/quota-stats` endpoint plus base URL. + +When citing docs in a user-facing answer, browse the current page and include source links. diff --git a/.agents/skills/qa-test/scripts/live_provider_matrix.sh b/.agents/skills/qa-test/scripts/live_provider_matrix.sh new file mode 100755 index 0000000000..0c8240ce05 --- /dev/null +++ b/.agents/skills/qa-test/scripts/live_provider_matrix.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +CLI="${CODEXBAR_CLI:-$ROOT/CodexBar.app/Contents/Helpers/CodexBarCLI}" +TIMEOUT_BIN="${TIMEOUT_BIN:-$(command -v gtimeout || command -v timeout || true)}" +WEB_TIMEOUT="${CODEXBAR_QA_WEB_TIMEOUT:-12}" +CASE_TIMEOUT="${CODEXBAR_QA_CASE_TIMEOUT:-60}" + +usage() { + cat <<'USAGE' +Usage: + live_provider_matrix.sh --enabled + live_provider_matrix.sh --default + live_provider_matrix.sh --provider all + live_provider_matrix.sh --providers openai,zai,deepseek + +Environment: + CODEXBAR_CLI=/path/to/CodexBarCLI + CODEXBAR_CONFIG=/path/to/config.json + CODEXBAR_QA_WEB_TIMEOUT=12 + CODEXBAR_QA_CASE_TIMEOUT=60 +USAGE +} + +if [[ ! -x "$CLI" ]]; then + echo "missing CodexBarCLI at $CLI" >&2 + exit 2 +fi +if [[ -z "$TIMEOUT_BIN" ]]; then + echo "missing timeout command (install coreutils for gtimeout)" >&2 + exit 2 +fi +if ! command -v node >/dev/null 2>&1; then + echo "missing node" >&2 + exit 2 +fi + +mode="${1:-}" +shift || true + +providers=() +case "$mode" in + --enabled) + provider_status="$(mktemp)" + provider_err="$(mktemp)" + provider_list="$(mktemp)" + if ! "$CLI" config providers --format json --json-only >"$provider_status" 2>"$provider_err"; then + rm -f "$provider_status" "$provider_err" "$provider_list" + echo "failed to list providers via CodexBarCLI config providers" >&2 + exit 2 + fi + if ! node - "$provider_status" >"$provider_list" <<'NODE'; then +const fs = require("fs"); +const path = process.argv[2]; +const raw = fs.readFileSync(path, "utf8").trim(); +const payload = JSON.parse(raw); +if (!Array.isArray(payload)) { + throw new Error("config providers output is not an array"); +} +for (const item of payload) { + if (item && item.enabled === true && typeof item.provider === "string" && item.provider) { + console.log(item.provider); + } +} +NODE + rm -f "$provider_status" "$provider_err" "$provider_list" + echo "failed to parse CodexBarCLI config providers output" >&2 + exit 2 + fi + while IFS= read -r provider; do + [[ -n "$provider" ]] && providers+=("$provider") + done <"$provider_list" + rm -f "$provider_status" "$provider_err" "$provider_list" + if [[ "${#providers[@]}" -eq 0 ]]; then + echo "no enabled providers found via CodexBarCLI config providers" >&2 + exit 2 + fi + ;; + --default) + providers=("__default__") + ;; + --provider) + if [[ -z "${1:-}" ]]; then + echo "missing provider" >&2 + exit 2 + fi + providers=("${1:-}") + ;; + --providers) + if [[ -z "${1:-}" ]]; then + echo "missing providers" >&2 + exit 2 + fi + IFS=',' read -r -a providers <<< "${1:-}" + ;; + -h|--help|"") + usage + exit 0 + ;; + *) + echo "unknown mode: $mode" >&2 + usage >&2 + exit 2 + ;; +esac + +redact_node=' +const redact = s => String(s || "") + .replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+/g, "") + .replace(/sk-[A-Za-z0-9_-]{12,}/g, "sk-REDACTED") + .replace(/gsk_[A-Za-z0-9_-]{12,}/g, "gsk_REDACTED") + .replace(/[A-Za-z0-9_-]{32,}/g, m => /[A-Za-z]/.test(m) && /[0-9]/.test(m) ? "" : m); +' + +run_one() { + local name="$1" + shift + local out err start end elapsed st node_status + out="$(mktemp)" + err="$(mktemp)" + start="$(date +%s)" + "$TIMEOUT_BIN" "$CASE_TIMEOUT" "$CLI" usage "$@" --format json --json-only --web-timeout "$WEB_TIMEOUT" >"$out" 2>"$err" + st=$? + end="$(date +%s)" + elapsed=$((end - start)) + node - "$name" "$st" "$elapsed" "$out" "$err" <&2 + exit 2 +fi +exit "$overall" diff --git a/.agents/skills/release-codexbar/SKILL.md b/.agents/skills/release-codexbar/SKILL.md new file mode 100644 index 0000000000..2d823e80df --- /dev/null +++ b/.agents/skills/release-codexbar/SKILL.md @@ -0,0 +1,141 @@ +--- +name: release-codexbar +description: "CodexBar release: versioning, notarization, appcast, Homebrew, post-release bump." +--- + +# CodexBar Release + +Use for releasing signed/notarized macOS apps, especially repos with Sparkle appcasts and Homebrew casks. + +## Start + +1. Work from the app repo unless asked otherwise. +2. Check repo state, current version, latest tag/release, and release docs/scripts. +3. Confirm `CHANGELOG.md` is complete, user-facing, deduped, and dated for the release. +4. Prefer the repo release script; patch small script/test blockers instead of bypassing the release path. +5. Never print key material. Keep 1Password references and local key paths as references only. +6. Load `$release-private` if it exists before resolving Peter-owned credential locators. + +## Key Material + +Use `$one-password` for secret handling. `op` only in tmux/persistent shell; no broad `env`, `set`, `export -p`, or secret scans. + +Known App Store Connect shape: + +- fields: `private_key_p8`, `key_id`, `issuer_id` +- keep all three fields from the same 1Password item; do not mix with stale values from `~/.profile` +- resolve Peter-owned item refs from `$release-private` + +Known Sparkle key: + +- resolve the private key file from `$release-private` +- pass as `SPARKLE_PRIVATE_KEY_FILE` + +Safe env file pattern: + +```text +APP_STORE_CONNECT_API_KEY_P8=<1Password ref from release-private> +APP_STORE_CONNECT_KEY_ID=<1Password ref from release-private> +APP_STORE_CONNECT_ISSUER_ID=<1Password ref from release-private> +SPARKLE_PRIVATE_KEY_FILE= +``` + +Run with `op run --account my.1password.com --env-file -- "#, + #"(?is)]*>.*?"#, + #"(?is)"#, + #"<[^>]+>"#, + #"\s+"#, + ] + + return patterns.enumerated().reduce(html) { result, item in + let replacement = item.offset == patterns.count - 1 ? " " : "" + return result.replacingOccurrences( + of: item.element, + with: replacement, + options: .regularExpression) + } + .trimmingCharacters(in: .whitespacesAndNewlines) + } } -struct MiniMaxCodingPlanPayload: Decodable, Sendable { +struct MiniMaxCodingPlanPayload: Decodable { let baseResp: MiniMaxBaseResponse? let data: MiniMaxCodingPlanData @@ -346,13 +744,14 @@ struct MiniMaxCodingPlanPayload: Decodable, Sendable { } } -struct MiniMaxCodingPlanData: Decodable, Sendable { +struct MiniMaxCodingPlanData: Decodable { let baseResp: MiniMaxBaseResponse? let currentSubscribeTitle: String? let planName: String? let comboTitle: String? let currentPlanTitle: String? let currentComboCard: MiniMaxComboCard? + let pointsBalance: Double? let modelRemains: [MiniMaxModelRemains] private enum CodingKeys: String, CodingKey { @@ -362,6 +761,11 @@ struct MiniMaxCodingPlanData: Decodable, Sendable { case comboTitle = "combo_title" case currentPlanTitle = "current_plan_title" case currentComboCard = "current_combo_card" + case pointsBalance = "points_balance" + case pointBalance = "point_balance" + case creditsBalance = "credits_balance" + case creditBalance = "credit_balance" + case balance case modelRemains = "model_remains" } @@ -373,40 +777,22 @@ struct MiniMaxCodingPlanData: Decodable, Sendable { self.comboTitle = try container.decodeIfPresent(String.self, forKey: .comboTitle) self.currentPlanTitle = try container.decodeIfPresent(String.self, forKey: .currentPlanTitle) self.currentComboCard = try container.decodeIfPresent(MiniMaxComboCard.self, forKey: .currentComboCard) + self.pointsBalance = MiniMaxDecoding.decodeDouble(container, forKeys: [ + .pointsBalance, + .pointBalance, + .creditsBalance, + .creditBalance, + .balance, + ]) self.modelRemains = try (container.decodeIfPresent([MiniMaxModelRemains].self, forKey: .modelRemains)) ?? [] } } -struct MiniMaxComboCard: Decodable, Sendable { +struct MiniMaxComboCard: Decodable { let title: String? } -struct MiniMaxModelRemains: Decodable, Sendable { - let currentIntervalTotalCount: Int? - let currentIntervalUsageCount: Int? - let startTime: Int? - let endTime: Int? - let remainsTime: Int? - - private enum CodingKeys: String, CodingKey { - case currentIntervalTotalCount = "current_interval_total_count" - case currentIntervalUsageCount = "current_interval_usage_count" - case startTime = "start_time" - case endTime = "end_time" - case remainsTime = "remains_time" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) - self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) - self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) - self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) - self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) - } -} - -struct MiniMaxBaseResponse: Decodable, Sendable { +struct MiniMaxBaseResponse: Decodable { let statusCode: Int? let statusMessage: String? @@ -422,22 +808,50 @@ struct MiniMaxBaseResponse: Decodable, Sendable { } } -enum MiniMaxDecoding { - static func decodeInt(_ container: KeyedDecodingContainer, forKey key: K) -> Int? { - if let value = try? container.decodeIfPresent(Int.self, forKey: key) { - return value - } - if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { - return Int(value) - } - if let value = try? container.decodeIfPresent(Double.self, forKey: key) { - return Int(value) - } - if let value = try? container.decodeIfPresent(String.self, forKey: key) { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return Int(trimmed) +// MARK: - Multi-Service API Response Structures + +struct MiniMaxMultiServicePayload: Decodable { + let data: MiniMaxMultiServiceData +} + +struct MiniMaxMultiServiceData: Decodable { + let services: [MiniMaxServiceItem] +} + +struct MiniMaxServiceItem: Decodable { + let serviceType: String? + let windowType: String? + let timeRange: String? + let usage: Int? + let limit: Int? + let percent: Double? + + private enum CodingKeys: String, CodingKey { + case serviceType = "service_type" + case windowType = "window_type" + case timeRange = "time_range" + case usage + case limit + case percent + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.serviceType = try container.decodeIfPresent(String.self, forKey: .serviceType) + self.windowType = try container.decodeIfPresent(String.self, forKey: .windowType) + self.timeRange = try container.decodeIfPresent(String.self, forKey: .timeRange) + self.usage = MiniMaxDecoding.decodeInt(container, forKey: .usage) + self.limit = MiniMaxDecoding.decodeInt(container, forKey: .limit) + // Handle both Double and String for percent (flexible parsing) + if let percentDouble = try? container.decodeIfPresent(Double.self, forKey: .percent) { + self.percent = percentDouble + } else if let percentString = try? container.decodeIfPresent(String.self, forKey: .percent), + let percentValue = Double(percentString) + { + self.percent = percentValue + } else { + self.percent = nil } - return nil } } @@ -447,6 +861,11 @@ enum MiniMaxUsageParser { return try decoder.decode(MiniMaxCodingPlanPayload.self, from: data) } + static func decodeMultiServicePayload(data: Data) throws -> MiniMaxMultiServicePayload { + let decoder = JSONDecoder() + return try decoder.decode(MiniMaxMultiServicePayload.self, from: data) + } + static func decodePayload(json: [String: Any]) throws -> MiniMaxCodingPlanPayload { let normalized = self.normalizeCodingPlanPayload(json) let data = try JSONSerialization.data(withJSONObject: normalized, options: []) @@ -454,6 +873,15 @@ enum MiniMaxUsageParser { } static func parseCodingPlanRemains(data: Data, now: Date = Date()) throws -> MiniMaxUsageSnapshot { + do { + if let multiServiceSnapshot = try self.parseMultiService(data: data, now: now) { + return multiServiceSnapshot + } + } catch { + // Log multi-service parsing failure but continue to single-service parsing + MiniMaxUsageFetcher.log.debug("MiniMax multi-service parsing failed: \(error.localizedDescription)") + } + let payload = try self.decodePayload(data: data) return try self.parseCodingPlanRemains(payload: payload, now: now) } @@ -498,29 +926,76 @@ enum MiniMaxUsageParser { throw MiniMaxUsageError.apiError(message) } - guard let first = payload.data.modelRemains.first else { + guard !payload.data.modelRemains.isEmpty else { throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } - let total = first.currentIntervalTotalCount - let remaining = first.currentIntervalUsageCount - let usedPercent = self.usedPercent(total: total, remaining: remaining) + // Convert model_remains to services array for multi-service UI display + var services: [MiniMaxServiceUsage] = [] + for item in payload.data.modelRemains { + guard let modelName = item.modelName else { continue } + let serviceTypeIdentifier = self.mapModelNameToServiceType(modelName: modelName) + + if let intervalService = self.makeServiceUsage( + ServiceUsageInput( + serviceType: serviceTypeIdentifier, + windowTypeOverride: nil, + total: item.currentIntervalTotalCount, + remaining: item.currentIntervalUsageCount, + remainingPercent: item.currentIntervalRemainingPercent, + status: item.currentIntervalStatus, + start: item.startTime, + end: item.endTime, + remainsTime: item.remainsTime, + boostPermille: item.intervalBoostPermille), + now: now) + { + services.append(intervalService) + } + + // current_weekly_usage_count is also REMAINING quota; render only when weekly quota is real. + if self.shouldRenderWeeklyWindow(for: modelName), + let weeklyService = self.makeServiceUsage( + ServiceUsageInput( + serviceType: serviceTypeIdentifier, + windowTypeOverride: "Weekly", + total: item.currentWeeklyTotalCount, + remaining: item.currentWeeklyUsageCount, + remainingPercent: item.currentWeeklyRemainingPercent, + status: item.currentWeeklyStatus, + start: item.weeklyStartTime, + end: item.weeklyEndTime, + remainsTime: item.weeklyRemainsTime, + boostPermille: item.weeklyBoostPermille), + now: now) + { + services.append(weeklyService) + } + } + + // Use first service for backward compatibility fields + let first = payload.data.modelRemains.first + let hasPercentQuota = first?.currentIntervalRemainingPercent != nil + let total = hasPercentQuota && first?.currentIntervalTotalCount == 0 ? nil : first?.currentIntervalTotalCount + let remaining = hasPercentQuota && first?.currentIntervalUsageCount == 0 + ? nil + : first?.currentIntervalUsageCount + let usedPercent = self.usedPercent( + total: total, + remaining: remaining, + remainingPercent: first?.currentIntervalRemainingPercent) let windowMinutes = self.windowMinutes( - start: self.dateFromEpoch(first.startTime), - end: self.dateFromEpoch(first.endTime)) + start: self.dateFromEpoch(first?.startTime), + end: self.dateFromEpoch(first?.endTime)) let resetsAt = self.resetsAt( - end: self.dateFromEpoch(first.endTime), - remains: first.remainsTime, + end: self.dateFromEpoch(first?.endTime), + remains: first?.remainsTime, now: now) let planName = self.parsePlanName(data: payload.data) - if planName == nil, total == nil, usedPercent == nil { - throw MiniMaxUsageError.parseFailed("Missing coding plan data.") - } - let currentPrompts: Int? = if let total, let remaining { max(0, total - remaining) } else { @@ -535,16 +1010,25 @@ enum MiniMaxUsageParser { windowMinutes: windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, - updatedAt: now) + updatedAt: now, + services: services.isEmpty ? nil : services, + pointsBalance: payload.data.pointsBalance) } - private static func usedPercent(total: Int?, remaining: Int?) -> Double? { + private static func usedPercent(total: Int?, remaining: Int?, remainingPercent: Double? = nil) -> Double? { + if let remainingPercent { + return self.usedPercent(remainingPercent: remainingPercent) + } guard let total, total > 0, let remaining else { return nil } let used = max(0, total - remaining) let percent = Double(used) / Double(total) * 100 return min(100, max(0, percent)) } + private static func usedPercent(remainingPercent: Double) -> Double { + min(100, max(0, 100 - remainingPercent)) + } + private static func dateFromEpoch(_ value: Int?) -> Date? { guard let raw = value else { return nil } if raw > 1_000_000_000_000 { @@ -572,40 +1056,53 @@ enum MiniMaxUsageParser { } private static func parsePlanName(data: MiniMaxCodingPlanData) -> String? { - let candidates = [ + [ data.currentSubscribeTitle, data.planName, data.comboTitle, data.currentPlanTitle, data.currentComboCard?.title, - ].compactMap(\.self) + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? self.inferredTokenPlanName(data: data) + } - for candidate in candidates { - let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - return nil + private static func inferredTokenPlanName(data: MiniMaxCodingPlanData) -> String? { + let hasTextGeneration = data.modelRemains.contains { $0.modelName.map(self.isTextGenerationModelName) ?? false } + let hasUnavailableVideo = data.modelRemains.contains { item in + item.modelName?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "video" && + self.isUnavailableQuotaPlaceholder(ServiceUsageInput( + serviceType: "Text to Video", + windowTypeOverride: nil, + total: item.currentIntervalTotalCount, + remaining: item.currentIntervalUsageCount, + remainingPercent: item.currentIntervalRemainingPercent, + status: item.currentIntervalStatus, + start: nil, + end: nil, + remainsTime: nil, + boostPermille: nil)) + } + return hasTextGeneration && hasUnavailableVideo ? "Plus" : nil } private static func parsePlanName(html: String, text: String) -> String? { - let candidates = [ + [ self.extractFirst(pattern: #"(?i)"planName"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)"plan"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)"packageName"\s*:\s*"([^"]+)""#, text: html), self.extractFirst(pattern: #"(?i)Coding\s*Plan\s*([A-Za-z0-9][A-Za-z0-9\s._-]{0,32})"#, text: text), - ].compactMap(\.self) - - for candidate in candidates { - let cleaned = UsageFormatter.cleanPlanName(candidate) - let trimmed = cleaned - .replacingOccurrences( - of: #"(?i)\s+available\s+usage.*$"#, - with: "", - options: .regularExpression) - .trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed } - } - return nil + ] + .compactMap(\.self) + .map { + UsageFormatter.cleanPlanName($0) + .replacingOccurrences( + of: #"(?i)\s+available\s+usage.*$"#, + with: "", + options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + .first { !$0.isEmpty } } private static func parseNextData(html: String, now: Date) -> MiniMaxUsageSnapshot? { @@ -666,6 +1163,12 @@ enum MiniMaxUsageParser { if normalized["base_resp"] == nil, let value = normalized["baseResp"] { normalized["base_resp"] = value } + if normalized["points_balance"] == nil, let value = normalized["pointsBalance"] { + normalized["points_balance"] = value + } + if normalized["credits_balance"] == nil, let value = normalized["creditsBalance"] { + normalized["credits_balance"] = value + } if let data = normalized["data"] as? [String: Any] { normalized["data"] = self.normalizeCodingPlanPayload(data) @@ -786,7 +1289,7 @@ enum MiniMaxUsageParser { if let tzHint = timeZoneHint?.trimmingCharacters(in: .whitespacesAndNewlines), !tzHint.isEmpty { - formatter.timeZone = TimeZone(identifier: tzHint) + formatter.timeZone = self.timeZone(from: tzHint) } formatter.locale = Locale(identifier: "en_US_POSIX") @@ -816,6 +1319,33 @@ enum MiniMaxUsageParser { return 0 } + private static func timeZone(from hint: String) -> TimeZone? { + let trimmed = hint.trimmingCharacters(in: .whitespacesAndNewlines) + if let timeZone = TimeZone(identifier: trimmed) { + return timeZone + } + + let pattern = #"(?i)^(?:UTC|GMT)\s*([+-])\s*(\d{1,2})(?::?(\d{2}))?$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: trimmed, range: NSRange(trimmed.startIndex..., in: trimmed)), + let signRange = Range(match.range(at: 1), in: trimmed), + let hourRange = Range(match.range(at: 2), in: trimmed) + else { + return nil + } + + let sign = trimmed[signRange] == "-" ? -1 : 1 + let hours = Int(trimmed[hourRange]) ?? 0 + let minutes = if match.range(at: 3).location != NSNotFound, + let minuteRange = Range(match.range(at: 3), in: trimmed) + { + Int(trimmed[minuteRange]) ?? 0 + } else { + 0 + } + return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60))) + } + private static func seconds(from value: Double, unit: String) -> TimeInterval { let lower = unit.lowercased() if lower.hasPrefix("d") { return value * 24 * 60 * 60 } @@ -855,24 +1385,283 @@ enum MiniMaxUsageParser { return String(text[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines) } } -} -public enum MiniMaxUsageError: LocalizedError, Sendable, Equatable { - case invalidCredentials - case networkError(String) - case apiError(String) - case parseFailed(String) + // MARK: - Multi-Service Parsing - public var errorDescription: String? { - switch self { - case .invalidCredentials: - "MiniMax credentials are invalid or expired." - case let .networkError(message): - "MiniMax network error: \(message)" - case let .apiError(message): - "MiniMax API error: \(message)" - case let .parseFailed(message): - "Failed to parse MiniMax coding plan: \(message)" + private static func parseMultiService(data: Data, now: Date) throws -> MiniMaxUsageSnapshot? { + let payload = try self.decodeMultiServicePayload(data: data) + + guard !payload.data.services.isEmpty else { + return nil } + + var services: [MiniMaxServiceUsage] = [] + for item in payload.data.services { + guard let serviceType = item.serviceType, + let windowType = item.windowType, + let timeRange = item.timeRange, + let usage = item.usage, + let limit = item.limit, + limit > 0 + else { + continue + } + + var percent = item.percent ?? 0.0 + if item.percent == nil, limit > 0 { + percent = Double(usage) / Double(limit) * 100.0 + } + + let resetsAt = self.parseResetsAtFromTimeRange(timeRange: timeRange, windowType: windowType, now: now) + let resetDescription = self.resetDescription( + for: windowType, + timeRange: timeRange, + now: now, + resetsAt: resetsAt) + + let serviceTypeIdentifier: String = if serviceType.lowercased().contains("text"), + serviceType.lowercased().contains("generation") + { + "text-generation" + } else if serviceType.lowercased().contains("text"), serviceType.lowercased().contains("speech") { + "text-to-speech" + } else if serviceType.lowercased().contains("image") { + "image" + } else { + serviceType.lowercased() + .replacingOccurrences(of: " ", with: "-") + .replacingOccurrences(of: "_", with: "-") + } + + let serviceUsage = MiniMaxServiceUsage( + serviceType: serviceTypeIdentifier, + windowType: windowType, + timeRange: timeRange, + usage: usage, + limit: limit, + percent: min(100.0, max(0.0, percent)), + resetsAt: resetsAt, + resetDescription: resetDescription) + services.append(serviceUsage) + } + + if services.isEmpty { + return nil + } + + let planName = self.extractPlanNameFromServices(services: payload.data.services) + + return MiniMaxUsageSnapshot( + planName: planName, + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: services) + } + + private static func parseResetsAtFromTimeRange(timeRange: String, windowType: String, now: Date) -> Date? { + let lowerWindow = windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + if lowerWindow == "today" { + let components = timeRange.split(separator: "-", maxSplits: 1) + guard components.count == 2 else { return nil } + + let endTimeStr = String(components[1].trimmingCharacters(in: .whitespacesAndNewlines)) + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + + return formatter.date(from: endTimeStr) + } + + if lowerWindow.contains("hour") || lowerWindow.contains("h") { + let timeComponents = timeRange.split(separator: "-") + guard timeComponents.count >= 2 else { return nil } + + let endTimePart = String(timeComponents[1]) + let endTimeClean = endTimePart.replacingOccurrences(of: "\\(.*\\)", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + return self.dateForTime(endTimeClean, timeZoneHint: "UTC+8", now: now) + } + + return nil + } + + private static func resetDescription( + for windowType: String, + timeRange: String, + now: Date, + resetsAt: Date?) -> String + { + if let resetsAt, resetsAt > now { + let interval = resetsAt.timeIntervalSince(now) + if interval < 60 { + return "Resets in \(Int(interval)) seconds" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "Resets in \(minutes) minute\(minutes == 1 ? "" : "s")" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "Resets in \(hours) hour\(hours == 1 ? "" : "s")" + } else { + let days = Int(interval / 86400) + return "Resets in \(days) day\(days == 1 ? "" : "s")" + } + } + + return "\(windowType): \(timeRange)" + } + + private static func extractPlanNameFromServices(services: [MiniMaxServiceItem]) -> String? { + for service in services { + if let serviceType = service.serviceType, + serviceType.lowercased().contains("pro") || serviceType.lowercased().contains("max") + { + return serviceType + } + } + + return nil + } + + private static func parseWindowInfo( + startTime: Date?, + endTime: Date?, + now: Date) -> (windowType: String, timeRange: String) + { + guard let startTime, let endTime else { + return (windowType: "Unknown", timeRange: "N/A") + } + + let durationSeconds = endTime.timeIntervalSince(startTime) + let durationHours = durationSeconds / 3600 + + // Determine window type based on duration + let windowType = if durationHours >= 23, durationHours <= 25 { + "Today" + } else if durationHours >= 4, durationHours <= 6 { + "5 hours" + } else if durationHours >= 1, durationHours < 23 { + "\(Int(durationHours)) hours" + } else { + "Custom" + } + + // Format time range + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current + let startStr = formatter.string(from: startTime) + let endStr = formatter.string(from: endTime) + + let timeRange = "\(startStr)-\(endStr)(UTC+8)" + + return (windowType: windowType, timeRange: timeRange) + } + + private struct ServiceUsageInput { + let serviceType: String + let windowTypeOverride: String? + let total: Int? + let remaining: Int? + let remainingPercent: Double? + let status: Int? + let start: Int? + let end: Int? + let remainsTime: Int? + let boostPermille: Int? + } + + private static func makeServiceUsage(_ input: ServiceUsageInput, now: Date) -> MiniMaxServiceUsage? { + guard self.shouldRenderQuotaWindow(input) else { return nil } + + let startTime = self.dateFromEpoch(input.start) + let endTime = self.dateFromEpoch(input.end) + var (windowType, timeRange) = self.parseWindowInfo(startTime: startTime, endTime: endTime, now: now) + if let windowTypeOverride = input.windowTypeOverride { windowType = windowTypeOverride } + if windowType.lowercased() == "weekly", + let weeklyRange = self.formatMiniMaxDateTimeRange(startTime: startTime, endTime: endTime) + { + timeRange = weeklyRange + } + + let isUnlimited = self.isUnlimitedQuotaWindow(input, windowType: windowType) + let resetsAt = isUnlimited ? nil : self.resetsAt(end: endTime, remains: input.remainsTime, now: now) + let resetDescription = if isUnlimited { + "Unlimited" + } else { + self.resetDescription( + for: windowType, + timeRange: timeRange, + now: now, + resetsAt: resetsAt) + } + let limit: Int + let usage: Int + let percent: Double + if isUnlimited { + percent = 0 + limit = 0 + usage = 0 + } else if let remainingPercent = input.remainingPercent { + let quotaLimit = self.percentQuotaLimit(boostPermille: input.boostPermille) + percent = self.usedPercent(remainingPercent: remainingPercent) + limit = quotaLimit + usage = Int((percent * Double(quotaLimit) / 100.0).rounded()) + } else { + guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } + let used = max(0, total - remaining) + percent = Double(used) / Double(total) * 100.0 + limit = total + usage = used + } + + return MiniMaxServiceUsage( + serviceType: input.serviceType, + windowType: windowType, + timeRange: timeRange, + usage: usage, + limit: limit, + percent: min(100.0, max(0.0, percent)), + isUnlimited: isUnlimited, + resetsAt: resetsAt, + resetDescription: resetDescription) + } + + private static func percentQuotaLimit(boostPermille: Int?) -> Int { + guard let boostPermille, boostPermille > 0 else { return 100 } + return max(1, Int((Double(boostPermille) / 10.0).rounded())) + } + + private static func shouldRenderQuotaWindow(_ input: ServiceUsageInput) -> Bool { + // MiniMax Token Plan returns status 3 for quota lanes that exist in the schema but are not included in + // the current subscription, for example Plus accounts receiving a video lane with 100% remaining and 0 count. + !self.isUnavailableQuotaPlaceholder(input) + } + + private static func isUnavailableQuotaPlaceholder(_ input: ServiceUsageInput) -> Bool { + if let windowType = input.windowTypeOverride, self.isUnlimitedQuotaWindow(input, windowType: windowType) { + return false + } + return input.status == 3 && + (input.total ?? 0) == 0 && + (input.remaining ?? 0) == 0 && + (input.remainingPercent.map { $0 >= 100 } ?? false) + } + + private static func isUnlimitedQuotaWindow(_ input: ServiceUsageInput, windowType: String) -> Bool { + let normalizedService = input.serviceType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedWindow = windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let unlimitedServices = ["text generation", "general"] + return input.status == 3 && + unlimitedServices.contains(normalizedService) && + normalizedWindow == "weekly" && + (input.remainingPercent.map { $0 >= 100 } ?? false) } } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift new file mode 100644 index 0000000000..d5048761b8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot+Metadata.swift @@ -0,0 +1,47 @@ +import Foundation + +extension MiniMaxUsageSnapshot { + func withPlanNameIfMissing(_ planName: String?) -> MiniMaxUsageSnapshot { + let existing = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + if let existing, !existing.isEmpty { return self } + let cleaned = planName?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let cleaned, !cleaned.isEmpty else { return self } + return MiniMaxUsageSnapshot( + planName: cleaned, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt) + } + + func withSubscriptionMetadata(_ metadata: MiniMaxSubscriptionMetadata) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: metadata.planName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: self.billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: metadata.subscriptionExpiresAt ?? self.subscriptionExpiresAt, + subscriptionRenewsAt: metadata.subscriptionRenewsAt ?? self.subscriptionRenewsAt) + } +} + +extension String { + fileprivate var nonEmpty: String? { + self.isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index 09ed671e26..fe73270d0d 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -9,6 +9,60 @@ public struct MiniMaxUsageSnapshot: Sendable { public let usedPercent: Double? public let resetsAt: Date? public let updatedAt: Date + public let services: [MiniMaxServiceUsage]? + public let billingSummary: MiniMaxBillingSummary? + public let pointsBalance: Double? + public let subscriptionExpiresAt: Date? + public let subscriptionRenewsAt: Date? + + public var primaryService: MiniMaxServiceUsage? { + self.orderedQuotaServices.first + } + + public var secondaryService: MiniMaxServiceUsage? { + let services = self.orderedQuotaServices + guard services.count >= 2 else { return nil } + return services[1] + } + + public var tertiaryService: MiniMaxServiceUsage? { + let services = self.orderedQuotaServices + guard services.count >= 3 else { return nil } + return services[2] + } + + public var orderedQuotaServices: [MiniMaxServiceUsage] { + guard let services, !services.isEmpty else { return [] } + return services.enumerated().sorted { lhs, rhs in + let lhsRank = self.quotaServiceRank(lhs.element, originalIndex: lhs.offset) + let rhsRank = self.quotaServiceRank(rhs.element, originalIndex: rhs.offset) + if lhsRank.primary != rhsRank.primary { + return lhsRank.primary < rhsRank.primary + } + if lhsRank.window != rhsRank.window { + return lhsRank.window < rhsRank.window + } + return lhsRank.originalIndex < rhsRank.originalIndex + }.map(\.element) + } + + private func quotaServiceRank( + _ service: MiniMaxServiceUsage, + originalIndex: Int) -> (primary: Int, window: Int, originalIndex: Int) + { + ( + primary: service.isPrimaryTextQuotaLane ? 0 : 1, + window: self.quotaWindowRank(service), + originalIndex: originalIndex) + } + + private func quotaWindowRank(_ service: MiniMaxServiceUsage) -> Int { + let window = service.windowType.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if window == "weekly" { + return 1 + } + return 0 + } public init( planName: String?, @@ -18,7 +72,12 @@ public struct MiniMaxUsageSnapshot: Sendable { windowMinutes: Int?, usedPercent: Double?, resetsAt: Date?, - updatedAt: Date) + updatedAt: Date, + services: [MiniMaxServiceUsage]? = nil, + billingSummary: MiniMaxBillingSummary? = nil, + pointsBalance: Double? = nil, + subscriptionExpiresAt: Date? = nil, + subscriptionRenewsAt: Date? = nil) { self.planName = planName self.availablePrompts = availablePrompts @@ -28,11 +87,60 @@ public struct MiniMaxUsageSnapshot: Sendable { self.usedPercent = usedPercent self.resetsAt = resetsAt self.updatedAt = updatedAt + self.services = services + self.billingSummary = billingSummary + self.pointsBalance = pointsBalance + self.subscriptionExpiresAt = subscriptionExpiresAt + self.subscriptionRenewsAt = subscriptionRenewsAt + } + + public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: billingSummary, + pointsBalance: self.pointsBalance, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt) } } extension MiniMaxUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { + // If we have services array, use that for multi-service support + if let services = self.services, !services.isEmpty { + let primaryWindow = self.rateWindow(for: self.primaryService) + let secondaryWindow = self.rateWindow(for: self.secondaryService) + let tertiaryWindow = self.rateWindow(for: self.tertiaryService) + + let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (planName?.isEmpty ?? true) ? nil : planName + let identity = ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: primaryWindow, + secondary: secondaryWindow, + tertiary: tertiaryWindow, + providerCost: self.pointsBalanceSnapshot(), + minimaxUsage: self, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, + updatedAt: self.updatedAt, + identity: identity) + } + + // Fallback to single-service mode for backward compatibility let used = max(0, min(100, self.usedPercent ?? 0)) let resetDescription = self.limitDescription() let primary = RateWindow( @@ -53,12 +161,24 @@ extension MiniMaxUsageSnapshot { primary: primary, secondary: nil, tertiary: nil, - providerCost: nil, + providerCost: self.pointsBalanceSnapshot(), minimaxUsage: self, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, updatedAt: self.updatedAt, identity: identity) } + private func rateWindow(for service: MiniMaxServiceUsage?) -> RateWindow? { + guard let service else { return nil } + let windowMinutes = self.windowMinutes(for: service) + return RateWindow( + usedPercent: max(0, min(100, service.percent)), + windowMinutes: windowMinutes, + resetsAt: service.resetsAt, + resetDescription: service.resetDescription) + } + private func limitDescription() -> String? { guard let availablePrompts, availablePrompts > 0 else { return self.windowDescription() @@ -82,4 +202,44 @@ extension MiniMaxUsageSnapshot { } return "\(windowMinutes) \(windowMinutes == 1 ? "minute" : "minutes")" } + + private func windowMinutes(for service: MiniMaxServiceUsage) -> Int? { + let windowType = service.windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + // Handle "Today" case - 24 hours = 1440 minutes + if windowType == "today" { + return 24 * 60 + } + if windowType == "weekly" { + return 7 * 24 * 60 + } + + // Handle time duration formats like "5 hours", "30 minutes", etc. + let components = windowType.split(separator: " ") + guard components.count >= 2 else { return nil } + + guard let value = Int(components[0]) else { return nil } + let unit = components[1].lowercased() + + switch unit { + case "hour", "hours", "h", "hr", "hrs": + return value * 60 + case "minute", "minutes", "min", "mins", "m": + return value + case "day", "days", "d": + return value * 24 * 60 + default: + return nil + } + } + + private func pointsBalanceSnapshot() -> ProviderCostSnapshot? { + guard let pointsBalance, pointsBalance >= 0 else { return nil } + return ProviderCostSnapshot( + used: pointsBalance, + limit: 0, + currencyCode: "Points", + period: "MiniMax points balance", + updatedAt: self.updatedAt) + } } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift new file mode 100644 index 0000000000..8d1c6d38df --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift @@ -0,0 +1,101 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +private let mistralCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.mistral]?.browserCookieOrder ?? Browser.defaultImportOrder + +public enum MistralCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["mistral.ai", "admin.mistral.ai", "auth.mistral.ai"] + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + + /// Extracts the CSRF token from the `csrftoken` cookie for the `X-CSRFTOKEN` header. + public var csrfToken: String? { + self.cookies.first { $0.name == "csrftoken" }?.value + } + } + + /// Returns `true` if any cookie name starts with `ory_session_` (the Ory Kratos session cookie). + private static func hasSessionCookie(_ cookies: [HTTPCookie]) -> Bool { + cookies.contains { $0.name.hasPrefix("ory_session_") } + } + + public static func importSession( + browserDetection: BrowserDetection, + preferredBrowsers: [Browser] = [.chrome], + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let log: (String) -> Void = { msg in logger?("[mistral-cookie] \(msg)") } + let installedBrowsers = preferredBrowsers.isEmpty + ? mistralCookieImportOrder.cookieImportCandidates(using: browserDetection) + : preferredBrowsers.cookieImportCandidates(using: browserDetection) + + for browserSource in installedBrowsers { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + for source in sources where !source.records.isEmpty { + let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + if !httpCookies.isEmpty { + guard Self.hasSessionCookie(httpCookies) else { + log("Skipping \(source.label) cookies: missing ory_session_* cookie") + continue + } + log("Found \(httpCookies.count) Mistral cookies in \(source.label)") + return SessionInfo(cookies: httpCookies, sourceLabel: source.label) + } + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + throw MistralCookieImportError.noCookies + } + + public static func hasSession( + browserDetection: BrowserDetection, + preferredBrowsers: [Browser] = [.chrome], + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession( + browserDetection: browserDetection, + preferredBrowsers: preferredBrowsers, + logger: logger) + return true + } catch { + return false + } + } +} + +enum MistralCookieImportError: LocalizedError { + case noCookies + + var errorDescription: String? { + switch self { + case .noCookies: + "No Mistral session cookies found in browsers." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift new file mode 100644 index 0000000000..1c8ab4ae42 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum MistralUsageError: LocalizedError, Sendable { + case missingCookie + case invalidCredentials + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCookie: + "No Mistral session cookies found in browsers." + case .invalidCredentials: + "Mistral session expired or invalid (HTTP 401/403)." + case let .apiError(detail): + "Mistral API error: \(detail)" + case let .parseFailed(detail): + "Failed to parse Mistral billing response: \(detail)" + } + } +} + +enum MistralSettingsError: LocalizedError { + case missingCookie + case invalidCookie + + var errorDescription: String? { + switch self { + case .missingCookie: + "No Mistral session cookies found in browsers." + case .invalidCookie: + "Mistral cookie header is invalid or missing ory_session cookie." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift new file mode 100644 index 0000000000..1470a76881 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -0,0 +1,250 @@ +import Foundation + +// MARK: - API Response Models + +/// Top-level response from `GET https://admin.mistral.ai/api/billing/v2/usage`. +struct MistralBillingResponse: Codable { + let completion: MistralModelUsageCategory? + let ocr: MistralModelUsageCategory? + let connectors: MistralModelUsageCategory? + let librariesApi: MistralLibrariesUsageCategory? + let fineTuning: MistralFineTuningCategory? + let audio: MistralModelUsageCategory? + let vibeUsage: Double? + let date: String? + let previousMonth: String? + let nextMonth: String? + let startDate: String? + let endDate: String? + let currency: String? + let currencySymbol: String? + let prices: [MistralPrice]? + + enum CodingKeys: String, CodingKey { + case completion, ocr, connectors, audio, date, currency, prices + case librariesApi = "libraries_api" + case fineTuning = "fine_tuning" + case vibeUsage = "vibe_usage" + case previousMonth = "previous_month" + case nextMonth = "next_month" + case startDate = "start_date" + case endDate = "end_date" + case currencySymbol = "currency_symbol" + } +} + +struct MistralModelUsageCategory: Codable { + let models: [String: MistralModelUsageData]? +} + +struct MistralLibrariesUsageCategory: Codable { + let pages: MistralModelUsageCategory? + let tokens: MistralModelUsageCategory? +} + +struct MistralFineTuningCategory: Codable { + let training: [String: MistralModelUsageData]? + let storage: [String: MistralModelUsageData]? +} + +struct MistralModelUsageData: Codable { + let input: [MistralUsageEntry]? + let output: [MistralUsageEntry]? + let cached: [MistralUsageEntry]? +} + +struct MistralUsageEntry: Codable { + let usageType: String? + let eventType: String? + let billingMetric: String? + let billingDisplayName: String? + let billingGroup: String? + let timestamp: String? + let value: Int? + let valuePaid: Int? + + enum CodingKeys: String, CodingKey { + case timestamp, value + case usageType = "usage_type" + case eventType = "event_type" + case billingMetric = "billing_metric" + case billingDisplayName = "billing_display_name" + case billingGroup = "billing_group" + case valuePaid = "value_paid" + } +} + +struct MistralPrice: Codable { + let eventType: String? + let billingMetric: String? + let billingGroup: String? + let price: String? + + enum CodingKeys: String, CodingKey { + case price + case eventType = "event_type" + case billingMetric = "billing_metric" + case billingGroup = "billing_group" + } +} + +// MARK: - Intermediate Snapshot + +public struct MistralDailyUsageBucket: Codable, Equatable, Sendable, Identifiable { + public struct ModelBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let cost: Double + public let inputTokens: Int + public let cachedTokens: Int + public let outputTokens: Int + + public var id: String { + self.name + } + + public var totalTokens: Int { + self.inputTokens + self.cachedTokens + self.outputTokens + } + + public init(name: String, cost: Double, inputTokens: Int, cachedTokens: Int, outputTokens: Int) { + self.name = name + self.cost = cost + self.inputTokens = inputTokens + self.cachedTokens = cachedTokens + self.outputTokens = outputTokens + } + } + + public let day: String + public let cost: Double + public let inputTokens: Int + public let cachedTokens: Int + public let outputTokens: Int + public let models: [ModelBreakdown] + + public var id: String { + self.day + } + + public var totalTokens: Int { + self.inputTokens + self.cachedTokens + self.outputTokens + } + + public init( + day: String, + cost: Double, + inputTokens: Int, + cachedTokens: Int, + outputTokens: Int, + models: [ModelBreakdown]) + { + self.day = day + self.cost = cost + self.inputTokens = inputTokens + self.cachedTokens = cachedTokens + self.outputTokens = outputTokens + self.models = models + } +} + +public struct MistralUsageSnapshot: Codable, Sendable { + public let totalCost: Double + public let currency: String + public let currencySymbol: String + public let totalInputTokens: Int + public let totalOutputTokens: Int + public let totalCachedTokens: Int + public let modelCount: Int + public let daily: [MistralDailyUsageBucket] + public let startDate: Date? + public let endDate: Date? + public let updatedAt: Date + + public init( + totalCost: Double, + currency: String, + currencySymbol: String, + totalInputTokens: Int, + totalOutputTokens: Int, + totalCachedTokens: Int, + modelCount: Int, + daily: [MistralDailyUsageBucket] = [], + startDate: Date?, + endDate: Date?, + updatedAt: Date) + { + self.totalCost = totalCost + self.currency = currency + self.currencySymbol = currencySymbol + self.totalInputTokens = totalInputTokens + self.totalOutputTokens = totalOutputTokens + self.totalCachedTokens = totalCachedTokens + self.modelCount = modelCount + self.daily = daily.sorted { $0.day < $1.day } + self.startDate = startDate + self.endDate = endDate + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + // Negative totalCost means a refund/credit adjustment; clamp to zero rather than + // showing a confusing negative amount in the menu bar. + let spendText = if self.totalCost > 0 { + "\(self.currencySymbol)\(String(format: "%.4f", self.totalCost)) this month" + } else { + "\(self.currencySymbol)0.0000 this month" + } + let identity = ProviderIdentitySnapshot( + providerID: .mistral, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API spend: \(spendText)") + return UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: nil, + mistralUsage: self, + updatedAt: self.updatedAt, + identity: identity) + } + + public func toCostUsageTokenSnapshot(historyDays: Int = 30) -> CostUsageTokenSnapshot { + let clampedHistoryDays = max(1, min(365, historyDays)) + let selected = self.daily + let entries = selected.map { bucket in + let modelBreakdowns = bucket.models.map { + CostUsageDailyReport.ModelBreakdown( + modelName: $0.name, + costUSD: max($0.cost, 0), + totalTokens: $0.totalTokens) + } + let modelsUsed = bucket.models.map(\.name) + return CostUsageDailyReport.Entry( + date: bucket.day, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cachedTokens, + cacheCreationTokens: nil, + totalTokens: bucket.totalTokens, + costUSD: max(bucket.cost, 0), + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + let latest = selected.last + let totalCost = max(self.totalCost, 0) + let totalTokens = selected.isEmpty + ? self.totalInputTokens + self.totalCachedTokens + self.totalOutputTokens + : selected.reduce(0) { $0 + $1.totalTokens } + let tokens = totalTokens > 0 ? totalTokens : nil + return CostUsageTokenSnapshot( + sessionTokens: latest?.totalTokens, + sessionCostUSD: latest.map { max($0.cost, 0) }, + last30DaysTokens: tokens, + last30DaysCostUSD: totalCost, + currencyCode: self.currency, + historyDays: selected.isEmpty ? clampedHistoryDays : max(1, min(365, selected.count)), + historyLabel: "This month", + daily: entries, + updatedAt: self.updatedAt) + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift new file mode 100644 index 0000000000..9c28175991 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift @@ -0,0 +1,175 @@ +import Foundation + +public enum MistralProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .mistral, + metadata: ProviderMetadata( + id: .mistral, + displayName: "Mistral", + sessionLabel: "Monthly", + weeklyLabel: "", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Mistral usage", + cliName: "mistral", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://admin.mistral.ai/organization/usage", + statusPageURL: nil, + statusLinkURL: "https://status.mistral.ai"), + branding: ProviderBranding( + iconStyle: .mistral, + iconResourceName: "ProviderIcon-mistral", + color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: true, + noDataMessage: { "Mistral cost history needs a billing web session." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MistralWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "mistral", + aliases: ["mistral-ai"], + versionDetector: nil)) + } +} + +struct MistralWebFetchStrategy: ProviderFetchStrategy { + let id: String = "mistral.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.mistral?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.mistral?.cookieSource ?? .auto + do { + let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: true) + let usage = try await Self.fetchUsageWithVibe( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: context.webTimeout) + return self.makeResult( + usage: usage, + sourceLabel: "web") + } catch MistralUsageError.invalidCredentials where cookieSource != .manual { + #if os(macOS) + CookieHeaderCache.clear(provider: .mistral) + let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: false) + let usage = try await Self.fetchUsageWithVibe( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: context.webTimeout) + return self.makeResult( + usage: usage, + sourceLabel: "web") + #else + throw MistralUsageError.invalidCredentials + #endif + } + } + + private static func fetchUsageWithVibe( + cookieHeader: String, + csrfToken: String?, + timeout: TimeInterval) async throws -> UsageSnapshot + { + let deadline = Date().addingTimeInterval(timeout) + let snapshot = try await MistralUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: timeout) + let remaining = deadline.timeIntervalSinceNow + let vibeResult: MistralUsageFetcher.MistralVibeUsageResult? = if let csrfToken, remaining > 0 { + try await Self.fetchOptionalVibeUsage( + csrfToken: csrfToken, + timeout: min(remaining, 4)) + } else { + nil + } + return Self.attachVibeWindow(to: snapshot.toUsageSnapshot(), vibeResult: vibeResult) + } + + static func fetchOptionalVibeUsage( + csrfToken: String, + timeout: TimeInterval, + transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws + -> MistralUsageFetcher.MistralVibeUsageResult? + { + do { + return try await MistralUsageFetcher.fetchVibeUsage( + csrfToken: csrfToken, + timeout: timeout, + transport: transport) + } catch { + if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled { + throw CancellationError() + } + return nil + } + } + + static func attachVibeWindow( + to usageSnapshot: UsageSnapshot, + vibeResult: MistralUsageFetcher.MistralVibeUsageResult?) -> UsageSnapshot + { + guard let vibeResult else { return usageSnapshot } + let window = RateWindow( + usedPercent: vibeResult.usagePercentage, + windowMinutes: nil, + resetsAt: vibeResult.resetAt, + resetDescription: nil) + let named = NamedRateWindow(id: "mistral-monthly-plan", title: "Monthly Plan", window: window) + let existing = usageSnapshot.extraRateWindows?.filter { $0.id != named.id } ?? [] + return usageSnapshot.with(extraRateWindows: existing + [named]) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveCookieHeader( + context: ProviderFetchContext, + allowCached: Bool) throws -> (cookieHeader: String, csrfToken: String?) + { + if let settings = context.settings?.mistral, settings.cookieSource == .manual { + if let header = CookieHeaderNormalizer.normalize(settings.manualCookieHeader) { + let pairs = CookieHeaderNormalizer.pairs(from: header) + let hasSessionCookie = pairs.contains { $0.name.hasPrefix("ory_session_") } + if hasSessionCookie { + let csrfToken = pairs.first { $0.name == "csrftoken" }?.value + return (header, csrfToken) + } + } + throw MistralSettingsError.invalidCookie + } + + #if os(macOS) + if allowCached, + let cached = CookieHeaderCache.load(provider: .mistral), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + let pairs = CookieHeaderNormalizer.pairs(from: cached.cookieHeader) + let csrfToken = pairs.first { $0.name == "csrftoken" }?.value + return (cached.cookieHeader, csrfToken) + } + let session = try MistralCookieImporter.importSession(browserDetection: context.browserDetection) + CookieHeaderCache.store( + provider: .mistral, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return (session.cookieHeader, session.csrfToken) + #else + throw MistralSettingsError.missingCookie + #endif + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift new file mode 100644 index 0000000000..0db8b4e09c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -0,0 +1,485 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum MistralUsageFetcher { + private static let baseURL = URL(string: "https://admin.mistral.ai")! + + public struct MistralVibeUsageResult: Equatable, Sendable { + public let usagePercentage: Double + public let resetAt: Date? + } + + public static func fetchUsage( + cookieHeader: String, + csrfToken: String?, + timeout: TimeInterval = 15) async throws -> MistralUsageSnapshot + { + let now = Date() + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let month = calendar.component(.month, from: now) + let year = calendar.component(.year, from: now) + + let usagePath = self.baseURL.appendingPathComponent("/api/billing/v2/usage") + var components = URLComponents(url: usagePath, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "month", value: "\(month)"), + URLQueryItem(name: "year", value: "\(year)"), + ] + guard let url = components.url else { + throw MistralUsageError.apiError("Failed to construct URL") + } + + var request = URLRequest(url: url, timeoutInterval: timeout) + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("https://admin.mistral.ai/organization/usage", forHTTPHeaderField: "Referer") + request.setValue("https://admin.mistral.ai", forHTTPHeaderField: "Origin") + if let csrfToken { + request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN") + } + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + + switch response.statusCode { + case 200: + break + case 401, 403: + throw MistralUsageError.invalidCredentials + default: + let body = String(data: data.prefix(200), encoding: .utf8) ?? "" + throw MistralUsageError.apiError("HTTP \(response.statusCode): \(body)") + } + + return try Self.parseResponse(data: data, updatedAt: now) + } + + public static func fetchVibeUsage( + csrfToken: String, + timeout: TimeInterval = 4, + transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MistralVibeUsageResult + { + let urlString = "https://console.mistral.ai/api-ui/trpc/billing.vibeUsage?batch=1&input=%7B%220%22%3A%7B%22json%22%3Anull%2C%22meta%22%3A%7B%22values%22%3A%5B%22undefined%22%5D%2C%22v%22%3A1%7D%7D%7D" + guard let url = URL(string: urlString) else { + throw MistralUsageError.apiError("Failed to construct URL") + } + + var request = URLRequest(url: url, timeoutInterval: timeout) + // The observed console request sends only csrftoken; keep admin Ory cookies origin-bound. + request.httpShouldHandleCookies = false + let validatedCSRFToken = try Self.validatedVibeCSRFToken(csrfToken) + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue("csrftoken=\(validatedCSRFToken)", forHTTPHeaderField: "Cookie") + request.setValue(validatedCSRFToken, forHTTPHeaderField: "X-CSRFToken") + + let response = try await transport.response(for: request) + let data = response.data + + switch response.statusCode { + case 200: + break + case 401, 403: + throw MistralUsageError.invalidCredentials + default: + let body = String(data: data.prefix(200), encoding: .utf8) ?? "" + throw MistralUsageError.apiError("HTTP \(response.statusCode): \(body)") + } + + return try Self.parseVibeUsage(data: data) + } + + static func parseVibeUsage(data: Data) throws -> MistralVibeUsageResult { + let responses: [VibeUsageResponse] + do { + responses = try JSONDecoder().decode([VibeUsageResponse].self, from: data) + } catch { + throw MistralUsageError.parseFailed(error.localizedDescription) + } + guard let json = responses.first?.result.data.json else { + throw MistralUsageError.parseFailed("Empty response array") + } + guard json.usagePercentage.isFinite, (0...100).contains(json.usagePercentage) else { + throw MistralUsageError.parseFailed("Invalid usage percentage") + } + return MistralVibeUsageResult( + usagePercentage: json.usagePercentage, + resetAt: json.resetAt.flatMap(Self.parseISO8601Date)) + } + + static func vibeCookieHeader(csrfToken: String) throws -> String { + try "csrftoken=\(self.validatedVibeCSRFToken(csrfToken))" + } + + private static func validatedVibeCSRFToken(_ csrfToken: String) throws -> String { + let token = csrfToken.trimmingCharacters(in: .whitespacesAndNewlines) + let forbidden = CharacterSet(charactersIn: ";,\r\n") + guard !token.isEmpty, token.rangeOfCharacter(from: forbidden) == nil else { + throw MistralUsageError.invalidCredentials + } + return token + } + + private static func parseISO8601Date(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } + + static func parseResponse(data: Data, updatedAt: Date) throws -> MistralUsageSnapshot { + let decoder = JSONDecoder() + let billing: MistralBillingResponse + do { + billing = try decoder.decode(MistralBillingResponse.self, from: data) + } catch { + throw MistralUsageError.parseFailed(error.localizedDescription) + } + + let prices = Self.buildPriceIndex(billing.prices ?? []) + var totalCost: Double = 0 + var totalInput = 0 + var totalOutput = 0 + var totalCached = 0 + var modelCount = 0 + var daily: [String: DailyAccumulator] = [:] + + // Aggregate completion tokens + if let models = billing.completion?.models { + for (modelName, modelData) in models { + modelCount += 1 + let (input, output, cached, cost) = Self.aggregateModel(modelData, prices: prices) + totalInput += input + totalOutput += output + totalCached += cached + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: true) + } + } + + // Aggregate OCR, connectors, audio if present + for category in [billing.ocr, billing.connectors, billing.audio] { + if let models = category?.models { + for (modelName, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: false) + } + } + } + + // Aggregate libraries_api (pages + tokens) + if let models = billing.librariesApi?.pages?.models { + for (modelName, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: false) + } + } + if let models = billing.librariesApi?.tokens?.models { + for (modelName, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: true) + } + } + + // Aggregate fine_tuning (training + storage) + for models in [billing.fineTuning?.training, billing.fineTuning?.storage] { + if let models { + for (modelName, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: false) + } + } + } + + let currency = billing.currency ?? "EUR" + let currencySymbol = billing.currencySymbol ?? "€" + + let startDate = billing.startDate.flatMap { Self.parseDate($0) } + let endDate = billing.endDate.flatMap { Self.parseDate($0) } + + return MistralUsageSnapshot( + totalCost: totalCost, + currency: currency, + currencySymbol: currencySymbol, + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + totalCachedTokens: totalCached, + modelCount: modelCount, + daily: daily.values.map { $0.makeBucket() }, + startDate: startDate, + endDate: endDate, + updatedAt: updatedAt) + } + + // MARK: - Private Helpers + + private static func buildPriceIndex(_ prices: [MistralPrice]) -> [String: Double] { + var index: [String: Double] = [:] + for price in prices { + guard let metric = price.billingMetric, + let group = price.billingGroup, + let priceStr = price.price, + let value = Double(priceStr) + else { continue } + let key = "\(metric)::\(group)" + index[key] = value + } + return index + } + + private static func aggregateModel( + _ data: MistralModelUsageData, + prices: [String: Double]) -> (input: Int, output: Int, cached: Int, cost: Double) + { + var totalInput = 0 + var totalOutput = 0 + var totalCached = 0 + var totalCost: Double = 0 + + for entry in data.input ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalInput += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + for entry in data.output ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalOutput += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + for entry in data.cached ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalCached += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + return (totalInput, totalOutput, totalCached, totalCost) + } + + private static func addDailyEntries( + modelName: String, + data: MistralModelUsageData, + prices: [String: Double], + daily: inout [String: DailyAccumulator], + countsTokens: Bool) + { + self.addDaily( + entries: data.input ?? [], + context: DailyEntryContext( + kind: .input, + modelName: modelName, + prices: prices, + countsTokens: countsTokens), + daily: &daily) + self.addDaily( + entries: data.output ?? [], + context: DailyEntryContext( + kind: .output, + modelName: modelName, + prices: prices, + countsTokens: countsTokens), + daily: &daily) + self.addDaily( + entries: data.cached ?? [], + context: DailyEntryContext( + kind: .cached, + modelName: modelName, + prices: prices, + countsTokens: countsTokens), + daily: &daily) + } + + fileprivate enum TokenKind { + case input + case cached + case output + } + + private static func addDaily( + entries: [MistralUsageEntry], + context: DailyEntryContext, + daily: inout [String: DailyAccumulator]) + { + for entry in entries { + guard let day = dayKey(from: entry.timestamp) else { continue } + let units = entry.valuePaid ?? entry.value ?? 0 + let cost = Self.cost(for: entry, units: units, prices: context.prices) + var accumulator = daily[day] ?? DailyAccumulator(day: day) + accumulator.add( + modelName: Self.displayModelName(context.modelName, entry: entry), + kind: context.kind, + units: units, + cost: cost, + countsTokens: context.countsTokens) + daily[day] = accumulator + } + } + + private static func cost(for entry: MistralUsageEntry, units: Int, prices: [String: Double]) -> Double { + guard let metric = entry.billingMetric, let group = entry.billingGroup else { return 0 } + return Double(units) * (prices["\(metric)::\(group)"] ?? 0) + } + + private static func displayModelName(_ raw: String, entry: MistralUsageEntry) -> String { + if let display = entry.billingDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines), + !display.isEmpty + { + return display + } + return raw.split(separator: "::").first.map(String.init) ?? raw + } + + private static func dayKey(from timestamp: String?) -> String? { + guard let trimmed = timestamp?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + if trimmed.count >= 10 { + return String(trimmed.prefix(10)) + } + return nil + } + + private static func parseDate(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } +} + +private struct DailyEntryContext { + let kind: MistralUsageFetcher.TokenKind + let modelName: String + let prices: [String: Double] + let countsTokens: Bool +} + +private struct DailyAccumulator { + let day: String + var cost: Double = 0 + var inputTokens = 0 + var cachedTokens = 0 + var outputTokens = 0 + var models: [String: ModelAccumulator] = [:] + + mutating func add( + modelName: String, + kind: MistralUsageFetcher.TokenKind, + units: Int, + cost: Double, + countsTokens: Bool) + { + self.cost += cost + var model = self.models[modelName] ?? ModelAccumulator(name: modelName) + model.cost += cost + guard countsTokens else { + self.models[modelName] = model + return + } + switch kind { + case .input: + self.inputTokens += units + model.inputTokens += units + case .cached: + self.cachedTokens += units + model.cachedTokens += units + case .output: + self.outputTokens += units + model.outputTokens += units + } + self.models[modelName] = model + } + + func makeBucket() -> MistralDailyUsageBucket { + MistralDailyUsageBucket( + day: self.day, + cost: self.cost, + inputTokens: self.inputTokens, + cachedTokens: self.cachedTokens, + outputTokens: self.outputTokens, + models: self.models.values + .map { $0.makeBreakdown() } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + }) + } +} + +private struct ModelAccumulator { + let name: String + var cost: Double = 0 + var inputTokens = 0 + var cachedTokens = 0 + var outputTokens = 0 + + func makeBreakdown() -> MistralDailyUsageBucket.ModelBreakdown { + MistralDailyUsageBucket.ModelBreakdown( + name: self.name, + cost: self.cost, + inputTokens: self.inputTokens, + cachedTokens: self.cachedTokens, + outputTokens: self.outputTokens) + } +} + +private struct VibeUsageResponse: Decodable { + let result: VibeResult + struct VibeResult: Decodable { + let data: VibeData + struct VibeData: Decodable { + let json: VibeJson + struct VibeJson: Decodable { + let usagePercentage: Double + let resetAt: String? + enum CodingKeys: String, CodingKey { + case usagePercentage = "usage_percentage" + case resetAt = "reset_at" + } + } + } + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift new file mode 100644 index 0000000000..a5552e2a8d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift @@ -0,0 +1,49 @@ +import Foundation + +public enum MoonshotProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .moonshot, + metadata: ProviderMetadata( + id: .moonshot, + displayName: "Moonshot / Kimi API", + sessionLabel: "Balance", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Moonshot / Kimi API balance", + cliName: "moonshot", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://platform.moonshot.ai/console/account", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .kimi, + iconResourceName: "ProviderIcon-kimi", + color: ProviderColor(red: 32 / 255, green: 93 / 255, blue: 235 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Moonshot / Kimi API cost summary is not available." }), + fetchPlan: .apiToken( + strategyID: "moonshot.api", + resolveToken: { ProviderTokenResolver.moonshotToken(environment: $0) }, + missingCredentialsError: { MoonshotUsageError.missingCredentials }, + loadUsage: { apiKey, context in + let region = + context.settings?.moonshot?.region ?? MoonshotSettingsReader.region(environment: context.env) + return try await MoonshotUsageFetcher.fetchUsage( + apiKey: apiKey, + region: region).toUsageSnapshot() + }), + cli: ProviderCLIConfig( + name: "moonshot", + aliases: [], + versionDetector: nil)) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift new file mode 100644 index 0000000000..420e3ff1e7 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum MoonshotRegion: String, CaseIterable, Sendable { + case international + case china + + private static let balancePath = "v1/users/me/balance" + + public var displayName: String { + switch self { + case .international: + "International (api.moonshot.ai)" + case .china: + "China (api.moonshot.cn)" + } + } + + public var apiBaseURLString: String { + switch self { + case .international: + "https://api.moonshot.ai" + case .china: + "https://api.moonshot.cn" + } + } + + public var balanceURL: URL { + URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.balancePath) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift new file mode 100644 index 0000000000..b01db6eca5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct MoonshotSettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = [ + "MOONSHOT_API_KEY", + "MOONSHOT_KEY", + ] + public static let regionEnvironmentKey = "MOONSHOT_REGION" + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + + return nil + } + + public static func region( + environment: [String: String] = ProcessInfo.processInfo.environment) -> MoonshotRegion + { + guard let raw = environment[self.regionEnvironmentKey] else { + return .international + } + let cleaned = Self.cleaned(raw).lowercased() + return MoonshotRegion(rawValue: cleaned) ?? .international + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) + || (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift new file mode 100644 index 0000000000..04a137d0d5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift @@ -0,0 +1,161 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct MoonshotUsageSnapshot: Sendable { + public let summary: MoonshotUsageSummary + + public init(summary: MoonshotUsageSummary) { + self.summary = summary + } + + public func toUsageSnapshot() -> UsageSnapshot { + self.summary.toUsageSnapshot() + } +} + +public struct MoonshotUsageSummary: Sendable { + public let availableBalance: Double + public let voucherBalance: Double + public let cashBalance: Double + public let updatedAt: Date + + public init( + availableBalance: Double, voucherBalance: Double, cashBalance: Double, updatedAt: Date) + { + self.availableBalance = availableBalance + self.voucherBalance = voucherBalance + self.cashBalance = cashBalance + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let balance = UsageFormatter.usdString(self.availableBalance) + let loginMethod: String + if self.cashBalance < 0 { + let deficit = UsageFormatter.usdString(abs(self.cashBalance)) + loginMethod = "Balance: \(balance) · \(deficit) in deficit" + } else { + loginMethod = "Balance: \(balance)" + } + let identity = ProviderIdentitySnapshot( + providerID: .moonshot, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +private struct MoonshotBalanceResponse: Decodable { + let code: Int + let data: MoonshotBalanceData + let scode: String + let status: Bool +} + +private struct MoonshotBalanceData: Decodable { + let availableBalance: Double + let voucherBalance: Double + let cashBalance: Double + + private enum CodingKeys: String, CodingKey { + case availableBalance = "available_balance" + case voucherBalance = "voucher_balance" + case cashBalance = "cash_balance" + } +} + +public enum MoonshotUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Moonshot API key." + case let .networkError(message): + "Moonshot network error: \(message)" + case let .apiError(message): + "Moonshot API error: \(message)" + case let .parseFailed(message): + "Failed to parse Moonshot response: \(message)" + } + } +} + +public struct MoonshotUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.moonshotUsage) + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + apiKey: String, + region: MoonshotRegion = .international, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MoonshotUsageSnapshot + { + let cleaned = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { + throw MoonshotUsageError.missingCredentials + } + + var request = URLRequest(url: self.resolveBalanceURL(region: region)) + request.httpMethod = "GET" + request.setValue("Bearer \(cleaned)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { + throw MoonshotUsageError.networkError("Invalid response") + } catch { + throw error + } + + guard response.statusCode == 200 else { + Self.log.error("Moonshot API returned HTTP \(response.statusCode)") + throw MoonshotUsageError.apiError("HTTP \(response.statusCode)") + } + + let summary = try self.parseSummary(data: response.data) + return MoonshotUsageSnapshot(summary: summary) + } + + public static func resolveBalanceURL(region: MoonshotRegion) -> URL { + region.balanceURL + } + + static func _parseSummaryForTesting(_ data: Data) throws -> MoonshotUsageSummary { + try self.parseSummary(data: data) + } + + private static func parseSummary(data: Data) throws -> MoonshotUsageSummary { + let response: MoonshotBalanceResponse + do { + response = try JSONDecoder().decode(MoonshotBalanceResponse.self, from: data) + } catch { + throw MoonshotUsageError.parseFailed(error.localizedDescription) + } + + guard response.code == 0, response.status else { + throw MoonshotUsageError.apiError("code \(response.code), scode \(response.scode)") + } + + return MoonshotUsageSummary( + availableBalance: response.data.availableBalance, + voucherBalance: response.data.voucherBalance, + cashBalance: response.data.cashBalance, + updatedAt: Date()) + } +} diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift index 4702609fae..16097ac553 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum OllamaProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .ollama, @@ -32,12 +31,32 @@ public enum OllamaProviderDescriptor { supportsTokenCost: false, noDataMessage: { "Ollama cost summary is not supported." }), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .web], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OllamaStatusFetchStrategy()] })), + sourceModes: [.auto, .web, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "ollama", versionDetector: nil)) } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + switch context.sourceMode { + case .web: + return [OllamaStatusFetchStrategy()] + case .api: + return [OllamaAPIFetchStrategy()] + case .cli, .oauth: + return [] + case .auto: + break + } + if context.settings?.ollama?.cookieSource == .off { + return [OllamaAPIFetchStrategy()] + } + if ProviderTokenResolver.ollamaToken(environment: context.env) != nil { + return [OllamaStatusFetchStrategy(), OllamaAPIFetchStrategy()] + } + return [OllamaStatusFetchStrategy()] + } } struct OllamaStatusFetchStrategy: ProviderFetchStrategy { @@ -65,8 +84,9 @@ struct OllamaStatusFetchStrategy: ProviderFetchStrategy { sourceLabel: "web") } - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + && ProviderTokenResolver.ollamaToken(environment: context.env) != nil } private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { @@ -74,3 +94,30 @@ struct OllamaStatusFetchStrategy: ProviderFetchStrategy { return CookieHeaderNormalizer.normalize(context.settings?.ollama?.manualCookieHeader) } } + +struct OllamaAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "ollama.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw OllamaUsageError.missingAPIKey + } + let snapshot = try await OllamaAPIUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.ollamaToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index 9e5c4b0689..d3aaea9763 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -9,6 +9,7 @@ import SweetCookieKit private let ollamaSessionCookieNames: Set = [ "session", + "__Secure-session", "ollama_session", "__Host-ollama_session", "__Secure-next-auth.session-token", @@ -29,18 +30,24 @@ private func hasRecognizedOllamaSessionCookie(in header: String) -> Bool { } public enum OllamaUsageError: LocalizedError, Sendable { + case missingAPIKey case notLoggedIn case invalidCredentials + case apiUnauthorized case parseFailed(String) case networkError(String) case noSessionCookie public var errorDescription: String? { switch self { + case .missingAPIKey: + "Missing Ollama API key. Set apiKey in ~/.codexbar/config.json or OLLAMA_API_KEY." case .notLoggedIn: "Not logged in to Ollama. Please log in via ollama.com/settings." case .invalidCredentials: "Ollama session cookie expired. Please log in again." + case .apiUnauthorized: + "Ollama API key is invalid or expired." case let .parseFailed(message): "Could not parse Ollama usage: \(message)" case let .networkError(message): @@ -59,6 +66,7 @@ public enum OllamaCookieImporter { private static let cookieClient = BrowserCookieClient() private static let cookieDomains = ["ollama.com", "www.ollama.com"] static let defaultPreferredBrowsers: [Browser] = [.chrome] + static let defaultAllowFallbackBrowsers = true public struct SessionInfo: Sendable { public let cookies: [HTTPCookie] @@ -199,7 +207,7 @@ public enum OllamaCookieImporter { for browserSource in browserSources { do { let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: logger) @@ -228,12 +236,12 @@ public struct OllamaUsageFetcher: Sendable { private static let settingsURL = URL(string: "https://ollama.com/settings")! @MainActor private static var recentDumps: [String] = [] - private struct CookieCandidate: Sendable { + private struct CookieCandidate { let cookieHeader: String let sourceLabel: String } - enum RetryableParseFailure: Error, Sendable { + enum RetryableParseFailure: Error { case missingUsageData } @@ -368,7 +376,11 @@ public struct OllamaUsageFetcher: Sendable { return [CookieCandidate(cookieHeader: manualHeader, sourceLabel: "manual cookie header")] } #if os(macOS) - let sessions = try OllamaCookieImporter.importSessions(browserDetection: self.browserDetection, logger: logger) + let sessions = try OllamaCookieImporter.importSessions( + browserDetection: self.browserDetection, + preferredBrowsers: OllamaCookieImporter.defaultPreferredBrowsers, + allowFallbackBrowsers: OllamaCookieImporter.defaultAllowFallbackBrowsers, + logger: logger) return sessions.map { session in CookieCandidate(cookieHeader: session.cookieHeader, sourceLabel: session.sourceLabel) } @@ -450,7 +462,11 @@ public struct OllamaUsageFetcher: Sendable { return manualHeader } #if os(macOS) - let session = try OllamaCookieImporter.importSession(browserDetection: self.browserDetection, logger: logger) + let session = try OllamaCookieImporter.importSession( + browserDetection: self.browserDetection, + preferredBrowsers: OllamaCookieImporter.defaultPreferredBrowsers, + allowFallbackBrowsers: OllamaCookieImporter.defaultAllowFallbackBrowsers, + logger: logger) logger?("[ollama] Using cookies from \(session.sourceLabel)") return session.cookieHeader #else @@ -508,13 +524,10 @@ public struct OllamaUsageFetcher: Sendable { request.setValue(Self.settingsURL.absoluteString, forHTTPHeaderField: "referer") let session = self.makeURLSession(diagnostics) - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw OllamaUsageError.networkError("Invalid response") - } + let httpResponse = try await session.response(for: request) let responseInfo = ResponseInfo( statusCode: httpResponse.statusCode, - url: httpResponse.url?.absoluteString ?? "unknown") + url: httpResponse.response.url?.absoluteString ?? "unknown") guard httpResponse.statusCode == 200 else { if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { @@ -523,7 +536,7 @@ public struct OllamaUsageFetcher: Sendable { throw OllamaUsageError.networkError("HTTP \(httpResponse.statusCode)") } - let html = String(data: data, encoding: .utf8) ?? "" + let html = String(data: httpResponse.data, encoding: .utf8) ?? "" return (html, responseInfo) } @@ -568,7 +581,7 @@ public struct OllamaUsageFetcher: Sendable { } } - private struct ResponseInfo: Sendable { + private struct ResponseInfo { let statusCode: Int let url: String } @@ -607,8 +620,123 @@ public struct OllamaUsageFetcher: Sendable { } static func shouldAttachCookie(to url: URL?) -> Bool { + guard url?.scheme?.lowercased() == "https" else { return false } guard let host = url?.host?.lowercased() else { return false } if host == "ollama.com" || host == "www.ollama.com" { return true } return host.hasSuffix(".ollama.com") } } + +public struct OllamaAPISettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = [ + "OLLAMA_API_KEY", + "OLLAMA_KEY", + ] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let value = self.cleaned(environment[key]), !value.isEmpty else { continue } + return value + } + return nil + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +public struct OllamaAPIUsageSnapshot: Sendable { + public let modelCount: Int + public let updatedAt: Date + + public init(modelCount: Int, updatedAt: Date) { + self.modelCount = modelCount + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .ollama, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API key")) + } +} + +public enum OllamaAPIUsageFetcher { + public static let tagsURL = URL(string: "https://ollama.com/api/tags")! + private static let timeoutSeconds: TimeInterval = 20 + + public static func fetchUsage( + apiKey: String, + tagsURL: URL = Self.tagsURL, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> OllamaAPIUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw OllamaUsageError.missingAPIKey + } + + var request = URLRequest(url: tagsURL) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodexBar/1.0", forHTTPHeaderField: "User-Agent") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw OllamaUsageError.networkError(error.localizedDescription) + } + + switch response.statusCode { + case 200: + return try Self.parseTags(data: response.data, now: now) + case 401, 403: + throw OllamaUsageError.apiUnauthorized + default: + throw OllamaUsageError.networkError("HTTP \(response.statusCode)") + } + } + + static func _parseTagsForTesting(_ data: Data, now: Date = Date()) throws -> OllamaAPIUsageSnapshot { + try self.parseTags(data: data, now: now) + } + + private static func parseTags(data: Data, now: Date) throws -> OllamaAPIUsageSnapshot { + do { + let response = try JSONDecoder().decode(TagsResponse.self, from: data) + return OllamaAPIUsageSnapshot(modelCount: response.models.count, updatedAt: now) + } catch { + throw OllamaUsageError.parseFailed(error.localizedDescription) + } + } + + private struct TagsResponse: Decodable { + let models: [Model] + } + + private struct Model: Decodable {} +} diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift index f93f358649..53d3cf51ff 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift @@ -2,13 +2,14 @@ import Foundation enum OllamaUsageParser { private static let primaryUsageLabels = ["Session usage", "Hourly usage"] + private static let usageLabels = primaryUsageLabels + ["Weekly usage"] - enum ParseFailure: Sendable, Equatable { + enum ParseFailure: Equatable { case notLoggedIn case missingUsageData } - enum ClassifiedParseResult: Sendable { + enum ClassifiedParseResult { case success(OllamaUsageSnapshot) case failure(ParseFailure) } @@ -44,12 +45,14 @@ enum OllamaUsageParser { weeklyUsedPercent: weekly?.usedPercent, sessionResetsAt: session?.resetsAt, weeklyResetsAt: weekly?.resetsAt, + sessionWindowMinutes: session?.windowMinutes, updatedAt: now)) } - private struct UsageBlock: Sendable { + private struct UsageBlock { let usedPercent: Double let resetsAt: Date? + let windowMinutes: Int? } private static func parsePlanName(_ html: String) -> String? { @@ -72,11 +75,15 @@ enum OllamaUsageParser { private static func parseUsageBlock(label: String, html: String) -> UsageBlock? { guard let labelRange = html.range(of: label) else { return nil } let tail = String(html[labelRange.upperBound...]) - let window = String(tail.prefix(800)) + let window = self.usageBlockWindow(after: label, in: tail) guard let usedPercent = self.parsePercent(in: window) else { return nil } let resetsAt = self.parseISODate(in: window) - return UsageBlock(usedPercent: usedPercent, resetsAt: resetsAt) + let windowMinutes = label == "Session usage" ? 5 * 60 : nil + return UsageBlock( + usedPercent: usedPercent, + resetsAt: resetsAt, + windowMinutes: windowMinutes) } private static func parseUsageBlock(labels: [String], html: String) -> UsageBlock? { @@ -88,6 +95,16 @@ enum OllamaUsageParser { return nil } + private static func usageBlockWindow(after label: String, in tail: String) -> String { + let maxLength = 4000 + let boundary = self.usageLabels + .filter { $0 != label } + .compactMap { tail.range(of: $0)?.lowerBound } + .min() + let bounded = boundary.map { String(tail[..<$0]) } ?? String(tail.prefix(maxLength)) + return String(bounded.prefix(maxLength)) + } + private static func parsePercent(in text: String) -> Double? { let usedPattern = #"([0-9]+(?:\.[0-9]+)?)\s*%\s*used"# if let raw = self.firstCapture(in: text, pattern: usedPattern, options: [.caseInsensitive]) { diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift index 002f78d817..d3c7e32739 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift @@ -7,6 +7,7 @@ public struct OllamaUsageSnapshot: Sendable { public let weeklyUsedPercent: Double? public let sessionResetsAt: Date? public let weeklyResetsAt: Date? + public let sessionWindowMinutes: Int? public let updatedAt: Date public init( @@ -16,6 +17,7 @@ public struct OllamaUsageSnapshot: Sendable { weeklyUsedPercent: Double?, sessionResetsAt: Date?, weeklyResetsAt: Date?, + sessionWindowMinutes: Int? = nil, updatedAt: Date) { self.planName = planName @@ -24,16 +26,17 @@ public struct OllamaUsageSnapshot: Sendable { self.weeklyUsedPercent = weeklyUsedPercent self.sessionResetsAt = sessionResetsAt self.weeklyResetsAt = weeklyResetsAt + self.sessionWindowMinutes = sessionWindowMinutes self.updatedAt = updatedAt } } extension OllamaUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - let sessionWindow = self.makeWindow( + let sessionWindow = self.makeSessionWindow( usedPercent: self.sessionUsedPercent, resetsAt: self.sessionResetsAt) - let weeklyWindow = self.makeWindow( + let weeklyWindow = self.makeWeeklyWindow( usedPercent: self.weeklyUsedPercent, resetsAt: self.weeklyResetsAt) @@ -54,12 +57,22 @@ extension OllamaUsageSnapshot { identity: identity) } - private func makeWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? { + private func makeSessionWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? { guard let usedPercent else { return nil } let clamped = min(100, max(0, usedPercent)) return RateWindow( usedPercent: clamped, - windowMinutes: nil, + windowMinutes: self.sessionWindowMinutes, + resetsAt: resetsAt, + resetDescription: nil) + } + + private func makeWeeklyWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? { + guard let usedPercent else { return nil } + let clamped = min(100, max(0, usedPercent)) + return RateWindow( + usedPercent: clamped, + windowMinutes: 7 * 24 * 60, resetsAt: resetsAt, resetDescription: nil) } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPICreditBalanceFetcher.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPICreditBalanceFetcher.swift new file mode 100644 index 0000000000..f35db443a1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPICreditBalanceFetcher.swift @@ -0,0 +1,185 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct OpenAIAPICreditGrant: Decodable, Sendable { + public let grantAmount: Double? + public let usedAmount: Double? + public let expiresAt: Date? + + private enum CodingKeys: String, CodingKey { + case grantAmount = "grant_amount" + case usedAmount = "used_amount" + case expiresAt = "expires_at" + } +} + +public struct OpenAIAPICreditGrantsList: Decodable, Sendable { + public let data: [OpenAIAPICreditGrant] +} + +public struct OpenAIAPICreditGrantsResponse: Decodable, Sendable { + public let totalGranted: Double + public let totalUsed: Double + public let totalAvailable: Double + public let grants: OpenAIAPICreditGrantsList? + + private enum CodingKeys: String, CodingKey { + case totalGranted = "total_granted" + case totalUsed = "total_used" + case totalAvailable = "total_available" + case grants + } +} + +public enum OpenAIAPICreditBalanceError: LocalizedError, Sendable, Equatable { + case missingCredentials + case networkError(String) + case apiError(Int) + case forbidden + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing OpenAI API key." + case let .networkError(message): + "OpenAI API credit balance network error: \(message)" + case let .apiError(statusCode): + "OpenAI API credit balance error: HTTP \(statusCode)" + case .forbidden: + "OpenAI API credit balance endpoint returned HTTP 403. Use a legacy/user API key with billing access; " + + "project keys may not expose credit grants." + case let .parseFailed(message): + "Failed to parse OpenAI API credit balance: \(message)" + } + } +} + +public struct OpenAIAPICreditBalanceSnapshot: Sendable { + public let totalGranted: Double + public let totalUsed: Double + public let totalAvailable: Double + public let nextGrantExpiry: Date? + public let updatedAt: Date + + public init( + totalGranted: Double, + totalUsed: Double, + totalAvailable: Double, + nextGrantExpiry: Date?, + updatedAt: Date = Date()) + { + self.totalGranted = totalGranted + self.totalUsed = totalUsed + self.totalAvailable = totalAvailable + self.nextGrantExpiry = nextGrantExpiry + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let usedPercent: Double = if self.totalGranted > 0 { + min(100, max(0, (self.totalUsed / self.totalGranted) * 100)) + } else { + self.totalAvailable > 0 ? 0 : 100 + } + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.nextGrantExpiry, + resetDescription: "\(Self.formatUSD(self.totalAvailable)) available") + + return UsageSnapshot( + primary: primary, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: max(0, self.totalUsed), + limit: max(0, self.totalGranted), + currencyCode: "USD", + period: "API credits", + resetsAt: self.nextGrantExpiry, + updatedAt: self.updatedAt), + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .openai, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API balance: \(Self.formatUSD(self.totalAvailable))")) + } + + private static func formatUSD(_ value: Double) -> String { + UsageFormatter.currencyString(max(0, value), currencyCode: "USD") + } +} + +public enum OpenAIAPICreditBalanceFetcher { + public static let creditGrantsURL = URL(string: "https://api.openai.com/v1/dashboard/billing/credit_grants")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchBalance( + apiKey: String, + url: URL = Self.creditGrantsURL, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> OpenAIAPICreditBalanceSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw OpenAIAPICreditBalanceError.missingCredentials + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw OpenAIAPICreditBalanceError.networkError(error.localizedDescription) + } + + guard response.statusCode != 403 else { + throw OpenAIAPICreditBalanceError.forbidden + } + guard response.statusCode == 200 else { + throw OpenAIAPICreditBalanceError.apiError(response.statusCode) + } + + return try self.parseSnapshot(response.data, now: now) + } + + public static func _parseSnapshotForTesting( + _ data: Data, + now: Date = Date()) throws -> OpenAIAPICreditBalanceSnapshot + { + try self.parseSnapshot(data, now: now) + } + + private static func parseSnapshot(_ data: Data, now: Date) throws -> OpenAIAPICreditBalanceSnapshot { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + + let decoded: OpenAIAPICreditGrantsResponse + do { + decoded = try decoder.decode(OpenAIAPICreditGrantsResponse.self, from: data) + } catch { + throw OpenAIAPICreditBalanceError.parseFailed(error.localizedDescription) + } + + let nextExpiry = decoded.grants?.data + .compactMap(\.expiresAt) + .filter { $0 > now } + .min() + + return OpenAIAPICreditBalanceSnapshot( + totalGranted: decoded.totalGranted, + totalUsed: decoded.totalUsed, + totalAvailable: decoded.totalAvailable, + nextGrantExpiry: nextExpiry, + updatedAt: now) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift new file mode 100644 index 0000000000..d2a5e6f151 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift @@ -0,0 +1,133 @@ +import Foundation + +public enum OpenAIAPIProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .openai, + metadata: ProviderMetadata( + id: .openai, + displayName: "OpenAI", + sessionLabel: "Spend", + weeklyLabel: "Requests", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show OpenAI usage", + cliName: "openai", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://platform.openai.com/usage", + statusPageURL: "https://status.openai.com"), + branding: ProviderBranding( + iconStyle: .openai, + iconResourceName: "ProviderIcon-codex", + color: ProviderColor(red: 0.06, green: 0.51, blue: 0.43)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: true, + noDataMessage: { "OpenAI usage needs an Admin API key for organization usage." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenAIAPIBalanceFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "openai", + aliases: ["openai-api"], + versionDetector: nil)) + } +} + +struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy { + let id: String = "openai.api.balance" + let kind: ProviderFetchKind = .apiToken + let usageFetcher: @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot + let balanceFetcher: @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot + + init( + usageFetcher: @escaping @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot = + OpenAIAPIBalanceFetchStrategy.fetchUsage(credential:days:), + balanceFetcher: @escaping @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot = { apiKey in + try await OpenAIAPICreditBalanceFetcher.fetchBalance(apiKey: apiKey) + }) + { + self.usageFetcher = usageFetcher + self.balanceFetcher = balanceFetcher + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + OpenAIAPIUsageCredential(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let credential = OpenAIAPIUsageCredential(environment: context.env) else { + throw OpenAIAPISettingsError.missingToken + } + + do { + let usage = try await self.usageFetcher(credential, context.costUsageHistoryDays) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: credential.sourceLabel) + } catch { + let usageError = error + if !credential.allowsLegacyBalanceFallback { + throw usageError + } + // Preserve the older balance-only path for unscoped keys and Admin API outages. + do { + let balance = try await self.balanceFetcher(credential.apiKey) + return self.makeResult( + usage: balance.toUsageSnapshot(), + sourceLabel: "billing-api") + } catch { + if (usageError as? OpenAIAPIUsageError)?.isCredentialRejected != true { + throw usageError + } + throw error + } + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func fetchUsage( + credential: OpenAIAPIUsageCredential, + days: Int) async throws -> OpenAIAPIUsageSnapshot + { + try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: credential.apiKey, + projectID: credential.projectID, + historyDays: days) + } +} + +struct OpenAIAPIUsageCredential: Equatable { + let apiKey: String + let projectID: String? + let usesAdminKey: Bool + + init?(environment: [String: String]) { + if let adminKey = OpenAIAPISettingsReader.adminAPIKey(environment: environment) { + self.apiKey = adminKey + self.usesAdminKey = true + } else if let apiKey = OpenAIAPISettingsReader.apiKey(environment: environment) { + self.apiKey = apiKey + self.usesAdminKey = false + } else { + return nil + } + self.projectID = OpenAIAPISettingsReader.projectID(environment: environment) + } + + var sourceLabel: String { + self.projectID == nil ? "admin-api" : "admin-api:project" + } + + var allowsLegacyBalanceFallback: Bool { + self.projectID == nil || !self.usesAdminKey + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift new file mode 100644 index 0000000000..a641872d11 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift @@ -0,0 +1,52 @@ +import Foundation + +public enum OpenAIAPISettingsReader { + public static let adminAPIKeyEnvironmentKey = "OPENAI_ADMIN_KEY" + public static let apiKeyEnvironmentKey = "OPENAI_API_KEY" + public static let projectIDEnvironmentKey = "OPENAI_PROJECT_ID" + public static let apiKeyEnvironmentKeys = [ + Self.adminAPIKeyEnvironmentKey, + Self.apiKeyEnvironmentKey, + ] + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + for key in self.apiKeyEnvironmentKeys { + if let token = self.cleaned(environment[key]) { return token } + } + return nil + } + + public static func adminAPIKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.adminAPIKeyEnvironmentKey]) + } + + public static func projectID(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.projectIDEnvironmentKey]) + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} + +public enum OpenAIAPISettingsError: LocalizedError, Sendable { + case missingToken + + public var errorDescription: String? { + switch self { + case .missingToken: + "OpenAI API key not configured. Set OPENAI_API_KEY or configure an API key in Settings." + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift new file mode 100644 index 0000000000..8faa3f6c7a --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift @@ -0,0 +1,462 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum OpenAIAPIUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case networkError(String) + case apiError(endpoint: String, statusCode: Int) + case parseFailed(endpoint: String, message: String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing OpenAI Admin API key." + case let .networkError(message): + "OpenAI API usage network error: \(message)" + case let .apiError(endpoint, statusCode): + "OpenAI API usage \(endpoint) error: HTTP \(statusCode)" + case let .parseFailed(endpoint, message): + "Failed to parse OpenAI API usage \(endpoint): \(message)" + } + } + + var isCredentialRejected: Bool { + switch self { + case let .apiError(_, statusCode): + statusCode == 401 || statusCode == 403 + default: + false + } + } +} + +public enum OpenAIAPIUsageFetcher { + public static let organizationCostsURL = URL(string: "https://api.openai.com/v1/organization/costs")! + public static let organizationCompletionsUsageURL = + URL(string: "https://api.openai.com/v1/organization/usage/completions")! + + private static let maxDailyBucketLimit = 31 + private static let maxPaginationPages = 100 + private static let timeoutSeconds: TimeInterval = 20 + + private struct EndpointRequestContext { + let apiKey: String + let projectID: String? + let transport: any ProviderHTTPTransport + let retryPolicy: ProviderHTTPRetryPolicy + } + + private struct UsageEndpoint { + let name: String + let baseURL: URL + let queryItems: [URLQueryItem] + let decodePage: (Data) throws -> UsagePage + } + + private struct UsagePage { + let data: [Bucket] + let hasMore: Bool + let nextPage: String? + } + + private typealias SnapshotMetadata = (now: Date, calendar: Calendar, historyDays: Int, projectID: String?) + + public static func fetchUsage( + apiKey: String, + projectID: String? = nil, + costsURL: URL = Self.organizationCostsURL, + completionsURL: URL = Self.organizationCompletionsUsageURL, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date(), + historyDays: Int = 30, + retryPolicy: ProviderHTTPRetryPolicy = .transientIdempotent) async throws -> OpenAIAPIUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw OpenAIAPIUsageError.missingCredentials + } + let normalizedProjectID = OpenAIAPISettingsReader.cleaned(projectID) + + let calendar = Self.utcCalendar + let clampedHistoryDays = max(1, min(365, historyDays)) + let ranges = Self.dailyRanges(now: now, calendar: calendar, historyDays: clampedHistoryDays) + let requestContext = EndpointRequestContext( + apiKey: trimmed, + projectID: normalizedProjectID, + transport: transport, + retryPolicy: retryPolicy) + let costs = try await Self.fetchBuckets( + endpoint: Self.costsEndpoint(baseURL: costsURL), + context: requestContext, + ranges: ranges) + let completions = try await Self.fetchBuckets( + endpoint: Self.completionsEndpoint(baseURL: completionsURL), + context: requestContext, + ranges: ranges) + + return Self.makeSnapshot( + costs: costs, + completions: completions, + metadata: SnapshotMetadata( + now: now, + calendar: calendar, + historyDays: clampedHistoryDays, + projectID: normalizedProjectID)) + } + + static func _parseSnapshotForTesting( + costs: Data, + completions: Data, + now: Date, + calendar: Calendar = Self.utcCalendar, + historyDays: Int = 30, + projectID: String? = nil) throws -> OpenAIAPIUsageSnapshot + { + try self.makeSnapshot( + costs: self.decodeCosts(costs).data, + completions: self.decodeCompletions(completions).data, + metadata: SnapshotMetadata( + now: now, + calendar: calendar, + historyDays: historyDays, + projectID: OpenAIAPISettingsReader.cleaned(projectID))) + } + + private static func costsEndpoint(baseURL: URL) -> UsageEndpoint { + UsageEndpoint( + name: "costs", + baseURL: baseURL, + queryItems: [URLQueryItem(name: "group_by", value: "line_item")], + decodePage: { + let response = try self.decodeCosts($0) + return UsagePage( + data: response.data, + hasMore: response.hasMore, + nextPage: response.nextPage) + }) + } + + private static func completionsEndpoint( + baseURL: URL) -> UsageEndpoint + { + UsageEndpoint( + name: "completions", + baseURL: baseURL, + queryItems: [URLQueryItem(name: "group_by", value: "model")], + decodePage: { + let response = try self.decodeCompletions($0) + return UsagePage( + data: response.data, + hasMore: response.hasMore, + nextPage: response.nextPage) + }) + } + + private static func fetchBuckets( + endpoint: UsageEndpoint, + context: EndpointRequestContext, + ranges: [DateRange]) async throws -> [Bucket] + { + var buckets: [Bucket] = [] + for range in ranges { + var nextPage: String? + var seenPages: Set = [] + var pageCount = 0 + + repeat { + pageCount += 1 + guard pageCount <= Self.maxPaginationPages else { + throw OpenAIAPIUsageError.parseFailed( + endpoint: endpoint.name, + message: "Pagination exceeded \(Self.maxPaginationPages) pages.") + } + + let url = Self.url( + baseURL: endpoint.baseURL, + range: range, + queryItems: endpoint.queryItems + Self.projectQueryItems(projectID: context.projectID), + page: nextPage) + let data = try await Self.fetchData( + url: url, + apiKey: context.apiKey, + endpoint: endpoint.name, + transport: context.transport, + retryPolicy: context.retryPolicy) + let page = try endpoint.decodePage(data) + buckets.append(contentsOf: page.data) + + guard page.hasMore else { + nextPage = nil + continue + } + guard let cursor = Self.cleanedPageCursor(page.nextPage) else { + throw OpenAIAPIUsageError.parseFailed( + endpoint: endpoint.name, + message: "Pagination cursor missing.") + } + guard seenPages.insert(cursor).inserted else { + throw OpenAIAPIUsageError.parseFailed( + endpoint: endpoint.name, + message: "Pagination cursor repeated.") + } + nextPage = cursor + } while nextPage != nil + } + return buckets + } + + private static func projectQueryItems(projectID: String?) -> [URLQueryItem] { + guard let projectID else { return [] } + return [URLQueryItem(name: "project_ids", value: projectID)] + } + + private static func fetchData( + url: URL, + apiKey: String, + endpoint: String, + transport: any ProviderHTTPTransport, + retryPolicy: ProviderHTTPRetryPolicy) async throws -> Data + { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request, retryPolicy: retryPolicy) + } catch { + throw OpenAIAPIUsageError.networkError(error.localizedDescription) + } + + guard response.statusCode == 200 else { + throw OpenAIAPIUsageError.apiError(endpoint: endpoint, statusCode: response.statusCode) + } + return response.data + } + + private static func decodeCosts(_ data: Data) throws -> CostsResponse { + do { + return try JSONDecoder().decode(CostsResponse.self, from: data) + } catch { + throw OpenAIAPIUsageError.parseFailed(endpoint: "costs", message: error.localizedDescription) + } + } + + private static func decodeCompletions(_ data: Data) throws -> CompletionsUsageResponse { + do { + return try JSONDecoder().decode(CompletionsUsageResponse.self, from: data) + } catch { + throw OpenAIAPIUsageError.parseFailed(endpoint: "completions", message: error.localizedDescription) + } + } + + private static func makeSnapshot( + costs: [OpenAICostBucket], + completions: [OpenAICompletionsUsageBucket], + metadata: SnapshotMetadata) -> OpenAIAPIUsageSnapshot + { + var accumulators: [Int: DailyAccumulator] = [:] + + for bucket in costs { + var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator( + startTime: bucket.startTime, + endTime: bucket.endTime) + for result in bucket.results { + let value = result.amount?.value ?? 0 + accumulator.costUSD += value + let lineItem = Self.displayName(result.lineItem, fallback: "API") + accumulator.lineItems[lineItem, default: 0] += value + } + accumulators[bucket.startTime] = accumulator + } + + for bucket in completions { + var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator( + startTime: bucket.startTime, + endTime: bucket.endTime) + for result in bucket.results { + let input = result.inputTokens ?? 0 + let cached = result.inputCachedTokens ?? 0 + let output = result.outputTokens ?? 0 + let audioInput = result.inputAudioTokens ?? 0 + let audioOutput = result.outputAudioTokens ?? 0 + let requests = result.numModelRequests ?? 0 + let totalTokens = input + output + audioInput + audioOutput + accumulator.requests += requests + accumulator.inputTokens += input + audioInput + accumulator.cachedInputTokens += cached + accumulator.outputTokens += output + audioOutput + accumulator.totalTokens += totalTokens + let modelName = Self.displayName(result.model, fallback: "Responses and Chat Completions") + accumulator.models[modelName, default: ModelAccumulator()].add( + requests: requests, + inputTokens: input + audioInput, + cachedInputTokens: cached, + outputTokens: output + audioOutput, + totalTokens: totalTokens) + } + accumulators[bucket.startTime] = accumulator + } + + let daily = accumulators.values + .filter { $0.startDate <= metadata.now } + .sorted { $0.startTime < $1.startTime } + .map { $0.makeBucket(calendar: metadata.calendar) } + return OpenAIAPIUsageSnapshot( + daily: daily, + updatedAt: metadata.now, + historyDays: metadata.historyDays, + projectID: metadata.projectID) + } + + private static func displayName(_ raw: String?, fallback: String) -> String { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return fallback + } + return trimmed + } + + private static func cleanedPageCursor(_ raw: String?) -> String? { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func url( + baseURL: URL, + range: DateRange, + queryItems extraItems: [URLQueryItem], + page: String? = nil) -> URL + { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + var queryItems = [ + URLQueryItem(name: "start_time", value: String(range.startTime)), + URLQueryItem(name: "end_time", value: String(range.endTime)), + URLQueryItem(name: "bucket_width", value: "1d"), + URLQueryItem(name: "limit", value: String(range.limit)), + ] + extraItems + if let page { + queryItems.append(URLQueryItem(name: "page", value: page)) + } + components.queryItems = queryItems + return components.url! + } + + private static func dailyRanges(now: Date, calendar: Calendar, historyDays: Int) -> [DateRange] { + let today = calendar.startOfDay(for: now) + let clampedHistoryDays = max(1, min(365, historyDays)) + var cursor = calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: today) ?? today + var remainingDays = clampedHistoryDays + var ranges: [DateRange] = [] + ranges.reserveCapacity(Int(ceil(Double(clampedHistoryDays) / Double(Self.maxDailyBucketLimit)))) + + while remainingDays > 0 { + let chunkDays = min(Self.maxDailyBucketLimit, remainingDays) + let end = calendar.date(byAdding: .day, value: chunkDays, to: cursor) ?? cursor + ranges.append(DateRange( + startTime: Int(cursor.timeIntervalSince1970), + endTime: Int(end.timeIntervalSince1970), + limit: chunkDays)) + cursor = end + remainingDays -= chunkDays + } + return ranges + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} + +private struct DateRange { + let startTime: Int + let endTime: Int + let limit: Int +} + +private struct DailyAccumulator { + let startTime: Int + let endTime: Int + var costUSD: Double = 0 + var requests: Int = 0 + var inputTokens: Int = 0 + var cachedInputTokens: Int = 0 + var outputTokens: Int = 0 + var totalTokens: Int = 0 + var lineItems: [String: Double] = [:] + var models: [String: ModelAccumulator] = [:] + + var startDate: Date { + Date(timeIntervalSince1970: TimeInterval(self.startTime)) + } + + func makeBucket(calendar: Calendar) -> OpenAIAPIUsageSnapshot.DailyBucket { + OpenAIAPIUsageSnapshot.DailyBucket( + day: Self.dayKey(from: self.startDate, calendar: calendar), + startTime: self.startDate, + endTime: Date(timeIntervalSince1970: TimeInterval(self.endTime)), + costUSD: self.costUSD, + requests: self.requests, + inputTokens: self.inputTokens, + cachedInputTokens: self.cachedInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens, + lineItems: self.lineItems + .map { OpenAIAPIUsageSnapshot.LineItemBreakdown(name: $0.key, costUSD: $0.value) } + .sorted { + if $0.costUSD == $1.costUSD { return $0.name < $1.name } + return $0.costUSD > $1.costUSD + }, + models: self.models + .map { $0.value.makeModel(name: $0.key) } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + }) + } + + private static func dayKey(from date: Date, calendar: Calendar) -> String { + let comps = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", comps.year ?? 0, comps.month ?? 0, comps.day ?? 0) + } +} + +private struct ModelAccumulator { + var requests = 0 + var inputTokens = 0 + var cachedInputTokens = 0 + var outputTokens = 0 + var totalTokens = 0 + + mutating func add( + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.requests += requests + self.inputTokens += inputTokens + self.cachedInputTokens += cachedInputTokens + self.outputTokens += outputTokens + self.totalTokens += totalTokens + } + + func makeModel(name: String) -> OpenAIAPIUsageSnapshot.ModelBreakdown { + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: name, + requests: self.requests, + inputTokens: self.inputTokens, + cachedInputTokens: self.cachedInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift new file mode 100644 index 0000000000..a540a0610f --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift @@ -0,0 +1,136 @@ +import Foundation + +struct CostsResponse: Decodable { + let data: [OpenAICostBucket] + let hasMore: Bool + let nextPage: String? + + private enum CodingKeys: String, CodingKey { + case data + case hasMore = "has_more" + case nextPage = "next_page" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.data = try container.decode([OpenAICostBucket].self, forKey: .data) + self.hasMore = try container.decode(Bool.self, forKey: .hasMore) + self.nextPage = try container.decodeIfPresent(String.self, forKey: .nextPage) + } +} + +struct OpenAICostBucket: Decodable { + let startTime: Int + let endTime: Int + let results: [OpenAICostResult] + + private enum CodingKeys: String, CodingKey { + case startTime = "start_time" + case endTime = "end_time" + case results + } +} + +struct OpenAICostResult: Decodable { + struct Amount: Decodable { + let value: Double? + let currency: String? + + private enum CodingKeys: String, CodingKey { + case value + case currency + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.value = try container.decodeFlexibleDoubleIfPresent(forKey: .value) + self.currency = try container.decodeIfPresent(String.self, forKey: .currency) + } + } + + let amount: Amount? + let lineItem: String? + + private enum CodingKeys: String, CodingKey { + case amount + case lineItem = "line_item" + } +} + +struct CompletionsUsageResponse: Decodable { + let data: [OpenAICompletionsUsageBucket] + let hasMore: Bool + let nextPage: String? + + private enum CodingKeys: String, CodingKey { + case data + case hasMore = "has_more" + case nextPage = "next_page" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.data = try container.decode([OpenAICompletionsUsageBucket].self, forKey: .data) + self.hasMore = try container.decode(Bool.self, forKey: .hasMore) + self.nextPage = try container.decodeIfPresent(String.self, forKey: .nextPage) + } +} + +struct OpenAICompletionsUsageBucket: Decodable { + let startTime: Int + let endTime: Int + let results: [OpenAICompletionsUsageResult] + + private enum CodingKeys: String, CodingKey { + case startTime = "start_time" + case endTime = "end_time" + case results + } +} + +struct OpenAICompletionsUsageResult: Decodable { + let inputTokens: Int? + let inputCachedTokens: Int? + let inputAudioTokens: Int? + let outputTokens: Int? + let outputAudioTokens: Int? + let numModelRequests: Int? + let model: String? + + private enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case inputCachedTokens = "input_cached_tokens" + case inputAudioTokens = "input_audio_tokens" + case outputTokens = "output_tokens" + case outputAudioTokens = "output_audio_tokens" + case numModelRequests = "num_model_requests" + case model + } +} + +extension KeyedDecodingContainer { + fileprivate func decodeFlexibleDoubleIfPresent(forKey key: Key) throws -> Double? { + guard self.contains(key), try !self.decodeNil(forKey: key) else { + return nil + } + + if let value = try? self.decode(Double.self, forKey: key) { + return value + } + + if let rawValue = try? self.decode(String.self, forKey: key) { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + if let value = Double(trimmed) { + return value + } + } + + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Expected a number or numeric string for \(key.stringValue)") + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift new file mode 100644 index 0000000000..9f741ea994 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -0,0 +1,280 @@ +import Foundation + +public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { + public struct DailyBucket: Codable, Equatable, Sendable, Identifiable { + public let day: String + public let startTime: Date + public let endTime: Date + public let costUSD: Double + public let requests: Int + public let inputTokens: Int + public let cachedInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + public let lineItems: [LineItemBreakdown] + public let models: [ModelBreakdown] + + public var id: String { + self.day + } + + public init( + day: String, + startTime: Date, + endTime: Date, + costUSD: Double, + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int, + lineItems: [LineItemBreakdown], + models: [ModelBreakdown]) + { + self.day = day + self.startTime = startTime + self.endTime = endTime + self.costUSD = costUSD + self.requests = requests + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + self.lineItems = lineItems + self.models = models + } + } + + public struct LineItemBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let costUSD: Double + + public var id: String { + self.name + } + + public init(name: String, costUSD: Double) { + self.name = name + self.costUSD = costUSD + } + } + + public struct ModelBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let requests: Int + public let inputTokens: Int + public let cachedInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + + public var id: String { + self.name + } + + public init( + name: String, + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.name = name + self.requests = requests + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + } + + public struct Summary: Equatable, Sendable { + public let costUSD: Double + public let requests: Int + public let inputTokens: Int + public let cachedInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + + public init( + costUSD: Double, + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.costUSD = costUSD + self.requests = requests + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + } + + public let daily: [DailyBucket] + public let updatedAt: Date + public let historyDays: Int + public let projectID: String? + + public init(daily: [DailyBucket], updatedAt: Date, historyDays: Int = 30, projectID: String? = nil) { + self.daily = daily.sorted { $0.startTime < $1.startTime } + self.updatedAt = updatedAt + self.historyDays = max(1, min(365, historyDays)) + self.projectID = OpenAIAPISettingsReader.cleaned(projectID) + } + + public var last30Days: Summary { + self.summary(days: self.historyDays) + } + + public var historyWindowLabel: String { + self.historyDays == 1 ? "Today" : "\(self.historyDays)d" + } + + public var historyWindowPeriodLabel: String { + self.historyDays == 1 ? "Today" : "Last \(self.historyDays) days" + } + + public var last7Days: Summary { + self.summary(days: 7) + } + + public var latestDay: Summary { + self.summary(days: 1) + } + + public func summary(days: Int) -> Summary { + let selected = self.daily.suffix(max(1, days)) + return Summary( + costUSD: selected.reduce(0) { $0 + $1.costUSD }, + requests: selected.reduce(0) { $0 + $1.requests }, + inputTokens: selected.reduce(0) { $0 + $1.inputTokens }, + cachedInputTokens: selected.reduce(0) { $0 + $1.cachedInputTokens }, + outputTokens: selected.reduce(0) { $0 + $1.outputTokens }, + totalTokens: selected.reduce(0) { $0 + $1.totalTokens }) + } + + public var topModels: [ModelBreakdown] { + var totals: [String: ModelAccumulator] = [:] + for day in self.daily { + for model in day.models { + totals[model.name, default: ModelAccumulator()].add(model) + } + } + return totals + .map { name, total in total.makeModel(name: name) } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + } + } + + public var topLineItems: [LineItemBreakdown] { + var totals: [String: Double] = [:] + for day in self.daily { + for item in day.lineItems { + totals[item.name, default: 0] += item.costUSD + } + } + return totals + .map { LineItemBreakdown(name: $0.key, costUSD: $0.value) } + .sorted { + if $0.costUSD == $1.costUSD { return $0.name < $1.name } + return $0.costUSD > $1.costUSD + } + } + + public func toUsageSnapshot() -> UsageSnapshot { + let total = self.last30Days + return UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: total.costUSD, + limit: 0, + currencyCode: "USD", + period: self.historyWindowPeriodLabel, + updatedAt: self.updatedAt), + openAIAPIUsage: self, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .openai, + accountEmail: nil, + accountOrganization: self.identityAccountOrganization, + loginMethod: self.identityLoginMethod)) + } + + private var identityLoginMethod: String { + guard let projectID else { return "Admin API" } + return "Admin API: \(projectID)" + } + + private var identityAccountOrganization: String? { + guard let projectID else { return nil } + return "Project: \(projectID)" + } + + public func toCostUsageTokenSnapshot() -> CostUsageTokenSnapshot { + let daily = self.daily.map { bucket in + let modelBreakdowns = bucket.models.map { + CostUsageDailyReport.ModelBreakdown( + modelName: $0.name, + costUSD: nil, + totalTokens: $0.totalTokens, + requestCount: $0.requests) + } + let modelsUsed = bucket.models.map(\.name) + return CostUsageDailyReport.Entry( + date: bucket.day, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cachedInputTokens, + cacheCreationTokens: nil, + totalTokens: bucket.totalTokens, + requestCount: bucket.requests, + costUSD: bucket.costUSD, + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + let latest = self.latestDay + let total = self.last30Days + return CostUsageTokenSnapshot( + sessionTokens: latest.totalTokens, + sessionCostUSD: latest.costUSD, + sessionRequests: latest.requests, + last30DaysTokens: total.totalTokens, + last30DaysCostUSD: total.costUSD, + last30DaysRequests: total.requests, + historyDays: self.historyDays, + daily: daily, + updatedAt: self.updatedAt) + } + + private struct ModelAccumulator { + var requests = 0 + var inputTokens = 0 + var cachedInputTokens = 0 + var outputTokens = 0 + var totalTokens = 0 + + mutating func add(_ model: ModelBreakdown) { + self.requests += model.requests + self.inputTokens += model.inputTokens + self.cachedInputTokens += model.cachedInputTokens + self.outputTokens += model.outputTokens + self.totalTokens += model.totalTokens + } + + func makeModel(name: String) -> ModelBreakdown { + ModelBreakdown( + name: name, + requests: self.requests, + inputTokens: self.inputTokens, + cachedInputTokens: self.cachedInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens) + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift index d91261fdbd..0532d8cae8 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift @@ -37,7 +37,7 @@ public enum OpenCodeCookieImporter { for browserSource in installedBrowsers { do { let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift index 18803a46cd..2f62ba57cb 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum OpenCodeProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .opencode, @@ -84,35 +83,14 @@ struct OpenCodeUsageFetchStrategy: ProviderFetchStrategy { } private static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { - if let settings = context.settings?.opencode, settings.cookieSource == .manual { - if let header = CookieHeaderNormalizer.normalize(settings.manualCookieHeader) { - let pairs = CookieHeaderNormalizer.pairs(from: header) - let hasAuthCookie = pairs.contains { pair in - pair.name == "auth" || pair.name == "__Host-auth" - } - if hasAuthCookie { - return header - } - } - throw OpenCodeSettingsError.invalidCookie - } - - #if os(macOS) - if allowCached, - let cached = CookieHeaderCache.load(provider: .opencode), - !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return cached.cookieHeader - } - let session = try OpenCodeCookieImporter.importSession(browserDetection: context.browserDetection) - CookieHeaderCache.store( - provider: .opencode, - cookieHeader: session.cookieHeader, - sourceLabel: session.sourceLabel) - return session.cookieHeader - #else - throw OpenCodeSettingsError.missingCookie - #endif + try OpenCodeWebCookieSupport.resolveCookieHeader( + context: OpenCodeWebCookieSupport.Context( + settings: context.settings?.opencode, + provider: .opencode, + browserDetection: context.browserDetection, + allowCached: allowCached), + invalidCookie: OpenCodeSettingsError.invalidCookie, + missingCookie: OpenCodeSettingsError.missingCookie) } } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index ed27f1d4f7..71aa427195 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -62,6 +62,10 @@ public struct OpenCodeUsageFetcher: Sendable { "renewAt", "renew_at", ] + private static let renewAtKeys = [ + "renewAt", + "renew_at", + ] private static func makeISO8601Formatter() -> ISO8601DateFormatter { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -83,25 +87,32 @@ public struct OpenCodeUsageFetcher: Sendable { cookieHeader: String, timeout: TimeInterval, now: Date = Date(), - workspaceIDOverride: String? = nil) async throws -> OpenCodeUsageSnapshot + workspaceIDOverride: String? = nil, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> OpenCodeUsageSnapshot { + guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { + throw OpenCodeUsageError.invalidCredentials + } let workspaceID: String = if let override = self.normalizeWorkspaceID(workspaceIDOverride) { override } else { try await self.fetchWorkspaceID( - cookieHeader: cookieHeader, - timeout: timeout) + cookieHeader: requestCookieHeader, + timeout: timeout, + transport: transport) } let subscriptionText = try await self.fetchSubscriptionInfo( workspaceID: workspaceID, - cookieHeader: cookieHeader, - timeout: timeout) + cookieHeader: requestCookieHeader, + timeout: timeout, + transport: transport) return try self.parseSubscription(text: subscriptionText, now: now) } private static func fetchWorkspaceID( cookieHeader: String, - timeout: TimeInterval) async throws -> String + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> String { let text = try await self.fetchServerText( request: ServerRequest( @@ -110,7 +121,8 @@ public struct OpenCodeUsageFetcher: Sendable { method: "GET", referer: self.baseURL), cookieHeader: cookieHeader, - timeout: timeout) + timeout: timeout, + transport: transport) if self.looksSignedOut(text: text) { throw OpenCodeUsageError.invalidCredentials } @@ -127,7 +139,8 @@ public struct OpenCodeUsageFetcher: Sendable { method: "POST", referer: self.baseURL), cookieHeader: cookieHeader, - timeout: timeout) + timeout: timeout, + transport: transport) if self.looksSignedOut(text: fallback) { throw OpenCodeUsageError.invalidCredentials } @@ -147,7 +160,8 @@ public struct OpenCodeUsageFetcher: Sendable { private static func fetchSubscriptionInfo( workspaceID: String, cookieHeader: String, - timeout: TimeInterval) async throws -> String + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> String { let referer = URL(string: "https://opencode.ai/workspace/\(workspaceID)/billing") ?? self.baseURL let text = try await self.fetchServerText( @@ -157,7 +171,8 @@ public struct OpenCodeUsageFetcher: Sendable { method: "GET", referer: referer), cookieHeader: cookieHeader, - timeout: timeout) + timeout: timeout, + transport: transport) if self.looksSignedOut(text: text) { throw OpenCodeUsageError.invalidCredentials } @@ -178,7 +193,8 @@ public struct OpenCodeUsageFetcher: Sendable { method: "POST", referer: referer), cookieHeader: cookieHeader, - timeout: timeout) + timeout: timeout, + transport: transport) if self.looksSignedOut(text: fallback) { throw OpenCodeUsageError.invalidCredentials } @@ -207,7 +223,7 @@ public struct OpenCodeUsageFetcher: Sendable { private static func missingSubscriptionDataError(workspaceID: String) -> OpenCodeUsageError { OpenCodeUsageError.apiError( "No subscription usage data was returned for workspace \(workspaceID). " + - "This usually means this workspace does not have OpenCode Black usage data.") + "This usually means this workspace does not have OpenCode subscription quota data available.") } private static func normalizeWorkspaceID(_ raw: String?) -> String? { @@ -236,7 +252,8 @@ public struct OpenCodeUsageFetcher: Sendable { private static func fetchServerText( request serverRequest: ServerRequest, cookieHeader: String, - timeout: TimeInterval) async throws -> String + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> String { let url = self.serverRequestURL( serverID: serverRequest.serverID, @@ -260,28 +277,33 @@ public struct OpenCodeUsageFetcher: Sendable { urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") } - let (data, response) = try await URLSession.shared.data(for: urlRequest) - guard let httpResponse = response as? HTTPURLResponse else { + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: urlRequest) + } catch let error as URLError where error.code == .badServerResponse { throw OpenCodeUsageError.networkError("Invalid response") + } catch { + throw error } - guard httpResponse.statusCode == 200 else { - let bodyText = String(data: data, encoding: .utf8) ?? "" - let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "unknown" - Self.log.error("OpenCode returned \(httpResponse.statusCode) (type=\(contentType) length=\(data.count))") + guard response.statusCode == 200 else { + let bodyText = String(data: response.data, encoding: .utf8) ?? "" + let contentType = response.response.value(forHTTPHeaderField: "Content-Type") ?? "unknown" + Self.log + .error("OpenCode returned \(response.statusCode) (type=\(contentType) length=\(response.data.count))") if self.looksSignedOut(text: bodyText) { throw OpenCodeUsageError.invalidCredentials } - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + if response.statusCode == 401 || response.statusCode == 403 { throw OpenCodeUsageError.invalidCredentials } if let message = self.extractServerErrorMessage(from: bodyText) { - throw OpenCodeUsageError.apiError("HTTP \(httpResponse.statusCode): \(message)") + throw OpenCodeUsageError.apiError("HTTP \(response.statusCode): \(message)") } - throw OpenCodeUsageError.apiError("HTTP \(httpResponse.statusCode)") + throw OpenCodeUsageError.apiError("HTTP \(response.statusCode)") } - guard let text = String(data: data, encoding: .utf8) else { + guard let text = String(data: response.data, encoding: .utf8) else { throw OpenCodeUsageError.parseFailed("Response was not UTF-8.") } return text @@ -428,7 +450,12 @@ public struct OpenCodeUsageFetcher: Sendable { private static func looksSignedOut(text: String) -> Bool { let lower = text.lowercased() - if lower.contains("login") || lower.contains("sign in") || lower.contains("auth/authorize") { + if lower.contains("login") || + lower.contains("sign in") || + lower.contains("auth/authorize") || + lower.contains("not associated with an account") || + lower.contains("actor of type \"public\"") + { return true } return false @@ -479,24 +506,33 @@ public struct OpenCodeUsageFetcher: Sendable { private static func parseUsageJSON(object: Any, now: Date) -> OpenCodeUsageSnapshot? { guard let dict = object as? [String: Any] else { return nil } - if let snapshot = self.parseUsageDictionary(dict, now: now) { + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) + if let snapshot = self.parseUsageDictionary(dict, now: now, inheritedRenewsAt: renewsAt) { return snapshot } for key in ["data", "result", "usage", "billing", "payload"] { if let nested = dict[key] as? [String: Any], - let snapshot = self.parseUsageDictionary(nested, now: now) + let snapshot = self.parseUsageDictionary(nested, now: now, inheritedRenewsAt: renewsAt) { return snapshot } } - return self.parseUsageNested(dict, now: now, depth: 0) + if let snapshot = self.parseUsageNested(dict, now: now, depth: 0, inheritedRenewsAt: renewsAt) { + return snapshot + } + return self.parseUsageFromCandidates(object: object, now: now, inheritedRenewsAt: renewsAt) } - private static func parseUsageDictionary(_ dict: [String: Any], now: Date) -> OpenCodeUsageSnapshot? { + private static func parseUsageDictionary( + _ dict: [String: Any], + now: Date, + inheritedRenewsAt: Date?) -> OpenCodeUsageSnapshot? + { + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) ?? inheritedRenewsAt if let usage = dict["usage"] as? [String: Any], - let snapshot = self.parseUsageDictionary(usage, now: now) + let snapshot = self.parseUsageDictionary(usage, now: now, inheritedRenewsAt: renewsAt) { return snapshot } @@ -508,14 +544,20 @@ public struct OpenCodeUsageFetcher: Sendable { let weekly = weeklyKeys.compactMap { dict[$0] as? [String: Any] }.first if let rolling, let weekly { - return self.buildSnapshot(rolling: rolling, weekly: weekly, now: now) + return self.buildSnapshot(rolling: rolling, weekly: weekly, now: now, renewsAt: renewsAt) } return nil } - private static func parseUsageNested(_ dict: [String: Any], now: Date, depth: Int) -> OpenCodeUsageSnapshot? { + private static func parseUsageNested( + _ dict: [String: Any], + now: Date, + depth: Int, + inheritedRenewsAt: Date?) -> OpenCodeUsageSnapshot? + { if depth > 3 { return nil } + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) ?? inheritedRenewsAt var rolling: [String: Any]? var weekly: [String: Any]? @@ -529,15 +571,18 @@ public struct OpenCodeUsageFetcher: Sendable { } } - if let rolling, let weekly, - let snapshot = self.buildSnapshot(rolling: rolling, weekly: weekly, now: now) - { - return snapshot + if let rolling, let weekly { + let snapshot = self.buildSnapshot(rolling: rolling, weekly: weekly, now: now, renewsAt: renewsAt) + if let snapshot { return snapshot } } for value in dict.values { if let sub = value as? [String: Any], - let snapshot = self.parseUsageNested(sub, now: now, depth: depth + 1) + let snapshot = self.parseUsageNested( + sub, + now: now, + depth: depth + 1, + inheritedRenewsAt: renewsAt) { return snapshot } @@ -546,7 +591,11 @@ public struct OpenCodeUsageFetcher: Sendable { return nil } - private static func parseUsageFromCandidates(object: Any, now: Date) -> OpenCodeUsageSnapshot? { + private static func parseUsageFromCandidates( + object: Any, + now: Date, + inheritedRenewsAt: Date? = nil) -> OpenCodeUsageSnapshot? + { let candidates = self.collectWindowCandidates(object: object, now: now) guard !candidates.isEmpty else { return nil } @@ -573,15 +622,18 @@ public struct OpenCodeUsageFetcher: Sendable { guard let rolling, let weekly else { return nil } + let renewsAt = self.dateValue(from: self.value(from: object as? [String: Any] ?? [:], keys: self.renewAtKeys)) + ?? inheritedRenewsAt return OpenCodeUsageSnapshot( rollingUsagePercent: rolling.percent, weeklyUsagePercent: weekly.percent, rollingResetInSec: rolling.resetInSec, weeklyResetInSec: weekly.resetInSec, + renewsAt: renewsAt, updatedAt: now) } - private struct WindowCandidate: Sendable { + private struct WindowCandidate { let id: UUID let percent: Double let resetInSec: Int @@ -656,7 +708,8 @@ public struct OpenCodeUsageFetcher: Sendable { private static func buildSnapshot( rolling: [String: Any], weekly: [String: Any], - now: Date) -> OpenCodeUsageSnapshot? + now: Date, + renewsAt: Date? = nil) -> OpenCodeUsageSnapshot? { guard let rollingWindow = self.parseWindow(rolling, now: now), let weeklyWindow = self.parseWindow(weekly, now: now) @@ -669,6 +722,7 @@ public struct OpenCodeUsageFetcher: Sendable { weeklyUsagePercent: weeklyWindow.percent, rollingResetInSec: rollingWindow.resetInSec, weeklyResetInSec: weeklyWindow.resetInSec, + renewsAt: renewsAt, updatedAt: now) } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift index aa1d87f282..672812525e 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift @@ -5,6 +5,7 @@ public struct OpenCodeUsageSnapshot: Sendable { public let weeklyUsagePercent: Double public let rollingResetInSec: Int public let weeklyResetInSec: Int + public let renewsAt: Date? public let updatedAt: Date public init( @@ -12,12 +13,14 @@ public struct OpenCodeUsageSnapshot: Sendable { weeklyUsagePercent: Double, rollingResetInSec: Int, weeklyResetInSec: Int, + renewsAt: Date? = nil, updatedAt: Date) { self.rollingUsagePercent = rollingUsagePercent self.weeklyUsagePercent = weeklyUsagePercent self.rollingResetInSec = rollingResetInSec self.weeklyResetInSec = weeklyResetInSec + self.renewsAt = renewsAt self.updatedAt = updatedAt } @@ -36,9 +39,20 @@ public struct OpenCodeUsageSnapshot: Sendable { resetsAt: weeklyReset, resetDescription: nil) + var extraWindows: [NamedRateWindow]? + if let renewsAt = self.renewsAt { + let renewalWindow = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: renewsAt, + resetDescription: nil) + extraWindows = [NamedRateWindow(id: "renewal", title: "Renews", window: renewalWindow)] + } + return UsageSnapshot( primary: primary, secondary: secondary, + extraRateWindows: extraWindows, updatedAt: self.updatedAt, identity: nil) } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift new file mode 100644 index 0000000000..5d239b08d0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift @@ -0,0 +1,62 @@ +import Foundation + +enum OpenCodeWebCookieSupport { + private static let requestCookieNames: Set = ["auth", "__Host-auth"] + + struct Context { + let settings: ProviderSettingsSnapshot.OpenCodeProviderSettings? + let provider: UsageProvider + let browserDetection: BrowserDetection + let allowCached: Bool + } + + static func requestCookieHeader(from rawHeader: String?) -> String? { + CookieHeaderNormalizer.filteredHeader(from: rawHeader, allowedNames: self.requestCookieNames) + } + + static func resolveCookieHeader( + context: Context, + invalidCookie: @autoclosure () -> Error, + missingCookie: @autoclosure () -> Error) throws -> String + { + if let settings = context.settings, settings.cookieSource == .manual { + if let header = self.requestCookieHeader(from: settings.manualCookieHeader) { + return header + } + throw invalidCookie() + } + + #if os(macOS) + if context.allowCached, + let cached = CookieHeaderCache.load(provider: context.provider), + let header = self.requestCookieHeader(from: cached.cookieHeader) + { + return header + } + let session = try OpenCodeCookieImporter.importSession( + browserDetection: context.browserDetection, + preferredBrowsers: self.automaticImportOrder(provider: context.provider)) + guard let header = self.requestCookieHeader(from: session.cookieHeader) else { + throw missingCookie() + } + CookieHeaderCache.store( + provider: context.provider, + cookieHeader: header, + sourceLabel: session.sourceLabel) + return header + #else + throw missingCookie() + #endif + } + + #if os(macOS) + static func automaticImportOrder(provider: UsageProvider) -> BrowserCookieImportOrder { + if provider == .opencodego, + let order = ProviderDefaults.metadata[provider]?.browserCookieOrder + { + return order + } + return [.chrome] + } + #endif +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift new file mode 100644 index 0000000000..e4cdf237e3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift @@ -0,0 +1,324 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +#if canImport(SQLite3) || canImport(CSQLite3) +public enum OpenCodeGoLocalUsageError: LocalizedError, Sendable, Equatable { + case notDetected + case historyUnavailable(String) + case sqliteFailed(String) + + public var errorDescription: String? { + switch self { + case .notDetected: + "OpenCode Go not detected. Log in with OpenCode Go or use it locally first." + case let .historyUnavailable(message): + "OpenCode Go local usage history is unavailable: \(message)" + case let .sqliteFailed(message): + "SQLite error reading OpenCode Go usage: \(message)" + } + } +} + +public struct OpenCodeGoLocalUsageReader: Sendable { + private static let fiveHours: TimeInterval = 5 * 60 * 60 + private static let week: TimeInterval = 7 * 24 * 60 * 60 + private static let limits = (session: 12.0, weekly: 30.0, monthly: 60.0) + + private let authURL: URL + private let databaseURL: URL + + public init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { + let openCodeDirectory = homeDirectory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + self.authURL = openCodeDirectory.appendingPathComponent("auth.json", isDirectory: false) + self.databaseURL = openCodeDirectory.appendingPathComponent("opencode.db", isDirectory: false) + } + + public init(authURL: URL, databaseURL: URL) { + self.authURL = authURL + self.databaseURL = databaseURL + } + + public func fetch(now: Date = Date()) throws -> OpenCodeGoUsageSnapshot { + let hasAuth = Self.hasAuthKey(at: self.authURL) + guard FileManager.default.fileExists(atPath: self.databaseURL.path) else { + if hasAuth { + throw OpenCodeGoLocalUsageError.historyUnavailable("database not found") + } + throw OpenCodeGoLocalUsageError.notDetected + } + + let rows = try self.readRows() + guard hasAuth || !rows.isEmpty else { + throw OpenCodeGoLocalUsageError.notDetected + } + guard !rows.isEmpty else { + throw OpenCodeGoLocalUsageError.historyUnavailable("no local usage rows") + } + return Self.snapshot(rows: rows, now: now) + } + + private func readRows() throws -> [UsageRow] { + var db: OpaquePointer? + guard sqlite3_open_v2(self.databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + sqlite3_close(db) + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let sql = self.hasTable(named: "part", db: db) ? Self.messageAndPartUsageSQL : Self.messageUsageSQL + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + defer { sqlite3_finalize(stmt) } + + var rows: [UsageRow] = [] + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + + let createdMs = sqlite3_column_int64(stmt, 0) + let cost = sqlite3_column_double(stmt, 1) + guard createdMs > 0, cost >= 0, cost.isFinite else { continue } + rows.append(UsageRow(createdMs: createdMs, cost: cost)) + } + return rows + } + + private func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static let messageUsageSQL = """ + SELECT + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + """ + + private static let messageAndPartUsageSQL = """ + WITH message_costs AS ( + SELECT + id AS messageID, + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + ) + SELECT createdMs, cost + FROM message_costs + UNION ALL + SELECT + CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.time_created) AS INTEGER) + AS createdMs, + CAST(json_extract(p.data, '$.cost') AS REAL) AS cost + FROM part p + JOIN message m ON m.id = p.message_id + WHERE json_valid(p.data) + AND json_valid(m.data) + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.cost') IN ('integer', 'real') + AND json_extract(m.data, '$.providerID') = 'opencode-go' + AND json_extract(m.data, '$.role') = 'assistant' + AND NOT EXISTS ( + SELECT 1 + FROM message_costs + WHERE message_costs.messageID = p.message_id + ) + """ + + private struct UsageRow { + let createdMs: Int64 + let cost: Double + } + + private static func hasAuthKey(at url: URL) -> Bool { + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let entry = object["opencode-go"] as? [String: Any], + let key = entry["key"] as? String + else { + return false + } + return !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private static func snapshot(rows: [UsageRow], now: Date) -> OpenCodeGoUsageSnapshot { + let nowMs = Int64(now.timeIntervalSince1970 * 1000) + let sessionStart = nowMs - Int64(Self.fiveHours * 1000) + let weekStart = self.startOfUTCWeek(now: now).timeIntervalSince1970 * 1000 + let weekStartMs = Int64(weekStart) + let weekEndMs = weekStartMs + Int64(Self.week * 1000) + let earliestMs = rows.map(\.createdMs).min() + let monthBounds = self.monthBounds(now: now, anchorMs: earliestMs) + + let sessionCost = self.sum(rows: rows, startMs: sessionStart, endMs: nowMs) + let weeklyCost = self.sum(rows: rows, startMs: weekStartMs, endMs: weekEndMs) + let monthlyCost = self.sum(rows: rows, startMs: monthBounds.startMs, endMs: monthBounds.endMs) + + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: self.percent(used: sessionCost, limit: self.limits.session), + weeklyUsagePercent: self.percent(used: weeklyCost, limit: self.limits.weekly), + monthlyUsagePercent: self.percent(used: monthlyCost, limit: self.limits.monthly), + rollingResetInSec: self.rollingReset(rows: rows, nowMs: nowMs), + weeklyResetInSec: max(0, Int((weekEndMs - nowMs) / 1000)), + monthlyResetInSec: max(0, Int((monthBounds.endMs - nowMs) / 1000)), + updatedAt: now) + } + + private static func sum(rows: [UsageRow], startMs: Int64, endMs: Int64) -> Double { + rows.reduce(0) { total, row in + guard row.createdMs >= startMs, row.createdMs < endMs else { return total } + return total + row.cost + } + } + + private static func percent(used: Double, limit: Double) -> Double { + guard used.isFinite, limit > 0 else { return 0 } + let value = max(0, min(100, used / limit * 100)) + return (value * 10).rounded() / 10 + } + + private static func rollingReset(rows: [UsageRow], nowMs: Int64) -> Int { + let sessionStart = nowMs - Int64(Self.fiveHours * 1000) + let oldest = rows + .filter { $0.createdMs >= sessionStart && $0.createdMs < nowMs } + .map(\.createdMs) + .min() ?? nowMs + return max(0, Int((oldest + Int64(Self.fiveHours * 1000) - nowMs) / 1000)) + } + + private static func startOfUTCWeek(now: Date) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current + calendar.firstWeekday = 2 + calendar.minimumDaysInFirstWeek = 4 + let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now) + return calendar.date(from: components) ?? now + } + + private static func monthBounds(now: Date, anchorMs: Int64?) -> (startMs: Int64, endMs: Int64) { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current + + guard let anchorMs else { + let start = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) ?? now + let end = calendar.date(byAdding: .month, value: 1, to: start) ?? start + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + + let anchor = Date(timeIntervalSince1970: TimeInterval(anchorMs) / 1000) + let anchorComponents = calendar.dateComponents([.day, .hour, .minute, .second, .nanosecond], from: anchor) + let nowComponents = calendar.dateComponents([.year, .month], from: now) + + var startMonthComponents = nowComponents + var start = self.anchoredMonth(calendar: calendar, month: startMonthComponents, anchor: anchorComponents) + if start > now { + guard let previous = calendar.date(byAdding: .month, value: -1, to: start) else { + let end = self.anchoredMonth( + calendar: calendar, + month: self.monthComponents(after: startMonthComponents, calendar: calendar), + anchor: anchorComponents) + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + startMonthComponents = calendar.dateComponents([.year, .month], from: previous) + start = self.anchoredMonth(calendar: calendar, month: startMonthComponents, anchor: anchorComponents) + } + let end = self.anchoredMonth( + calendar: calendar, + month: self.monthComponents(after: startMonthComponents, calendar: calendar), + anchor: anchorComponents) + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + + private static func monthComponents(after month: DateComponents, calendar: Calendar) -> DateComponents { + let monthStart = calendar.date(from: month) ?? Date() + let nextMonth = calendar.date(byAdding: .month, value: 1, to: monthStart) ?? monthStart + return calendar.dateComponents([.year, .month], from: nextMonth) + } + + private static func anchoredMonth( + calendar: Calendar, + month: DateComponents, + anchor: DateComponents) -> Date + { + var components = DateComponents() + components.calendar = calendar + components.timeZone = calendar.timeZone + components.year = month.year + components.month = month.month + components.day = anchor.day + components.hour = anchor.hour + components.minute = anchor.minute + components.second = anchor.second + components.nanosecond = anchor.nanosecond + + if let date = calendar.date(from: components), + calendar.component(.month, from: date) == month.month + { + return date + } + + components.day = calendar.range(of: .day, in: .month, for: calendar.date(from: month) ?? Date())?.count + return calendar.date(from: components) ?? Date() + } +} + +#else + +public enum OpenCodeGoLocalUsageError: LocalizedError, Sendable, Equatable { + case notSupported + + public var errorDescription: String? { + "OpenCode Go local usage is only supported on macOS." + } +} + +public struct OpenCodeGoLocalUsageReader: Sendable { + public init(homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser) {} + public init(authURL _: URL, databaseURL _: URL) {} + + public func fetch(now _: Date = Date()) throws -> OpenCodeGoUsageSnapshot { + throw OpenCodeGoLocalUsageError.notSupported + } +} + +#endif diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift new file mode 100644 index 0000000000..b3306b67be --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -0,0 +1,192 @@ +import Foundation + +public enum OpenCodeGoProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .opencodego, + metadata: ProviderMetadata( + id: .opencodego, + displayName: "OpenCode Go", + sessionLabel: "5-hour", + weeklyLabel: "Weekly", + opusLabel: "Monthly", + supportsOpus: true, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show OpenCode Go usage", + cliName: "opencodego", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://opencode.ai", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .opencodego, + iconResourceName: "ProviderIcon-opencodego", + color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "OpenCode Go cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), + cli: ProviderCLIConfig( + name: "opencodego", + versionDetector: nil)) + } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + if context.sourceMode == .web { + return [OpenCodeGoUsageFetchStrategy()] + } + return [ + OpenCodeGoUsageFetchStrategy(), + OpenCodeGoLocalUsageFetchStrategy(), + ] + } +} + +struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { + let id: String = "opencodego.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.snapshot(context: context) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "local") + } + + func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { + error is OpenCodeGoLocalUsageError + } + + private func snapshot(context: ProviderFetchContext) async throws -> OpenCodeGoUsageSnapshot { + let snapshot = try OpenCodeGoLocalUsageReader().fetch() + guard context.includeOptionalUsage, + context.settings?.opencodego?.cookieSource != .off + else { + return snapshot + } + + guard let cookieHeader = Self.cachedOrManualCookieHeader(context: context) else { + return snapshot + } + + let workspaceOverride = context.settings?.opencodego?.workspaceID + ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] + let zenBalanceTask = Task { + do { + return try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance( + cookieHeader: cookieHeader, + timeout: context.webTimeout, + workspaceIDOverride: workspaceOverride) + } catch is CancellationError { + throw CancellationError() + } catch { + return nil + } + } + let zenBalance = try await OpenCodeGoUsageFetcher.completedOptionalZenBalance(from: zenBalanceTask) + return snapshot.withZenBalanceUSD(zenBalance) + } + + private static func cachedOrManualCookieHeader(context: ProviderFetchContext) -> String? { + if let settings = context.settings?.opencodego, settings.cookieSource == .manual { + return OpenCodeWebCookieSupport.requestCookieHeader(from: settings.manualCookieHeader) + } + + #if os(macOS) + guard let cached = CookieHeaderCache.load(provider: .opencodego) else { return nil } + return OpenCodeWebCookieSupport.requestCookieHeader(from: cached.cookieHeader) + #else + return nil + #endif + } +} + +struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { + let id: String = "opencodego.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.opencodego?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let workspaceOverride = context.settings?.opencodego?.workspaceID + ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] + let cookieSource = context.settings?.opencodego?.cookieSource ?? .auto + do { + let cookieHeader = try Self.resolveCookieHeader(context: context, allowCached: true) + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + timeout: context.webTimeout, + workspaceIDOverride: workspaceOverride, + includeZenBalance: context.includeOptionalUsage) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } catch OpenCodeGoUsageError.invalidCredentials where cookieSource != .manual { + #if os(macOS) + CookieHeaderCache.clear(provider: .opencodego) + let cookieHeader = try Self.resolveCookieHeader(context: context, allowCached: false) + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + timeout: context.webTimeout, + workspaceIDOverride: workspaceOverride, + includeZenBalance: context.includeOptionalUsage) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + #else + throw OpenCodeGoUsageError.invalidCredentials + #endif + } + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + guard context.sourceMode == .auto else { return false } + return switch error { + case OpenCodeGoSettingsError.missingCookie, + OpenCodeGoSettingsError.invalidCookie, + OpenCodeGoUsageError.invalidCredentials: + true + default: + false + } + } + + static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { + try OpenCodeWebCookieSupport.resolveCookieHeader( + context: OpenCodeWebCookieSupport.Context( + settings: context.settings?.opencodego, + provider: .opencodego, + browserDetection: context.browserDetection, + allowCached: allowCached), + invalidCookie: OpenCodeGoSettingsError.invalidCookie, + missingCookie: OpenCodeGoSettingsError.missingCookie) + } +} + +enum OpenCodeGoSettingsError: LocalizedError { + case missingCookie + case invalidCookie + + var errorDescription: String? { + switch self { + case .missingCookie: + "No OpenCode Go session cookies found in browsers." + case .invalidCookie: + "OpenCode Go cookie header is invalid." + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift new file mode 100644 index 0000000000..cdcb7d30dd --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -0,0 +1,1004 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum OpenCodeGoUsageError: LocalizedError { + case invalidCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "OpenCode Go session cookie is invalid or expired." + case let .networkError(message): + "OpenCode Go network error: \(message)" + case let .apiError(message): + "OpenCode Go API error: \(message)" + case let .parseFailed(message): + "OpenCode Go parse error: \(message)" + } + } +} + +public struct OpenCodeGoUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.opencodeGoUsage) + private static let baseURL = URL(string: "https://opencode.ai")! + private static let serverURL = URL(string: "https://opencode.ai/_server")! + private static let workspacesServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f" + private static let billingServerID = "c83b78a614689c38ebee981f9b39a8b377716db85c1fd7dbab604adc02d3313d" + + private static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + + private final class RedirectGuardDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) + { + guard OpenCodeGoUsageFetcher.allowsRedirect( + from: task.originalRequest?.url, + to: request.url) + else { + completionHandler(nil) + return + } + completionHandler(request) + } + } + + private struct ServerRequest { + let serverID: String + let args: String? + let method: String + let referer: URL + } + + // swiftformat:disable:next redundantSendable + struct ZenBalanceRequest: Sendable { + let workspaceID: String + let cookieHeader: String + let timeout: TimeInterval + let session: URLSession + } + + private static let percentKeys = [ + "usagePercent", + "usedPercent", + "percentUsed", + "percent", + "usage_percent", + "used_percent", + "utilization", + "utilizationPercent", + "utilization_percent", + "usage", + ] + private static let resetInKeys = [ + "resetInSec", + "resetInSeconds", + "resetSeconds", + "reset_sec", + "reset_in_sec", + "resetsInSec", + "resetsInSeconds", + "resetIn", + "resetSec", + ] + private static let resetAtKeys = [ + "resetAt", + "resetsAt", + "reset_at", + "resets_at", + "nextReset", + "next_reset", + "renewAt", + "renew_at", + ] + private static let renewAtKeys = [ + "renewAt", + "renew_at", + ] + private static let redirectGuardDelegate = RedirectGuardDelegate() + private static let redirectGuardSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + return URLSession( + configuration: configuration, + delegate: OpenCodeGoUsageFetcher.redirectGuardDelegate, + delegateQueue: nil) + }() + + public static func fetchUsage( + cookieHeader: String, + timeout: TimeInterval, + now: Date = Date(), + workspaceIDOverride: String? = nil, + includeZenBalance: Bool = true, + session: URLSession? = nil) async throws -> OpenCodeGoUsageSnapshot + { + let session = session ?? self.redirectGuardSession + guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { + throw OpenCodeGoUsageError.invalidCredentials + } + let workspaceID: String = if let override = self.normalizeWorkspaceID(workspaceIDOverride) { + override + } else { + try await self.fetchWorkspaceID( + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + } + let zenBalanceRequest = ZenBalanceRequest( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + let subscriptionTask = Task { + try await self.fetchUsagePage( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + } + let zenBalanceTask = includeZenBalance ? Task { + try await Task.sleep(for: self.optionalZenBalanceStartDelay) + return try await self.fetchZenBalance( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + } : nil + defer { + subscriptionTask.cancel() + zenBalanceTask?.cancel() + } + let subscriptionText: String + do { + subscriptionText = try await withTaskCancellationHandler { + try await subscriptionTask.value + } onCancel: { + subscriptionTask.cancel() + zenBalanceTask?.cancel() + } + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch let error as OpenCodeGoUsageError { + return try await self.requiredZenBalanceFallback( + from: zenBalanceTask, + for: error, + request: zenBalanceRequest, + now: now) + } catch { + throw error + } + let snapshot: OpenCodeGoUsageSnapshot + do { + snapshot = try self.parseSubscription(text: subscriptionText, now: now) + } catch let error as OpenCodeGoUsageError { + return try await self.requiredZenBalanceFallback( + from: zenBalanceTask, + for: error, + request: zenBalanceRequest, + now: now) + } + guard let zenBalanceTask else { + return snapshot + } + let zenBalance = try await self.completedOptionalZenBalance(from: zenBalanceTask) + return snapshot.withZenBalanceUSD(zenBalance) + } + + static func requiredZenBalanceFallback( + from task: Task?, + for error: OpenCodeGoUsageError, + request: ZenBalanceRequest, + now: Date) async throws -> OpenCodeGoUsageSnapshot + { + guard case let .parseFailed(message) = error, + message.contains("Missing usage fields") + else { + throw error + } + let task = task ?? Task { + try await self.fetchZenBalance( + workspaceID: request.workspaceID, + cookieHeader: request.cookieHeader, + timeout: request.timeout, + session: request.session) + } + defer { + task.cancel() + } + let zenBalance = try await self.completedRequiredZenBalance(from: task) + guard let zenBalance else { + throw error + } + return OpenCodeGoUsageSnapshot.zenBalanceOnly(balanceUSD: zenBalance, updatedAt: now) + } + + static func fetchOptionalZenBalance( + cookieHeader: String, + timeout: TimeInterval, + workspaceIDOverride: String? = nil, + session: URLSession? = nil) async throws -> Double? + { + let session = session ?? self.redirectGuardSession + guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { + throw OpenCodeGoUsageError.invalidCredentials + } + let workspaceID: String = if let override = self.normalizeWorkspaceID(workspaceIDOverride) { + override + } else { + try await self.fetchWorkspaceID( + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + } + return try await self.fetchOptionalZenBalance( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: min(timeout, self.optionalZenBalanceTimeout), + session: session) + } + + static func allowsRedirect(from sourceURL: URL?, to destinationURL: URL?) -> Bool { + guard let sourceHost = sourceURL?.host?.lowercased(), + let destinationHost = destinationURL?.host?.lowercased(), + sourceHost == destinationHost, + destinationURL?.scheme?.lowercased() == "https" + else { return false } + return true + } + + public static func dashboardURL(workspaceID raw: String?) -> URL { + guard let workspaceID = self.normalizeWorkspaceID(raw), + let url = URL(string: "\(self.baseURL.absoluteString)/workspace/\(workspaceID)/go") + else { + return self.baseURL + } + return url + } +} + +extension OpenCodeGoUsageFetcher { + static func fetchZenBillingText( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> String + { + let argsData = try JSONSerialization.data(withJSONObject: [workspaceID]) + guard let args = String(data: argsData, encoding: .utf8) else { + throw OpenCodeGoUsageError.parseFailed("Could not encode billing request.") + } + return try await self.fetchServerText( + request: ServerRequest( + serverID: self.billingServerID, + args: args, + method: "GET", + referer: self.zenDashboardURL(workspaceID: workspaceID)), + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + } + + private static func fetchWorkspaceID( + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> String + { + let text = try await self.fetchServerText( + request: ServerRequest( + serverID: self.workspacesServerID, + args: nil, + method: "GET", + referer: self.baseURL), + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + if self.looksSignedOut(text: text) { + throw OpenCodeGoUsageError.invalidCredentials + } + var ids = self.parseWorkspaceIDs(text: text) + if ids.isEmpty { + ids = self.parseWorkspaceIDsFromJSON(text: text) + } + if ids.isEmpty { + Self.log.error("OpenCode Go workspace ids missing after GET; retrying with POST.") + let fallback = try await self.fetchServerText( + request: ServerRequest( + serverID: self.workspacesServerID, + args: "[]", + method: "POST", + referer: self.baseURL), + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + if self.looksSignedOut(text: fallback) { + throw OpenCodeGoUsageError.invalidCredentials + } + ids = self.parseWorkspaceIDs(text: fallback) + if ids.isEmpty { + ids = self.parseWorkspaceIDsFromJSON(text: fallback) + } + if ids.isEmpty { + throw OpenCodeGoUsageError.parseFailed("Missing workspace id.") + } + return ids[0] + } + return ids[0] + } + + static func normalizeWorkspaceID(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("wrk_"), trimmed.count > 4 { + return trimmed + } + if let url = URL(string: trimmed) { + let parts = url.pathComponents + if let index = parts.firstIndex(of: "workspace"), + parts.count > index + 1 + { + let candidate = parts[index + 1] + if candidate.hasPrefix("wrk_"), candidate.count > 4 { + return candidate + } + } + } + if let match = trimmed.range(of: #"wrk_[A-Za-z0-9]+"#, options: .regularExpression) { + return String(trimmed[match]) + } + return nil + } + + static func parseWorkspaceIDs(text: String) -> [String] { + let pattern = #"id\s*:\s*\"(wrk_[^\"]+)\""# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + let nsrange = NSRange(text.startIndex.. [String] { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + return [] + } + var results: [String] = [] + self.collectWorkspaceIDs(object: object, out: &results) + return results + } + + private static func collectWorkspaceIDs(object: Any, out: inout [String]) { + if let dict = object as? [String: Any] { + for (_, value) in dict { + self.collectWorkspaceIDs(object: value, out: &out) + } + return + } + if let array = object as? [Any] { + for value in array { + self.collectWorkspaceIDs(object: value, out: &out) + } + return + } + if let string = object as? String, + string.hasPrefix("wrk_"), + !out.contains(string) + { + out.append(string) + } + } + + private static func fetchUsagePage( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> String + { + let url = URL(string: "https://opencode.ai/workspace/\(workspaceID)/go") ?? self.baseURL + let text = try await self.fetchPageText( + url: url, + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + if self.looksSignedOut(text: text) { + throw OpenCodeGoUsageError.invalidCredentials + } + guard self.parseSubscriptionJSON(text: text, now: Date()) != nil || + self.extractDouble( + pattern: #"rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, + text: text) != nil + else { + Self.log.error("OpenCode Go usage page payload missing usage fields.") + throw OpenCodeGoUsageError.parseFailed("Missing usage fields.") + } + return text + } + + static func parseSubscription(text: String, now: Date) throws -> OpenCodeGoUsageSnapshot { + if let snapshot = self.parseSubscriptionJSON(text: text, now: now) { + return snapshot + } + + guard let rollingPercent = self.extractDouble( + pattern: #"rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, + text: text), + let rollingReset = self.extractInt( + pattern: #"rollingUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, + text: text), + let weeklyPercent = self.extractDouble( + pattern: #"weeklyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, + text: text), + let weeklyReset = self.extractInt( + pattern: #"weeklyUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, + text: text) + else { + throw OpenCodeGoUsageError.parseFailed("Missing usage fields.") + } + + let monthlyPercent = self.extractDouble( + pattern: #"monthlyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, + text: text) + let monthlyReset = self.extractInt( + pattern: #"monthlyUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, + text: text) + + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: monthlyPercent != nil || monthlyReset != nil, + rollingUsagePercent: rollingPercent, + weeklyUsagePercent: weeklyPercent, + monthlyUsagePercent: monthlyPercent ?? 0, + rollingResetInSec: rollingReset, + weeklyResetInSec: weeklyReset, + monthlyResetInSec: monthlyReset ?? 0, + updatedAt: now) + } + + private static func parseSubscriptionJSON(text: String, now: Date) -> OpenCodeGoUsageSnapshot? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []), + let dict = object as? [String: Any] + else { + return nil + } + + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) + if let snapshot = self.parseUsageDictionary(dict, now: now, inheritedRenewsAt: renewsAt) { + return snapshot + } + for key in ["data", "result", "usage", "billing", "payload"] { + if let nested = dict[key] as? [String: Any], + let snapshot = self.parseUsageDictionary(nested, now: now, inheritedRenewsAt: renewsAt) + { + return snapshot + } + } + if let snapshot = self.parseUsageNested(dict, now: now, depth: 0, inheritedRenewsAt: renewsAt) { + return snapshot + } + return self.parseUsageFromCandidates(object: object, now: now, inheritedRenewsAt: renewsAt) + } + + private static func parseUsageDictionary( + _ dict: [String: Any], + now: Date, + inheritedRenewsAt: Date?) -> OpenCodeGoUsageSnapshot? + { + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) ?? inheritedRenewsAt + if let usage = dict["usage"] as? [String: Any], + let snapshot = self.parseUsageDictionary(usage, now: now, inheritedRenewsAt: renewsAt) + { + return snapshot + } + + let rollingKeys = ["rollingUsage", "rolling", "rolling_usage", "rollingWindow", "rolling_window"] + let weeklyKeys = ["weeklyUsage", "weekly", "weekly_usage", "weeklyWindow", "weekly_window"] + let monthlyKeys = ["monthlyUsage", "monthly", "monthly_usage", "monthlyWindow", "monthly_window"] + + let rolling = self.firstDict(from: dict, keys: rollingKeys) + let weekly = self.firstDict(from: dict, keys: weeklyKeys) + let monthly = self.firstDict(from: dict, keys: monthlyKeys) + + guard let rolling, let weekly else { return nil } + + return self.buildSnapshot(rolling: rolling, weekly: weekly, monthly: monthly, now: now, renewsAt: renewsAt) + } + + private static func parseUsageNested( + _ dict: [String: Any], + now: Date, + depth: Int, + inheritedRenewsAt: Date?) -> OpenCodeGoUsageSnapshot? + { + if depth > 3 { return nil } + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) ?? inheritedRenewsAt + var rolling: [String: Any]? + var weekly: [String: Any]? + var monthly: [String: Any]? + + for (key, value) in dict { + guard let sub = value as? [String: Any] else { continue } + let lower = key.lowercased() + if lower.contains("rolling") || lower.contains("hour") || lower.contains("5h") || lower.contains("5-hour") { + rolling = sub + } else if lower.contains("weekly") || lower.contains("week") { + weekly = sub + } else if lower.contains("monthly") || lower.contains("month") { + monthly = sub + } + } + + if let rolling, let weekly { + let snapshot = self.buildSnapshot( + rolling: rolling, + weekly: weekly, + monthly: monthly, + now: now, + renewsAt: renewsAt) + if let snapshot { return snapshot } + } + + for value in dict.values { + if let sub = value as? [String: Any], + let snapshot = self.parseUsageNested( + sub, + now: now, + depth: depth + 1, + inheritedRenewsAt: renewsAt) + { + return snapshot + } + } + + return nil + } + + private static func parseUsageFromCandidates( + object: Any, + now: Date, + inheritedRenewsAt: Date? = nil) -> OpenCodeGoUsageSnapshot? + { + let candidates = self.collectWindowCandidates(object: object, now: now) + guard !candidates.isEmpty else { return nil } + + let rollingCandidates = candidates.filter { candidate in + candidate.pathLower.contains("rolling") || + candidate.pathLower.contains("hour") || + candidate.pathLower.contains("5h") || + candidate.pathLower.contains("5-hour") + } + let weeklyCandidates = candidates.filter { candidate in + candidate.pathLower.contains("weekly") || + candidate.pathLower.contains("week") + } + let monthlyCandidates = candidates.filter { candidate in + candidate.pathLower.contains("monthly") || + candidate.pathLower.contains("month") + } + + let rolling = self.pickCandidate( + preferred: rollingCandidates, + fallback: candidates, + pickShorter: true) + let weekly = self.pickCandidate( + from: weeklyCandidates.filter { candidate in + candidate.id != rolling?.id + }, + pickShorter: false) + let monthly = self.pickCandidate( + from: monthlyCandidates.filter { candidate in + candidate.id != rolling?.id && candidate.id != weekly?.id + }, + pickShorter: false) + + guard let rolling, let weekly else { return nil } + + let renewsAt = self.dateValue(from: self.value(from: object as? [String: Any] ?? [:], keys: self.renewAtKeys)) + ?? inheritedRenewsAt + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: monthly != nil, + rollingUsagePercent: rolling.percent, + weeklyUsagePercent: weekly.percent, + monthlyUsagePercent: monthly?.percent ?? 0, + rollingResetInSec: rolling.resetInSec, + weeklyResetInSec: weekly.resetInSec, + monthlyResetInSec: monthly?.resetInSec ?? 0, + renewsAt: renewsAt, + updatedAt: now) + } + + private struct WindowCandidate { + let id: UUID + let percent: Double + let resetInSec: Int + let pathLower: String + } + + private static func collectWindowCandidates(object: Any, now: Date) -> [WindowCandidate] { + var candidates: [WindowCandidate] = [] + self.collectWindowCandidates(object: object, now: now, path: [], out: &candidates) + return candidates + } + + private static func collectWindowCandidates( + object: Any, + now: Date, + path: [String], + out: inout [WindowCandidate]) + { + if let dict = object as? [String: Any] { + if let window = self.parseWindow(dict, now: now) { + let pathLower = path.joined(separator: ".").lowercased() + out.append(WindowCandidate( + id: UUID(), + percent: window.percent, + resetInSec: window.resetInSec, + pathLower: pathLower)) + } + for (key, value) in dict { + self.collectWindowCandidates(object: value, now: now, path: path + [key], out: &out) + } + return + } + + if let array = object as? [Any] { + for (index, value) in array.enumerated() { + self.collectWindowCandidates( + object: value, + now: now, + path: path + ["[\(index)]"], + out: &out) + } + } + } + + private static func pickCandidate( + preferred: [WindowCandidate], + fallback: [WindowCandidate], + pickShorter: Bool, + excluding excluded: UUID? = nil) -> WindowCandidate? + { + let filteredPreferred = preferred.filter { $0.id != excluded } + if let picked = self.pickCandidate(from: filteredPreferred, pickShorter: pickShorter) { + return picked + } + let filteredFallback = fallback.filter { $0.id != excluded } + return self.pickCandidate(from: filteredFallback, pickShorter: pickShorter) + } + + private static func pickCandidate(from candidates: [WindowCandidate], pickShorter: Bool) -> WindowCandidate? { + guard !candidates.isEmpty else { return nil } + let comparator: (WindowCandidate, WindowCandidate) -> Bool = { lhs, rhs in + if pickShorter { + if lhs.resetInSec == rhs.resetInSec { return lhs.percent > rhs.percent } + return lhs.resetInSec < rhs.resetInSec + } + if lhs.resetInSec == rhs.resetInSec { return lhs.percent > rhs.percent } + return lhs.resetInSec > rhs.resetInSec + } + return candidates.min(by: comparator) + } + + private static func firstDict(from dict: [String: Any], keys: [String]) -> [String: Any]? { + for key in keys { + if let value = dict[key] as? [String: Any] { + return value + } + } + return nil + } + + private static func buildSnapshot( + rolling: [String: Any], + weekly: [String: Any], + monthly: [String: Any]?, + now: Date, + renewsAt: Date? = nil) -> OpenCodeGoUsageSnapshot? + { + guard let rollingWindow = self.parseWindow(rolling, now: now), + let weeklyWindow = self.parseWindow(weekly, now: now) + else { + return nil + } + + let monthlyWindow = monthly.flatMap { self.parseWindow($0, now: now) } + + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: monthlyWindow != nil, + rollingUsagePercent: rollingWindow.percent, + weeklyUsagePercent: weeklyWindow.percent, + monthlyUsagePercent: monthlyWindow?.percent ?? 0, + rollingResetInSec: rollingWindow.resetInSec, + weeklyResetInSec: weeklyWindow.resetInSec, + monthlyResetInSec: monthlyWindow?.resetInSec ?? 0, + renewsAt: renewsAt, + updatedAt: now) + } + + private static func parseWindow(_ dict: [String: Any], now: Date) -> (percent: Double, resetInSec: Int)? { + var percent: Double? + + for key in self.percentKeys { + if let value = self.doubleValue(from: dict[key]) { + percent = value + break + } + } + + if percent == nil { + let usedKeys = ["used", "usage", "consumed", "count", "usedTokens"] + let limitKeys = ["limit", "total", "quota", "max", "cap", "tokenLimit"] + var used: Double? + for key in usedKeys { + if let value = self.doubleValue(from: dict[key]) { + used = value + break + } + } + var limit: Double? + for key in limitKeys { + if let value = self.doubleValue(from: dict[key]) { + limit = value + break + } + } + if let used, let limit, limit > 0 { + percent = (used / limit) * 100 + } + } + + guard var resolvedPercent = percent else { return nil } + if resolvedPercent <= 1.0, resolvedPercent >= 0 { + resolvedPercent *= 100 + } + resolvedPercent = max(0, min(100, resolvedPercent)) + + var resetInSec: Int? + for key in self.resetInKeys { + if let value = self.intValue(from: dict[key]) { + resetInSec = value + break + } + } + + if resetInSec == nil { + for key in self.resetAtKeys { + if let resetAt = self.dateValue(from: dict[key]) { + resetInSec = max(0, Int(resetAt.timeIntervalSince(now))) + break + } + } + } + + let resolvedReset = max(0, resetInSec ?? 0) + return (resolvedPercent, resolvedReset) + } + + private static func fetchServerText( + request serverRequest: ServerRequest, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> String + { + let url = self.serverRequestURL( + serverID: serverRequest.serverID, + args: serverRequest.args, + method: serverRequest.method) + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = serverRequest.method + urlRequest.timeoutInterval = timeout + urlRequest.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + urlRequest.setValue(serverRequest.serverID, forHTTPHeaderField: "X-Server-Id") + urlRequest.setValue("server-fn:\(UUID().uuidString)", forHTTPHeaderField: "X-Server-Instance") + urlRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + urlRequest.setValue(self.baseURL.absoluteString, forHTTPHeaderField: "Origin") + urlRequest.setValue(serverRequest.referer.absoluteString, forHTTPHeaderField: "Referer") + urlRequest.setValue("text/javascript, application/json;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept") + if serverRequest.method.uppercased() != "GET", + let args = serverRequest.args + { + urlRequest.httpBody = args.data(using: .utf8) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let httpResponse = try await session.response(for: urlRequest) + + guard httpResponse.statusCode == 200 else { + let bodyText = String(data: httpResponse.data, encoding: .utf8) ?? "" + let contentType = httpResponse.response.value(forHTTPHeaderField: "Content-Type") ?? "unknown" + let dataLength = httpResponse.data.count + Self.log.error( + "OpenCode Go returned \(httpResponse.statusCode) (type=\(contentType) length=\(dataLength))") + if self.looksSignedOut(text: bodyText) { + throw OpenCodeGoUsageError.invalidCredentials + } + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw OpenCodeGoUsageError.invalidCredentials + } + if let message = self.extractServerErrorMessage(from: bodyText) { + throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode): \(message)") + } + throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + + guard let text = String(data: httpResponse.data, encoding: .utf8) else { + throw OpenCodeGoUsageError.parseFailed("Response was not UTF-8.") + } + return text + } + + static func fetchPageText( + url: URL, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> String + { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue( + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + forHTTPHeaderField: "Accept") + + let httpResponse = try await session.response(for: request) + guard httpResponse.statusCode == 200 else { + let bodyText = String(data: httpResponse.data, encoding: .utf8) ?? "" + if self.looksSignedOut(text: bodyText) { + throw OpenCodeGoUsageError.invalidCredentials + } + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw OpenCodeGoUsageError.invalidCredentials + } + if let message = self.extractServerErrorMessage(from: bodyText) { + throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode): \(message)") + } + throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + guard let text = String(data: httpResponse.data, encoding: .utf8) else { + throw OpenCodeGoUsageError.parseFailed("Response was not UTF-8.") + } + return text + } + + private static func serverRequestURL(serverID: String, args: String?, method: String) -> URL { + guard method.uppercased() == "GET" else { + return self.serverURL + } + + var components = URLComponents(url: self.serverURL, resolvingAgainstBaseURL: false) + var queryItems = [URLQueryItem(name: "id", value: serverID)] + if let args, !args.isEmpty { + queryItems.append(URLQueryItem(name: "args", value: args)) + } + components?.queryItems = queryItems + return components?.url ?? self.serverURL + } + + static func looksSignedOut(text: String) -> Bool { + let lower = text.lowercased() + return lower.contains("login") || + lower.contains("sign in") || + lower.contains("auth/authorize") || + lower.contains("not associated with an account") || + lower.contains("actor of type \"public\"") + } + + private static func extractServerErrorMessage(from text: String) -> String? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + if let match = text.range(of: #"(?i)([^<]+)"#, options: .regularExpression) { + return String(text[match].dropFirst(7).dropLast(8)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + guard let dict = object as? [String: Any] else { return nil } + if let message = dict["message"] as? String, !message.isEmpty { + return message + } + if let error = dict["error"] as? String, !error.isEmpty { + return error + } + if let detail = dict["detail"] as? String, !detail.isEmpty { + return detail + } + return nil + } + + private static func extractDouble(pattern: String, text: String) -> Double? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let nsrange = NSRange(text.startIndex.. Int? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let nsrange = NSRange(text.startIndex.. Double? { + switch value { + case let number as Double: + number + case let number as NSNumber: + number.doubleValue + case let string as String: + Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + nil + } + } + + private static func intValue(from value: Any?) -> Int? { + switch value { + case let number as Int: + number + case let number as NSNumber: + number.intValue + case let string as String: + Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + nil + } + } + + private static func value(from dict: [String: Any], keys: [String]) -> Any? { + for key in keys { + if let value = dict[key] { + return value + } + } + return nil + } + + private static func dateValue(from value: Any?) -> Date? { + guard let value else { return nil } + if let number = self.doubleValue(from: value) { + if number > 1_000_000_000_000 { + return Date(timeIntervalSince1970: number / 1000) + } + if number > 1_000_000_000 { + return Date(timeIntervalSince1970: number) + } + } + if let string = value as? String { + if let number = Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) { + return self.dateValue(from: number) + } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = formatter.date(from: string) { + return parsed + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift new file mode 100644 index 0000000000..d1c1a2ce2d --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -0,0 +1,136 @@ +import Foundation + +public struct OpenCodeGoUsageSnapshot: Sendable { + public let isBalanceOnly: Bool + public let hasMonthlyUsage: Bool + public let rollingUsagePercent: Double + public let weeklyUsagePercent: Double + public let monthlyUsagePercent: Double + public let rollingResetInSec: Int + public let weeklyResetInSec: Int + public let monthlyResetInSec: Int + public let zenBalanceUSD: Double? + public let renewsAt: Date? + public let updatedAt: Date + + public init( + isBalanceOnly: Bool = false, + hasMonthlyUsage: Bool, + rollingUsagePercent: Double, + weeklyUsagePercent: Double, + monthlyUsagePercent: Double, + rollingResetInSec: Int, + weeklyResetInSec: Int, + monthlyResetInSec: Int, + zenBalanceUSD: Double? = nil, + renewsAt: Date? = nil, + updatedAt: Date) + { + self.isBalanceOnly = isBalanceOnly + self.hasMonthlyUsage = hasMonthlyUsage + self.rollingUsagePercent = rollingUsagePercent + self.weeklyUsagePercent = weeklyUsagePercent + self.monthlyUsagePercent = monthlyUsagePercent + self.rollingResetInSec = rollingResetInSec + self.weeklyResetInSec = weeklyResetInSec + self.monthlyResetInSec = monthlyResetInSec + self.zenBalanceUSD = zenBalanceUSD + self.renewsAt = renewsAt + self.updatedAt = updatedAt + } + + public static func zenBalanceOnly(balanceUSD: Double, updatedAt: Date) -> OpenCodeGoUsageSnapshot { + OpenCodeGoUsageSnapshot( + isBalanceOnly: true, + hasMonthlyUsage: false, + rollingUsagePercent: 0, + weeklyUsagePercent: 0, + monthlyUsagePercent: 0, + rollingResetInSec: 0, + weeklyResetInSec: 0, + monthlyResetInSec: 0, + zenBalanceUSD: balanceUSD, + updatedAt: updatedAt) + } + + public func toUsageSnapshot() -> UsageSnapshot { + if self.isBalanceOnly { + return UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: self.providerCostSnapshot, + updatedAt: self.updatedAt, + identity: nil) + } + + let rollingReset = self.updatedAt.addingTimeInterval(TimeInterval(self.rollingResetInSec)) + let weeklyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.weeklyResetInSec)) + + let primary = RateWindow( + usedPercent: self.rollingUsagePercent, + windowMinutes: 5 * 60, + resetsAt: rollingReset, + resetDescription: nil) + let secondary = RateWindow( + usedPercent: self.weeklyUsagePercent, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil) + let tertiary: RateWindow? + if self.hasMonthlyUsage { + let monthlyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.monthlyResetInSec)) + tertiary = RateWindow( + usedPercent: self.monthlyUsagePercent, + windowMinutes: 30 * 24 * 60, + resetsAt: monthlyReset, + resetDescription: nil) + } else { + tertiary = nil + } + + var extraWindows: [NamedRateWindow]? + if let renewsAt = self.renewsAt { + let renewalWindow = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: renewsAt, + resetDescription: nil) + extraWindows = [NamedRateWindow(id: "renewal", title: "Renews", window: renewalWindow)] + } + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + extraRateWindows: extraWindows, + providerCost: self.providerCostSnapshot, + updatedAt: self.updatedAt, + identity: nil) + } + + private var providerCostSnapshot: ProviderCostSnapshot? { + self.zenBalanceUSD.map { + ProviderCostSnapshot( + used: $0, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: self.updatedAt) + } + } + + public func withZenBalanceUSD(_ balance: Double?) -> OpenCodeGoUsageSnapshot { + OpenCodeGoUsageSnapshot( + isBalanceOnly: self.isBalanceOnly, + hasMonthlyUsage: self.hasMonthlyUsage, + rollingUsagePercent: self.rollingUsagePercent, + weeklyUsagePercent: self.weeklyUsagePercent, + monthlyUsagePercent: self.monthlyUsagePercent, + rollingResetInSec: self.rollingResetInSec, + weeklyResetInSec: self.weeklyResetInSec, + monthlyResetInSec: self.monthlyResetInSec, + zenBalanceUSD: balance, + renewsAt: self.renewsAt, + updatedAt: self.updatedAt) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift new file mode 100644 index 0000000000..cbc0b3f4e0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift @@ -0,0 +1,166 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +private final class OpenCodeGoZenBalanceTaskRace: @unchecked Sendable { + private let lock = NSLock() + private let sourceTask: Task + private var result: Result? + private var continuation: CheckedContinuation? + private var observerTask: Task? + private var timeoutTask: Task? + + init(sourceTask: Task) { + self.sourceTask = sourceTask + } + + func value(timeout: Duration? = nil) async throws -> Double? { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + self.lock.lock() + if let result = self.result { + self.lock.unlock() + continuation.resume(with: result) + return + } + + self.continuation = continuation + let sourceTask = self.sourceTask + self.observerTask = Task { [weak self] in + do { + let value = try await sourceTask.value + self?.resolve(.success(value), cancelSource: false) + } catch { + self?.resolve(.failure(error), cancelSource: false) + } + } + if let timeout { + self.timeoutTask = Task { [weak self] in + do { + try await Task.sleep(for: timeout) + self?.resolve(.success(nil), cancelSource: true) + } catch { + // The source completed or the caller canceled the race. + } + } + } + self.lock.unlock() + } + } onCancel: { + self.resolve(.failure(CancellationError()), cancelSource: true) + } + } + + private func resolve(_ result: Result, cancelSource: Bool) { + self.lock.lock() + guard self.result == nil else { + self.lock.unlock() + return + } + + self.result = result + let continuation = self.continuation + self.continuation = nil + let observerTask = self.observerTask + let timeoutTask = self.timeoutTask + self.observerTask = nil + self.timeoutTask = nil + self.lock.unlock() + + if cancelSource { + self.sourceTask.cancel() + } + observerTask?.cancel() + timeoutTask?.cancel() + continuation?.resume(with: result) + } +} + +extension OpenCodeGoUsageFetcher { + static let optionalZenBalanceTimeout: TimeInterval = 5 + static let optionalZenBalanceStartDelay: Duration = .milliseconds(25) + static let optionalZenBalanceJoinGrace: Duration = .milliseconds(250) + + public static func zenDashboardURL(workspaceID raw: String?) -> URL { + guard let workspaceID = self.normalizeWorkspaceID(raw), + let url = URL(string: "https://opencode.ai/workspace/\(workspaceID)") + else { + return URL(string: "https://opencode.ai")! + } + return url + } + + static func fetchOptionalZenBalance( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> Double? + { + do { + let balance = try await self.fetchZenBalance( + workspaceID: workspaceID, + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + try Task.checkCancellation() + return balance + } catch is CancellationError { + throw CancellationError() + } catch { + if Task.isCancelled { + throw CancellationError() + } + return nil + } + } + + static func completedOptionalZenBalance(from task: Task) async throws -> Double? { + let race = OpenCodeGoZenBalanceTaskRace(sourceTask: task) + do { + return try await race.value(timeout: self.optionalZenBalanceJoinGrace) + } catch is CancellationError { + throw CancellationError() + } catch { + return nil + } + } + + static func completedRequiredZenBalance(from task: Task) async throws -> Double? { + let race = OpenCodeGoZenBalanceTaskRace(sourceTask: task) + return try await race.value() + } + + static func parseZenBalance(text: String) -> Double? { + OpenCodeGoZenBalanceParser.parse(text: text) + } + + static func fetchZenBalance( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> Double? + { + let text = try await self.fetchPageText( + url: self.zenDashboardURL(workspaceID: workspaceID), + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + if self.looksSignedOut(text: text) { + throw OpenCodeGoUsageError.invalidCredentials + } + if let balance = self.parseZenBalance(text: text) { + return balance + } + + let billingText = try await self.fetchZenBillingText( + workspaceID: workspaceID, + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + if self.looksSignedOut(text: billingText) { + throw OpenCodeGoUsageError.invalidCredentials + } + return OpenCodeGoZenBalanceParser.parseBillingServerResponse(text: billingText) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift new file mode 100644 index 0000000000..45fc7cb8c4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift @@ -0,0 +1,155 @@ +import Foundation + +enum OpenCodeGoZenBalanceParser { + private static let billingScale = 100_000_000.0 + + static func parse(text: String) -> Double? { + if let value = self.parseJSON(text: text) { + return value + } + let localizedPattern = [ + #"(?i)(?:current\s+balance|zen\s+balance|現在の残高)"#, + #"[^$]{0,80}\$\s*([0-9][0-9,]*(?:\.[0-9]+)?)"#, + ].joined() + if let value = self.extractDollarValue(pattern: localizedPattern, text: text) { + return value + } + let nearbyPattern = #"(?i)(?:balance|残高)[\s\S]{0,120}?\$\s*([0-9][0-9,]*(?:\.[0-9]+)?)"# + return self.extractDollarValue(pattern: nearbyPattern, text: text) + } + + static func parseBillingServerResponse(text: String) -> Double? { + if let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []), + let rawBalance = self.findRawBillingBalance(in: object) + { + return rawBalance / self.billingScale + } + + let customerPattern = + #"(?:\"customerID\"|customerID)\s*:\s*(?:\$R\[\d+\]\s*=\s*)?\"[^\"]+\""# + guard self.containsMatch(pattern: customerPattern, text: text) else { + return nil + } + let pattern = #"(?:\"balance\"|balance)\s*:\s*(?:\$R\[\d+\]\s*=\s*)?(-?[0-9]+(?:\.[0-9]+)?)"# + guard let rawBalance = self.extractNumber(pattern: pattern, text: text) else { + return nil + } + return rawBalance / self.billingScale + } + + private static func parseJSON(text: String) -> Double? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + return nil + } + return self.findBalanceValue(in: object, path: []) + } + + private static func findBalanceValue(in object: Any, path: [String]) -> Double? { + if let dict = object as? [String: Any] { + for (key, value) in dict { + let nextPath = path + [key] + if self.isExplicitBalanceAmountKey(key), + let number = self.doubleValue(from: value) + { + return number + } + if let found = self.findBalanceValue(in: value, path: nextPath) { + return found + } + } + return nil + } + if let array = object as? [Any] { + for (index, value) in array.enumerated() { + if let found = self.findBalanceValue(in: value, path: path + ["[\(index)]"]) { + return found + } + } + } + return nil + } + + private static func findRawBillingBalance(in object: Any) -> Double? { + if let dict = object as? [String: Any] { + if dict["balance"] != nil { + guard let customerID = dict["customerID"] as? String, + !customerID.isEmpty + else { + return nil + } + guard let rawBalance = self.doubleValue(from: dict["balance"]) else { + return nil + } + return rawBalance + } + for value in dict.values { + if let found = self.findRawBillingBalance(in: value) { + return found + } + } + } else if let array = object as? [Any] { + for value in array { + if let found = self.findRawBillingBalance(in: value) { + return found + } + } + } + return nil + } + + private static func containsMatch(pattern: String, text: String) -> Bool { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return false } + let nsrange = NSRange(text.startIndex.. Bool { + let normalized = key + .lowercased() + .filter { $0.isLetter || $0.isNumber } + return [ + "zenbalance", + "zencurrentbalance", + "currentbalance", + "currentbalanceusd", + "balanceusd", + "usdbalance", + ].contains(normalized) + } + + private static func extractDollarValue(pattern: String, text: String) -> Double? { + self.extractNumber(pattern: pattern, text: text) + } + + private static func extractNumber(pattern: String, text: String) -> Double? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let nsrange = NSRange(text.startIndex.. Double? { + switch value { + case is Bool: + nil + case let number as Double: + number + case let number as NSNumber: + number.doubleValue + case let string as String: + Double( + string + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ",", with: "")) + default: + nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift index 7119541985..7cc05ef485 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum OpenRouterProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .openrouter, @@ -31,9 +30,15 @@ public enum OpenRouterProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "OpenRouter cost summary is not yet supported." }), - fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .api], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenRouterAPIFetchStrategy()] })), + fetchPlan: .apiToken( + strategyID: "openrouter.api", + resolveToken: { ProviderTokenResolver.openRouterToken(environment: $0) }, + missingCredentialsError: { OpenRouterSettingsError.missingToken }, + loadUsage: { apiKey, context in + try await OpenRouterUsageFetcher.fetchUsage( + apiKey: apiKey, + environment: context.env).toUsageSnapshot() + }), cli: ProviderCLIConfig( name: "openrouter", aliases: ["or"], @@ -41,43 +46,17 @@ public enum OpenRouterProviderDescriptor { } } -struct OpenRouterAPIFetchStrategy: ProviderFetchStrategy { - let id: String = "openrouter.api" - let kind: ProviderFetchKind = .apiToken - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let apiKey = Self.resolveToken(environment: context.env) else { - throw OpenRouterSettingsError.missingToken - } - let usage = try await OpenRouterUsageFetcher.fetchUsage( - apiKey: apiKey, - environment: context.env) - return self.makeResult( - usage: usage.toUsageSnapshot(), - sourceLabel: "api") - } - - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false - } - - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.openRouterToken(environment: environment) - } -} - /// Errors related to OpenRouter settings -public enum OpenRouterSettingsError: LocalizedError, Sendable { +public enum OpenRouterSettingsError: LocalizedError, Sendable, Equatable { case missingToken + case invalidEndpointOverride(String) public var errorDescription: String? { switch self { case .missingToken: "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." + case let .invalidEndpointOverride(key): + "OpenRouter endpoint override \(key) must use HTTPS or a bare host." } } } diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift index e5e3f4d78e..3219e56c0d 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift @@ -12,14 +12,20 @@ public enum OpenRouterSettingsReader { /// Returns the API URL, defaulting to production endpoint public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { - if let override = environment["OPENROUTER_API_URL"], - let url = URL(string: cleaned(override) ?? "") - { - return url + if let override = self.validAPIURL(environment: environment) { + return override } return URL(string: "https://openrouter.ai/api/v1")! } + public static func validateEndpointOverrides( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard let raw = self.cleaned(environment["OPENROUTER_API_URL"]) else { return } + guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) == nil else { return } + throw OpenRouterSettingsError.invalidEndpointOverride("OPENROUTER_API_URL") + } + static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil @@ -28,11 +34,15 @@ public enum OpenRouterSettingsReader { if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { - value.removeFirst() - value.removeLast() + value = String(value.dropFirst().dropLast()) } value = value.trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } + + private static func validAPIURL(environment: [String: String]) -> URL? { + guard let raw = self.cleaned(environment["OPENROUTER_API_URL"]) else { return nil } + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) + } } diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index fbcc3e44c5..bfbbacc554 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -45,11 +45,20 @@ public struct OpenRouterKeyData: Decodable, Sendable { public let limit: Double? /// Current usage public let usage: Double? + /// API key usage for the current UTC day. + public let usageDaily: Double? + /// API key usage for the current UTC week. + public let usageWeekly: Double? + /// API key usage for the current UTC month. + public let usageMonthly: Double? private enum CodingKeys: String, CodingKey { case rateLimit = "rate_limit" case limit case usage + case usageDaily = "usage_daily" + case usageWeekly = "usage_weekly" + case usageMonthly = "usage_monthly" } } @@ -59,6 +68,11 @@ public struct OpenRouterRateLimit: Codable, Sendable { public let requests: Int /// Interval for the rate limit (e.g., "10s", "1m") public let interval: String + + public init(requests: Int, interval: String) { + self.requests = requests + self.interval = interval + } } public enum OpenRouterKeyQuotaStatus: String, Codable, Sendable { @@ -76,6 +90,9 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { public let keyDataFetched: Bool public let keyLimit: Double? public let keyUsage: Double? + public let keyUsageDaily: Double? + public let keyUsageWeekly: Double? + public let keyUsageMonthly: Double? public let rateLimit: OpenRouterRateLimit? public let updatedAt: Date @@ -87,6 +104,9 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { keyDataFetched: Bool = false, keyLimit: Double? = nil, keyUsage: Double? = nil, + keyUsageDaily: Double? = nil, + keyUsageWeekly: Double? = nil, + keyUsageMonthly: Double? = nil, rateLimit: OpenRouterRateLimit?, updatedAt: Date) { @@ -94,9 +114,13 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { self.totalUsage = totalUsage self.balance = balance self.usedPercent = usedPercent - self.keyDataFetched = keyDataFetched || keyLimit != nil || keyUsage != nil + self.keyDataFetched = keyDataFetched || keyLimit != nil || keyUsage != nil || + keyUsageDaily != nil || keyUsageWeekly != nil || keyUsageMonthly != nil self.keyLimit = keyLimit self.keyUsage = keyUsage + self.keyUsageDaily = keyUsageDaily + self.keyUsageWeekly = keyUsageWeekly + self.keyUsageMonthly = keyUsageMonthly self.rateLimit = rateLimit self.updatedAt = updatedAt } @@ -201,6 +225,7 @@ public struct OpenRouterUsageFetcher: Sendable { guard !apiKey.isEmpty else { throw OpenRouterUsageError.invalidCredentials } + try OpenRouterSettingsReader.validateEndpointOverrides(environment: environment) let baseURL = OpenRouterSettingsReader.apiURL(environment: environment) let creditsURL = baseURL.appendingPathComponent("credits") @@ -216,21 +241,17 @@ public struct OpenRouterUsageFetcher: Sendable { let title = Self.sanitizedHeaderValue(environment[self.clientTitleEnvKey]) ?? Self.defaultClientTitle request.setValue(title, forHTTPHeaderField: "X-Title") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw OpenRouterUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { let errorSummary = LogRedactor.redact(Self.sanitizedResponseBodySummary(data)) if Self.debugFullErrorBodiesEnabled(environment: environment), let debugBody = Self.redactedDebugResponseBody(data) { Self.log.debug("OpenRouter non-200 body (redacted): \(LogRedactor.redact(debugBody))") } - Self.log.error("OpenRouter API returned \(httpResponse.statusCode): \(errorSummary)") - throw OpenRouterUsageError.apiError("HTTP \(httpResponse.statusCode)") + Self.log.error("OpenRouter API returned \(response.statusCode): \(errorSummary)") + throw OpenRouterUsageError.apiError("HTTP \(response.statusCode)") } do { @@ -239,7 +260,7 @@ public struct OpenRouterUsageFetcher: Sendable { // Optionally fetch key quota/rate-limit info from /key endpoint, but keep this bounded so // credits updates are not blocked by a slow or unavailable secondary endpoint. - let keyFetch = await fetchKeyData( + let keyFetch = try await fetchKeyData( apiKey: apiKey, baseURL: baseURL, timeoutSeconds: Self.rateLimitTimeoutSeconds) @@ -252,8 +273,13 @@ public struct OpenRouterUsageFetcher: Sendable { keyDataFetched: keyFetch.fetched, keyLimit: keyFetch.data?.limit, keyUsage: keyFetch.data?.usage, + keyUsageDaily: keyFetch.data?.usageDaily, + keyUsageWeekly: keyFetch.data?.usageWeekly, + keyUsageMonthly: keyFetch.data?.usageMonthly, rateLimit: keyFetch.data?.rateLimit, updatedAt: Date()) + } catch is CancellationError { + throw CancellationError() } catch let error as DecodingError { Self.log.error("OpenRouter JSON decoding error: \(error.localizedDescription)") throw OpenRouterUsageError.parseFailed(error.localizedDescription) @@ -266,7 +292,7 @@ public struct OpenRouterUsageFetcher: Sendable { } /// Fetches key quota/rate-limit info from /key endpoint - private struct OpenRouterKeyFetchResult: Sendable { + private struct OpenRouterKeyFetchResult { let data: OpenRouterKeyData? let fetched: Bool } @@ -274,37 +300,47 @@ public struct OpenRouterUsageFetcher: Sendable { private static func fetchKeyData( apiKey: String, baseURL: URL, - timeoutSeconds: TimeInterval) async -> OpenRouterKeyFetchResult + timeoutSeconds: TimeInterval) async throws -> OpenRouterKeyFetchResult { let timeout = max(0.1, timeoutSeconds) - let timeoutNanoseconds = UInt64(timeout * 1_000_000_000) - - return await withTaskGroup(of: OpenRouterKeyFetchResult.self) { group in - group.addTask { - await Self.fetchKeyDataRequest( - apiKey: apiKey, - baseURL: baseURL, - timeoutSeconds: timeout) - } - group.addTask { - do { - try await Task.sleep(nanoseconds: timeoutNanoseconds) - } catch { - // Cancelled because the /key request finished first. - return OpenRouterKeyFetchResult(data: nil, fetched: false) - } - guard !Task.isCancelled else { - return OpenRouterKeyFetchResult(data: nil, fetched: false) - } - Self.log.debug("OpenRouter /key enrichment timed out after \(timeout)s") - return OpenRouterKeyFetchResult(data: nil, fetched: false) - } + return try await self.boundedKeyFetch(timeout: .seconds(timeout)) { + await Self.fetchKeyDataRequest( + apiKey: apiKey, + baseURL: baseURL, + timeoutSeconds: timeout) + } + } - let result = await group.next() - group.cancelAll() - if let result { - return result - } + static func _boundedKeyFetchForTesting( + timeout: Duration, + operation: @escaping @Sendable () async -> Void) async throws -> Bool + { + let result = try await self.boundedKeyFetch(timeout: timeout) { + await operation() + return OpenRouterKeyFetchResult(data: nil, fetched: true) + } + return result.fetched + } + + private static func boundedKeyFetch( + timeout: Duration, + operation: @escaping @Sendable () async -> OpenRouterKeyFetchResult) async throws -> OpenRouterKeyFetchResult + { + let sourceTask = Task { + await operation() + } + let race = BoundedTaskJoin(sourceTask: sourceTask) + switch await race.value(joinGrace: timeout) { + case let .value(result): + try Task.checkCancellation() + return result + case .timedOut: + try Task.checkCancellation() + Self.log.debug("OpenRouter /key enrichment timed out") + return OpenRouterKeyFetchResult(data: nil, fetched: false) + case .failure: + sourceTask.cancel() + try Task.checkCancellation() return OpenRouterKeyFetchResult(data: nil, fetched: false) } } @@ -323,16 +359,13 @@ public struct OpenRouterUsageFetcher: Sendable { request.timeoutInterval = timeoutSeconds do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { return OpenRouterKeyFetchResult(data: nil, fetched: false) } let decoder = JSONDecoder() - let keyResponse = try decoder.decode(OpenRouterKeyResponse.self, from: data) + let keyResponse = try decoder.decode(OpenRouterKeyResponse.self, from: response.data) return OpenRouterKeyFetchResult(data: keyResponse.data, fetched: true) } catch { Self.log.debug("Failed to fetch OpenRouter /key enrichment: \(error.localizedDescription)") diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift new file mode 100644 index 0000000000..35baf93509 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum PerplexityAPIError: LocalizedError, Sendable, Equatable { + case missingToken + case invalidCookie + case invalidToken + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingToken: + "Perplexity session token is missing. Please log into Perplexity in your browser." + case .invalidCookie: + "Perplexity manual cookie header is empty or invalid." + case .invalidToken: + "Perplexity session token is invalid or expired. Please log in again." + case let .networkError(message): + "Perplexity network error: \(message)" + case let .apiError(message): + "Perplexity API error: \(message)" + case let .parseFailed(message): + "Failed to parse Perplexity usage data: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift new file mode 100644 index 0000000000..1c66a95f2c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift @@ -0,0 +1,129 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct PerplexityCookieOverride: Sendable { + public let name: String + public let token: String + public let requestCookieNames: [String] + + public init(name: String, token: String, requestCookieNames: [String]? = nil) { + self.name = name + self.token = token + self.requestCookieNames = requestCookieNames ?? [name] + } +} + +public enum PerplexityCookieHeader { + public static let defaultSessionCookieName = "__Secure-next-auth.session-token" + public static let supportedSessionCookieNames = [ + "__Secure-authjs.session-token", + "authjs.session-token", + "__Secure-next-auth.session-token", + "next-auth.session-token", + ] + + public static func resolveCookieOverride(context: ProviderFetchContext) -> PerplexityCookieOverride? { + if let settings = context.settings?.perplexity, settings.cookieSource == .manual { + if let manual = settings.manualCookieHeader, !manual.isEmpty { + return self.override(from: manual) + } + } + return nil + } + + public static func override(from raw: String?) -> PerplexityCookieOverride? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + + // Accept bare token value + if !raw.contains("="), !raw.contains(";") { + return PerplexityCookieOverride( + name: self.defaultSessionCookieName, + token: raw, + requestCookieNames: self.supportedSessionCookieNames) + } + + // Extract a supported session cookie from a full cookie string. + if let cookie = self.extractSessionCookie(from: raw) { + return cookie + } + + return nil + } + + static func sessionCookie(from cookies: [HTTPCookie]) -> PerplexityCookieOverride? { + self.extractSessionCookie(from: cookies.map { (name: $0.name, value: $0.value) }) + } + + private static func extractSessionCookie(from raw: String) -> PerplexityCookieOverride? { + let pairs = raw.split(separator: ";") + var cookies: [(name: String, value: String)] = [] + for pair in pairs { + let trimmed = pair.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + guard let separator = trimmed.firstIndex(of: "=") else { continue } + let key = String(trimmed[.. PerplexityCookieOverride? { + var cookieMap: [String: (name: String, value: String)] = [:] + var chunkedCookies: [String: [Int: (name: String, value: String)]] = [:] + + for cookie in cookies { + let loweredName = cookie.name.lowercased() + cookieMap[loweredName] = cookie + + for expected in self.supportedSessionCookieNames { + let loweredExpected = expected.lowercased() + let prefix = "\(loweredExpected)." + guard loweredName.hasPrefix(prefix) else { continue } + let suffix = String(loweredName.dropFirst(prefix.count)) + guard let index = Int(suffix) else { continue } + chunkedCookies[loweredExpected, default: [:]][index] = cookie + } + } + + for expected in self.supportedSessionCookieNames { + let loweredExpected = expected.lowercased() + if let match = cookieMap[loweredExpected] { + return PerplexityCookieOverride(name: match.name, token: match.value) + } + if let chunked = self.reassembleChunkedSessionCookie(from: chunkedCookies[loweredExpected]) { + return chunked + } + } + return nil + } + + private static func reassembleChunkedSessionCookie( + from chunks: [Int: (name: String, value: String)]?) -> PerplexityCookieOverride? + { + guard let chunks, + let firstChunk = chunks[0], + let maxIndex = chunks.keys.max() + else { + return nil + } + + var tokenParts: [String] = [] + tokenParts.reserveCapacity(maxIndex + 1) + for index in 0...maxIndex { + guard let chunk = chunks[index] else { return nil } + tokenParts.append(chunk.value) + } + + guard let suffixStart = firstChunk.name.lastIndex(of: ".") else { return nil } + let baseName = String(firstChunk.name[.. Void)?) throws -> SessionInfo)? + nonisolated(unsafe) static var importSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])? + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var sessionCookie: PerplexityCookieOverride? { + PerplexityCookieHeader.sessionCookie(from: self.cookies) + } + + public var sessionToken: String? { + self.sessionCookie?.token + } + } + + public static func importSessions( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + if let cached = self.cachedImportSessions() { + return cached + } + if let override = self.importSessionsOverrideForTesting { + let sessions = try override(browserDetection, logger) + self.storeImportSessions(sessions) + return sessions + } + if let override = self.importSessionOverrideForTesting { + let session = try override(browserDetection, logger) + let sessions = [session] + self.storeImportSessions(sessions) + return sessions + } + + var sessions: [SessionInfo] = [] + let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + for browserSource in candidates { + do { + let perSource = try self.importSessions(from: browserSource, logger: logger) + sessions.append(contentsOf: perSource) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + self.emit( + "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)", + logger: logger) + } + } + + guard !sessions.isEmpty else { + throw PerplexityCookieImportError.noCookies + } + self.storeImportSessions(sessions) + return sessions + } + + public static func importSessions( + from browserSource: Browser, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let log: (String) -> Void = { msg in self.emit(msg, logger: logger) } + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + + var sessions: [SessionInfo] = [] + let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id }) + let sortedGroups = grouped.values.sorted { lhs, rhs in + self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs) + } + + for group in sortedGroups where !group.isEmpty { + let label = self.mergedLabel(for: group) + let mergedRecords = self.mergeRecords(group) + guard !mergedRecords.isEmpty else { continue } + let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin) + guard !httpCookies.isEmpty else { continue } + + let session = SessionInfo(cookies: httpCookies, sourceLabel: label) + guard let sessionCookie = session.sessionCookie else { + continue + } + + log("Found \(sessionCookie.name) cookie in \(label)") + sessions.append(session) + } + return sessions + } + + public static func importSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) + guard let first = sessions.first else { + throw PerplexityCookieImportError.noCookies + } + return first + } + + public static func hasSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession(browserDetection: browserDetection, logger: logger) + return true + } catch { + return false + } + } + + static func invalidateImportSessionCache() { + self.importSessionCache.invalidate() + } + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[perplexity-cookie] \(message)") + self.log.debug(message) + } + + private static func cachedImportSessions(now: Date = Date()) -> [SessionInfo]? { + self.importSessionCache.load(now: now) + } + + private static func storeImportSessions(_ sessions: [SessionInfo], now: Date = Date()) { + self.importSessionCache.store(sessions, now: now) + } + + private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { + guard let base = sources.map(\.label).min() else { return "Unknown" } + if base.hasSuffix(" (Network)") { + return String(base.dropLast(" (Network)".count)) + } + return base + } + + private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] { + let sortedSources = sources.sorted { lhs, rhs in + self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind) + } + var mergedByKey: [String: BrowserCookieRecord] = [:] + for source in sortedSources { + for record in source.records { + let key = self.recordKey(record) + if let existing = mergedByKey[key] { + if self.shouldReplace(existing: existing, candidate: record) { + mergedByKey[key] = record + } + } else { + mergedByKey[key] = record + } + } + } + return Array(mergedByKey.values) + } + + private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int { + switch kind { + case .network: 0 + case .primary: 1 + case .safari: 2 + } + } + + private static func recordKey(_ record: BrowserCookieRecord) -> String { + "\(record.name)|\(record.domain)|\(record.path)" + } + + private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { + switch (existing.expires, candidate.expires) { + case let (lhs?, rhs?): rhs > lhs + case (nil, .some): true + case (.some, nil): false + case (nil, nil): false + } + } + + private final class ImportSessionCache: @unchecked Sendable { + private let ttl: TimeInterval + private let lock = NSLock() + private var entry: (sessions: [SessionInfo], expiresAt: Date)? + + init(ttl: TimeInterval) { + self.ttl = ttl + } + + func load(now: Date) -> [SessionInfo]? { + self.lock.lock() + defer { self.lock.unlock() } + guard let entry = self.entry else { return nil } + guard entry.expiresAt > now else { + self.entry = nil + return nil + } + return entry.sessions + } + + func store(_ sessions: [SessionInfo], now: Date) { + self.lock.lock() + defer { self.lock.unlock() } + self.entry = (sessions: sessions, expiresAt: now.addingTimeInterval(self.ttl)) + } + + func invalidate() { + self.lock.lock() + defer { self.lock.unlock() } + self.entry = nil + } + } +} + +enum PerplexityCookieImportError: LocalizedError { + case noCookies + + var errorDescription: String? { + switch self { + case .noCookies: + "No Perplexity session cookies found in browsers. Please log into perplexity.ai." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityModels.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityModels.swift new file mode 100644 index 0000000000..ae4ae592bf --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityModels.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct PerplexityCreditsResponse: Codable { + public let balanceCents: Double + public let renewalDateTs: TimeInterval + public let currentPeriodPurchasedCents: Double + public let creditGrants: [PerplexityCreditGrant] + public let totalUsageCents: Double + + enum CodingKeys: String, CodingKey { + case balanceCents = "balance_cents" + case renewalDateTs = "renewal_date_ts" + case currentPeriodPurchasedCents = "current_period_purchased_cents" + case creditGrants = "credit_grants" + case totalUsageCents = "total_usage_cents" + } +} + +public struct PerplexityCreditGrant: Codable { + public let type: String + public let amountCents: Double + public let expiresAtTs: TimeInterval? + + enum CodingKeys: String, CodingKey { + case type + case amountCents = "amount_cents" + case expiresAtTs = "expires_at_ts" + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift new file mode 100644 index 0000000000..2700549054 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift @@ -0,0 +1,237 @@ +import Foundation + +public enum PerplexityProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .perplexity, + metadata: ProviderMetadata( + id: .perplexity, + displayName: "Perplexity", + sessionLabel: "Credits", + weeklyLabel: "Bonus credits", + opusLabel: "Purchased", + supportsOpus: true, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Perplexity usage", + cliName: "perplexity", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://www.perplexity.ai/account/usage", + statusPageURL: nil, + statusLinkURL: "https://status.perplexity.com/"), + branding: ProviderBranding( + iconStyle: .perplexity, + iconResourceName: "ProviderIcon-perplexity", + color: ProviderColor(red: 32 / 255, green: 178 / 255, blue: 170 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Perplexity cost tracking is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [PerplexityWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "perplexity", + aliases: [], + versionDetector: nil)) + } +} + +struct PerplexityWebFetchStrategy: ProviderFetchStrategy { + private enum SessionCookieSource { + case manual + case cache + case browser + case environment + + var shouldCacheAfterFetch: Bool { + self == .browser + } + } + + private struct ResolvedSessionCookie { + let value: PerplexityCookieOverride + let source: SessionCookieSource + } + + private struct SessionFetchResult { + let snapshot: PerplexityUsageSnapshot + let cookie: PerplexityCookieOverride + } + + let id: String = "perplexity.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.perplexity?.cookieSource != .off else { return false } + if context.settings?.perplexity?.cookieSource == .manual { return true } + + // Priority order mirrors resolveSessionCookie: manual override → cache → browser import → env var + if PerplexityCookieHeader.resolveCookieOverride(context: context) != nil { + return true + } + + if CookieHeaderCache.load(provider: .perplexity) != nil { + return true + } + + #if os(macOS) + if context.settings?.perplexity?.cookieSource != .off { + if PerplexityCookieImporter.hasSession() { return true } + } + #endif + + if PerplexitySettingsReader.sessionToken(environment: context.env) != nil { + return true + } + + return false + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let resolvedCookies = try self.resolveSessionCookies(context: context) + guard !resolvedCookies.isEmpty else { + throw PerplexityAPIError.missingToken + } + var sawInvalidToken = false + + for resolvedCookie in resolvedCookies { + do { + let result = try await self.fetchSnapshot(using: resolvedCookie) + self.cacheSessionCookieIfNeeded(resolvedCookie, usedCookie: result.cookie, sourceLabel: "web") + return self.makeResult( + usage: result.snapshot.toUsageSnapshot(), + sourceLabel: "web") + } catch PerplexityAPIError.invalidToken { + sawInvalidToken = true + if resolvedCookie.source == .cache { + CookieHeaderCache.clear(provider: .perplexity) + } + continue + } + } + + if sawInvalidToken { + throw PerplexityAPIError.invalidToken + } + throw PerplexityAPIError.missingToken + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + if case PerplexityAPIError.missingToken = error { return false } + if case PerplexityAPIError.invalidCookie = error { return false } + if case PerplexityAPIError.invalidToken = error { return false } + return true + } + + private func resolveSessionCookies(context: ProviderFetchContext) throws -> [ResolvedSessionCookie] { + guard context.settings?.perplexity?.cookieSource != .off else { return [] } + + if context.settings?.perplexity?.cookieSource == .manual { + guard let override = PerplexityCookieHeader.resolveCookieOverride(context: context) else { + throw PerplexityAPIError.invalidCookie + } + return [ResolvedSessionCookie(value: override, source: .manual)] + } + + var cookies: [ResolvedSessionCookie] = [] + + // Try cached cookie before expensive browser import + if let cached = CookieHeaderCache.load(provider: .perplexity) { + if let override = PerplexityCookieHeader.override(from: cached.cookieHeader) { + cookies.append(ResolvedSessionCookie(value: override, source: .cache)) + } + } + + cookies.append(contentsOf: self.resolveSessionCookiesFromBrowserOrEnv(context: context)) + return self.deduplicatedSessionCookies(cookies) + } + + private func resolveSessionCookiesFromBrowserOrEnv( + context: ProviderFetchContext, + preferEnvironment: Bool = false) -> [ResolvedSessionCookie] + { + guard context.settings?.perplexity?.cookieSource != .off else { return [] } + var cookies: [ResolvedSessionCookie] = [] + + if preferEnvironment, + let cookie = PerplexitySettingsReader.sessionCookieOverride(environment: context.env) + { + cookies.append(ResolvedSessionCookie(value: cookie, source: .environment)) + } + + // Try browser cookie import when auto mode is enabled + #if os(macOS) + do { + let sessions = try PerplexityCookieImporter.importSessions() + cookies.append(contentsOf: sessions.compactMap { session in + guard let cookie = session.sessionCookie else { return nil } + return ResolvedSessionCookie(value: cookie, source: .browser) + }) + } catch { + // No browser cookies found + } + #endif + + // Fall back to environment + if !preferEnvironment, + let cookie = PerplexitySettingsReader.sessionCookieOverride(environment: context.env) + { + cookies.append(ResolvedSessionCookie(value: cookie, source: .environment)) + } + return self.deduplicatedSessionCookies(cookies) + } + + private func deduplicatedSessionCookies(_ cookies: [ResolvedSessionCookie]) -> [ResolvedSessionCookie] { + var deduplicated: [ResolvedSessionCookie] = [] + for cookie in cookies { + if deduplicated.contains(where: { self.isEquivalentCookie($0.value, cookie.value) }) { + continue + } + deduplicated.append(cookie) + } + return deduplicated + } + + private func cacheSessionCookieIfNeeded( + _ cookie: ResolvedSessionCookie, + usedCookie: PerplexityCookieOverride, + sourceLabel: String) + { + guard cookie.source.shouldCacheAfterFetch else { return } + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(usedCookie.name)=\(usedCookie.token)", + sourceLabel: sourceLabel) + } + + private func fetchSnapshot(using cookie: ResolvedSessionCookie) async throws -> SessionFetchResult { + var lastInvalidToken = false + for cookieName in cookie.value.requestCookieNames { + do { + let snapshot = try await PerplexityUsageFetcher.fetchCredits( + sessionToken: cookie.value.token, + cookieName: cookieName) + return SessionFetchResult( + snapshot: snapshot, + cookie: PerplexityCookieOverride(name: cookieName, token: cookie.value.token)) + } catch PerplexityAPIError.invalidToken { + lastInvalidToken = true + continue + } + } + + if lastInvalidToken { + throw PerplexityAPIError.invalidToken + } + throw PerplexityAPIError.missingToken + } + + private func isEquivalentCookie(_ lhs: PerplexityCookieOverride, _ rhs: PerplexityCookieOverride) -> Bool { + lhs.token == rhs.token && lhs.requestCookieNames == rhs.requestCookieNames + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift new file mode 100644 index 0000000000..ef1952b5a8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum PerplexitySettingsReader { + public static func sessionCookieOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) -> PerplexityCookieOverride? + { + let raw = environment["PERPLEXITY_SESSION_TOKEN"] + ?? environment["perplexity_session_token"] + if let token = self.cleaned(raw) { return PerplexityCookieHeader.override(from: token) } + + // PERPLEXITY_COOKIE may be a full Cookie header string; preserve the matching session cookie name. + if let cookieRaw = environment["PERPLEXITY_COOKIE"] { + return PerplexityCookieHeader.override(from: self.cleaned(cookieRaw)) + } + return nil + } + + public static func sessionToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.sessionCookieOverride(environment: environment)?.token + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift new file mode 100644 index 0000000000..84e735e262 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift @@ -0,0 +1,71 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct PerplexityUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.perplexityAPI) + private static let creditsURL = + URL(string: "https://www.perplexity.ai/rest/billing/credits?version=2.18&source=default")! + @TaskLocal static var fetchCreditsOverride: + (@Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot)? + + /// Testing hook: parse a raw JSON response without making network calls. + public static func _parseResponseForTesting(_ data: Data, now: Date = Date()) throws -> PerplexityUsageSnapshot { + do { + let decoded = try JSONDecoder().decode(PerplexityCreditsResponse.self, from: data) + return PerplexityUsageSnapshot(response: decoded, now: now) + } catch { + throw PerplexityAPIError.parseFailed(error.localizedDescription) + } + } + + public static func fetchCredits( + sessionToken: String, + cookieName: String = PerplexityCookieHeader.defaultSessionCookieName, + now: Date = Date()) async throws -> PerplexityUsageSnapshot + { + if let override = self.fetchCreditsOverride { + return try await override(sessionToken, cookieName, now) + } + + var request = URLRequest(url: self.creditsURL) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue( + "\(cookieName)=\(sessionToken)", + forHTTPHeaderField: "Cookie") + request.setValue("https://www.perplexity.ai", forHTTPHeaderField: "Origin") + request.setValue("https://www.perplexity.ai/account/usage", forHTTPHeaderField: "Referer") + let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let statusCode = response.statusCode + let body = String(data: data, encoding: .utf8) ?? "" + let truncated = body.count > 200 ? String(body.prefix(200)) + "…" : body + Self.log.error("Perplexity API returned \(statusCode): \(truncated)") + if statusCode == 401 || statusCode == 403 { + throw PerplexityAPIError.invalidToken + } + throw PerplexityAPIError.apiError("HTTP \(statusCode)") + } + + do { + let decoded = try JSONDecoder().decode(PerplexityCreditsResponse.self, from: data) + let snapshot = PerplexityUsageSnapshot(response: decoded, now: now) + Self.log.debug( + "Perplexity credits parsed balance=\(snapshot.balanceCents) totalUsage=\(snapshot.totalUsageCents)") + return snapshot + } catch { + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "" + Self.log.error("Perplexity parse failed: \(error) — response: \(preview)") + throw PerplexityAPIError.parseFailed(error.localizedDescription) + } + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift new file mode 100644 index 0000000000..48e4cf9cc4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift @@ -0,0 +1,134 @@ +import Foundation + +public struct PerplexityUsageSnapshot: Sendable { + public let recurringTotal: Double + public let recurringUsed: Double + public let promoTotal: Double + public let promoUsed: Double + public let purchasedTotal: Double + public let purchasedUsed: Double + public let balanceCents: Double + public let totalUsageCents: Double + public let renewalDate: Date + public let promoExpiration: Date? + public let updatedAt: Date + + public init(response: PerplexityCreditsResponse, now: Date) { + let recurring = response.creditGrants.filter { $0.type == "recurring" } + let promotional = response.creditGrants.filter { + $0.type == "promotional" && ($0.expiresAtTs ?? .infinity) > now.timeIntervalSince1970 + } + let purchased = response.creditGrants.filter { $0.type == "purchased" } + + // All timestamps from the Perplexity API are Unix seconds (verified Feb 2026). + let recurringSum = max(0, recurring.reduce(0.0) { $0 + $1.amountCents }) + let promoSum = max(0, promotional.reduce(0.0) { $0 + $1.amountCents }) + // Purchased credits may appear in the top-level field, in the credit_grants + // array (type == "purchased"), or both. Take whichever is larger to avoid + // double-counting while still catching either source. + let purchasedFromGrants = max(0, purchased.reduce(0.0) { $0 + $1.amountCents }) + let purchasedFromField = max(0, response.currentPeriodPurchasedCents) + let purchasedSum = max(purchasedFromGrants, purchasedFromField) + + // Waterfall attribution: recurring → purchased → promotional + var remaining = response.totalUsageCents + let usedFromRecurring = min(remaining, recurringSum); remaining -= usedFromRecurring + let usedFromPurchased = min(remaining, purchasedSum); remaining -= usedFromPurchased + let usedFromPromo = min(remaining, promoSum) + + self.recurringTotal = recurringSum + self.recurringUsed = usedFromRecurring + self.promoTotal = promoSum + self.promoUsed = usedFromPromo + self.purchasedTotal = purchasedSum + self.purchasedUsed = usedFromPurchased + self.balanceCents = response.balanceCents + self.totalUsageCents = response.totalUsageCents + self.renewalDate = Date(timeIntervalSince1970: response.renewalDateTs) + self.promoExpiration = promotional + .compactMap { $0.expiresAtTs.map { Date(timeIntervalSince1970: $0) } } + .min() + self.updatedAt = now + } + + /// Infer plan name from recurring credit allotment. + /// Free = 0, Pro = small pool (~500–1000), Max = 10,000+. + public var planName: String? { + if self.recurringTotal <= 0 { return nil } + if self.recurringTotal < 5000 { return "Pro" } + return "Max" + } + + private static let promoExpiryFormatter: DateFormatter = { + let fmt = DateFormatter() + fmt.dateFormat = "MMM d" + return fmt + }() +} + +extension PerplexityUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + // Primary: recurring (monthly) credits + let hasFallbackCredits = self.promoTotal > 0 || self.purchasedTotal > 0 + let primaryWindow: RateWindow? = { + if self.recurringTotal > 0 { + let primaryPercent = min(100, max(0, self.recurringUsed / self.recurringTotal * 100)) + return RateWindow( + usedPercent: primaryPercent, + windowMinutes: nil, + resetsAt: self.renewalDate, + resetDescription: "\(Int(self.recurringUsed.rounded()))/\(Int(self.recurringTotal)) credits") + } + if hasFallbackCredits { + // When recurring is absent but bonus/purchased credits remain, omit the fake 0/0 primary lane + // so automatic menu-bar rendering can fall through to the usable pool. + return nil + } + return RateWindow( + usedPercent: 100, + windowMinutes: nil, + resetsAt: self.renewalDate, + resetDescription: "0/0 credits") + }() + + // Secondary: promotional bonus credits — always shown. + // usedPercent=100 when promoTotal==0 so the bar renders empty rather than full. + let promoPercent = self.promoTotal > 0 + ? min(100, max(0, self.promoUsed / self.promoTotal * 100)) + : 100.0 + var promoDesc = "\(Int(promoUsed.rounded()))/\(Int(self.promoTotal)) bonus" + if let expiry = promoExpiration { + promoDesc += " \u{00b7} exp. \(Self.promoExpiryFormatter.string(from: expiry))" + } + let secondary = RateWindow( + usedPercent: promoPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: promoDesc) + + // Tertiary: on-demand purchased credits — always shown. + // usedPercent=100 when purchasedTotal==0 so the bar renders empty rather than full. + let purchasedPercent = self.purchasedTotal > 0 + ? min(100, max(0, self.purchasedUsed / self.purchasedTotal * 100)) + : 100.0 + let tertiary = RateWindow( + usedPercent: purchasedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(Int(purchasedUsed.rounded()))/\(Int(self.purchasedTotal)) credits") + + let identity = ProviderIdentitySnapshot( + providerID: .perplexity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: planName) + + return UsageSnapshot( + primary: primaryWindow, + secondary: secondary, + tertiary: tertiary, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} diff --git a/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift new file mode 100644 index 0000000000..176e2ee18c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift @@ -0,0 +1,69 @@ +import Foundation + +public enum PoeProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .poe, + metadata: ProviderMetadata( + id: .poe, + displayName: "Poe", + sessionLabel: "Points", + weeklyLabel: "Points", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Poe usage", + cliName: "poe", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://poe.com/api/keys", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .poe, + iconResourceName: "ProviderIcon-poe", + color: ProviderColor(red: 0.15, green: 0.68, blue: 0.38)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Poe usage history is unavailable." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [PoeAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "poe", + aliases: [], + versionDetector: nil)) + } +} + +struct PoeAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "poe.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw PoeUsageError.missingCredentials + } + let usage = try await PoeUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.poeToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Poe/PoeSettingsReader.swift b/Sources/CodexBarCore/Providers/Poe/PoeSettingsReader.swift new file mode 100644 index 0000000000..7cb6f0ca28 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Poe/PoeSettingsReader.swift @@ -0,0 +1,26 @@ +import Foundation + +public enum PoeSettingsReader { + public static let apiKeyEnvironmentKey = "POE_API_KEY" + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.apiKeyEnvironmentKey]) + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift new file mode 100644 index 0000000000..92fb866f5a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift @@ -0,0 +1,287 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum PoeUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case unauthorized + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Poe API token." + case let .networkError(message): + "Poe network error: \(message)" + case let .apiError(message): + "Poe API error: \(message)" + case .unauthorized: + "Invalid or expired Poe API token." + case let .parseFailed(message): + "Failed to parse Poe response: \(message)" + } + } +} + +public struct PoeUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.poeUsage) + private static let usageURL = URL(string: "https://api.poe.com/usage/current_balance")! + private static let historyURL = URL(string: "https://api.poe.com/usage/points_history")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage(apiKey: String) async throws -> PoeUsageSnapshot { + try await self._fetchUsage(apiKey: apiKey, transport: ProviderHTTPClient.shared) + } + + static func _fetchUsage( + apiKey: String, + transport: any ProviderHTTPTransport) async throws -> PoeUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw PoeUsageError.missingCredentials + } + + var request = URLRequest(url: self.usageURL) + request.httpMethod = "GET" + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await self.perform(request: request, transport: transport) + let balance = try self.parseSnapshot(data: response.data).currentPointBalance + // `points_history` is a best-effort supplement; never let an optional + // history failure cost the user the current balance display. + let history: PoeUsageHistorySnapshot? + do { + history = try await self.fetchHistory(apiKey: trimmed, transport: transport) + } catch { + Self.log.error("Poe points_history fetch failed; returning balance only: \(error)") + history = nil + } + return PoeUsageSnapshot( + currentPointBalance: balance, + history: history, + updatedAt: Date()) + } + + static func _parseSnapshotForTesting(_ data: Data) throws -> PoeUsageSnapshot { + try self.parseSnapshot(data: data) + } + + static func _parseHistoryPageForTesting(_ data: Data) throws + -> (entries: [PoeUsageHistorySnapshot.Entry], nextCursor: String?) + { + try self.parseHistoryPage(data: data) + } + + static func _buildDailyBucketsForTesting(entries: [PoeUsageHistorySnapshot.Entry]) + -> [PoeUsageHistorySnapshot.DailyBucket] { + self.buildDailyBuckets(entries: entries) + } + + private static func parseSnapshot(data: Data) throws -> PoeUsageSnapshot { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw PoeUsageError.parseFailed("Invalid JSON") + } + + let balance = self.double(from: root["current_point_balance"]) + + return PoeUsageSnapshot( + currentPointBalance: balance, + updatedAt: Date()) + } + + private static func fetchHistory( + apiKey: String, + transport: any ProviderHTTPTransport) async throws -> PoeUsageHistorySnapshot? + { + var cursor: String? + var entries: [PoeUsageHistorySnapshot.Entry] = [] + let cutoff = Date().addingTimeInterval(-30 * 24 * 3600) + var page = 0 + + while page < 5 { + page += 1 + var components = URLComponents(url: self.historyURL, resolvingAgainstBaseURL: false) + var query: [URLQueryItem] = [URLQueryItem(name: "limit", value: "100")] + if let cursor, !cursor.isEmpty { + query.append(URLQueryItem(name: "starting_after", value: cursor)) + } + components?.queryItems = query + guard let url = components?.url else { + throw PoeUsageError.parseFailed("Invalid points_history URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response = try await self.perform(request: request, transport: transport) + let parsed = try self.parseHistoryPage(data: response.data) + entries.append(contentsOf: parsed.entries) + cursor = parsed.nextCursor + + if parsed.entries.last?.createdAt ?? .distantPast < cutoff { break } + if cursor == nil { break } + } + + guard !entries.isEmpty else { return nil } + let filtered = entries.filter { $0.createdAt >= cutoff } + guard !filtered.isEmpty else { return nil } + let daily = self.buildDailyBuckets(entries: filtered) + return PoeUsageHistorySnapshot(entries: filtered, daily: daily, updatedAt: Date()) + } + + private static func buildDailyBuckets(entries: [PoeUsageHistorySnapshot.Entry]) + -> [PoeUsageHistorySnapshot.DailyBucket] { + var acc: [String: (points: Double, requests: Int, costUSD: Double)] = [:] + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + for entry in entries { + let key = formatter.string(from: entry.createdAt) + var row = acc[key] ?? (points: 0, requests: 0, costUSD: 0) + row.points += max(0, entry.points) + row.requests += 1 + row.costUSD += max(0, entry.costUSD ?? 0) + acc[key] = row + } + return acc.keys.sorted().map { day in + let row = acc[day] ?? (0, 0, 0) + return PoeUsageHistorySnapshot.DailyBucket( + day: day, + points: row.points, + requests: row.requests, + costUSD: row.costUSD > 0 ? row.costUSD : nil) + } + } + + private static func parseHistoryPage(data: Data) throws + -> (entries: [PoeUsageHistorySnapshot.Entry], nextCursor: String?) + { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw PoeUsageError.parseFailed("Invalid history JSON") + } + + let rawEntries: [[String: Any]] = if let rows = root["data"] as? [[String: Any]] { + rows + } else if let rows = root["items"] as? [[String: Any]] { + rows + } else if let rows = root["results"] as? [[String: Any]] { + rows + } else { + [] + } + + let entries = rawEntries.compactMap { row -> PoeUsageHistorySnapshot.Entry? in + guard let createdAt = self + .date(fromHistoryValue: row["creation_time"] ?? row["timestamp"] ?? row["created_at"]) + else { + return nil + } + let model = (row["bot_name"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? "unknown" + let usageType = (row["usage_type"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + ?? "unknown" + let points = self.double(from: row["cost_points"]) + ?? self.double(from: row["points"]) + ?? self.double(from: row["point_cost"]) + ?? 0 + let costUSD = self.double(from: row["cost_usd"] ?? row["usd"]) + let id = (row["query_id"] as? String) + ?? (row["message_id"] as? String) + ?? (row["id"] as? String) + ?? "\(createdAt.timeIntervalSince1970)-\(model)" + return PoeUsageHistorySnapshot.Entry( + id: id, + createdAt: createdAt, + model: model.isEmpty ? "unknown" : model, + usageType: usageType.isEmpty ? "unknown" : usageType, + points: max(0, points), + costUSD: costUSD) + } + + let nextCursor = (root["next_cursor"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let nextCursor, !nextCursor.isEmpty { + return (entries: entries, nextCursor: nextCursor) + } + if let hasMore = root["has_more"] as? Bool, hasMore { + let fallbackCursor = (rawEntries.last?["query_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let fallbackCursor, !fallbackCursor.isEmpty { + return (entries: entries, nextCursor: fallbackCursor) + } + } + return (entries: entries, nextCursor: nil) + } + + private static func date(fromHistoryValue value: Any?) -> Date? { + switch value { + case let number as NSNumber: + return self.date(fromNumericTimestamp: number.doubleValue) + case let string as String: + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + if let numeric = Double(trimmed) { + return self.date(fromNumericTimestamp: numeric) + } + let formatter = ISO8601DateFormatter() + return formatter.date(from: trimmed) + default: + return nil + } + } + + private static func date(fromNumericTimestamp raw: Double) -> Date? { + guard raw.isFinite, raw > 0 else { return nil } + if raw > 1_000_000_000_000 { + return Date(timeIntervalSince1970: raw / 1_000_000) + } + return Date(timeIntervalSince1970: raw) + } + + private static func perform( + request: URLRequest, + transport: any ProviderHTTPTransport) async throws -> ProviderHTTPResponse + { + let (data, urlResponse) = try await transport.data(for: request) + guard let httpResponse = urlResponse as? HTTPURLResponse else { + throw PoeUsageError.networkError("Non-HTTP response") + } + let response = ProviderHTTPResponse(data: data, response: httpResponse) + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("Poe API returned \(response.statusCode): \(body)") + if response.statusCode == 401 || response.statusCode == 403 { + throw PoeUsageError.unauthorized + } + throw PoeUsageError.apiError("HTTP \(response.statusCode)") + } + return response + } + + // MARK: - Value parsing + + private static func double(from value: Any?) -> Double? { + switch value { + case let number as NSNumber: + let raw = number.doubleValue + return raw.isFinite ? raw : nil + case let string as String: + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let raw = Double(trimmed), raw.isFinite else { return nil } + return raw + default: + return nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift b/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift new file mode 100644 index 0000000000..63cceb5e58 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift @@ -0,0 +1,153 @@ +import Foundation + +public struct PoeUsageHistorySnapshot: Codable, Equatable, Sendable { + public struct BreakdownItem: Equatable, Sendable { + public let name: String + public let points: Double + public let requests: Int + public let costUSD: Double? + } + + public struct Entry: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let createdAt: Date + public let model: String + public let usageType: String + public let points: Double + public let costUSD: Double? + + public init( + id: String, + createdAt: Date, + model: String, + usageType: String, + points: Double, + costUSD: Double?) + { + self.id = id + self.createdAt = createdAt + self.model = model + self.usageType = usageType + self.points = points + self.costUSD = costUSD + } + } + + public struct DailyBucket: Codable, Equatable, Sendable, Identifiable { + public let day: String + public let points: Double + public let requests: Int + public let costUSD: Double? + + public init(day: String, points: Double, requests: Int, costUSD: Double?) { + self.day = day + self.points = points + self.requests = requests + self.costUSD = costUSD + } + + public var id: String { + self.day + } + } + + public struct Summary: Equatable, Sendable { + public let points: Double + public let requests: Int + public let costUSD: Double? + } + + public let entries: [Entry] + public let daily: [DailyBucket] + public let updatedAt: Date + + public init(entries: [Entry], daily: [DailyBucket], updatedAt: Date) { + self.entries = entries.sorted { $0.createdAt < $1.createdAt } + self.daily = daily.sorted { $0.day < $1.day } + self.updatedAt = updatedAt + } + + public var latestDay: Summary { + self.summary(days: 1) + } + + public var last7Days: Summary { + self.summary(days: 7) + } + + public var last30Days: Summary { + self.summary(days: 30) + } + + public func summary(days: Int) -> Summary { + let selected = self.daily.suffix(max(1, days)) + let points = selected.reduce(0) { $0 + $1.points } + let requests = selected.reduce(0) { $0 + $1.requests } + let costValues = selected.compactMap(\.costUSD) + let cost: Double? = costValues.isEmpty ? nil : costValues.reduce(0, +) + return Summary(points: points, requests: requests, costUSD: cost) + } + + public var topModel: String? { + self.topModels.first?.name + } + + public var topModels: [BreakdownItem] { + self.breakdown( + groupedBy: \.model, + fallback: "unknown") + } + + public var topUsageTypes: [BreakdownItem] { + self.breakdown( + groupedBy: \.usageType, + fallback: "unknown") + } + + public func recentEntries(limit: Int = 3) -> [Entry] { + let clamped = max(1, limit) + return Array(self.entries.sorted { $0.createdAt > $1.createdAt }.prefix(clamped)) + } + + private func breakdown( + groupedBy keyPath: KeyPath, + fallback: String) -> [BreakdownItem] + { + struct Acc { + var points: Double = 0 + var requests: Int = 0 + var costUSD: Double = 0 + var hasCost = false + } + + var grouped: [String: Acc] = [:] + for entry in self.entries { + let raw = entry[keyPath: keyPath].trimmingCharacters(in: .whitespacesAndNewlines) + let key = raw.isEmpty ? fallback : raw + var row = grouped[key] ?? Acc() + row.points += max(0, entry.points) + row.requests += 1 + if let cost = entry.costUSD { + row.costUSD += max(0, cost) + row.hasCost = true + } + grouped[key] = row + } + + return grouped.map { key, value in + BreakdownItem( + name: key, + points: value.points, + requests: value.requests, + costUSD: value.hasCost ? value.costUSD : nil) + } + .sorted { + if $0.points == $1.points { return $0.name < $1.name } + return $0.points > $1.points + } + } + + public var topUsageType: String? { + self.topUsageTypes.first?.name + } +} diff --git a/Sources/CodexBarCore/Providers/Poe/PoeUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Poe/PoeUsageSnapshot.swift new file mode 100644 index 0000000000..b1dab545c3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Poe/PoeUsageSnapshot.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct PoeUsageSnapshot: Sendable { + public let currentPointBalance: Double? + public let history: PoeUsageHistorySnapshot? + public let updatedAt: Date + + public init( + currentPointBalance: Double? = nil, + history: PoeUsageHistorySnapshot? = nil, + updatedAt: Date = Date()) + { + self.currentPointBalance = currentPointBalance + self.history = history + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let identity = ProviderIdentitySnapshot( + providerID: .poe, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.balanceLabel) + + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + poeUsage: self.history, + updatedAt: self.updatedAt, + identity: identity) + } + + private var balanceLabel: String? { + guard let balance = self.currentPointBalance, balance.isFinite else { return nil } + return "Balance: \(Self.compactNumber(balance)) points" + } + + static func compactNumber(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale(identifier: "en_US") + formatter.maximumFractionDigits = value >= 1000 ? 0 : 1 + return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift b/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift index 5b20482362..f3009d24c8 100644 --- a/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift +++ b/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift @@ -1,6 +1,6 @@ import Foundation -enum ProviderCandidateRetryRunnerError: Error, Sendable { +enum ProviderCandidateRetryRunnerError: Error { case noCandidates } diff --git a/Sources/CodexBarCore/Providers/ProviderCookieSettingsResolver.swift b/Sources/CodexBarCore/Providers/ProviderCookieSettingsResolver.swift new file mode 100644 index 0000000000..2da2671839 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ProviderCookieSettingsResolver.swift @@ -0,0 +1,25 @@ +import Foundation + +public enum ProviderCookieSettingsResolver { + public static func resolve( + provider: UsageProvider, + configuredSource: ProviderCookieSource, + configuredHeader: String?, + selectedAccount: ProviderTokenAccount?) -> ProviderSettingsSnapshot.CookieProviderSettings + { + guard let support = TokenAccountSupportCatalog.support(for: provider), + case .cookieHeader = support.injection, + let selectedAccount + else { + return ProviderSettingsSnapshot.CookieProviderSettings( + cookieSource: configuredSource, + manualCookieHeader: configuredHeader) + } + + return ProviderSettingsSnapshot.CookieProviderSettings( + cookieSource: support.requiresManualCookieSource ? .manual : configuredSource, + manualCookieHeader: TokenAccountSupportCatalog.normalizedCookieHeader( + selectedAccount.token, + support: support)) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index d7a3669d4a..99ee47ccd1 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -54,15 +54,22 @@ public enum ProviderDescriptorRegistry { private static let store = Store() private static let descriptorsByID: [UsageProvider: ProviderDescriptor] = [ .codex: CodexProviderDescriptor.descriptor, + .openai: OpenAIAPIProviderDescriptor.descriptor, + .azureopenai: AzureOpenAIProviderDescriptor.descriptor, .claude: ClaudeProviderDescriptor.descriptor, .cursor: CursorProviderDescriptor.descriptor, .opencode: OpenCodeProviderDescriptor.descriptor, + .opencodego: OpenCodeGoProviderDescriptor.descriptor, + .alibaba: AlibabaCodingPlanProviderDescriptor.descriptor, + .alibabatokenplan: AlibabaTokenPlanProviderDescriptor.descriptor, .factory: FactoryProviderDescriptor.descriptor, .gemini: GeminiProviderDescriptor.descriptor, .antigravity: AntigravityProviderDescriptor.descriptor, .copilot: CopilotProviderDescriptor.descriptor, + .devin: DevinProviderDescriptor.descriptor, .zai: ZaiProviderDescriptor.descriptor, .minimax: MiniMaxProviderDescriptor.descriptor, + .manus: ManusProviderDescriptor.descriptor, .kimi: KimiProviderDescriptor.descriptor, .kilo: KiloProviderDescriptor.descriptor, .kiro: KiroProviderDescriptor.descriptor, @@ -70,11 +77,35 @@ public enum ProviderDescriptorRegistry { .augment: AugmentProviderDescriptor.descriptor, .jetbrains: JetBrainsProviderDescriptor.descriptor, .kimik2: KimiK2ProviderDescriptor.descriptor, + .moonshot: MoonshotProviderDescriptor.descriptor, .amp: AmpProviderDescriptor.descriptor, + .t3chat: T3ChatProviderDescriptor.descriptor, .ollama: OllamaProviderDescriptor.descriptor, .synthetic: SyntheticProviderDescriptor.descriptor, .openrouter: OpenRouterProviderDescriptor.descriptor, + .elevenlabs: ElevenLabsProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, + .windsurf: WindsurfProviderDescriptor.descriptor, + .zed: ZedProviderDescriptor.descriptor, + .perplexity: PerplexityProviderDescriptor.descriptor, + .mimo: MiMoProviderDescriptor.descriptor, + .doubao: DoubaoProviderDescriptor.descriptor, + .abacus: AbacusProviderDescriptor.descriptor, + .mistral: MistralProviderDescriptor.descriptor, + .deepseek: DeepSeekProviderDescriptor.descriptor, + .codebuff: CodebuffProviderDescriptor.descriptor, + .crof: CrofProviderDescriptor.descriptor, + .venice: VeniceProviderDescriptor.descriptor, + .commandcode: CommandCodeProviderDescriptor.descriptor, + .stepfun: StepFunProviderDescriptor.descriptor, + .bedrock: BedrockProviderDescriptor.descriptor, + .grok: GrokProviderDescriptor.descriptor, + .groq: GroqProviderDescriptor.descriptor, + .llmproxy: LLMProxyProviderDescriptor.descriptor, + .litellm: LiteLLMProviderDescriptor.descriptor, + .deepgram: DeepgramProviderDescriptor.descriptor, + .poe: PoeProviderDescriptor.descriptor, + .chutes: ChutesProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift new file mode 100644 index 0000000000..c12c59f857 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -0,0 +1,583 @@ +import Foundation + +public struct ProviderDiagnosticBatchExport: Codable, Sendable { + public let schemaVersion: String + public let timestamp: Date + public let diagnostics: [ProviderDiagnosticExport] + + public init( + schemaVersion: String = "1.0", + timestamp: Date, + diagnostics: [ProviderDiagnosticExport]) + { + self.schemaVersion = schemaVersion + self.timestamp = timestamp + self.diagnostics = diagnostics + } +} + +public struct ProviderDiagnosticExport: Codable, Sendable { + public let schemaVersion: String + public let timestamp: Date + public let platform: String + public let appVersion: String? + public let provider: String + public let displayName: String + public let source: String + public let sourceMode: String + public let auth: ProviderDiagnosticAuthSummary + public let usage: ProviderDiagnosticUsageSummary? + public let fetchAttempts: [ProviderDiagnosticFetchAttempt] + public let error: ProviderDiagnosticError? + public let settings: ProviderDiagnosticSettingsSummary + public let details: ProviderDiagnosticDetails? + + private enum CodingKeys: String, CodingKey { + case schemaVersion + case timestamp + case platform + case appVersion + case provider + case displayName + case source + case sourceMode + case auth + case usage + case fetchAttempts + case error + case settings + case details + } + + public init( + schemaVersion: String = "1.0", + timestamp: Date, + platform: String = ProviderDiagnosticPlatform.current, + appVersion: String? = nil, + provider: String, + displayName: String, + source: String, + sourceMode: String, + auth: ProviderDiagnosticAuthSummary, + usage: ProviderDiagnosticUsageSummary?, + fetchAttempts: [ProviderDiagnosticFetchAttempt], + error: ProviderDiagnosticError?, + settings: ProviderDiagnosticSettingsSummary, + details: ProviderDiagnosticDetails?) + { + self.schemaVersion = schemaVersion + self.timestamp = timestamp + self.platform = platform + self.appVersion = appVersion + self.provider = provider + self.displayName = displayName + self.source = source + self.sourceMode = sourceMode + self.auth = auth + self.usage = usage + self.fetchAttempts = fetchAttempts + self.error = error + self.settings = settings + self.details = details + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + schemaVersion: container.decode(String.self, forKey: .schemaVersion), + timestamp: container.decode(Date.self, forKey: .timestamp), + platform: container.decodeIfPresent(String.self, forKey: .platform) + ?? ProviderDiagnosticPlatform.current, + appVersion: container.decodeIfPresent(String.self, forKey: .appVersion), + provider: container.decode(String.self, forKey: .provider), + displayName: container.decode(String.self, forKey: .displayName), + source: container.decode(String.self, forKey: .source), + sourceMode: container.decode(String.self, forKey: .sourceMode), + auth: container.decode(ProviderDiagnosticAuthSummary.self, forKey: .auth), + usage: container.decodeIfPresent(ProviderDiagnosticUsageSummary.self, forKey: .usage), + fetchAttempts: container.decode([ProviderDiagnosticFetchAttempt].self, forKey: .fetchAttempts), + error: container.decodeIfPresent(ProviderDiagnosticError.self, forKey: .error), + settings: container.decode(ProviderDiagnosticSettingsSummary.self, forKey: .settings), + details: container.decodeIfPresent(ProviderDiagnosticDetails.self, forKey: .details)) + } +} + +public enum ProviderDiagnosticPlatform { + public static var current: String { + #if os(macOS) + "macOS" + #elseif os(Linux) + "Linux" + #else + "unknown" + #endif + } +} + +public struct ProviderDiagnosticAuthSummary: Codable, Sendable { + public let configured: Bool + public let modes: [String] + + public init(configured: Bool, modes: [String]) { + self.configured = configured + self.modes = modes + } + + public func resolved(with outcome: ProviderFetchOutcome) -> ProviderDiagnosticAuthSummary { + var resolvedModes = self.modes + if outcome.isSuccess { + for attempt in outcome.attempts where attempt.wasAvailable { + let mode = ProviderDiagnosticFetchAttempt.kindLabel(attempt.kind) + if !resolvedModes.contains(mode) { + resolvedModes.append(mode) + } + } + } + let configured = self.configured || outcome.isSuccess + return ProviderDiagnosticAuthSummary(configured: configured, modes: resolvedModes) + } +} + +public struct ProviderDiagnosticUsageSummary: Codable, Sendable { + public let updatedAt: Date + public let dataConfidence: String + public let windows: [ProviderDiagnosticRateWindow] + public let extraWindowCount: Int + public let providerCostPresent: Bool + public let providerSpecificData: [String] + + private enum CodingKeys: String, CodingKey { + case updatedAt + case dataConfidence + case windows + case extraWindowCount + case providerCostPresent + case providerSpecificData + } + + public init(from snapshot: UsageSnapshot) { + var windows: [ProviderDiagnosticRateWindow] = [] + if let primary = snapshot.primary { + windows.append(ProviderDiagnosticRateWindow(label: "primary", window: primary)) + } + if let secondary = snapshot.secondary { + windows.append(ProviderDiagnosticRateWindow(label: "secondary", window: secondary)) + } + if let tertiary = snapshot.tertiary { + windows.append(ProviderDiagnosticRateWindow(label: "tertiary", window: tertiary)) + } + for extra in snapshot.extraRateWindows ?? [] { + windows.append(ProviderDiagnosticRateWindow( + label: extra.title, + window: extra.window, + usageKnown: extra.usageKnown)) + } + + var providerSpecificData: [String] = [] + if snapshot.kiroUsage != nil { providerSpecificData.append("kiroUsage") } + if snapshot.ampUsage != nil { providerSpecificData.append("ampUsage") } + if snapshot.zaiUsage != nil { providerSpecificData.append("zaiUsage") } + if snapshot.minimaxUsage != nil { providerSpecificData.append("minimaxUsage") } + if snapshot.deepseekUsage != nil { providerSpecificData.append("deepseekUsage") } + if snapshot.openRouterUsage != nil { providerSpecificData.append("openRouterUsage") } + if snapshot.openAIAPIUsage != nil { providerSpecificData.append("openAIAPIUsage") } + if snapshot.claudeAdminAPIUsage != nil { providerSpecificData.append("claudeAdminAPIUsage") } + if snapshot.mistralUsage != nil { providerSpecificData.append("mistralUsage") } + if snapshot.deepgramUsage != nil { providerSpecificData.append("deepgramUsage") } + if snapshot.cursorRequests != nil { providerSpecificData.append("cursorRequests") } + + self.updatedAt = snapshot.updatedAt + self.dataConfidence = snapshot.dataConfidence.rawValue + self.windows = windows + self.extraWindowCount = snapshot.extraRateWindows?.count ?? 0 + self.providerCostPresent = snapshot.providerCost != nil + self.providerSpecificData = providerSpecificData.sorted() + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) + self.dataConfidence = try container.decodeIfPresent(String.self, forKey: .dataConfidence) + ?? UsageDataConfidence.unknown.rawValue + self.windows = try container.decode([ProviderDiagnosticRateWindow].self, forKey: .windows) + self.extraWindowCount = try container.decode(Int.self, forKey: .extraWindowCount) + self.providerCostPresent = try container.decode(Bool.self, forKey: .providerCostPresent) + self.providerSpecificData = try container.decode([String].self, forKey: .providerSpecificData) + } +} + +public struct ProviderDiagnosticRateWindow: Codable, Sendable { + public let label: String + public let usedPercent: Double + public let windowMinutes: Int? + public let resetsAt: Date? + public let hasResetDescription: Bool + public let nextRegenPercent: Double? + public let usageKnown: Bool + + private enum CodingKeys: String, CodingKey { + case label + case usedPercent + case windowMinutes + case resetsAt + case hasResetDescription + case nextRegenPercent + case usageKnown + } + + public init(label: String, window: RateWindow, usageKnown: Bool = true) { + self.label = label + self.usedPercent = window.usedPercent + self.windowMinutes = window.windowMinutes + self.resetsAt = window.resetsAt + self.hasResetDescription = window.resetDescription?.isEmpty == false + self.nextRegenPercent = window.nextRegenPercent + self.usageKnown = usageKnown + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.label = try container.decode(String.self, forKey: .label) + self.usedPercent = try container.decode(Double.self, forKey: .usedPercent) + self.windowMinutes = try container.decodeIfPresent(Int.self, forKey: .windowMinutes) + self.resetsAt = try container.decodeIfPresent(Date.self, forKey: .resetsAt) + self.hasResetDescription = try container.decode(Bool.self, forKey: .hasResetDescription) + self.nextRegenPercent = try container.decodeIfPresent(Double.self, forKey: .nextRegenPercent) + self.usageKnown = try container.decodeIfPresent(Bool.self, forKey: .usageKnown) ?? true + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.label, forKey: .label) + try container.encode(self.usedPercent, forKey: .usedPercent) + try container.encodeIfPresent(self.windowMinutes, forKey: .windowMinutes) + try container.encodeIfPresent(self.resetsAt, forKey: .resetsAt) + try container.encode(self.hasResetDescription, forKey: .hasResetDescription) + try container.encodeIfPresent(self.nextRegenPercent, forKey: .nextRegenPercent) + if !self.usageKnown { + try container.encode(false, forKey: .usageKnown) + } + } +} + +public struct ProviderDiagnosticFetchAttempt: Codable, Sendable { + public let kind: String + public let wasAvailable: Bool + public let errorCategory: String? + + public init( + kind: String, + wasAvailable: Bool, + errorCategory: String?) + { + self.kind = kind + self.wasAvailable = wasAvailable + self.errorCategory = errorCategory + } + + public init(from attempt: ProviderFetchAttempt) { + self.kind = Self.kindLabel(attempt.kind) + self.wasAvailable = attempt.wasAvailable + self.errorCategory = attempt.errorDescription.map(Self.errorCategoryLabel) + } + + public static func kindLabel(_ kind: ProviderFetchKind) -> String { + switch kind { + case .cli: "cli" + case .web: "web" + case .oauth: "oauth" + case .apiToken: "api" + case .localProbe: "local" + case .webDashboard: "web" + } + } + + public static func errorCategoryLabel(_ description: String?) -> String { + guard let desc = description?.lowercased() else { return "unknown" } + if desc.contains("endpoint override") { + return "configuration" + } + if desc.contains("network") || desc.contains("timeout") || desc.contains("connection") { + return "network" + } + if desc.contains("auth") || desc.contains("credential") || desc.contains("token") || desc.contains("cookie") || + desc.contains("api key") || desc.contains("key not configured") || desc.contains("missing key") + { + return "auth" + } + if desc.contains("source") || desc.contains("not supported") || desc.contains("unavailable") { + return "configuration" + } + if desc.contains("api") || desc.contains("http") || desc.contains("404") || desc.contains("403") { + return "api" + } + if desc.contains("parse") || desc.contains("format") || desc.contains("decode") { + return "parse" + } + return "unknown" + } +} + +public struct ProviderDiagnosticError: Codable, Sendable { + public let category: String + public let safeDescription: String + + public init(category: String, safeDescription: String) { + self.category = category + self.safeDescription = safeDescription + } + + public init(from error: Error, authConfigured: Bool) { + self.category = Self.errorCategory(error, authConfigured: authConfigured) + self.safeDescription = Self.safeDescription(category: self.category) + } + + private static func errorCategory(_ error: Error, authConfigured: Bool) -> String { + if case ProviderFetchError.noAvailableStrategy = error { + return authConfigured ? "configuration" : "auth" + } + if error is ProviderEndpointOverrideError { + return "configuration" + } + if let minimaxError = error as? MiniMaxUsageError { + switch minimaxError { + case .networkError: return "network" + case .invalidCredentials: return "auth" + case .apiError: return "api" + case .parseFailed: return "parse" + } + } + if let alibabaError = error as? AlibabaCodingPlanUsageError { + switch alibabaError { + case .networkError: return "network" + case .loginRequired, .invalidCredentials: return "auth" + case .apiError, .apiKeyUnavailableInRegion: return "api" + case .parseFailed: return "parse" + } + } + if error is MiniMaxSettingsError || error is MiniMaxAPISettingsError { return "auth" } + return ProviderDiagnosticFetchAttempt.errorCategoryLabel(error.localizedDescription) + } + + private static func safeDescription(category: String) -> String { + switch category { + case "network": + "Network error - check your connection" + case "auth": + "Authentication or setup issue - check provider credentials" + case "api": + "API error - service returned an unexpected response" + case "parse": + "Parse error - unexpected response format" + case "configuration": + "Configuration issue - check provider source and settings" + default: + "An unexpected error occurred" + } + } +} + +public struct ProviderDiagnosticSettingsSummary: Codable, Sendable { + public let sourceMode: String + public let apiRegion: String? + + public init(sourceMode: ProviderSourceMode, apiRegion: String? = nil) { + self.sourceMode = sourceMode.rawValue + self.apiRegion = apiRegion + } +} + +public enum ProviderDiagnosticDetails: Codable, Sendable { + case minimax(MiniMaxDiagnosticDetails) + + private enum CodingKeys: String, CodingKey { + case type + case minimax + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "minimax": + self = try .minimax(container.decode(MiniMaxDiagnosticDetails.self, forKey: .minimax)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown provider diagnostic detail type") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .minimax(details): + try container.encode("minimax", forKey: .type) + try container.encode(details, forKey: .minimax) + } + } +} + +public struct MiniMaxDiagnosticDetails: Codable, Sendable { + public let planName: String? + public let availablePrompts: Int? + public let currentPrompts: Int? + public let remainingPrompts: Int? + public let windowMinutes: Int? + public let usedPercent: Double? + public let resetsAt: Date? + public let services: [MiniMaxDiagnosticServiceUsage]? + public let billingSummaryPresent: Bool + + public init(from snapshot: MiniMaxUsageSnapshot) { + self.planName = snapshot.planName + self.availablePrompts = snapshot.availablePrompts + self.currentPrompts = snapshot.currentPrompts + self.remainingPrompts = snapshot.remainingPrompts + self.windowMinutes = snapshot.windowMinutes + self.usedPercent = snapshot.usedPercent + self.resetsAt = snapshot.resetsAt + self.services = snapshot.services?.map { MiniMaxDiagnosticServiceUsage(from: $0) } + self.billingSummaryPresent = snapshot.billingSummary != nil + } +} + +public struct MiniMaxDiagnosticServiceUsage: Codable, Sendable { + public let displayName: String + public let percent: Double + public let usage: Int + public let limit: Int + public let remaining: Int? + public let isUnlimited: Bool + public let windowType: String + public let resetsAt: Date? + public let hasResetDescription: Bool + + private enum CodingKeys: String, CodingKey { + case displayName + case percent + case usage + case limit + case remaining + case isUnlimited + case windowType + case resetsAt + case hasResetDescription + } + + public init(from service: MiniMaxServiceUsage) { + self.displayName = service.displayName + self.percent = service.percent + self.usage = service.usage + self.limit = service.limit + self.remaining = service.isUnlimited ? nil : max(0, service.limit - service.usage) + self.isUnlimited = service.isUnlimited + self.windowType = service.windowType + self.resetsAt = service.resetsAt + self.hasResetDescription = !service.resetDescription.isEmpty + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.displayName = try container.decode(String.self, forKey: .displayName) + self.percent = try container.decode(Double.self, forKey: .percent) + self.usage = try container.decodeIfPresent(Int.self, forKey: .usage) ?? 0 + self.limit = try container.decodeIfPresent(Int.self, forKey: .limit) ?? 0 + self.remaining = try container.decodeIfPresent(Int.self, forKey: .remaining) + self.isUnlimited = try container.decodeIfPresent(Bool.self, forKey: .isUnlimited) ?? false + self.windowType = try container.decode(String.self, forKey: .windowType) + self.resetsAt = try container.decodeIfPresent(Date.self, forKey: .resetsAt) + self.hasResetDescription = try container.decode(Bool.self, forKey: .hasResetDescription) + } +} + +public enum ProviderDiagnosticExportBuilder { + public struct Input: Sendable { + public let provider: UsageProvider + public let descriptor: ProviderDescriptor + public let outcome: ProviderFetchOutcome + public let sourceMode: ProviderSourceMode + public let settings: ProviderSettingsSnapshot? + public let auth: ProviderDiagnosticAuthSummary + public let appVersion: String? + + public init( + provider: UsageProvider, + descriptor: ProviderDescriptor, + outcome: ProviderFetchOutcome, + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot?, + auth: ProviderDiagnosticAuthSummary, + appVersion: String? = nil) + { + self.provider = provider + self.descriptor = descriptor + self.outcome = outcome + self.sourceMode = sourceMode + self.settings = settings + self.auth = auth + self.appVersion = appVersion + } + } + + public static func build(_ input: Input) -> ProviderDiagnosticExport { + let resolvedAuth = input.auth.resolved(with: input.outcome) + let usage = input.outcome.usageSnapshot.map { ProviderDiagnosticUsageSummary(from: $0) } + let error = input.outcome.failureError + .map { ProviderDiagnosticError(from: $0, authConfigured: resolvedAuth.configured) } + let settingsSummary = ProviderDiagnosticSettingsSummary( + sourceMode: input.sourceMode, + apiRegion: Self.safeAPIRegion(provider: input.provider, settings: input.settings)) + + return ProviderDiagnosticExport( + timestamp: Date(), + appVersion: input.appVersion, + provider: input.provider.rawValue, + displayName: input.descriptor.metadata.displayName, + source: input.outcome.sourceLabel, + sourceMode: input.sourceMode.rawValue, + auth: resolvedAuth, + usage: usage, + fetchAttempts: input.outcome.attempts.map { ProviderDiagnosticFetchAttempt(from: $0) }, + error: error, + settings: settingsSummary, + details: Self.details(provider: input.provider, outcome: input.outcome)) + } + + private static func safeAPIRegion(provider: UsageProvider, settings: ProviderSettingsSnapshot?) -> String? { + guard provider == .minimax else { return nil } + return settings?.minimax?.apiRegion.rawValue ?? "global" + } + + private static func details(provider: UsageProvider, outcome: ProviderFetchOutcome) -> ProviderDiagnosticDetails? { + guard provider == .minimax, + let usage = outcome.usageSnapshot?.minimaxUsage + else { + return nil + } + return .minimax(MiniMaxDiagnosticDetails(from: usage)) + } +} + +extension ProviderFetchOutcome { + fileprivate var isSuccess: Bool { + guard case .success = self.result else { return false } + return true + } + + fileprivate var sourceLabel: String { + guard case let .success(result) = result else { return "failed" } + return result.sourceLabel + } + + fileprivate var usageSnapshot: UsageSnapshot? { + guard case let .success(result) = result else { return nil } + return result.usage + } + + fileprivate var failureError: Error? { + guard case let .failure(error) = result else { return nil } + return error + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderEnvironmentResolver.swift b/Sources/CodexBarCore/Providers/ProviderEnvironmentResolver.swift new file mode 100644 index 0000000000..4bc0e53a9b --- /dev/null +++ b/Sources/CodexBarCore/Providers/ProviderEnvironmentResolver.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum ProviderEnvironmentResolver { + public static func resolve( + base: [String: String], + provider: UsageProvider, + config: ProviderConfig?, + selectedAccount: ProviderTokenAccount?) -> [String: String] + { + var environment = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: base, + provider: provider, + config: config) + guard let selectedAccount else { return environment } + + TokenAccountSupportCatalog.scrubEnvironmentForSelectedAccount( + &environment, + provider: provider, + token: selectedAccount.token) + if let override = TokenAccountSupportCatalog.envOverride( + for: provider, + token: selectedAccount.token) + { + environment.merge(override) { _, selectedAccountValue in selectedAccountValue } + } + return environment + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index cadbdc813c..95fabe2d9d 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -18,9 +18,13 @@ public enum ProviderSourceMode: String, CaseIterable, Sendable, Codable { } public struct ProviderFetchContext: Sendable { + public typealias TokenAccountTokenUpdater = @Sendable (UsageProvider, UUID, String) async -> Void + public typealias ProviderManualTokenUpdater = @Sendable (UsageProvider, String) async -> Void + public let runtime: ProviderRuntime public let sourceMode: ProviderSourceMode public let includeCredits: Bool + public let includeOptionalUsage: Bool public let webTimeout: TimeInterval public let webDebugDumpHTML: Bool public let verbose: Bool @@ -29,11 +33,25 @@ public struct ProviderFetchContext: Sendable { public let fetcher: UsageFetcher public let claudeFetcher: any ClaudeUsageFetching public let browserDetection: BrowserDetection + public let selectedTokenAccountID: UUID? + public let tokenAccountTokenUpdater: TokenAccountTokenUpdater? + public let providerManualTokenUpdater: ProviderManualTokenUpdater? + public let costUsageHistoryDays: Int + /// Whether warm CLI helper sessions (such as the managed Antigravity `agy` + /// process) may outlive a single fetch. True for long-lived hosts (the app, + /// `codexbar serve`); false for one-shot CLI invocations that should reset + /// the session after each fetch. + public let persistsCLISessions: Bool + /// Minimum idle lifetime for persistent CLI helper sessions. Long-lived + /// hosts set this beyond their refresh cadence so a slow cold start can + /// recover on the next refresh. + public let persistentCLISessionIdleWindow: TimeInterval? public init( runtime: ProviderRuntime, sourceMode: ProviderSourceMode, includeCredits: Bool, + includeOptionalUsage: Bool = true, webTimeout: TimeInterval, webDebugDumpHTML: Bool, verbose: Bool, @@ -41,11 +59,18 @@ public struct ProviderFetchContext: Sendable { settings: ProviderSettingsSnapshot?, fetcher: UsageFetcher, claudeFetcher: any ClaudeUsageFetching, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + selectedTokenAccountID: UUID? = nil, + tokenAccountTokenUpdater: TokenAccountTokenUpdater? = nil, + providerManualTokenUpdater: ProviderManualTokenUpdater? = nil, + costUsageHistoryDays: Int = 30, + persistsCLISessions: Bool = false, + persistentCLISessionIdleWindow: TimeInterval? = nil) { self.runtime = runtime self.sourceMode = sourceMode self.includeCredits = includeCredits + self.includeOptionalUsage = includeOptionalUsage self.webTimeout = webTimeout self.webDebugDumpHTML = webDebugDumpHTML self.verbose = verbose @@ -54,6 +79,18 @@ public struct ProviderFetchContext: Sendable { self.fetcher = fetcher self.claudeFetcher = claudeFetcher self.browserDetection = browserDetection + self.selectedTokenAccountID = selectedTokenAccountID + self.tokenAccountTokenUpdater = tokenAccountTokenUpdater + self.providerManualTokenUpdater = providerManualTokenUpdater + self.costUsageHistoryDays = max(1, min(365, costUsageHistoryDays)) + self.persistsCLISessions = persistsCLISessions + self.persistentCLISessionIdleWindow = persistentCLISessionIdleWindow + } +} + +public enum ProviderCLISessionLifecycle { + public static func shutdownPersistentSessions() async { + await AntigravityCLISession.shared.reset() } } @@ -162,6 +199,7 @@ public struct ProviderFetchPipeline: Sendable { let strategies = await self.resolveStrategies(context) var attempts: [ProviderFetchAttempt] = [] attempts.reserveCapacity(strategies.count) + var lastAvailableError: Error? for strategy in strategies { let available = await strategy.isAvailable(context) @@ -184,6 +222,7 @@ public struct ProviderFetchPipeline: Sendable { errorDescription: nil)) return ProviderFetchOutcome(result: .success(result), attempts: attempts) } catch { + lastAvailableError = error attempts.append(ProviderFetchAttempt( strategyID: strategy.id, kind: strategy.kind, @@ -196,7 +235,7 @@ public struct ProviderFetchPipeline: Sendable { } } - let error = ProviderFetchError.noAvailableStrategy(provider) + let error = lastAvailableError ?? ProviderFetchError.noAvailableStrategy(provider) return ProviderFetchOutcome(result: .failure(error), attempts: attempts) } } diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index e9bf84f9e7..88cd14f15d 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -1,5 +1,12 @@ import Foundation +public protocol ProviderCookieSettings: Sendable { + var cookieSource: ProviderCookieSource { get } + var manualCookieHeader: String? { get } + + init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) +} + public struct ProviderSettingsSnapshot: Sendable { public static func make( debugMenuEnabled: Bool = false, @@ -8,16 +15,30 @@ public struct ProviderSettingsSnapshot: Sendable { claude: ClaudeProviderSettings? = nil, cursor: CursorProviderSettings? = nil, opencode: OpenCodeProviderSettings? = nil, + opencodego: OpenCodeProviderSettings? = nil, + alibaba: AlibabaCodingPlanProviderSettings? = nil, + alibabaTokenPlan: AlibabaTokenPlanProviderSettings? = nil, factory: FactoryProviderSettings? = nil, minimax: MiniMaxProviderSettings? = nil, + manus: ManusProviderSettings? = nil, zai: ZaiProviderSettings? = nil, copilot: CopilotProviderSettings? = nil, kilo: KiloProviderSettings? = nil, kimi: KimiProviderSettings? = nil, augment: AugmentProviderSettings? = nil, + moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings? = nil, + t3chat: T3ChatProviderSettings? = nil, + devin: DevinProviderSettings? = nil, + commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, - jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot + jetbrains: JetBrainsProviderSettings? = nil, + windsurf: WindsurfProviderSettings? = nil, + perplexity: PerplexityProviderSettings? = nil, + mimo: MiMoProviderSettings? = nil, + abacus: AbacusProviderSettings? = nil, + mistral: MistralProviderSettings? = nil, + stepfun: StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: debugMenuEnabled, @@ -26,31 +47,60 @@ public struct ProviderSettingsSnapshot: Sendable { claude: claude, cursor: cursor, opencode: opencode, + opencodego: opencodego, + alibaba: alibaba, + alibabaTokenPlan: alibabaTokenPlan, factory: factory, minimax: minimax, + manus: manus, zai: zai, copilot: copilot, kilo: kilo, kimi: kimi, augment: augment, + moonshot: moonshot, amp: amp, + t3chat: t3chat, + devin: devin, + commandcode: commandcode, ollama: ollama, - jetbrains: jetbrains) + jetbrains: jetbrains, + windsurf: windsurf, + perplexity: perplexity, + mimo: mimo, + abacus: abacus, + mistral: mistral, + stepfun: stepfun) } public struct CodexProviderSettings: Sendable { public let usageDataSource: CodexUsageDataSource public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? + public let managedAccountStoreUnreadable: Bool + public let managedAccountTargetUnavailable: Bool + public let profileAccountTargetUnavailable: Bool + public let openAIWebCacheScope: CookieHeaderCache.Scope? + public let dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] public init( usageDataSource: CodexUsageDataSource, cookieSource: ProviderCookieSource, - manualCookieHeader: String?) + manualCookieHeader: String?, + managedAccountStoreUnreadable: Bool = false, + managedAccountTargetUnavailable: Bool = false, + profileAccountTargetUnavailable: Bool = false, + openAIWebCacheScope: CookieHeaderCache.Scope? = nil, + dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] = []) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader + self.managedAccountStoreUnreadable = managedAccountStoreUnreadable + self.managedAccountTargetUnavailable = managedAccountTargetUnavailable + self.profileAccountTargetUnavailable = profileAccountTargetUnavailable + self.openAIWebCacheScope = openAIWebCacheScope + self.dashboardAuthorityKnownOwners = dashboardAuthorityKnownOwners } } @@ -59,21 +109,34 @@ public struct ProviderSettingsSnapshot: Sendable { public let webExtrasEnabled: Bool public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? + public let organizationID: String? public init( usageDataSource: ClaudeUsageDataSource, webExtrasEnabled: Bool, cookieSource: ProviderCookieSource, - manualCookieHeader: String?) + manualCookieHeader: String?, + organizationID: String? = nil) { self.usageDataSource = usageDataSource self.webExtrasEnabled = webExtrasEnabled self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader + self.organizationID = organizationID } } - public struct CursorProviderSettings: Sendable { + public struct CookieProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource = .auto, manualCookieHeader: String? = nil) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct CursorProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -95,7 +158,33 @@ public struct ProviderSettingsSnapshot: Sendable { } } - public struct FactoryProviderSettings: Sendable { + public struct AlibabaCodingPlanProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + public let apiRegion: AlibabaCodingPlanAPIRegion + + public init( + cookieSource: ProviderCookieSource = .auto, + manualCookieHeader: String? = nil, + apiRegion: AlibabaCodingPlanAPIRegion = .international) + { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + self.apiRegion = apiRegion + } + } + + public struct AlibabaTokenPlanProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource = .auto, manualCookieHeader: String? = nil) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct FactoryProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -121,6 +210,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct ManusProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct ZaiProviderSettings: Sendable { public let apiRegion: ZaiAPIRegion @@ -130,7 +229,28 @@ public struct ProviderSettingsSnapshot: Sendable { } public struct CopilotProviderSettings: Sendable { - public init() {} + public let apiToken: String? + public let enterpriseHost: String? + public let selectedAccountExternalIdentifier: String? + public let budgetExtrasEnabled: Bool + public let budgetCookieSource: ProviderCookieSource + public let manualBudgetCookieHeader: String? + + public init( + apiToken: String? = nil, + enterpriseHost: String? = nil, + selectedAccountExternalIdentifier: String? = nil, + budgetExtrasEnabled: Bool = false, + budgetCookieSource: ProviderCookieSource = .auto, + manualBudgetCookieHeader: String? = nil) + { + self.apiToken = apiToken + self.enterpriseHost = enterpriseHost + self.selectedAccountExternalIdentifier = selectedAccountExternalIdentifier + self.budgetExtrasEnabled = budgetExtrasEnabled + self.budgetCookieSource = budgetCookieSource + self.manualBudgetCookieHeader = manualBudgetCookieHeader + } } public struct KiloProviderSettings: Sendable { @@ -143,7 +263,7 @@ public struct ProviderSettingsSnapshot: Sendable { } } - public struct KimiProviderSettings: Sendable { + public struct KimiProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -153,7 +273,7 @@ public struct ProviderSettingsSnapshot: Sendable { } } - public struct AugmentProviderSettings: Sendable { + public struct AugmentProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -163,6 +283,14 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct MoonshotProviderSettings: Sendable { + public let region: MoonshotRegion? + + public init(region: MoonshotRegion? = nil) { + self.region = region + } + } + public struct JetBrainsProviderSettings: Sendable { public let ideBasePath: String? @@ -171,7 +299,75 @@ public struct ProviderSettingsSnapshot: Sendable { } } - public struct AmpProviderSettings: Sendable { + public struct AmpProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct T3ChatProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct DevinProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualBearerToken: String? + public let organization: String? + + public init(cookieSource: ProviderCookieSource, manualBearerToken: String?, organization: String?) { + self.cookieSource = cookieSource + self.manualBearerToken = manualBearerToken + self.organization = organization + } + } + + public struct CommandCodeProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct OllamaProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct WindsurfProviderSettings: Sendable { + public let usageDataSource: WindsurfUsageDataSource + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init( + usageDataSource: WindsurfUsageDataSource, + cookieSource: ProviderCookieSource, + manualCookieHeader: String?) + { + self.usageDataSource = usageDataSource + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct PerplexityProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -181,7 +377,7 @@ public struct ProviderSettingsSnapshot: Sendable { } } - public struct OllamaProviderSettings: Sendable { + public struct MiMoProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -191,22 +387,75 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct AbacusProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct MistralProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct StepFunProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualToken: String + public let username: String + public let password: String + + public init( + cookieSource: ProviderCookieSource = .auto, + manualToken: String = "", + username: String = "", + password: String = "") + { + self.cookieSource = cookieSource + self.manualToken = manualToken + self.username = username + self.password = password + } + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? public let claude: ClaudeProviderSettings? public let cursor: CursorProviderSettings? public let opencode: OpenCodeProviderSettings? + public let opencodego: OpenCodeProviderSettings? + public let alibaba: AlibabaCodingPlanProviderSettings? + public let alibabaTokenPlan: AlibabaTokenPlanProviderSettings? public let factory: FactoryProviderSettings? public let minimax: MiniMaxProviderSettings? + public let manus: ManusProviderSettings? public let zai: ZaiProviderSettings? public let copilot: CopilotProviderSettings? public let kilo: KiloProviderSettings? public let kimi: KimiProviderSettings? public let augment: AugmentProviderSettings? + public let moonshot: MoonshotProviderSettings? public let amp: AmpProviderSettings? + public let t3chat: T3ChatProviderSettings? + public let devin: DevinProviderSettings? + public let commandcode: CommandCodeProviderSettings? public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? + public let windsurf: WindsurfProviderSettings? + public let perplexity: PerplexityProviderSettings? + public let mimo: MiMoProviderSettings? + public let abacus: AbacusProviderSettings? + public let mistral: MistralProviderSettings? + public let stepfun: StepFunProviderSettings? public var jetbrainsIDEBasePath: String? { self.jetbrains?.ideBasePath @@ -219,16 +468,30 @@ public struct ProviderSettingsSnapshot: Sendable { claude: ClaudeProviderSettings?, cursor: CursorProviderSettings?, opencode: OpenCodeProviderSettings?, + opencodego: OpenCodeProviderSettings?, + alibaba: AlibabaCodingPlanProviderSettings?, + alibabaTokenPlan: AlibabaTokenPlanProviderSettings? = nil, factory: FactoryProviderSettings?, minimax: MiniMaxProviderSettings?, + manus: ManusProviderSettings?, zai: ZaiProviderSettings?, copilot: CopilotProviderSettings?, kilo: KiloProviderSettings?, kimi: KimiProviderSettings?, augment: AugmentProviderSettings?, + moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings?, + t3chat: T3ChatProviderSettings? = nil, + devin: DevinProviderSettings? = nil, + commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings?, - jetbrains: JetBrainsProviderSettings? = nil) + jetbrains: JetBrainsProviderSettings? = nil, + windsurf: WindsurfProviderSettings? = nil, + perplexity: PerplexityProviderSettings? = nil, + mimo: MiMoProviderSettings? = nil, + abacus: AbacusProviderSettings? = nil, + mistral: MistralProviderSettings? = nil, + stepfun: StepFunProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive @@ -236,16 +499,30 @@ public struct ProviderSettingsSnapshot: Sendable { self.claude = claude self.cursor = cursor self.opencode = opencode + self.opencodego = opencodego + self.alibaba = alibaba + self.alibabaTokenPlan = alibabaTokenPlan self.factory = factory self.minimax = minimax + self.manus = manus self.zai = zai self.copilot = copilot self.kilo = kilo self.kimi = kimi self.augment = augment + self.moonshot = moonshot self.amp = amp + self.t3chat = t3chat + self.devin = devin + self.commandcode = commandcode self.ollama = ollama self.jetbrains = jetbrains + self.windsurf = windsurf + self.perplexity = perplexity + self.mimo = mimo + self.abacus = abacus + self.mistral = mistral + self.stepfun = stepfun } } @@ -254,16 +531,30 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case claude(ProviderSettingsSnapshot.ClaudeProviderSettings) case cursor(ProviderSettingsSnapshot.CursorProviderSettings) case opencode(ProviderSettingsSnapshot.OpenCodeProviderSettings) + case opencodego(ProviderSettingsSnapshot.OpenCodeProviderSettings) + case alibaba(ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings) + case alibabaTokenPlan(ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings) case factory(ProviderSettingsSnapshot.FactoryProviderSettings) case minimax(ProviderSettingsSnapshot.MiniMaxProviderSettings) + case manus(ProviderSettingsSnapshot.ManusProviderSettings) case zai(ProviderSettingsSnapshot.ZaiProviderSettings) case copilot(ProviderSettingsSnapshot.CopilotProviderSettings) case kilo(ProviderSettingsSnapshot.KiloProviderSettings) case kimi(ProviderSettingsSnapshot.KimiProviderSettings) case augment(ProviderSettingsSnapshot.AugmentProviderSettings) + case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) + case t3chat(ProviderSettingsSnapshot.T3ChatProviderSettings) + case devin(ProviderSettingsSnapshot.DevinProviderSettings) + case commandcode(ProviderSettingsSnapshot.CommandCodeProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) + case windsurf(ProviderSettingsSnapshot.WindsurfProviderSettings) + case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings) + case mimo(ProviderSettingsSnapshot.MiMoProviderSettings) + case abacus(ProviderSettingsSnapshot.AbacusProviderSettings) + case mistral(ProviderSettingsSnapshot.MistralProviderSettings) + case stepfun(ProviderSettingsSnapshot.StepFunProviderSettings) } public struct ProviderSettingsSnapshotBuilder: Sendable { @@ -273,38 +564,67 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var claude: ProviderSettingsSnapshot.ClaudeProviderSettings? public var cursor: ProviderSettingsSnapshot.CursorProviderSettings? public var opencode: ProviderSettingsSnapshot.OpenCodeProviderSettings? + public var opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings? + public var alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? + public var alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings? public var factory: ProviderSettingsSnapshot.FactoryProviderSettings? public var minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? + public var manus: ProviderSettingsSnapshot.ManusProviderSettings? public var zai: ProviderSettingsSnapshot.ZaiProviderSettings? public var copilot: ProviderSettingsSnapshot.CopilotProviderSettings? public var kilo: ProviderSettingsSnapshot.KiloProviderSettings? public var kimi: ProviderSettingsSnapshot.KimiProviderSettings? public var augment: ProviderSettingsSnapshot.AugmentProviderSettings? + public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? + public var t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings? + public var devin: ProviderSettingsSnapshot.DevinProviderSettings? + public var commandcode: ProviderSettingsSnapshot.CommandCodeProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? + public var windsurf: ProviderSettingsSnapshot.WindsurfProviderSettings? + public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? + public var mimo: ProviderSettingsSnapshot.MiMoProviderSettings? + public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings? + public var mistral: ProviderSettingsSnapshot.MistralProviderSettings? + public var stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive } + // swiftlint:disable:next cyclomatic_complexity public mutating func apply(_ contribution: ProviderSettingsSnapshotContribution) { switch contribution { case let .codex(value): self.codex = value case let .claude(value): self.claude = value case let .cursor(value): self.cursor = value case let .opencode(value): self.opencode = value + case let .opencodego(value): self.opencodego = value + case let .alibaba(value): self.alibaba = value + case let .alibabaTokenPlan(value): self.alibabaTokenPlan = value case let .factory(value): self.factory = value case let .minimax(value): self.minimax = value + case let .manus(value): self.manus = value case let .zai(value): self.zai = value case let .copilot(value): self.copilot = value case let .kilo(value): self.kilo = value case let .kimi(value): self.kimi = value case let .augment(value): self.augment = value + case let .moonshot(value): self.moonshot = value case let .amp(value): self.amp = value + case let .t3chat(value): self.t3chat = value + case let .devin(value): self.devin = value + case let .commandcode(value): self.commandcode = value case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value + case let .windsurf(value): self.windsurf = value + case let .perplexity(value): self.perplexity = value + case let .mimo(value): self.mimo = value + case let .abacus(value): self.abacus = value + case let .mistral(value): self.mistral = value + case let .stepfun(value): self.stepfun = value } } @@ -316,15 +636,29 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { claude: self.claude, cursor: self.cursor, opencode: self.opencode, + opencodego: self.opencodego, + alibaba: self.alibaba, + alibabaTokenPlan: self.alibabaTokenPlan, factory: self.factory, minimax: self.minimax, + manus: self.manus, zai: self.zai, copilot: self.copilot, kilo: self.kilo, kimi: self.kimi, augment: self.augment, + moonshot: self.moonshot, amp: self.amp, + t3chat: self.t3chat, + devin: self.devin, + commandcode: self.commandcode, ollama: self.ollama, - jetbrains: self.jetbrains) + jetbrains: self.jetbrains, + windsurf: self.windsurf, + perplexity: self.perplexity, + mimo: self.mimo, + abacus: self.abacus, + mistral: self.mistral, + stepfun: self.stepfun) } } diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index ada9fac8d2..3bf7117fa7 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -16,6 +16,10 @@ public struct ProviderTokenResolution: Sendable { } public enum ProviderTokenResolver { + public static func ampToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.ampResolution(environment: environment)?.token + } + public static func zaiToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.zaiResolution(environment: environment)?.token } @@ -26,6 +30,24 @@ public enum ProviderTokenResolver { self.syntheticResolution(environment: environment)?.token } + public static func openAIAPIToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.openAIAPIResolution(environment: environment)?.token + } + + public static func azureOpenAIToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.azureOpenAIResolution(environment: environment)?.token + } + + public static func claudeAdminAPIToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.claudeAdminAPIResolution(environment: environment)?.token + } + public static func copilotToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.copilotResolution(environment: environment)?.token } @@ -34,6 +56,10 @@ public enum ProviderTokenResolver { self.minimaxTokenResolution(environment: environment)?.token } + public static func alibabaToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.alibabaTokenResolution(environment: environment)?.token + } + public static func minimaxCookie(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.minimaxCookieResolution(environment: environment)?.token } @@ -42,10 +68,22 @@ public enum ProviderTokenResolver { self.kimiAuthResolution(environment: environment)?.token } + public static func kimiAPIToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.kimiAPIResolution(environment: environment)?.token + } + public static func kimiK2Token(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.kimiK2Resolution(environment: environment)?.token } + public static func moonshotToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.moonshotResolution(environment: environment)?.token + } + + public static func ollamaToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.ollamaResolution(environment: environment)?.token + } + public static func kiloToken( environment: [String: String] = ProcessInfo.processInfo.environment, authFileURL: URL? = nil) -> String? @@ -61,6 +99,125 @@ public enum ProviderTokenResolver { self.openRouterResolution(environment: environment)?.token } + public static func elevenLabsToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.elevenLabsResolution(environment: environment)?.token + } + + public static func groqToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.groqResolution(environment: environment)?.token + } + + public static func llmProxyToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.llmProxyResolution(environment: environment)?.token + } + + public static func liteLLMToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.liteLLMResolution(environment: environment)?.token + } + + public static func perplexitySessionToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.perplexityResolution(environment: environment)?.token + } + + public static func deepseekToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.deepseekResolution(environment: environment)?.token + } + + public static func poeToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.poeResolution(environment: environment)?.token + } + + public static func crofToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.crofResolution(environment: environment)?.token + } + + public static func veniceToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.veniceResolution(environment: environment)?.token + } + + public static func stepfunToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.stepfunResolution(environment: environment)?.token + } + + public static func doubaoToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.doubaoResolution(environment: environment)?.token + } + + public static func bedrockAccessKeyID( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.bedrockResolution(environment: environment)?.token + } + + public static func bedrockResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(BedrockSettingsReader.accessKeyID(environment: environment)) + } + + public static func ampResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(AmpSettingsReader.apiToken(environment: environment)) + } + + public static func deepseekResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(DeepSeekSettingsReader.apiKey(environment: environment)) + } + + public static func poeResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(PoeSettingsReader.apiKey(environment: environment)) + } + + public static func crofResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(CrofSettingsReader.apiKey(environment: environment)) + } + + public static func veniceResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(VeniceSettingsReader.apiKey(environment: environment)) + } + + public static func codebuffToken( + environment: [String: String] = ProcessInfo.processInfo.environment, + authFileURL: URL? = nil) -> String? + { + self.codebuffResolution(environment: environment, authFileURL: authFileURL)?.token + } + + public static func stepfunResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(StepFunSettingsReader.token(environment: environment)) + } + + public static func doubaoResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(DoubaoSettingsReader.apiKey(environment: environment)) + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -73,6 +230,24 @@ public enum ProviderTokenResolver { self.resolveEnv(SyntheticSettingsReader.apiKey(environment: environment)) } + public static func openAIAPIResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(OpenAIAPISettingsReader.apiKey(environment: environment)) + } + + public static func azureOpenAIResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(AzureOpenAISettingsReader.apiKey(environment: environment)) + } + + public static func claudeAdminAPIResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(ClaudeAdminAPISettingsReader.apiKey(environment: environment)) + } + public static func copilotResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -85,6 +260,12 @@ public enum ProviderTokenResolver { self.resolveEnv(MiniMaxAPISettingsReader.apiToken(environment: environment)) } + public static func alibabaTokenResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(AlibabaCodingPlanSettingsReader.apiToken(environment: environment)) + } + public static func minimaxCookieResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -110,12 +291,30 @@ public enum ProviderTokenResolver { return nil } + public static func kimiAPIResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(KimiSettingsReader.apiKey(environment: environment)) + } + public static func kimiK2Resolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { self.resolveEnv(KimiK2SettingsReader.apiKey(environment: environment)) } + public static func moonshotResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(MoonshotSettingsReader.apiKey(environment: environment)) + } + + public static func ollamaResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(OllamaAPISettingsReader.apiKey(environment: environment)) + } + public static func kiloResolution( environment: [String: String] = ProcessInfo.processInfo.environment, authFileURL: URL? = nil) -> ProviderTokenResolution? @@ -141,6 +340,80 @@ public enum ProviderTokenResolver { self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment)) } + public static func elevenLabsResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(ElevenLabsSettingsReader.apiKey(environment: environment)) + } + + public static func groqResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(GroqSettingsReader.apiKey(environment: environment)) + } + + public static func llmProxyResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(LLMProxySettingsReader.apiKey(environment: environment)) + } + + public static func liteLLMResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(LiteLLMSettingsReader.apiKey(environment: environment)) + } + + public enum DeepgramCredentialKind: Sendable { + case apiKey + case projectID + } + + public static func deepgramResolution( + type: DeepgramCredentialKind, + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + switch type { + case .apiKey: + self.resolveEnv(DeepgramSettingsReader.apiKey(environment: environment))?.token + + case .projectID: + self.resolveEnv(DeepgramSettingsReader.projectID(environment: environment))?.token + } + } + + public static func codebuffResolution( + environment: [String: String] = ProcessInfo.processInfo.environment, + authFileURL: URL? = nil) -> ProviderTokenResolution? + { + if let resolution = self.resolveEnv(CodebuffSettingsReader.apiKey(environment: environment)) { + return resolution + } + if let token = CodebuffSettingsReader.authToken(authFileURL: authFileURL) { + return ProviderTokenResolution(token: token, source: .authFile) + } + return nil + } + + public static func perplexityResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + if let resolution = self.resolveEnv(PerplexitySettingsReader.sessionToken(environment: environment)) { + return resolution + } + #if os(macOS) + do { + let session = try PerplexityCookieImporter.importSession() + if let token = session.sessionToken { + return ProviderTokenResolution(token: token, source: .environment) + } + } catch { + // No browser cookies found, continue to fallback + } + #endif + return nil + } + private static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil @@ -149,8 +422,7 @@ public enum ProviderTokenResolver { if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { - value.removeFirst() - value.removeLast() + value = String(value.dropFirst().dropLast()) } value = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift index 15dd131daa..071b0606c6 100644 --- a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift +++ b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift @@ -1,3 +1,10 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif import Foundation public enum ProviderVersionDetector { @@ -10,7 +17,8 @@ public enum ProviderVersionDetector { options: TTYCommandRunner.Options( timeout: 5.0, extraArgs: ["--allowed-tools", "", "--version"], - initialDelay: 0.0)).text + initialDelay: 0.0, + useClaudeProbeWorkingDirectory: true)).text let trimmed = TextParsing.stripANSICodes(out).trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } catch { @@ -45,41 +53,61 @@ public enum ProviderVersionDetector { return nil } - private static func run(path: String, args: [String]) -> String? { + static func run( + path: String, + args: [String], + timeout: TimeInterval = 2.0, + environment: [String: String]? = nil, + mergeStandardError: Bool = false) -> String? + { let proc = Process() proc.executableURL = URL(fileURLWithPath: path) proc.arguments = args + proc.environment = environment let out = Pipe() proc.standardOutput = out - proc.standardError = Pipe() + proc.standardError = mergeStandardError ? out : FileHandle.nullDevice + proc.standardInput = nil + let outputCapture = ProcessPipeCapture(pipe: out) + outputCapture.start() + + let exitSemaphore = DispatchSemaphore(value: 0) + proc.terminationHandler = { _ in + exitSemaphore.signal() + } do { try proc.run() } catch { + outputCapture.stop() return nil } - let deadline = Date().addingTimeInterval(2.0) - while proc.isRunning, Date() < deadline { - usleep(50000) - } - if proc.isRunning { - proc.terminate() - let killDeadline = Date().addingTimeInterval(0.5) - while proc.isRunning, Date() < killDeadline { - usleep(20000) - } - if proc.isRunning { - kill(proc.processIdentifier, SIGKILL) - } + let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success + if !didExit, !Self.forceExit(proc, exitSemaphore: exitSemaphore) { + outputCapture.stop() + return nil } - let data = out.fileHandleForReading.readDataToEndOfFile() + let data = outputCapture.finishSynchronously(timeout: 0.25) guard proc.terminationStatus == 0, - let text = String(data: data, encoding: .utf8)? + let text = ProcessPipeCapture.decodeUTF8(data) .split(whereSeparator: \.isNewline).first else { return nil } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } + + private static func forceExit(_ proc: Process, exitSemaphore: DispatchSemaphore) -> Bool { + guard proc.isRunning else { return true } + + proc.terminate() + if exitSemaphore.wait(timeout: .now() + 0.5) == .success { + return true + } + + guard proc.isRunning else { return true } + kill(proc.processIdentifier, SIGKILL) + return exitSemaphore.wait(timeout: .now() + 1.0) == .success + } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index f48eefe437..005a6d2e4d 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -4,15 +4,22 @@ import SweetCookieKit // swiftformat:disable sortDeclarations public enum UsageProvider: String, CaseIterable, Sendable, Codable { case codex + case openai + case azureopenai case claude case cursor case opencode + case opencodego + case alibaba + case alibabatokenplan case factory case gemini case antigravity case copilot + case devin case zai case minimax + case manus case kimi case kilo case kiro @@ -20,26 +27,55 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case augment case jetbrains case kimik2 + case moonshot case amp + case t3chat case ollama case synthetic case warp case openrouter + case elevenlabs + case windsurf + case zed + case perplexity + case mimo + case doubao + case abacus + case mistral + case deepseek + case codebuff + case crof + case venice + case commandcode + case stepfun + case bedrock + case grok + case groq + case llmproxy + case litellm + case deepgram + case poe + case chutes } // swiftformat:enable sortDeclarations -public enum IconStyle: Sendable, CaseIterable { +public enum IconStyle: String, Sendable, CaseIterable { case codex + case openai case claude case zai case minimax + case manus case gemini case antigravity case cursor case opencode + case opencodego + case alibaba case factory case copilot + case devin case kimi case kimik2 case kilo @@ -47,11 +83,35 @@ public enum IconStyle: Sendable, CaseIterable { case vertexai case augment case jetbrains + case moonshot case amp + case t3chat case ollama case synthetic case warp case openrouter + case elevenlabs + case windsurf + case zed + case perplexity + case mimo + case doubao + case abacus + case mistral + case deepseek + case codebuff + case crof + case venice + case commandcode + case stepfun + case bedrock + case grok + case groq + case llmproxy + case litellm + case deepgram + case poe + case chutes case combined } @@ -72,6 +132,8 @@ public struct ProviderMetadata: Sendable { public let browserCookieOrder: BrowserCookieImportOrder? public let dashboardURL: String? public let subscriptionDashboardURL: String? + /// Provider-specific release notes or changelog URL for CLI/provider updates. + public let changelogURL: String? /// Statuspage.io base URL for incident polling (append /api/v2/status.json). public let statusPageURL: String? /// Browser-only status link (no API polling); used when statusPageURL is nil. @@ -96,6 +158,7 @@ public struct ProviderMetadata: Sendable { browserCookieOrder: BrowserCookieImportOrder? = nil, dashboardURL: String?, subscriptionDashboardURL: String? = nil, + changelogURL: String? = nil, statusPageURL: String?, statusLinkURL: String? = nil, statusWorkspaceProductID: String? = nil) @@ -116,6 +179,7 @@ public struct ProviderMetadata: Sendable { self.browserCookieOrder = browserCookieOrder self.dashboardURL = dashboardURL self.subscriptionDashboardURL = subscriptionDashboardURL + self.changelogURL = changelogURL self.statusPageURL = statusPageURL self.statusLinkURL = statusLinkURL self.statusWorkspaceProductID = statusWorkspaceProductID @@ -136,4 +200,64 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// Safari first for Cursor: active sessions often live only there, and Chromium profiles may carry stale tokens. + public static var cursorCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.safari] + Browser.defaultImportOrder.filter { $0 != .safari } + #else + nil + #endif + } + + /// Preserve the legacy Codex prompt behavior: prefer Safari/Chrome/Firefox before + /// probing additional Chromium variants that may trigger Safe Storage prompts. + public static var codexCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + let preferredPrefix: [Browser] = [.safari, .chrome, .firefox] + return preferredPrefix + Browser.defaultImportOrder.filter { !preferredPrefix.contains($0) } + #else + nil + #endif + } + + /// Grok is normally signed in through Chrome; keep this narrow so CLI/live probes do not touch + /// unrelated browser keychains. + public static var grokCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } + + /// MiMo Auto: Safari first (no Keychain prompt), keep the existing Chrome-family + /// entries from main, and add Firefox/Edge per #1304. Other Chromium forks stay on + /// Manual import to avoid scanning the full SweetCookieKit default order. + public static var mimoCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.safari, .chrome, .chromeBeta, .chromeCanary, .firefox, .edge] + #else + nil + #endif + } + + /// Devin sessions are normally in Chrome. Keep automatic import narrow so live probes do not + /// touch unrelated browser keychains; users can select another browser explicitly. + public static var devinCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } + + /// Copilot budget imports should stay Chrome-only by default to avoid prompting unrelated browsers. + public static var copilotCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } } diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift new file mode 100644 index 0000000000..1cdac836fa --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -0,0 +1,315 @@ +import Foundation + +public enum StepFunProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .stepfun, + metadata: ProviderMetadata( + id: .stepfun, + displayName: "StepFun", + sessionLabel: "5h Window", + weeklyLabel: "Weekly Window", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show StepFun usage", + cliName: "stepfun", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://platform.stepfun.com/plan-usage", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .stepfun, + iconResourceName: "ProviderIcon-stepfun", + color: ProviderColor(red: 0.13, green: 0.59, blue: 0.95)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "StepFun per-day cost history is not available via API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [StepFunWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "stepfun", + aliases: ["step-fun", "sf"], + versionDetector: nil)) + } +} + +struct StepFunWebFetchStrategy: ProviderFetchStrategy { + let id: String = "stepfun.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + context.settings?.stepfun?.cookieSource != .off + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + do { + let resolved = try await Self.resolveToken(context: context, allowCached: true) + let usage = try await StepFunUsageFetcher.fetchUsage(token: resolved.token) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } catch let error where Self.isAuthenticationFailure(error) { + return try await self.recoverFromAuthenticationFailure(context: context, originalError: error) + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + // MARK: - Token Resolution + + private struct ResolvedToken { + let token: String + let source: TokenSource + } + + private enum TokenSource { + case manual + case cached + case settingsLogin + case environmentToken + case environmentLogin + } + + private static func resolveToken( + context: ProviderFetchContext, + allowCached: Bool) async throws -> ResolvedToken + { + let settings = context.settings?.stepfun + + // 1. Manual mode: use the token directly from settings + if settings?.cookieSource == .manual { + let manualToken = settings?.manualToken ?? "" + guard !manualToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw StepFunUsageError.missingToken + } + return ResolvedToken( + token: StepFunTokenNormalizer.normalize(manualToken), + source: .manual) + } + + // 2. Cached token from previous login + if allowCached, let cached = CookieHeaderCache.load(provider: .stepfun) { + return ResolvedToken( + token: StepFunTokenNormalizer.normalize(cached.cookieHeader), + source: .cached) + } + + // 3. Username + password from Settings UI → perform full login flow + // (register device → sign in by password → get Oasis-Token) + if let settings, !settings.username.isEmpty, !settings.password.isEmpty { + let token = try await StepFunUsageFetcher.login( + username: settings.username, + password: settings.password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return ResolvedToken(token: token, source: .settingsLogin) + } + + // 4. Direct token from env var + if let token = StepFunSettingsReader.token(environment: context.env) { + return ResolvedToken(token: token, source: .environmentToken) + } + + // 5. Username + password from env vars → perform full login flow + if let username = StepFunSettingsReader.username(environment: context.env), + let password = StepFunSettingsReader.password(environment: context.env) + { + let token = try await StepFunUsageFetcher.login(username: username, password: password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return ResolvedToken(token: token, source: .environmentLogin) + } + + throw StepFunUsageError.missingCredentials + } + + private func recoverFromAuthenticationFailure( + context: ProviderFetchContext, + originalError: Error) async throws -> ProviderFetchResult + { + let resolved = try await Self.resolveToken(context: context, allowCached: true) + let refreshed: String + do { + refreshed = try await StepFunUsageFetcher.refreshToken(token: resolved.token) + } catch { + if let fallback = try await Self.resolvedTokenWithoutStaleCache(context: context, source: resolved.source) { + do { + let usage = try await StepFunUsageFetcher.fetchUsage(token: fallback.token) + await Self.persistRecoveredToken(fallback.token, source: fallback.source, context: context) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } catch { + if !Self.isAuthenticationFailure(error) { + throw error + } + } + } + if let loginToken = try await Self.loginTokenIfAvailable(context: context, source: resolved.source) { + let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } + throw Self.actionableAuthenticationError(for: resolved.source, originalError: originalError) + } + + await Self.persistRecoveredToken(refreshed, source: resolved.source, context: context) + + do { + let usage = try await StepFunUsageFetcher.fetchUsage(token: refreshed) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } catch let retryError where Self.isAuthenticationFailure(retryError) { + if let loginToken = try await Self.loginTokenIfAvailable(context: context, source: resolved.source) { + let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } + throw Self.actionableAuthenticationError(for: resolved.source, originalError: originalError) + } + } + + private static func resolvedTokenWithoutStaleCache( + context: ProviderFetchContext, + source: TokenSource) async throws -> ResolvedToken? + { + guard case .cached = source else { return nil } + CookieHeaderCache.clear(provider: .stepfun) + do { + return try await self.resolveToken(context: context, allowCached: false) + } catch StepFunUsageError.missingCredentials { + return nil + } catch StepFunUsageError.missingToken { + return nil + } + } + + private static func loginTokenIfAvailable( + context: ProviderFetchContext, + source: TokenSource) async throws -> String? + { + if case .manual = source { + return nil + } + + let settings = context.settings?.stepfun + if settings?.cookieSource != .manual, + let settings, + !settings.username.isEmpty, + !settings.password.isEmpty + { + CookieHeaderCache.clear(provider: .stepfun) + let token = try await StepFunUsageFetcher.login( + username: settings.username, + password: settings.password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return token + } + + if let username = StepFunSettingsReader.username(environment: context.env), + let password = StepFunSettingsReader.password(environment: context.env) + { + CookieHeaderCache.clear(provider: .stepfun) + let token = try await StepFunUsageFetcher.login(username: username, password: password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return token + } + + return nil + } + + private static func persistRecoveredToken( + _ token: String, + source: TokenSource, + context: ProviderFetchContext) async + { + switch source { + case .cached, .settingsLogin, .environmentLogin: + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "refresh") + case .manual: + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { + await context.providerManualTokenUpdater?(.stepfun, token) + return + } + await updater(.stepfun, accountID, token) + case .environmentToken: + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { return } + await updater(.stepfun, accountID, token) + } + } + + private static func isAuthenticationFailure(_ error: Error) -> Bool { + guard case let StepFunUsageError.apiError(message) = error else { + return false + } + let lower = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return lower.contains("401") || + lower.contains("403") || + lower.contains("unauthorized") || + lower.contains("unauthenticated") || + lower.contains("invalid credentials") || + lower.contains("invalid token") || + lower.contains("token expired") || + lower.contains("expired token") + } + + private static func actionableAuthenticationError( + for source: TokenSource, + originalError: Error) -> StepFunUsageError + { + let suffix = switch source { + case .manual: + "Refresh the Oasis-Token, or switch StepFun to auto auth with username/password." + case .environmentToken: + "Refresh STEPFUN_TOKEN, or configure STEPFUN_USERNAME and STEPFUN_PASSWORD." + case .cached, .settingsLogin, .environmentLogin: + "Refresh the StepFun credentials and try again." + } + return .apiError("\(Self.authenticationFailureMessage(originalError)). \(suffix)") + } + + private static func authenticationFailureMessage(_ error: Error) -> String { + if case let StepFunUsageError.apiError(message) = error { + return message + } + return error.localizedDescription + } +} + +// MARK: - Token Normalizer + +public enum StepFunTokenNormalizer { + /// Normalize a StepFun token value — extracts the Oasis-Token from a cookie header + /// or returns the raw token value if it's not a cookie header. + public static func normalize(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + // If it looks like a cookie header, extract Oasis-Token + if trimmed.contains("Oasis-Token=") { + let parts = trimmed.components(separatedBy: "Oasis-Token=") + if parts.count > 1 { + let afterToken = parts[1] + return afterToken.components(separatedBy: ";").first? + .trimmingCharacters(in: .whitespaces) ?? afterToken + } + } + + return trimmed + } +} diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift new file mode 100644 index 0000000000..b8a497a6f7 --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift @@ -0,0 +1,38 @@ +import Foundation + +public struct StepFunSettingsReader: Sendable { + public static let usernameEnvironmentKey = "STEPFUN_USERNAME" + public static let passwordEnvironmentKey = "STEPFUN_PASSWORD" + public static let tokenEnvironmentKey = "STEPFUN_TOKEN" + + public static func username( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.usernameEnvironmentKey]) + } + + public static func password( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.passwordEnvironmentKey]) + } + + public static func token( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.tokenEnvironmentKey]) + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift new file mode 100644 index 0000000000..9c8bca186b --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -0,0 +1,554 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - API response types + +/// A flexible number type that can decode from both JSON integers and floats. +/// The StepFun API returns `five_hour_usage_left_rate: 1` (int) or `0.99781543` (float). +public struct StepFunFlexibleNumber: Decodable, Sendable { + public let value: Double + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intVal = try? container.decode(Int.self) { + self.value = Double(intVal) + } else if let doubleVal = try? container.decode(Double.self) { + self.value = doubleVal + } else { + self.value = 0 + } + } + + public init(_ value: Double) { + self.value = value + } +} + +/// A flexible timestamp type that can decode from both JSON strings and integers. +/// The StepFun API returns timestamps as strings like `"1777528800"`. +public struct StepFunFlexibleTimestamp: Decodable, Sendable { + public let value: Int64 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let strVal = try? container.decode(String.self), let parsed = Int64(strVal) { + self.value = parsed + } else if let intVal = try? container.decode(Int64.self) { + self.value = intVal + } else { + self.value = 0 + } + } + + public init(_ value: Int64) { + self.value = value + } +} + +public struct StepFunRateLimitResponse: Decodable, Sendable { + public let status: Int? + public let code: Int? + public let message: String? + public let desc: String? + public let fiveHourUsageLeftRate: StepFunFlexibleNumber? + public let weeklyUsageLeftRate: StepFunFlexibleNumber? + public let fiveHourUsageResetTime: StepFunFlexibleTimestamp? + public let weeklyUsageResetTime: StepFunFlexibleTimestamp? + + enum CodingKeys: String, CodingKey { + case status + case code + case message + case desc + case fiveHourUsageLeftRate = "five_hour_usage_left_rate" + case weeklyUsageLeftRate = "weekly_usage_left_rate" + case fiveHourUsageResetTime = "five_hour_usage_reset_time" + case weeklyUsageResetTime = "weekly_usage_reset_time" + } + + public var isSuccess: Bool { + self.status == 1 + } +} + +// MARK: - Plan status response types + +struct StepFunPlanStatusResponse: Decodable { + let status: Int? + let subscription: StepFunSubscription? + + var planName: String? { + self.subscription?.name?.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +struct StepFunSubscription: Decodable { + let name: String? + let planType: Int? + let planStatus: Int? + + enum CodingKeys: String, CodingKey { + case name + case planType = "plan_type" + case planStatus = "status" + } +} + +// MARK: - Auth response types + +struct StepFunRegisterDeviceResponse: Decodable { + let accessToken: StepFunTokenPair? + let refreshToken: StepFunTokenPair? +} + +struct StepFunLoginResponse: Decodable { + let accessToken: StepFunTokenPair? + let refreshToken: StepFunTokenPair? +} + +struct StepFunRefreshTokenResponse: Decodable { + let accessToken: StepFunTokenPair? + let refreshToken: StepFunTokenPair? +} + +struct StepFunTokenPair: Decodable { + let raw: String +} + +// MARK: - Domain snapshot + +public struct StepFunUsageSnapshot: Sendable { + public let fiveHourUsageLeftRate: Double + public let weeklyUsageLeftRate: Double + public let fiveHourUsageResetTime: Date + public let weeklyUsageResetTime: Date + public let planName: String? + public let updatedAt: Date + + public init( + fiveHourUsageLeftRate: Double, + weeklyUsageLeftRate: Double, + fiveHourUsageResetTime: Date, + weeklyUsageResetTime: Date, + planName: String? = nil, + updatedAt: Date) + { + self.fiveHourUsageLeftRate = fiveHourUsageLeftRate + self.weeklyUsageLeftRate = weeklyUsageLeftRate + self.fiveHourUsageResetTime = fiveHourUsageResetTime + self.weeklyUsageResetTime = weeklyUsageResetTime + self.planName = planName + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + // Five-hour window: primary + let fiveHourUsedPercent = max(0, min(100, (1.0 - self.fiveHourUsageLeftRate) * 100)) + let fiveHourResetDescription = UsageFormatter.resetDescription(from: self.fiveHourUsageResetTime) + let fiveHourWindow = RateWindow( + usedPercent: fiveHourUsedPercent, + windowMinutes: 300, + resetsAt: self.fiveHourUsageResetTime, + resetDescription: fiveHourResetDescription) + + // Weekly window: secondary + let weeklyUsedPercent = max(0, min(100, (1.0 - self.weeklyUsageLeftRate) * 100)) + let weeklyResetDescription = UsageFormatter.resetDescription(from: self.weeklyUsageResetTime) + let weeklyWindow = RateWindow( + usedPercent: weeklyUsedPercent, + windowMinutes: 10080, + resetsAt: self.weeklyUsageResetTime, + resetDescription: weeklyResetDescription) + + let trimmedPlan = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (trimmedPlan?.isEmpty ?? true) ? "password" : trimmedPlan + + let identity = ProviderIdentitySnapshot( + providerID: .stepfun, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: fiveHourWindow, + secondary: weeklyWindow, + tertiary: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +// MARK: - Errors + +public enum StepFunUsageError: LocalizedError, Sendable { + case missingCredentials + case missingToken + case networkError(String) + case apiError(String) + case parseFailed(String) + case loginFailed(String) + case tokenRefreshFailed(String) + case deviceRegistrationFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing StepFun username or password. Set STEPFUN_USERNAME and STEPFUN_PASSWORD environment variables." + case .missingToken: + "Missing StepFun authentication token." + case let .networkError(message): + "StepFun network error: \(message)" + case let .apiError(message): + "StepFun API error: \(message)" + case let .parseFailed(message): + "Failed to parse StepFun response: \(message)" + case let .loginFailed(message): + "StepFun login failed: \(message)" + case let .tokenRefreshFailed(message): + "StepFun token refresh failed: \(message)" + case let .deviceRegistrationFailed(message): + "StepFun device registration failed: \(message)" + } + } +} + +// MARK: - Fetcher + +public struct StepFunUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.stepfunUsage) + private static let platformURL = URL(string: "https://platform.stepfun.com")! + private static let apiURL = + URL(string: "https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/QueryStepPlanRateLimit")! + private static let planStatusURL = + URL(string: "https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/GetStepPlanStatus")! + private static let registerDeviceURL = + URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RegisterDevice")! + private static let loginURL = + URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/SignInByPassword")! + private static let refreshTokenURL = + URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RefreshToken")! + private static let timeoutSeconds: TimeInterval = 15 + + private static let webID = "c8a1002d2c457e758785a9979832217c7c0b884c" + private static let appID = "10300" + + private static let baseHeaders: [String: String] = [ + "content-type": "application/json", + "oasis-appid": appID, + "oasis-platform": "web", + "oasis-webid": webID, + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + ] + + // MARK: - Public API + + /// Perform the full login flow (username + password → Oasis-Token) and return the token. + /// Does NOT fetch usage — the caller should cache the token and then call `fetchUsage(token:)`. + public static func login(username: String, password: String) async throws -> String { + try await self.fullLogin(username: username, password: password) + } + + /// Refresh an existing Oasis-Token and return a fresh access + refresh token pair. + public static func refreshToken(token: String) async throws -> String { + try await self.refreshOasisToken(token: token) + } + + /// Fetch usage data using an existing Oasis-Token (from env var or cached). + public static func fetchUsage(token: String) async throws -> StepFunUsageSnapshot { + guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw StepFunUsageError.missingToken + } + return try await self.queryUsage(token: token) + } + + /// Full login flow: username + password → token, then fetch usage. + public static func fetchUsage(username: String, password: String) async throws -> StepFunUsageSnapshot { + let token = try await self.fullLogin(username: username, password: password) + return try await self.queryUsage(token: token) + } + + // MARK: - Login + + private static func fullLogin(username: String, password: String) async throws -> String { + // Step 1: Get INGRESSCOOKIE by visiting the platform homepage + let (ingressCookie, _) = try await self.getIngressCookie() + + // Step 2: RegisterDevice → get anonymous token + let anonToken = try await self.registerDevice(ingressCookie: ingressCookie) + + // Step 3: SignInByPassword → get authenticated token + return try await self.signInByPassword( + username: username, + password: password, + ingressCookie: ingressCookie, + anonToken: anonToken) + } + + private static func getIngressCookie() async throws -> (String, HTTPURLResponse) { + var request = URLRequest(url: self.platformURL) + request.httpMethod = "GET" + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let httpResponse = response.response + + // Extract INGRESSCOOKIE from Set-Cookie headers + let setCookieHeaders = httpResponse.allHeaderFields.filter { ($0.key as? String)?.lowercased() == "set-cookie" } + var ingressCookie = "" + for (_, value) in setCookieHeaders { + let cookieString = "\(value)" + if cookieString.contains("INGRESSCOOKIE=") { + let parts = cookieString.components(separatedBy: "INGRESSCOOKIE=") + if parts.count > 1 { + let valuePart = parts[1].components(separatedBy: ";").first ?? "" + ingressCookie = valuePart.trimmingCharacters(in: .whitespaces) + } + } + } + + // Also check cookies from the URLSession cookie store + if ingressCookie.isEmpty { + let cookies = HTTPCookieStorage.shared.cookies(for: self.platformURL) ?? [] + for cookie in cookies where cookie.name == "INGRESSCOOKIE" { + ingressCookie = cookie.value + break + } + } + + guard !ingressCookie.isEmpty else { + throw StepFunUsageError.loginFailed("Could not obtain INGRESSCOOKIE") + } + + return (ingressCookie, httpResponse) + } + + private static func registerDevice(ingressCookie: String) async throws -> String { + var request = URLRequest(url: self.registerDeviceURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue("INGRESSCOOKIE=\(ingressCookie)", forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("StepFun RegisterDevice returned \(response.statusCode): \(body)") + throw StepFunUsageError.deviceRegistrationFailed("HTTP \(response.statusCode)") + } + + let decoded: StepFunRegisterDeviceResponse + do { + decoded = try JSONDecoder().decode(StepFunRegisterDeviceResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed("RegisterDevice response: \(error.localizedDescription)") + } + + guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { + throw StepFunUsageError.deviceRegistrationFailed("No access token in RegisterDevice response") + } + + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) + } + + private static func signInByPassword( + username: String, + password: String, + ingressCookie: String, + anonToken: String) async throws -> String + { + var request = URLRequest(url: self.loginURL) + request.httpMethod = "POST" + let body: [String: String] = ["username": username, "password": password] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue( + "Oasis-Token=\(anonToken); Oasis-Webid=\(self.webID); INGRESSCOOKIE=\(ingressCookie)", + forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("StepFun SignInByPassword returned \(response.statusCode): \(body)") + throw StepFunUsageError.loginFailed("HTTP \(response.statusCode)") + } + + let decoded: StepFunLoginResponse + do { + decoded = try JSONDecoder().decode(StepFunLoginResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed("SignInByPassword response: \(error.localizedDescription)") + } + + guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { + throw StepFunUsageError.loginFailed("No access token in login response") + } + + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) + } + + private static func refreshOasisToken(token: String) async throws -> String { + let normalized = StepFunTokenNormalizer.normalize(token) + guard !normalized.isEmpty else { + throw StepFunUsageError.missingToken + } + + var request = URLRequest(url: self.refreshTokenURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue(normalized, forHTTPHeaderField: "Oasis-Token") + request.setValue( + "Oasis-Token=\(normalized); Oasis-Webid=\(self.webID)", + forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("StepFun RefreshToken returned \(response.statusCode): \(body)") + throw StepFunUsageError.tokenRefreshFailed("HTTP \(response.statusCode)") + } + + let decoded: StepFunRefreshTokenResponse + do { + decoded = try JSONDecoder().decode(StepFunRefreshTokenResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed("RefreshToken response: \(error.localizedDescription)") + } + + guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { + throw StepFunUsageError.tokenRefreshFailed("No access token in refresh response") + } + + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) + } + + private static func combinedToken(accessToken: String, refreshToken: String?) -> String { + guard let refreshToken, !refreshToken.isEmpty else { + return accessToken + } + return "\(accessToken)...\(refreshToken)" + } + + // MARK: - Query usage + + private static func queryUsage(token: String) async throws -> StepFunUsageSnapshot { + var request = URLRequest(url: self.apiURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue("Oasis-Token=\(token); Oasis-Webid=\(self.webID)", forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("StepFun API returned \(response.statusCode): \(body)") + throw StepFunUsageError.apiError("HTTP \(response.statusCode)") + } + + if let jsonString = String(data: data, encoding: .utf8) { + Self.log.debug("StepFun API response: \(jsonString)") + } + + var snapshot = try self.parseSnapshot(data: data) + + // Fetch plan name in parallel is not needed — just do it sequentially. + // If plan status fails, we still return usage data without plan name. + if let planName = try? await self.queryPlanStatus(token: token) { + snapshot = StepFunUsageSnapshot( + fiveHourUsageLeftRate: snapshot.fiveHourUsageLeftRate, + weeklyUsageLeftRate: snapshot.weeklyUsageLeftRate, + fiveHourUsageResetTime: snapshot.fiveHourUsageResetTime, + weeklyUsageResetTime: snapshot.weeklyUsageResetTime, + planName: planName, + updatedAt: snapshot.updatedAt) + } + + return snapshot + } + + // MARK: - Plan Status + + private static func queryPlanStatus(token: String) async throws -> String? { + var request = URLRequest(url: self.planStatusURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue("Oasis-Token=\(token); Oasis-Webid=\(self.webID)", forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { + Self.log.debug("StepFun plan status request failed, skipping plan name") + return nil + } + + let decoded: StepFunPlanStatusResponse + do { + decoded = try JSONDecoder().decode(StepFunPlanStatusResponse.self, from: response.data) + } catch { + Self.log.debug("StepFun plan status parse failed: \(error.localizedDescription)") + return nil + } + + return decoded.planName + } + + public static func _parseSnapshotForTesting(_ data: Data) throws -> StepFunUsageSnapshot { + try self.parseSnapshot(data: data) + } + + private static func parseSnapshot(data: Data) throws -> StepFunUsageSnapshot { + let decoded: StepFunRateLimitResponse + do { + decoded = try JSONDecoder().decode(StepFunRateLimitResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed(error.localizedDescription) + } + + guard decoded.isSuccess else { + let msg = [decoded.message, decoded.desc] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? decoded.code.map(String.init) ?? "unknown" + throw StepFunUsageError.apiError(msg) + } + + guard let fiveHourRate = decoded.fiveHourUsageLeftRate, + let weeklyRate = decoded.weeklyUsageLeftRate, + let fiveHourReset = decoded.fiveHourUsageResetTime, + let weeklyReset = decoded.weeklyUsageResetTime + else { + throw StepFunUsageError.parseFailed("Missing usage rate or reset time fields") + } + + return StepFunUsageSnapshot( + fiveHourUsageLeftRate: fiveHourRate.value, + weeklyUsageLeftRate: weeklyRate.value, + fiveHourUsageResetTime: Date(timeIntervalSince1970: TimeInterval(fiveHourReset.value)), + weeklyUsageResetTime: Date(timeIntervalSince1970: TimeInterval(weeklyReset.value)), + updatedAt: Date()) + } +} diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift index 550ab9190a..84ad7806a2 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift @@ -1,21 +1,20 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum SyntheticProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .synthetic, metadata: ProviderMetadata( id: .synthetic, displayName: "Synthetic", - sessionLabel: "Quota", - weeklyLabel: "Usage", - opusLabel: nil, - supportsOpus: false, + sessionLabel: "Five-hour quota", + weeklyLabel: "Weekly tokens", + opusLabel: "Search hourly", + supportsOpus: true, supportsCredits: false, - creditsHint: "", + creditsHint: "Weekly token quota regenerates continuously.", toggleTitle: "Show Synthetic usage", cliName: "synthetic", defaultEnabled: false, @@ -30,39 +29,16 @@ public enum SyntheticProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Synthetic cost summary is not supported." }), - fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .api], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [SyntheticAPIFetchStrategy()] })), + fetchPlan: .apiToken( + strategyID: "synthetic.api", + resolveToken: { ProviderTokenResolver.syntheticToken(environment: $0) }, + missingCredentialsError: { SyntheticSettingsError.missingToken }, + loadUsage: { apiKey, _ in + try await SyntheticUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }), cli: ProviderCLIConfig( name: "synthetic", aliases: ["synthetic.new"], versionDetector: nil)) } } - -struct SyntheticAPIFetchStrategy: ProviderFetchStrategy { - let id: String = "synthetic.api" - let kind: ProviderFetchKind = .apiToken - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let apiKey = Self.resolveToken(environment: context.env) else { - throw SyntheticSettingsError.missingToken - } - let usage = try await SyntheticUsageFetcher.fetchUsage(apiKey: apiKey) - return self.makeResult( - usage: usage.toUsageSnapshot(), - sourceLabel: "api") - } - - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false - } - - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.syntheticToken(environment: environment) - } -} diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticSettingsReader.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticSettingsReader.swift index a6134f8833..14e07f4303 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticSettingsReader.swift @@ -18,8 +18,7 @@ public struct SyntheticSettingsReader: Sendable { if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { - value.removeFirst() - value.removeLast() + value = String(value.dropFirst().dropLast()) } value = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift index 198c42c252..a1ff84f954 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift @@ -9,29 +9,45 @@ public struct SyntheticQuotaEntry: Sendable { public let windowMinutes: Int? public let resetsAt: Date? public let resetDescription: String? + public let nextRegenPercent: Double? + public let cost: ProviderCostSnapshot? public init( label: String?, usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, - resetDescription: String?) + resetDescription: String?, + nextRegenPercent: Double? = nil, + cost: ProviderCostSnapshot? = nil) { self.label = label self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.nextRegenPercent = nextRegenPercent + self.cost = cost } } public struct SyntheticUsageSnapshot: Sendable { public let quotas: [SyntheticQuotaEntry] + /// Slot-identified lanes for the known Synthetic response shape: [rolling-5h, weekly, search-hourly]. + /// When set, `toUsageSnapshot` maps slot 0 → primary, slot 1 → secondary, slot 2 → tertiary, + /// so a missing lane stays nil instead of promoting the next lane into the wrong UI label. + public let slottedQuotas: [SyntheticQuotaEntry?]? public let planName: String? public let updatedAt: Date - public init(quotas: [SyntheticQuotaEntry], planName: String?, updatedAt: Date) { + public init( + quotas: [SyntheticQuotaEntry], + slottedQuotas: [SyntheticQuotaEntry?]? = nil, + planName: String?, + updatedAt: Date) + { self.quotas = quotas + self.slottedQuotas = slottedQuotas self.planName = planName self.updatedAt = updatedAt } @@ -39,11 +55,13 @@ public struct SyntheticUsageSnapshot: Sendable { extension SyntheticUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - let primaryEntry = self.quotas.first - let secondaryEntry = self.quotas.dropFirst().first + let slots = self.slottedQuotas + ?? [self.quotas.first, self.quotas.dropFirst().first, self.quotas.dropFirst(2).first] + let entries: [SyntheticQuotaEntry?] = (0..<3).map { slots.indices.contains($0) ? slots[$0] : nil } - let primary = primaryEntry.map(Self.rateWindow(for:)) - let secondary = secondaryEntry.map(Self.rateWindow(for:)) + let primary = entries[0].map(Self.rateWindow(for:)) + let secondary = entries[1].map(Self.rateWindow(for:)) + let tertiary = entries[2].map(Self.rateWindow(for:)) let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -56,8 +74,8 @@ extension SyntheticUsageSnapshot { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: nil, - providerCost: nil, + tertiary: tertiary, + providerCost: self.quotas.first(where: { $0.cost != nil })?.cost, updatedAt: self.updatedAt, identity: identity) } @@ -67,7 +85,8 @@ extension SyntheticUsageSnapshot { usedPercent: quota.usedPercent, windowMinutes: quota.windowMinutes, resetsAt: quota.resetsAt, - resetDescription: quota.resetDescription) + resetDescription: quota.resetDescription, + nextRegenPercent: quota.nextRegenPercent) } } @@ -85,19 +104,15 @@ public struct SyntheticUsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw SyntheticUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" - Self.log.error("Synthetic API returned \(httpResponse.statusCode): \(errorMessage)") - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + Self.log.error("Synthetic API returned \(response.statusCode): \(errorMessage)") + if response.statusCode == 401 || response.statusCode == 403 { throw SyntheticUsageError.invalidCredentials } - throw SyntheticUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)") + throw SyntheticUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)") } do { @@ -147,20 +162,46 @@ enum SyntheticUsageParser { }() let planName = self.planName(from: root) - let quotaObjects = self.quotaObjects(from: root) - let quotas = quotaObjects.compactMap { self.parseQuota($0) } + if let slots = self.prioritizedQuotaSlots(from: root) { + let slotted: [SyntheticQuotaEntry?] = slots.map { $0.flatMap(self.parseQuota) } + let flat = slotted.compactMap(\.self) + guard !flat.isEmpty else { + throw SyntheticUsageError.parseFailed("Missing quota data.") + } + return SyntheticUsageSnapshot( + quotas: flat, + slottedQuotas: slotted, + planName: planName, + updatedAt: now) + } + + let quotas = self.fallbackQuotaObjects(from: root).compactMap(self.parseQuota) guard !quotas.isEmpty else { throw SyntheticUsageError.parseFailed("Missing quota data.") } - return SyntheticUsageSnapshot( quotas: quotas, planName: planName, updatedAt: now) } - private static func quotaObjects(from root: [String: Any]) -> [[String: Any]] { + /// Returns slot-positional quota payloads `[rolling-5h, weekly, search-hourly]` when the known Synthetic + /// response shape is detected. Missing lanes stay nil in their slot so downstream code doesn't shift + /// labels. Returns nil if none of the known keys appear, so the fallback path runs. + private static func prioritizedQuotaSlots(from root: [String: Any]) -> [[String: Any]?]? { + let dataDict = root["data"] as? [String: Any] + let rolling = self.namedQuota(root["rollingFiveHourLimit"], label: "Rolling five-hour limit") + ?? self.namedQuota(dataDict?["rollingFiveHourLimit"], label: "Rolling five-hour limit") + let weekly = self.namedQuota(root["weeklyTokenLimit"], label: "Weekly token limit") + ?? self.namedQuota(dataDict?["weeklyTokenLimit"], label: "Weekly token limit") + let searchHourly = self.namedQuota((root["search"] as? [String: Any])?["hourly"], label: "Search hourly") + ?? self.namedQuota((dataDict?["search"] as? [String: Any])?["hourly"], label: "Search hourly") + let slots: [[String: Any]?] = [rolling, weekly, searchHourly] + return slots.contains(where: { $0 != nil }) ? slots : nil + } + + private static func fallbackQuotaObjects(from root: [String: Any]) -> [[String: Any]] { let dataDict = root["data"] as? [String: Any] let candidates: [Any?] = [ root["quotas"], @@ -179,14 +220,8 @@ enum SyntheticUsageParser { ] for candidate in candidates { - if let array = candidate as? [[String: Any]] { return array } - if let array = candidate as? [Any] { - let dicts = array.compactMap { $0 as? [String: Any] } - if !dicts.isEmpty { return dicts } - } - if let dict = candidate as? [String: Any], self.isQuotaPayload(dict) { - return [dict] - } + let quotas = self.extractQuotaObjects(from: candidate) + if !quotas.isEmpty { return quotas } } return [] } @@ -239,14 +274,22 @@ enum SyntheticUsageParser { let windowMinutes = windowMinutes(from: payload) let resetsAt = self.firstDate(in: payload, keys: self.resetKeys) + // Leave resetDescription nil when resetsAt is set so the UI rebuilds the countdown each render + // against the current clock instead of freezing a stale "in Xm" string at parse time. let resetDescription = resetsAt == nil ? self.windowDescription(minutes: windowMinutes) : nil + let cost = self.providerCost(from: payload, usedPercent: clamped, resetsAt: resetsAt) + let nextRegenPercent = self.normalizedPercent( + self.firstDouble(in: payload, keys: Self.tickPercentKeys)) + return SyntheticQuotaEntry( label: label, usedPercent: clamped, windowMinutes: windowMinutes, resetsAt: resetsAt, - resetDescription: resetDescription) + resetDescription: resetDescription, + nextRegenPercent: nextRegenPercent, + cost: cost) } private static func isQuotaPayload(_ payload: [String: Any]) -> Bool { @@ -271,9 +314,78 @@ enum SyntheticUsageParser { if let seconds = self.firstDouble(in: payload, keys: windowSecondsKeys) { return Int((seconds / 60).rounded()) } + if let text = self.firstString(in: payload, keys: windowStringKeys) { + return self.windowMinutes(fromText: text) + } return nil } + private static func namedQuota(_ candidate: Any?, label: String) -> [String: Any]? { + guard var payload = candidate as? [String: Any], self.isQuotaPayload(payload) else { return nil } + if payload["label"] == nil, payload["name"] == nil { + payload["label"] = label + } + return payload + } + + private static func extractQuotaObjects(from candidate: Any?) -> [[String: Any]] { + switch candidate { + case let array as [[String: Any]]: + var nestedQuotas: [[String: Any]] = [] + for entry in array { + if self.isQuotaPayload(entry) { + nestedQuotas.append(entry) + } else { + nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: entry)) + } + } + return nestedQuotas + case let array as [Any]: + return array.flatMap { self.extractQuotaObjects(from: $0) } + case let dict as [String: Any]: + if self.isQuotaPayload(dict) { + return [dict] + } + var nestedQuotas: [[String: Any]] = [] + for key in dict.keys.sorted() { + nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: dict[key])) + } + return nestedQuotas + default: + return [] + } + } + + /// Parses durations like `"5hr"`, `"30min"`, `"2 days"`. Suffixes are sorted longest-first so + /// multi-letter units always win over their single-letter aliases — no ordering surprises if a + /// future unit shares a trailing letter with another. + static func windowMinutes(fromText text: String) -> Int? { + let normalized = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: "") + guard !normalized.isEmpty else { return nil } + + for (suffix, multiplier) in Self.windowSuffixMultipliers { + guard normalized.hasSuffix(suffix) else { continue } + let valueText = String(normalized.dropLast(suffix.count)) + guard let value = Double(valueText), value > 0 else { return nil } + return Int((value * multiplier).rounded()) + } + return nil + } + + private static let windowSuffixMultipliers: [(suffix: String, multiplier: Double)] = { + let raw: [(String, Double)] = [ + ("minutes", 1), ("minute", 1), ("mins", 1), ("min", 1), ("m", 1), + ("hours", 60), ("hour", 60), ("hrs", 60), ("hr", 60), ("h", 60), + ("days", 24 * 60), ("day", 24 * 60), ("d", 24 * 60), + ] + return raw + .sorted { $0.0.count > $1.0.count } + .map { (suffix: $0.0, multiplier: $0.1) } + }() + private static func windowDescription(minutes: Int?) -> String? { guard let minutes, minutes > 0 else { return nil } let dayMinutes = 24 * 60 @@ -288,6 +400,57 @@ enum SyntheticUsageParser { return "\(minutes) minute\(minutes == 1 ? "" : "s") window" } + private static func providerCost( + from payload: [String: Any], + usedPercent: Double, + resetsAt: Date?) -> ProviderCostSnapshot? + { + guard let limit = self.firstCurrency(in: payload, keys: self.costLimitKeys) else { return nil } + + let remaining = self.firstCurrency(in: payload, keys: self.costRemainingKeys) + let usedFromPayload = self.firstCurrency(in: payload, keys: self.costUsedKeys) + let nextRegenAmount = self.firstCurrency(in: payload, keys: self.regenAmountKeys) + let used = if let usedFromPayload { + usedFromPayload + } else if let remaining { + max(0, limit - remaining) + } else { + (usedPercent.clamped(to: 0...100) / 100) * limit + } + + return ProviderCostSnapshot( + used: used, + limit: limit, + currencyCode: "USD", + period: "Weekly", + resetsAt: resetsAt, + nextRegenAmount: nextRegenAmount, + updatedAt: Date()) + } + + private static func firstCurrency(in payload: [String: Any], keys: [String]) -> Double? { + for key in keys { + guard let value = payload[key] else { continue } + if let text = value as? String, + let parsed = self.parseCurrency(text) + { + return parsed + } + if let number = self.doubleValue(value) { + return number + } + } + return nil + } + + private static func parseCurrency(_ text: String) -> Double? { + let cleaned = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "$", with: "") + .replacingOccurrences(of: ",", with: "") + return Double(cleaned) + } + private static func normalizedPercent(_ value: Double?) -> Double? { guard let value else { return nil } if value <= 1 { return value * 100 } @@ -423,6 +586,13 @@ enum SyntheticUsageParser { private static let limitKeys = [ "limit", + "messageLimit", + "message_limit", + "messages", + "maxRequests", + "max_requests", + "requestLimit", + "request_limit", "quota", "max", "total", @@ -433,6 +603,10 @@ enum SyntheticUsageParser { private static let usedKeys = [ "used", "usage", + "usedMessages", + "used_messages", + "messagesUsed", + "messages_used", "requests", "requestCount", "request_count", @@ -456,6 +630,10 @@ enum SyntheticUsageParser { "renew_at", "renewsAt", "renews_at", + "nextTickAt", + "next_tick_at", + "nextRegenAt", + "next_regen_at", "periodEnd", "period_end", "expiresAt", @@ -464,6 +642,33 @@ enum SyntheticUsageParser { "end_at", ] + private static let regenAmountKeys = [ + "nextRegenCredits", + "next_regen_credits", + ] + + private static let tickPercentKeys = [ + "tickPercent", + "tick_percent", + "nextTickPercent", + "next_tick_percent", + ] + + private static let costLimitKeys = [ + "maxCredits", + "max_credits", + ] + + private static let costRemainingKeys = [ + "remainingCredits", + "remaining_credits", + ] + + private static let costUsedKeys = [ + "usedCredits", + "used_credits", + ] + private static let windowMinutesKeys = [ "windowMinutes", "window_minutes", @@ -491,6 +696,15 @@ enum SyntheticUsageParser { "periodSeconds", "period_seconds", ] + + private static let windowStringKeys = [ + "window", + "windowLabel", + "window_label", + "period", + "periodLabel", + "period_label", + ] } public enum SyntheticUsageError: LocalizedError, Sendable { diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift new file mode 100644 index 0000000000..d06649fc4b --- /dev/null +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift @@ -0,0 +1,84 @@ +import Foundation + +public enum T3ChatProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .t3chat, + metadata: ProviderMetadata( + id: .t3chat, + displayName: "T3 Chat", + sessionLabel: "Base", + weeklyLabel: "Overage", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show T3 Chat usage", + cliName: "t3chat", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://t3.chat/settings/customization", + subscriptionDashboardURL: "https://t3.chat/settings/subscription", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .t3chat, + iconResourceName: "ProviderIcon-t3chat", + color: ProviderColor(red: 245 / 255, green: 102 / 255, blue: 71 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "T3 Chat cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [T3ChatWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "t3chat", + aliases: ["t3-chat", "t3"], + versionDetector: nil)) + } +} + +struct T3ChatWebFetchStrategy: ProviderFetchStrategy { + let id: String = "t3chat.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let cookieSource = context.settings?.t3chat?.cookieSource ?? .auto + guard cookieSource != .off else { return false } + if cookieSource == .manual { + return T3ChatUsageFetcher.requestContext(from: context.settings?.t3chat?.manualCookieHeader) != nil + } + #if os(macOS) + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = T3ChatUsageFetcher(browserDetection: context.browserDetection) + let manual = Self.manualCookieHeader(from: context) + let logger: ((String) -> Void)? = context.verbose + ? { msg in CodexBarLog.logger(LogCategories.t3chat).verbose(msg) } + : nil + let snapshot = try await fetcher.fetch( + cookieHeaderOverride: manual, + timeout: context.webTimeout, + logger: logger) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.t3chat?.cookieSource == .manual else { return nil } + return context.settings?.t3chat?.manualCookieHeader + } +} diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift new file mode 100644 index 0000000000..bddc960e47 --- /dev/null +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift @@ -0,0 +1,366 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if os(macOS) +import SweetCookieKit +#endif + +#if os(macOS) +private let t3ChatCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.t3chat]?.browserCookieOrder ?? Browser.defaultImportOrder + +public enum T3ChatCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["t3.chat", "www.t3.chat"] + + public struct SessionInfo: Sendable { + public let cookieHeader: String + public let sourceLabel: String + + public init(cookieHeader: String, sourceLabel: String) { + self.cookieHeader = cookieHeader + self.sourceLabel = sourceLabel + } + } + + public static func importSession( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let log: (String) -> Void = { msg in logger?("[t3chat-cookie] \(msg)") } + let installed = t3ChatCookieImportOrder.cookieImportCandidates(using: browserDetection) + + for browserSource in installed { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + for source in sources where !source.records.isEmpty { + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + guard !cookies.isEmpty else { continue } + let names = cookies.map(\.name).joined(separator: ", ") + log("\(source.label) cookies: \(names)") + let header = cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + return SessionInfo(cookieHeader: header, sourceLabel: source.label) + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + throw T3ChatUsageError.noSessionCookie + } +} +#endif + +public struct T3ChatUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.t3chat) + private static let baseURL = URL(string: "https://t3.chat")! + private static let refererURL = URL(string: "https://t3.chat/settings/customization")! + /// Browser fingerprint defaults are only fallbacks; full cURL captures override these forwarded headers. + private static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + /// Captured from T3 Chat's getCustomerData tRPC request shape in May 2026. + private static let input = #"{"0":{"json":{"sessionId":null},"meta":{"values":{"sessionId":["undefined"]}}}}"# + private static let forwardedManualHeaders = [ + "accept": "Accept", + "accept-language": "Accept-Language", + "cache-control": "Cache-Control", + "pragma": "Pragma", + "priority": "Priority", + "referer": "Referer", + "sec-fetch-dest": "Sec-Fetch-Dest", + "sec-fetch-mode": "Sec-Fetch-Mode", + "sec-fetch-site": "Sec-Fetch-Site", + "trpc-accept": "trpc-accept", + "user-agent": "User-Agent", + "x-client-context": "x-client-context", + "x-deployment-id": "X-Deployment-Id", + "x-trpc-batch": "x-trpc-batch", + "x-trpc-source": "x-trpc-source", + ] + + public struct RequestContext: Sendable { + public let cookieHeader: String + public let headers: [String: String] + + public init(cookieHeader: String, headers: [String: String] = [:]) { + self.cookieHeader = cookieHeader + self.headers = headers + } + } + + public let browserDetection: BrowserDetection + + public init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + } + + public func fetch( + cookieHeaderOverride: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot + { + let log: (String) -> Void = { msg in logger?("[t3chat] \(msg)") } + let context = try await self.resolveRequestContext(override: cookieHeaderOverride, logger: log) + if let logger { + let names = CookieHeaderNormalizer.pairs(from: context.cookieHeader).map(\.name) + if !names.isEmpty { + logger("[t3chat] Cookie names: \(names.joined(separator: ", "))") + } + if !context.headers.isEmpty { + let headerNames = context.headers.keys.sorted().joined(separator: ", ") + logger("[t3chat] Forwarding captured headers: \(headerNames)") + } + } + return try await Self.fetchCustomerData( + context: context, + timeout: timeout, + now: now, + transport: transport) + } + + public func debugRawProbe(cookieHeaderOverride: String? = nil) async -> String { + let stamp = ISO8601DateFormatter().string(from: Date()) + var lines: [String] = [] + lines.append("=== T3 Chat Debug Probe @ \(stamp) ===") + lines.append("") + + do { + let snapshot = try await self.fetch( + cookieHeaderOverride: cookieHeaderOverride, + logger: { msg in lines.append(msg) }) + lines.append("") + lines.append("Fetch Success") + lines.append("subTier=\(snapshot.customerData.subTier ?? "nil")") + lines.append("usageBand=\(snapshot.customerData.usageBand ?? "nil")") + lines + .append( + "usageFourHourPercentage=\(snapshot.customerData.usageFourHourPercentage?.description ?? "nil")") + lines.append("usageMonthPercentage=\(snapshot.customerData.usageMonthPercentage?.description ?? "nil")") + lines.append("usagePeriodPercentage=\(snapshot.customerData.usagePeriodPercentage?.description ?? "nil")") + lines + .append( + "usageFourHourNextResetAt=\(snapshot.customerData.usageFourHourNextResetAt?.description ?? "nil")") + lines.append("billingNextResetAt=\(snapshot.customerData.billingNextResetAt?.description ?? "nil")") + } catch { + lines.append("") + lines.append("Probe Failed: \(error.localizedDescription)") + } + + return lines.joined(separator: "\n") + } + + public static func fetchCustomerData( + cookieHeader: String, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot + { + guard let normalizedCookieHeader = CookieHeaderNormalizer.normalize(cookieHeader) else { + throw T3ChatUsageError.noSessionCookie + } + return try await self.fetchCustomerData( + context: RequestContext(cookieHeader: normalizedCookieHeader), + timeout: timeout, + now: now, + transport: transport) + } + + public static func fetchCustomerData( + context: RequestContext, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot + { + guard let normalizedCookieHeader = CookieHeaderNormalizer.normalize(context.cookieHeader) else { + throw T3ChatUsageError.noSessionCookie + } + + let url = try self.customerDataURL() + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + self.applyDefaultHeaders(to: &request) + for (name, value) in context.headers { + request.setValue(value, forHTTPHeaderField: name) + } + request.setValue(self.baseURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(normalizedCookieHeader, forHTTPHeaderField: "Cookie") + + let response = try await transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data.prefix(200), encoding: .utf8) ?? "" + Self.log.error("T3 Chat API returned \(response.statusCode): \(body)") + if response.statusCode == 401 || response.statusCode == 403 { + throw T3ChatUsageError.invalidCredentials + } + if response.statusCode == 429, + response.response.value(forHTTPHeaderField: "x-vercel-mitigated") == "challenge" + { + throw T3ChatUsageError.vercelChallenge + } + throw T3ChatUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + return try T3ChatUsageParser.parseJSONLines(data, now: now) + } catch { + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "" + Self.log.error("T3 Chat parse failed: \(error.localizedDescription) response=\(preview)") + throw error + } + } + + private func resolveRequestContext( + override: String?, + logger: ((String) -> Void)?) async throws -> RequestContext + { + if let override = Self.requestContext(from: override) { + let source = override.headers.isEmpty ? "manual cookie header" : "manual cURL capture" + logger?("[t3chat] Using \(source)") + return override + } + + #if os(macOS) + let session = try T3ChatCookieImporter.importSession( + browserDetection: self.browserDetection, + logger: logger) + logger?("[t3chat] Using cookies from \(session.sourceLabel)") + return RequestContext(cookieHeader: session.cookieHeader) + #else + throw T3ChatUsageError.noSessionCookie + #endif + } + + static func requestContext(from raw: String?) -> RequestContext? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + let headerFields = Self.headerFields(from: raw) + guard let cookieHeader = Self.cookieHeader(from: headerFields) ?? CookieHeaderNormalizer.normalize(raw) else { + return nil + } + let headers = Self.forwardedHeaders(from: headerFields) + return RequestContext(cookieHeader: cookieHeader, headers: headers) + } + + private static func applyDefaultHeaders(to request: inout URLRequest) { + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue("application/jsonl", forHTTPHeaderField: "trpc-accept") + request.setValue("web-client", forHTTPHeaderField: "x-trpc-source") + request.setValue("true", forHTTPHeaderField: "x-trpc-batch") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") + request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") + request.setValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site") + request.setValue("u=4", forHTTPHeaderField: "Priority") + request.setValue("no-cache", forHTTPHeaderField: "Pragma") + request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") + } + + private static func forwardedHeaders(from fields: [String]) -> [String: String] { + var headers: [String: String] = [:] + for field in fields { + guard let colon = field.firstIndex(of: ":") else { continue } + let rawName = field[.. String? { + for field in fields { + guard let colon = field.firstIndex(of: ":") else { continue } + let rawName = field[.. [String] { + var fields: [String] = [] + let pattern = + #"(?s)(?:^|\s)(?:-H|--header)(?:\s+|=|(?=['"$]))"# + + #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|"((?:\\.|[^"])*)"|(\S+))"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return fields } + let range = NSRange(raw.startIndex.. String? { + guard match.numberOfRanges > index, + let range = Range(match.range(at: index), in: raw) + else { + return nil + } + return String(raw[range]) + } + + private static func unescapeShellSegment(_ raw: String, ansi: Bool) -> String { + var output = "" + var index = raw.startIndex + while index < raw.endIndex { + guard raw[index] == "\\" else { + output.append(raw[index]) + index = raw.index(after: index) + continue + } + let next = raw.index(after: index) + guard next < raw.endIndex else { return output } + switch raw[next] { + case "n" where ansi: + output.append("\n") + case "r" where ansi: + output.append("\r") + case "t" where ansi: + output.append("\t") + case "\n": + break + default: + output.append(raw[next]) + } + index = raw.index(after: next) + } + return output + } + + private static func customerDataURL() throws -> URL { + var components = URLComponents(string: "https://t3.chat/api/trpc/getCustomerData")! + components.queryItems = [ + URLQueryItem(name: "batch", value: "1"), + URLQueryItem(name: "input", value: self.input), + ] + guard let url = components.url else { + throw T3ChatUsageError.apiError("Failed to build customer data URL.") + } + return url + } +} diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageSnapshot.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageSnapshot.swift new file mode 100644 index 0000000000..e75c05e7ed --- /dev/null +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageSnapshot.swift @@ -0,0 +1,180 @@ +import Foundation + +public enum T3ChatUsageError: LocalizedError, Sendable { + case noSessionCookie + case invalidCredentials + case vercelChallenge + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .noSessionCookie: + "No T3 Chat cookies found. Please log in to t3.chat in your browser." + case .invalidCredentials: + "T3 Chat session cookie is invalid or expired." + case .vercelChallenge: + "T3 Chat returned a Vercel security challenge. Paste the full browser cURL request, " + + "not just the Cookie header." + case let .apiError(message): + "T3 Chat API error: \(message)" + case let .parseFailed(message): + "Could not parse T3 Chat usage: \(message)" + } + } +} + +public struct T3ChatSubscription: Decodable, Sendable { + public let productId: String? + public let productName: String? + public let status: String? + public let currentPeriodStart: TimeInterval? + public let currentPeriodEnd: TimeInterval? + public let canceledAt: TimeInterval? + public let trialEndsAt: TimeInterval? +} + +public struct T3ChatCustomerData: Decodable, Sendable { + public let subTier: String? + public let subscription: T3ChatSubscription? + public let lifetimeBalance: Double? + public let usageBand: String? + public let billingNextResetAt: TimeInterval? + public let usageFourHourPercentage: Double? + public let usageMonthPercentage: Double? + public let usageFourHourNextResetAt: TimeInterval? + public let usagePeriodPercentage: Double? + public let usageWindowNextResetAt: TimeInterval? + + public var planName: String? { + let raw = self.subscription?.productName ?? self.subTier + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + return raw.split(separator: "-").map { part in + part.prefix(1).uppercased() + String(part.dropFirst()) + }.joined(separator: " ") + } +} + +public struct T3ChatUsageSnapshot: Sendable { + public let customerData: T3ChatCustomerData + public let updatedAt: Date + + public init(customerData: T3ChatCustomerData, updatedAt: Date) { + self.customerData = customerData + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let baseReset = Self.date(fromMilliseconds: self.customerData.usageFourHourNextResetAt) + ?? Self.date(fromMilliseconds: self.customerData.usageWindowNextResetAt) + // billingNextResetAt tracks the usage window reset, not the overage billing period. + // If subscription metadata is absent, leave the overage reset unknown instead of showing the base reset. + let overageReset = Self.date(fromMilliseconds: self.customerData.subscription?.currentPeriodEnd) + + let primary = RateWindow( + usedPercent: Self.percent(self.customerData.usageFourHourPercentage), + windowMinutes: 4 * 60, + resetsAt: baseReset, + resetDescription: Self.description(label: "Base", usageBand: self.customerData.usageBand)) + + let secondaryPercent = self.customerData.usageMonthPercentage + ?? self.customerData.usagePeriodPercentage + let secondary = RateWindow( + usedPercent: Self.percent(secondaryPercent), + windowMinutes: nil, + resetsAt: overageReset, + resetDescription: "Overage") + + let identity = ProviderIdentitySnapshot( + providerID: .t3chat, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.customerData.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: self.updatedAt, + identity: identity) + } + + private static func percent(_ raw: Double?) -> Double { + min(100, max(0, raw ?? 0)) + } + + private static func date(fromMilliseconds raw: TimeInterval?) -> Date? { + guard let raw, raw > 0 else { return nil } + // T3 Chat currently returns JavaScript epoch milliseconds, while some subscription fields may be seconds. + let seconds = raw > 10_000_000_000 ? raw / 1000 : raw + return Date(timeIntervalSince1970: seconds) + } + + private static func description(label: String, usageBand: String?) -> String { + guard let usageBand = usageBand?.trimmingCharacters(in: .whitespacesAndNewlines), + !usageBand.isEmpty + else { + return label + } + return "\(label) - \(usageBand)" + } +} + +public enum T3ChatUsageParser { + public static func parseJSONLines(_ data: Data, now: Date = Date()) throws -> T3ChatUsageSnapshot { + guard let text = String(data: data, encoding: .utf8) else { + throw T3ChatUsageError.parseFailed("Response is not UTF-8.") + } + return try self.parseJSONLines(text, now: now) + } + + public static func parseJSONLines(_ text: String, now: Date = Date()) throws -> T3ChatUsageSnapshot { + let lines = text.split(whereSeparator: \.isNewline) + for line in lines { + guard let data = String(line).data(using: .utf8) else { continue } + guard let object = try? JSONSerialization.jsonObject(with: data) else { continue } + guard let customerObject = self.findCustomerData(in: object) else { continue } + let customerData = try self.decodeCustomerData(customerObject) + return T3ChatUsageSnapshot(customerData: customerData, updatedAt: now) + } + + throw T3ChatUsageError.parseFailed("Missing customer data object.") + } + + private static func findCustomerData(in object: Any) -> [String: Any]? { + if let dictionary = object as? [String: Any] { + if dictionary["usageFourHourPercentage"] != nil || + dictionary["usageMonthPercentage"] != nil || + dictionary["subscription"] != nil && dictionary["usageBand"] != nil + { + return dictionary + } + + for value in dictionary.values { + if let found = self.findCustomerData(in: value) { + return found + } + } + } + + if let array = object as? [Any] { + for value in array { + if let found = self.findCustomerData(in: value) { + return found + } + } + } + + return nil + } + + private static func decodeCustomerData(_ object: [String: Any]) throws -> T3ChatCustomerData { + do { + let data = try JSONSerialization.data(withJSONObject: object, options: []) + return try JSONDecoder().decode(T3ChatCustomerData.self, from: data) + } catch { + throw T3ChatUsageError.parseFailed(error.localizedDescription) + } + } +} diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift new file mode 100644 index 0000000000..d080293003 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift @@ -0,0 +1,46 @@ +import Foundation + +public enum VeniceProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .venice, + metadata: ProviderMetadata( + id: .venice, + displayName: "Venice", + sessionLabel: "Balance", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Venice usage", + cliName: "venice", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://venice.ai/settings/api", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .venice, + iconResourceName: "ProviderIcon-venice", + color: ProviderColor(red: 0.2, green: 0.6, blue: 1.0)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Venice per-day cost history is not available via API." }), + fetchPlan: .apiToken( + strategyID: "venice.api", + resolveToken: { ProviderTokenResolver.veniceToken(environment: $0) }, + missingCredentialsError: { VeniceUsageError.missingCredentials }, + loadUsage: { apiKey, _ in + try await VeniceUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }), + cli: ProviderCLIConfig( + name: "venice", + aliases: ["ven"], + versionDetector: nil)) + } +} diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceSettingsReader.swift b/Sources/CodexBarCore/Providers/Venice/VeniceSettingsReader.swift new file mode 100644 index 0000000000..fcfffa0c38 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Venice/VeniceSettingsReader.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct VeniceSettingsReader: Sendable { + public static let apiKeyEnvironmentKey = "VENICE_API_KEY" + public static let apiKeyEnvironmentKeys = [Self.apiKeyEnvironmentKey, "VENICE_KEY"] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift b/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift new file mode 100644 index 0000000000..0326a40ddf --- /dev/null +++ b/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift @@ -0,0 +1,236 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - API response types + +public struct VeniceBalanceResponse: Decodable, Sendable { + public let canConsume: Bool + public let consumptionCurrency: String? + public let balances: VeniceBalances + public let diemEpochAllocation: Double? + + enum CodingKeys: String, CodingKey { + case canConsume + case consumptionCurrency + case balances + case diemEpochAllocation + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.canConsume = try container.decode(Bool.self, forKey: .canConsume) + self.consumptionCurrency = try container.decodeIfPresent(String.self, forKey: .consumptionCurrency) + self.balances = try container.decode(VeniceBalances.self, forKey: .balances) + self.diemEpochAllocation = try container.decodeFlexibleDoubleIfPresent(forKey: .diemEpochAllocation) + } +} + +public struct VeniceBalances: Decodable, Sendable { + public let diem: Double? + public let usd: Double? + + enum CodingKeys: String, CodingKey { + case diem + case usd + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.diem = try container.decodeFlexibleDoubleIfPresent(forKey: .diem) + self.usd = try container.decodeFlexibleDoubleIfPresent(forKey: .usd) + } +} + +// MARK: - Domain snapshot + +public struct VeniceUsageSnapshot: Sendable { + public let canConsume: Bool + public let consumptionCurrency: String? + public let diemBalance: Double? + public let usdBalance: Double? + public let diemEpochAllocation: Double? + public let updatedAt: Date + + public init( + canConsume: Bool, + consumptionCurrency: String?, + diemBalance: Double?, + usdBalance: Double?, + diemEpochAllocation: Double?, + updatedAt: Date) + { + self.canConsume = canConsume + self.consumptionCurrency = consumptionCurrency + self.diemBalance = diemBalance + self.usdBalance = usdBalance + self.diemEpochAllocation = diemEpochAllocation + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let balanceDetail: String + let usedPercent: Double + let activeCurrency = self.consumptionCurrency?.uppercased() + + if !self.canConsume { + balanceDetail = "Balance unavailable for API calls" + usedPercent = 100 + } else if activeCurrency == "USD", let usd = self.usdBalance, usd > 0 { + let usdStr = String(format: "%.2f", usd) + balanceDetail = "$\(usdStr) USD remaining" + usedPercent = 0 + } else if activeCurrency != "USD", let diem = self.diemBalance, let allocation = self.diemEpochAllocation, + allocation > 0 + { + // DIEM balance with epoch allocation + let remaining = diem + let usedAmount = allocation - remaining + let used = clamp(usedAmount / allocation * 100, min: 0, max: 100) + usedPercent = used + let allocationStr = String(format: "%.2f", allocation) + let remainingStr = String(format: "%.2f", remaining) + balanceDetail = "DIEM \(remainingStr) / \(allocationStr) epoch allocation" + } else if activeCurrency == "DIEM", let diem = self.diemBalance, diem > 0 { + let diemStr = String(format: "%.2f", diem) + balanceDetail = "DIEM \(diemStr) remaining" + usedPercent = 0 + } else if let diem = self.diemBalance, diem > 0 { + // DIEM balance without allocation + let diemStr = String(format: "%.2f", diem) + balanceDetail = "DIEM \(diemStr) remaining" + usedPercent = 0 + } else if let usd = self.usdBalance, usd > 0 { + // USD balance + let usdStr = String(format: "%.2f", usd) + balanceDetail = "$\(usdStr) USD remaining" + usedPercent = 0 + } else { + balanceDetail = "No Venice API balance available" + usedPercent = 100 + } + + let identity = ProviderIdentitySnapshot( + providerID: .venice, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let balanceWindow = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: balanceDetail) + + return UsageSnapshot( + primary: balanceWindow, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +// MARK: - Errors + +public enum VeniceUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Venice API key." + case let .networkError(message): + "Venice network error: \(message)" + case let .apiError(message): + "Venice API error: \(message)" + case let .parseFailed(message): + "Failed to parse Venice response: \(message)" + } + } +} + +// MARK: - Fetcher + +public struct VeniceUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.veniceUsage) + private static let balanceURL = URL(string: "https://api.venice.ai/api/v1/billing/balance")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage(apiKey: String) async throws -> VeniceUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw VeniceUsageError.missingCredentials + } + + var request = URLRequest(url: self.balanceURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { + Self.log.error("Venice API returned \(response.statusCode)") + throw VeniceUsageError.apiError("HTTP \(response.statusCode)") + } + + return try Self.parseSnapshot(data: response.data) + } + + static func _parseSnapshotForTesting(_ data: Data) throws -> VeniceUsageSnapshot { + try self.parseSnapshot(data: data) + } + + private static func parseSnapshot(data: Data) throws -> VeniceUsageSnapshot { + let decoded: VeniceBalanceResponse + do { + decoded = try JSONDecoder().decode(VeniceBalanceResponse.self, from: data) + } catch { + throw VeniceUsageError.parseFailed(error.localizedDescription) + } + + return VeniceUsageSnapshot( + canConsume: decoded.canConsume, + consumptionCurrency: decoded.consumptionCurrency, + diemBalance: decoded.balances.diem, + usdBalance: decoded.balances.usd, + diemEpochAllocation: decoded.diemEpochAllocation, + updatedAt: Date()) + } +} + +// MARK: - Helper + +private func clamp(_ value: Double, min: Double, max: Double) -> Double { + Swift.min(Swift.max(value, min), max) +} + +extension KeyedDecodingContainer { + fileprivate func decodeFlexibleDoubleIfPresent(forKey key: K) throws -> Double? { + if try self.decodeNil(forKey: key) { + return nil + } + if let value = try? self.decode(Double.self, forKey: key) { + return value + } + if let stringValue = try? self.decode(String.self, forKey: key) { + let trimmed = stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let parsed = Double(trimmed) { + return parsed + } + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Expected a numeric string for \(key.stringValue), got '\(stringValue)'") + } + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Expected a number or numeric string for \(key.stringValue)") + } +} diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift index f1400800a9..4356d24fbb 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift @@ -55,10 +55,28 @@ public enum VertexAIOAuthCredentialsError: LocalizedError, Sendable { } public enum VertexAIOAuthCredentialsStore { - private static var credentialsFilePath: URL { + #if DEBUG + @TaskLocal static var gcloudAccessTokenOverrideForTesting: (@Sendable ([String: String]) async throws -> String)? + #endif + + private struct ServiceAccountMetadata { + let email: String + let projectId: String? + } + + private static func credentialsFilePath( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let path = environment["GOOGLE_APPLICATION_CREDENTIALS"]?.trimmingCharacters( + in: .whitespacesAndNewlines), + !path.isEmpty + { + return URL(fileURLWithPath: path) + } + let home = FileManager.default.homeDirectoryForCurrentUser // gcloud application default credentials location - if let configDir = ProcessInfo.processInfo.environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( + if let configDir = environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( in: .whitespacesAndNewlines), !configDir.isEmpty { @@ -71,9 +89,11 @@ public enum VertexAIOAuthCredentialsStore { .appendingPathComponent("application_default_credentials.json") } - private static var projectFilePath: URL { + private static func projectFilePath( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { let home = FileManager.default.homeDirectoryForCurrentUser - if let configDir = ProcessInfo.processInfo.environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( + if let configDir = environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( in: .whitespacesAndNewlines), !configDir.isEmpty { @@ -88,28 +108,88 @@ public enum VertexAIOAuthCredentialsStore { .appendingPathComponent("config_default") } - public static func load() throws -> VertexAIOAuthCredentials { - let url = self.credentialsFilePath + public static func hasCredentials( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + let url = self.credentialsFilePath(environment: environment) + guard FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let json = try? self.parseJSONObject(data: data) + else { + return false + } + + if self.parseServiceAccountMetadata(json: json) != nil { + return true + } + + return (try? self.parseUserCredentials(json: json, environment: environment)) != nil + } + + public static func load( + environment: [String: String] = ProcessInfo.processInfo.environment) throws -> VertexAIOAuthCredentials + { + let url = self.credentialsFilePath(environment: environment) + guard FileManager.default.fileExists(atPath: url.path) else { + throw VertexAIOAuthCredentialsError.notFound + } + + let data = try Data(contentsOf: url) + return try self.parse(data: data, environment: environment) + } + + public static func loadForFetch( + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> VertexAIOAuthCredentials + { + let url = self.credentialsFilePath(environment: environment) guard FileManager.default.fileExists(atPath: url.path) else { throw VertexAIOAuthCredentialsError.notFound } let data = try Data(contentsOf: url) - return try self.parse(data: data) + let json = try self.parseJSONObject(data: data) + if let serviceAccount = self.parseServiceAccountMetadata(json: json) { + let token = try await self.printAccessToken(environment: environment) + return VertexAIOAuthCredentials( + accessToken: token, + refreshToken: "", + clientId: "", + clientSecret: "", + projectId: serviceAccount.projectId ?? self.loadProjectId(environment: environment), + email: serviceAccount.email, + expiryDate: Date().addingTimeInterval(50 * 60)) + } + + return try self.parseUserCredentials(json: json, environment: environment) } public static func parse(data: Data) throws -> VertexAIOAuthCredentials { + try self.parse(data: data, environment: ProcessInfo.processInfo.environment) + } + + public static func parse( + data: Data, + environment: [String: String]) throws -> VertexAIOAuthCredentials + { + let json = try self.parseJSONObject(data: data) + return try self.parseUserCredentials(json: json, environment: environment) + } + + private static func parseJSONObject(data: Data) throws -> [String: Any] { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw VertexAIOAuthCredentialsError.decodeFailed("Invalid JSON") } + return json + } + private static func parseUserCredentials( + json: [String: Any], + environment: [String: String]) throws -> VertexAIOAuthCredentials + { // Check for service account credentials - if json["client_email"] is String, - json["private_key"] is String - { - // Service account - use JWT for access token (simplified) + if self.parseServiceAccountMetadata(json: json) != nil { throw VertexAIOAuthCredentialsError.decodeFailed( - "Service account credentials not yet supported. Use `gcloud auth application-default login`.") + "Service account credentials require `gcloud auth application-default print-access-token`.") } // User credentials from gcloud auth application-default login @@ -127,7 +207,7 @@ public enum VertexAIOAuthCredentialsStore { let accessToken = json["access_token"] as? String ?? "" // Try to get project ID from gcloud config - let projectId = Self.loadProjectId() + let projectId = Self.loadProjectId(environment: environment) // Try to extract email from ID token if present let email = Self.extractEmailFromIdToken(json["id_token"] as? String) @@ -154,12 +234,56 @@ public enum VertexAIOAuthCredentialsStore { // The refresh happens on each app launch if needed } - private static func loadProjectId() -> String? { - let configPath = self.projectFilePath - guard let content = try? String(contentsOf: configPath, encoding: .utf8) else { + private static func parseServiceAccountMetadata(json: [String: Any]) -> ServiceAccountMetadata? { + guard let email = (json["client_email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !email.isEmpty, + let privateKey = (json["private_key"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !privateKey.isEmpty + else { return nil } + let projectId = (json["project_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return ServiceAccountMetadata( + email: email, + projectId: projectId?.isEmpty == false ? projectId : nil) + } + + private static func printAccessToken(environment: [String: String]) async throws -> String { + #if DEBUG + if let override = self.gcloudAccessTokenOverrideForTesting { + let token = try await override(environment) + return try self.cleanAccessToken(token) + } + #endif + + let env = TTYCommandRunner.enrichedEnvironment(baseEnv: environment) + let result = try await SubprocessRunner.run( + binary: "/usr/bin/env", + arguments: ["gcloud", "auth", "application-default", "print-access-token"], + environment: env, + timeout: 20, + label: "vertexai-gcloud-adc-token") + return try self.cleanAccessToken(result.stdout) + } + + private static func cleanAccessToken(_ token: String) throws -> String { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw VertexAIOAuthCredentialsError.missingTokens + } + return trimmed + } + + private static func loadProjectId(environment: [String: String]) -> String? { + let configPath = self.projectFilePath(environment: environment) + guard let content = try? String(contentsOf: configPath, encoding: .utf8) else { + return environment["GOOGLE_CLOUD_PROJECT"] + ?? environment["GCLOUD_PROJECT"] + ?? environment["CLOUDSDK_CORE_PROJECT"] + } + // Parse INI-style config for project for line in content.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -172,9 +296,9 @@ public enum VertexAIOAuthCredentialsStore { } // Try environment variable - return ProcessInfo.processInfo.environment["GOOGLE_CLOUD_PROJECT"] - ?? ProcessInfo.processInfo.environment["GCLOUD_PROJECT"] - ?? ProcessInfo.processInfo.environment["CLOUDSDK_CORE_PROJECT"] + return environment["GOOGLE_CLOUD_PROJECT"] + ?? environment["GCLOUD_PROJECT"] + ?? environment["CLOUDSDK_CORE_PROJECT"] } private static func extractEmailFromIdToken(_ token: String?) -> String? { diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift index b5e8e3f682..206fc3060a 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift @@ -49,12 +49,10 @@ public enum VertexAITokenRefresher { request.httpBody = bodyString.data(using: .utf8) do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw RefreshError.invalidResponse("No HTTP response") - } + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data - if http.statusCode == 400 || http.statusCode == 401 { + if response.statusCode == 400 || response.statusCode == 401 { if let errorCode = Self.extractErrorCode(from: data) { switch errorCode.lowercased() { case "invalid_grant": @@ -68,8 +66,8 @@ public enum VertexAITokenRefresher { throw RefreshError.expired } - guard http.statusCode == 200 else { - throw RefreshError.invalidResponse("Status \(http.statusCode)") + guard response.statusCode == 200 else { + throw RefreshError.invalidResponse("Status \(response.statusCode)") } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift index 2c9da20338..e3ad58b3ab 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift @@ -215,20 +215,15 @@ public enum VertexAIUsageFetcher { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 30 - let data: Data - let response: URLResponse + let response: ProviderHTTPResponse do { - (data, response) = try await URLSession.shared.data(for: request) + response = try await ProviderHTTPClient.shared.response(for: request) } catch { throw VertexAIFetchError.networkError(error) } - guard let http = response as? HTTPURLResponse else { - throw VertexAIFetchError.invalidResponse("No HTTP response") - } - - switch http.statusCode { + switch response.statusCode { case 401: throw VertexAIFetchError.unauthorized case 403: @@ -236,11 +231,11 @@ public enum VertexAIUsageFetcher { case 200: break default: - let body = String(data: data, encoding: .utf8) ?? "" - throw VertexAIFetchError.invalidResponse("HTTP \(http.statusCode): \(body)") + let body = String(data: response.data, encoding: .utf8) ?? "" + throw VertexAIFetchError.invalidResponse("HTTP \(response.statusCode): \(body)") } - let decoded = try JSONDecoder().decode(MonitoringTimeSeriesResponse.self, from: data) + let decoded = try JSONDecoder().decode(MonitoringTimeSeriesResponse.self, from: response.data) if let series = decoded.timeSeries { allSeries.append(contentsOf: series) } diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift index f81e0d1f2b..f076951afd 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum VertexAIProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .vertexai, @@ -45,12 +44,12 @@ struct VertexAIOAuthFetchStrategy: ProviderFetchStrategy { let id: String = "vertexai.oauth" let kind: ProviderFetchKind = .oauth - func isAvailable(_: ProviderFetchContext) async -> Bool { - (try? VertexAIOAuthCredentialsStore.load()) != nil + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + VertexAIOAuthCredentialsStore.hasCredentials(environment: context.env) } - func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { - var credentials = try VertexAIOAuthCredentialsStore.load() + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + var credentials = try await VertexAIOAuthCredentialsStore.loadForFetch(environment: context.env) // Refresh token if expired if credentials.needsRefresh { diff --git a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift index 29506321ca..d089134224 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum WarpProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .warp, @@ -31,39 +30,16 @@ public enum WarpProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Warp cost summary is not available." }), - fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .api], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [WarpAPIFetchStrategy()] })), + fetchPlan: .apiToken( + strategyID: "warp.api", + resolveToken: { ProviderTokenResolver.warpToken(environment: $0) }, + missingCredentialsError: { WarpUsageError.missingCredentials }, + loadUsage: { apiKey, _ in + try await WarpUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }), cli: ProviderCLIConfig( name: "warp", aliases: ["warp-ai", "warp-terminal"], versionDetector: nil)) } } - -struct WarpAPIFetchStrategy: ProviderFetchStrategy { - let id: String = "warp.api" - let kind: ProviderFetchKind = .apiToken - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let apiKey = Self.resolveToken(environment: context.env) else { - throw WarpUsageError.missingCredentials - } - let usage = try await WarpUsageFetcher.fetchUsage(apiKey: apiKey) - return self.makeResult( - usage: usage.toUsageSnapshot(), - sourceLabel: "api") - } - - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false - } - - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.warpToken(environment: environment) - } -} diff --git a/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift b/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift index cd3c639c1b..afe5df0c37 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift @@ -28,8 +28,7 @@ public struct WarpSettingsReader: Sendable { if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { - value.removeFirst() - value.removeLast() + value = String(value.dropFirst().dropLast()) } return value.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift index 79ec2e8f0b..0114d81c4a 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift @@ -206,16 +206,12 @@ public struct WarpUsageFetcher: Sendable { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw WarpUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { - let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data) - Self.log.error("Warp API returned \(httpResponse.statusCode): \(summary)") - throw WarpUsageError.apiError(httpResponse.statusCode, summary) + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let summary = Self.apiErrorSummary(statusCode: response.statusCode, data: data) + Self.log.error("Warp API returned \(response.statusCode): \(summary)") + throw WarpUsageError.apiError(response.statusCode, summary) } do { @@ -293,13 +289,13 @@ public struct WarpUsageFetcher: Sendable { bonusNextExpirationRemaining: bonus.nextExpirationRemaining) } - private struct BonusGrant: Sendable { + private struct BonusGrant { let granted: Int let remaining: Int let expiration: Date? } - private struct BonusSummary: Sendable { + private struct BonusSummary { let remaining: Int let total: Int let nextExpiration: Date? diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift new file mode 100644 index 0000000000..2d6955aabb --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift @@ -0,0 +1,308 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +#if os(macOS) +enum WindsurfDevinSessionImporter { + nonisolated(unsafe) static var importSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? + nonisolated(unsafe) static var importPreferredSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? + nonisolated(unsafe) static var importFallbackSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? + static let defaultPreferredBrowsers: [Browser] = [.chrome] + static let fallbackBrowsers: [Browser] = [ + .chromeBeta, + .chromeCanary, + .edge, + .edgeBeta, + .edgeCanary, + .brave, + .braveBeta, + .braveNightly, + .vivaldi, + .arc, + .arcBeta, + .arcCanary, + .dia, + .chatgptAtlas, + .chromium, + .helium, + ] + + struct SessionInfo: Equatable { + let session: WindsurfDevinSessionAuth + let sourceLabel: String + } + + static func importSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importSessionsOverrideForTesting { + return override(browserDetection, logger) + } + + let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } + let preferredSessions = self.importSessions( + browserDetection: browserDetection, + browsers: self.defaultPreferredBrowsers, + logger: log) + if !preferredSessions.isEmpty { + return preferredSessions + } + + log("No Windsurf devin session found in Chrome; trying fallback Chromium browsers") + let sessions = self.importSessions( + browserDetection: browserDetection, + browsers: self.fallbackBrowsersExcluding(self.defaultPreferredBrowsers), + logger: log) + + if sessions.isEmpty { + log("No Windsurf devin session found in browser local storage") + } + + return sessions + } + + static func importPreferredSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importPreferredSessionsOverrideForTesting { + return override(browserDetection, logger) + } + let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } + return self.importSessions( + browserDetection: browserDetection, + browsers: self.defaultPreferredBrowsers, + logger: log) + } + + static func importFallbackSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importFallbackSessionsOverrideForTesting { + return override(browserDetection, logger) + } + let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } + return self.importSessions( + browserDetection: browserDetection, + browsers: self.fallbackBrowsersExcluding(self.defaultPreferredBrowsers), + logger: log) + } + + static func fallbackBrowsersExcluding(_ preferredBrowsers: [Browser]) -> [Browser] { + let preferred = Set(preferredBrowsers) + return self.fallbackBrowsers.filter { !preferred.contains($0) } + } + + static func deduplicateSessions(_ sessions: [SessionInfo]) -> [SessionInfo] { + var deduplicated: [SessionInfo] = [] + var seenSessionTokens = Set() + + for session in sessions { + guard seenSessionTokens.insert(session.session.sessionToken).inserted else { continue } + deduplicated.append(session) + } + + return deduplicated + } + + static func session(from storage: [String: String], sourceLabel: String) -> SessionInfo? { + guard let sessionToken = storage["devin_session_token"], + let auth1Token = storage["devin_auth1_token"], + let accountID = storage["devin_account_id"], + let primaryOrgID = storage["devin_primary_org_id"] + else { + return nil + } + + return SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: sessionToken, + auth1Token: auth1Token, + accountID: accountID, + primaryOrgID: primaryOrgID), + sourceLabel: sourceLabel) + } + + static func decodedStorageValue(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + + if let data = trimmed.data(using: .utf8), + let decoded = try? JSONDecoder().decode(String.self, from: data) + { + return decoded.trimmingCharacters(in: .whitespacesAndNewlines) + } + + return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + struct LocalStorageCandidate { + let label: String + let url: URL + } + + struct LocalStorageSnapshot: Equatable { + let storage: [String: String] + let sourceSuffix: String? + } + + typealias LocalStorageOriginEntries = ( + origin: URL, + entries: [SweetCookieKit.ChromiumLocalStorageEntry]) + + static let localStorageOrigins = [ + URL(string: "https://app.devin.ai")!, + URL(string: "https://windsurf.com")!, + ] + + private static func importSessions( + browserDetection: BrowserDetection, + browsers: [Browser], + logger: @escaping (String) -> Void) -> [SessionInfo] + { + var sessions: [SessionInfo] = [] + let candidates = self.chromeLocalStorageCandidates( + browserDetection: browserDetection, + browsers: browsers) + if !candidates.isEmpty { + logger("Chrome local storage candidates: \(candidates.count)") + } + + for candidate in candidates { + let snapshots = self.readLocalStorageSnapshots(from: candidate.url, logger: logger) + for snapshot in snapshots { + let sourceLabel = self.sourceLabel(candidate.label, suffix: snapshot.sourceSuffix) + guard let session = self.session(from: snapshot.storage, sourceLabel: sourceLabel) else { continue } + logger("Found Windsurf devin session in \(sourceLabel)") + sessions.append(session) + } + } + + return self.deduplicateSessions(sessions) + } + + static func chromeLocalStorageCandidates( + browserDetection: BrowserDetection, + browsers: [Browser]) -> [LocalStorageCandidate] + { + let installedBrowsers = browsers.browsersWithProfileData(using: browserDetection) + let roots = ChromiumProfileLocator + .roots(for: installedBrowsers, homeDirectories: BrowserCookieClient.defaultHomeDirectories()) + .map { (url: $0.url, labelPrefix: $0.labelPrefix) } + + var candidates: [LocalStorageCandidate] = [] + for root in roots { + candidates.append(contentsOf: self.chromeProfileLocalStorageDirs( + root: root.url, + labelPrefix: root.labelPrefix)) + } + return candidates + } + + private static func chromeProfileLocalStorageDirs(root: URL, labelPrefix: String) -> [LocalStorageCandidate] { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return [] } + + let profileDirs = entries.filter { url in + guard let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else { + return false + } + let name = url.lastPathComponent + return name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-") + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + + return profileDirs.compactMap { dir in + let levelDBURL = dir.appendingPathComponent("Local Storage").appendingPathComponent("leveldb") + guard FileManager.default.fileExists(atPath: levelDBURL.path) else { return nil } + let label = "\(labelPrefix) \(dir.lastPathComponent)" + return LocalStorageCandidate(label: label, url: levelDBURL) + } + } + + private static func readLocalStorageSnapshots( + from levelDBURL: URL, + logger: ((String) -> Void)? = nil) -> [LocalStorageSnapshot] + { + let originEntries = Self.localStorageOrigins.map { origin in + let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries( + for: origin.absoluteString, + in: levelDBURL, + logger: logger) + return (origin: origin, entries: entries) + } + + let textEntries = SweetCookieKit.ChromiumLocalStorageReader.readTextEntries( + in: levelDBURL, + logger: logger) + return self.localStorageSnapshots(from: originEntries, textEntries: textEntries) + } + + static func localStorageSnapshots( + from originEntries: [LocalStorageOriginEntries], + textEntries: [SweetCookieKit.ChromiumLevelDBTextEntry]) -> [LocalStorageSnapshot] + { + var snapshots = self.localStorageSnapshots(from: originEntries) + let textStorage = self.storage(from: textEntries) + if textStorage.count == Self.targetKeys.count { + snapshots.append(LocalStorageSnapshot(storage: textStorage, sourceSuffix: nil)) + } + + return snapshots + } + + static func localStorageSnapshots(from originEntries: [LocalStorageOriginEntries]) -> [LocalStorageSnapshot] { + originEntries.compactMap { originEntry in + let storage = self.storage(from: originEntry.entries) + guard storage.count == Self.targetKeys.count else { return nil } + return LocalStorageSnapshot( + storage: storage, + sourceSuffix: originEntry.origin.host ?? originEntry.origin.absoluteString) + } + } + + private static func storage( + from entries: [SweetCookieKit.ChromiumLocalStorageEntry]) -> [String: String] + { + var storage: [String: String] = [:] + for entry in entries where storage[entry.key] == nil && Self.targetKeys.contains(entry.key) { + storage[entry.key] = self.decodedStorageValue(entry.value) + } + return storage + } + + private static func storage( + from entries: [SweetCookieKit.ChromiumLevelDBTextEntry]) -> [String: String] + { + var storage: [String: String] = [:] + for entry in entries { + guard storage[entry.key] == nil, Self.targetKeys.contains(entry.key) else { continue } + storage[entry.key] = self.decodedStorageValue(entry.value) + } + + return storage + } + + private static func sourceLabel(_ label: String, suffix: String?) -> String { + guard let suffix else { return label } + return "\(label) (\(suffix))" + } + + private static let targetKeys: Set = [ + "devin_session_token", + "devin_auth1_token", + "devin_account_id", + "devin_primary_org_id", + ] +} +#endif diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift new file mode 100644 index 0000000000..facf988ca1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -0,0 +1,100 @@ +import Foundation + +public enum WindsurfProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .windsurf, + metadata: ProviderMetadata( + id: .windsurf, + displayName: "Windsurf", + sessionLabel: "Daily", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Windsurf usage", + cliName: "windsurf", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://windsurf.com/subscription/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .windsurf, + iconResourceName: "ProviderIcon-windsurf", + color: ProviderColor(red: 52 / 255, green: 232 / 255, blue: 187 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Windsurf cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [WindsurfWebFetchStrategy(), WindsurfLocalFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "windsurf", + versionDetector: nil)) + } +} + +struct WindsurfWebFetchStrategy: ProviderFetchStrategy { + let id: String = "windsurf.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.sourceMode.usesWeb else { return false } + guard context.settings?.windsurf?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + #if os(macOS) + let cookieSource = context.settings?.windsurf?.cookieSource ?? .auto + let manualToken = Self.manualToken(from: context) + let usage = try await WindsurfWebFetcher.fetchUsage( + browserDetection: context.browserDetection, + cookieSource: cookieSource, + manualSessionInput: manualToken, + timeout: context.webTimeout, + logger: context.verbose ? { print($0) } : nil) + return self.makeResult(usage: usage, sourceLabel: "windsurf-web") + #else + throw WindsurfStatusProbeError.notSupported + #endif + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } + + private static func manualToken(from context: ProviderFetchContext) -> String? { + guard context.settings?.windsurf?.cookieSource == .manual else { return nil } + let header = context.settings?.windsurf?.manualCookieHeader ?? "" + return header.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : header + } +} + +struct WindsurfLocalFetchStrategy: ProviderFetchStrategy { + let id: String = "windsurf.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + context.sourceMode != .web + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let probe = WindsurfStatusProbe() + let planInfo = try probe.fetch() + let usage = planInfo.toUsageSnapshot() + return self.makeResult( + usage: usage, + sourceLabel: "local") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift new file mode 100644 index 0000000000..73f863ec8e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift @@ -0,0 +1,281 @@ +import Foundation + +// MARK: - Cached Plan Info (Codable) + +public struct WindsurfCachedPlanInfo: Codable, Sendable { + public let planName: String? + public let startTimestamp: Int64? + public let endTimestamp: Int64? + public let usage: Usage? + public let quotaUsage: QuotaUsage? + + public struct Usage: Codable, Sendable { + public let messages: Int? + public let usedMessages: Int? + public let remainingMessages: Int? + public let flowActions: Int? + public let usedFlowActions: Int? + public let remainingFlowActions: Int? + public let flexCredits: Int? + public let usedFlexCredits: Int? + public let remainingFlexCredits: Int? + } + + public struct QuotaUsage: Codable, Sendable { + public let dailyRemainingPercent: Double? + public let weeklyRemainingPercent: Double? + public let dailyResetAtUnix: Int64? + public let weeklyResetAtUnix: Int64? + } +} + +// MARK: - Errors & Probe + +#if os(macOS) + +import SQLite3 + +public enum WindsurfStatusProbeError: LocalizedError, Sendable, Equatable { + case dbNotFound(String) + case sqliteFailed(String) + case noData + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case let .dbNotFound(path): + "Windsurf database not found at \(path). Ensure Windsurf is installed and has been launched at least once." + case let .sqliteFailed(message): + "SQLite error reading Windsurf data: \(message)" + case .noData: + "No plan data found in Windsurf database. Sign in to Windsurf first." + case let .parseFailed(message): + "Could not parse Windsurf plan data: \(message)" + } + } +} + +// MARK: - Probe + +public struct WindsurfStatusProbe: Sendable { + private static let defaultDBPath: String = { + let home = NSHomeDirectory() + return "\(home)/Library/Application Support/Windsurf/User/globalStorage/state.vscdb" + }() + + private static let query = "SELECT value FROM ItemTable WHERE key = 'windsurf.settings.cachedPlanInfo' LIMIT 1;" + + private let dbPath: String + + public init(dbPath: String? = nil) { + self.dbPath = dbPath ?? Self.defaultDBPath + } + + public func fetch() throws -> WindsurfCachedPlanInfo { + guard FileManager.default.fileExists(atPath: self.dbPath) else { + throw WindsurfStatusProbeError.dbNotFound(self.dbPath) + } + + var db: OpaquePointer? + guard sqlite3_open_v2(self.dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + sqlite3_close(db) + throw WindsurfStatusProbeError.sqliteFailed(message) + } + defer { sqlite3_close(db) } + + sqlite3_busy_timeout(db, 250) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, Self.query, -1, &stmt, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw WindsurfStatusProbeError.sqliteFailed(message) + } + defer { sqlite3_finalize(stmt) } + + let stepResult = sqlite3_step(stmt) + guard stepResult == SQLITE_ROW else { + if stepResult == SQLITE_DONE { + throw WindsurfStatusProbeError.noData + } + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw WindsurfStatusProbeError.sqliteFailed(message) + } + + guard let jsonString = Self.decodeSQLiteValue(stmt: stmt, index: 0) else { + throw WindsurfStatusProbeError.noData + } + guard let jsonData = jsonString.data(using: .utf8) else { + throw WindsurfStatusProbeError.parseFailed("Invalid UTF-8 encoding") + } + + do { + return try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: jsonData) + } catch { + throw WindsurfStatusProbeError.parseFailed(error.localizedDescription) + } + } + + private static func decodeSQLiteValue(stmt: OpaquePointer?, index: Int32) -> String? { + switch sqlite3_column_type(stmt, index) { + case SQLITE_TEXT: + guard let c = sqlite3_column_text(stmt, index) else { return nil } + return String(cString: c) + case SQLITE_BLOB: + guard let bytes = sqlite3_column_blob(stmt, index) else { return nil } + let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) + // VSCode/Windsurf state.vscdb schema declares value as BLOB; + // only accept decodes that still parse as JSON to avoid UTF-16 mojibake. + return self.decodeJSONBlob(data) + default: + return nil + } + } + + private static func decodeJSONBlob(_ data: Data) -> String? { + for encoding in [String.Encoding.utf8, .utf16LittleEndian] { + guard let decoded = String(data: data, encoding: encoding) else { continue } + let trimmed = decoded.trimmingCharacters(in: .controlCharacters) + guard let jsonData = trimmed.data(using: .utf8), + (try? JSONSerialization.jsonObject(with: jsonData)) != nil + else { + continue + } + return trimmed + } + return nil + } +} + +#else + +// MARK: - Windsurf (Unsupported) + +public enum WindsurfStatusProbeError: LocalizedError, Sendable, Equatable { + case notSupported + + public var errorDescription: String? { + "Windsurf is only supported on macOS." + } +} + +public struct WindsurfStatusProbe: Sendable { + public init(dbPath _: String? = nil) {} + + public func fetch() throws -> WindsurfCachedPlanInfo { + throw WindsurfStatusProbeError.notSupported + } +} + +#endif + +// MARK: - Conversion to UsageSnapshot + +extension WindsurfCachedPlanInfo { + public func toUsageSnapshot() -> UsageSnapshot { + var primary: RateWindow? + var secondary: RateWindow? + + if let quota = self.quotaUsage { + // Primary: daily usage (usedPercent = 100 - dailyRemainingPercent) + if let daily = quota.dailyRemainingPercent { + let resetDate = quota.dailyResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + primary = RateWindow( + usedPercent: max(0, min(100, 100 - daily)), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + + // Secondary: weekly usage + if let weekly = quota.weeklyRemainingPercent { + let resetDate = quota.weeklyResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + secondary = RateWindow( + usedPercent: max(0, min(100, 100 - weekly)), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + } + + if primary == nil, let usage = self.usage { + primary = Self.makeUsageWindow( + used: usage.usedMessages, + remaining: usage.remainingMessages, + total: usage.messages, + unit: "messages") + } + + if secondary == nil, let usage = self.usage { + secondary = Self.makeUsageWindow( + used: usage.usedFlowActions, + remaining: usage.remainingFlowActions, + total: usage.flowActions, + unit: "flow actions") + } + + // Identity + var orgDescription: String? + if let endTimestamp = self.endTimestamp { + let endDate = Date(timeIntervalSince1970: TimeInterval(endTimestamp) / 1000) + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + orgDescription = "Expires \(formatter.string(from: endDate))" + } + + let identity = ProviderIdentitySnapshot( + providerID: .windsurf, + accountEmail: nil, + accountOrganization: orgDescription, + loginMethod: self.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: Date(), + identity: identity) + } + + private static func makeUsageWindow( + used rawUsed: Int?, + remaining rawRemaining: Int?, + total rawTotal: Int?, + unit: String) -> RateWindow? + { + guard let total = rawTotal, total > 0 else { return nil } + let inferredUsed = rawUsed ?? rawRemaining.map { max(0, total - $0) } + guard let used = inferredUsed else { return nil } + let clampedUsed = max(0, min(total, used)) + let usedPercent = max(0, min(100, Double(clampedUsed) / Double(total) * 100)) + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(clampedUsed) / \(total) \(unit)") + } + + private static func formatResetDescription(_ date: Date?) -> String? { + guard let date else { return nil } + let now = Date() + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "Expired" } + + let hours = Int(interval / 3600) + let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) + + if hours > 24 { + let days = hours / 24 + let remainingHours = hours % 24 + return "Resets in \(days)d \(remainingHours)h" + } else if hours > 0 { + return "Resets in \(hours)h \(minutes)m" + } else { + return "Resets in \(minutes)m" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift new file mode 100644 index 0000000000..1330be5988 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum WindsurfUsageDataSource: String, CaseIterable, Identifiable, Sendable { + case auto + case web + case cli + + public var id: String { + self.rawValue + } + + public var displayName: String { + switch self { + case .auto: "Auto" + case .web: "Web API (IndexedDB)" + case .cli: "Local (SQLite cache)" + } + } + + public var sourceLabel: String { + switch self { + case .auto: "auto" + case .web: "web" + case .cli: "cli" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift new file mode 100644 index 0000000000..27bc8579f0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift @@ -0,0 +1,679 @@ +import Foundation + +// MARK: - API Response Model + +public struct WindsurfGetPlanStatusResponse: Sendable, Equatable { + public let planStatus: PlanStatus? + + public struct PlanStatus: Sendable, Equatable { + public let planInfo: PlanInfo? + public let planStart: Date? + public let planEnd: Date? + public let dailyQuotaRemainingPercent: Int? + public let weeklyQuotaRemainingPercent: Int? + public let dailyQuotaResetAtUnix: Int64? + public let weeklyQuotaResetAtUnix: Int64? + public let topUpStatus: TopUpStatus? + public let gracePeriodStatus: Int? + + public struct PlanInfo: Sendable, Equatable { + public let planName: String? + public let teamsTier: Int? + } + + public struct TopUpStatus: Sendable, Equatable { + public let topUpTransactionStatus: Int? + } + } +} + +// MARK: - Conversion to UsageSnapshot + +extension WindsurfGetPlanStatusResponse { + public func toUsageSnapshot() -> UsageSnapshot { + var primary: RateWindow? + var secondary: RateWindow? + + if let status = self.planStatus { + if let daily = status.dailyQuotaRemainingPercent { + let resetDate = status.dailyQuotaResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + primary = RateWindow( + usedPercent: max(0, min(100, 100 - Double(daily))), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + + if let weekly = status.weeklyQuotaRemainingPercent { + let resetDate = status.weeklyQuotaResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + secondary = RateWindow( + usedPercent: max(0, min(100, 100 - Double(weekly))), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + } + + var orgDescription: String? + if let endDate = self.planStatus?.planEnd { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + orgDescription = "Expires \(formatter.string(from: endDate))" + } + + let identity = ProviderIdentitySnapshot( + providerID: .windsurf, + accountEmail: nil, + accountOrganization: orgDescription, + loginMethod: self.planStatus?.planInfo?.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: Date(), + identity: identity) + } + + private static func formatResetDescription(_ date: Date?) -> String? { + guard let date else { return nil } + let now = Date() + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "Expired" } + + let hours = Int(interval / 3600) + let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) + + if hours > 24 { + let days = hours / 24 + let remainingHours = hours % 24 + return "Resets in \(days)d \(remainingHours)h" + } else if hours > 0 { + return "Resets in \(hours)h \(minutes)m" + } else { + return "Resets in \(minutes)m" + } + } +} + +// MARK: - Session Material + +#if os(macOS) + +struct WindsurfDevinSessionAuth: Codable, Equatable { + let sessionToken: String + let auth1Token: String + let accountID: String + let primaryOrgID: String +} + +public enum WindsurfWebFetcherError: LocalizedError, Sendable { + case noSessionData + case invalidManualSession(String) + case apiCallFailed(String) + + public var errorDescription: String? { + switch self { + case .noSessionData: + "No Windsurf web session found in Chromium localStorage. " + + "Sign in to app.devin.ai or windsurf.com in Chrome first." + case let .invalidManualSession(message): + "Invalid Windsurf session payload: \(message)" + case let .apiCallFailed(message): + "Windsurf API call failed: \(message)" + } + } +} + +public enum WindsurfWebFetcher { + private static let windsurfOrigin = "https://windsurf.com" + private static let windsurfProfileReferer = "https://windsurf.com/profile" + private static let getPlanStatusURL = "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus" + + public static func fetchUsage( + browserDetection: BrowserDetection, + cookieSource: ProviderCookieSource = .auto, + manualSessionInput: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> UsageSnapshot + { + let log: (String) -> Void = { msg in logger?("[windsurf-web] \(msg)") } + + if cookieSource == .manual { + guard let manualSessionInput, + !manualSessionInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw WindsurfWebFetcherError.invalidManualSession("empty input") + } + log("Using manual Windsurf session bundle") + let auth = try self.parseManualSessionInput(manualSessionInput) + let response = try await self.fetchPlanStatus(auth: auth, timeout: timeout, transport: transport) + return response.toUsageSnapshot() + } + + guard cookieSource != .off else { + throw WindsurfWebFetcherError.noSessionData + } + + let preferredSessionInfos = WindsurfDevinSessionImporter.importPreferredSessions( + browserDetection: browserDetection, + logger: logger) + let sessionInfos = preferredSessionInfos.isEmpty + ? WindsurfDevinSessionImporter.importFallbackSessions( + browserDetection: browserDetection, + logger: logger) + : preferredSessionInfos + guard !sessionInfos.isEmpty else { + throw WindsurfWebFetcherError.noSessionData + } + + do { + return try await self.fetchUsage( + sessionInfos: sessionInfos, + timeout: timeout, + logger: log, + transport: transport) + } catch { + guard !preferredSessionInfos.isEmpty, self.isRecoverableImportedSessionError(error) else { + throw error + } + } + + log("Chrome Windsurf sessions failed; trying fallback Chromium browser sessions") + let fallbackSessionInfos = WindsurfDevinSessionImporter.importFallbackSessions( + browserDetection: browserDetection, + logger: logger) + guard !fallbackSessionInfos.isEmpty else { + throw WindsurfWebFetcherError.noSessionData + } + return try await self.fetchUsage( + sessionInfos: fallbackSessionInfos, + timeout: timeout, + logger: log, + transport: transport) + } + + static func parseManualSessionInput(_ raw: String) throws -> WindsurfDevinSessionAuth { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw WindsurfWebFetcherError.invalidManualSession("empty input") + } + + if let auth = self.parseJSONSessionInput(trimmed) { + return auth + } + + if let auth = self.parseKeyValueSessionInput(trimmed) { + return auth + } + + throw WindsurfWebFetcherError.invalidManualSession( + "expected JSON with devin_session_token, devin_auth1_token, devin_account_id, and devin_primary_org_id") + } + + private static func parseJSONSessionInput(_ raw: String) -> WindsurfDevinSessionAuth? { + guard let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + return self.sessionAuth(from: json) + } + + private static func parseKeyValueSessionInput(_ raw: String) -> WindsurfDevinSessionAuth? { + let separators = CharacterSet(charactersIn: "\n,;") + let segments = raw + .trimmingCharacters(in: CharacterSet(charactersIn: "{}")) + .components(separatedBy: separators) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + var values: [String: String] = [:] + for segment in segments { + let delimiter: Character? = segment.contains("=") ? "=" : (segment.contains(":") ? ":" : nil) + guard let delimiter, let index = segment.firstIndex(of: delimiter) else { continue } + let key = String(segment[.. Bool { + guard case let WindsurfWebFetcherError.apiCallFailed(message) = error else { + return false + } + + return ["HTTP 400", "HTTP 401", "HTTP 403"].contains { message.hasPrefix($0) } + } + + private static func fetchUsage( + sessionInfos: [WindsurfDevinSessionImporter.SessionInfo], + timeout: TimeInterval, + logger log: (String) -> Void, + transport: any ProviderHTTPTransport) async throws -> UsageSnapshot + { + var lastError: Error? + for sessionInfo in sessionInfos { + do { + log("Using devin session from \(sessionInfo.sourceLabel)") + let response = try await self.fetchPlanStatus( + auth: sessionInfo.session, + timeout: timeout, + transport: transport) + return response.toUsageSnapshot() + } catch { + guard self.isRecoverableImportedSessionError(error) else { + throw error + } + lastError = error + log("Windsurf devin session from \(sessionInfo.sourceLabel) failed; trying next imported session") + } + } + + throw lastError ?? WindsurfWebFetcherError.noSessionData + } + + private static func sessionAuth(from values: [String: Any]) -> WindsurfDevinSessionAuth? { + func stringValue(for keys: [String]) -> String? { + for key in keys { + if let value = values[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } + + guard let sessionToken = stringValue(for: ["devin_session_token", "devinSessionToken", "sessionToken"]), + let auth1Token = stringValue(for: ["devin_auth1_token", "devinAuth1Token", "auth1Token"]), + let accountID = stringValue(for: ["devin_account_id", "devinAccountId", "accountID", "accountId"]), + let primaryOrgID = stringValue(for: [ + "devin_primary_org_id", + "devinPrimaryOrgId", + "primaryOrgID", + "primaryOrgId", + ]) + else { + return nil + } + + return WindsurfDevinSessionAuth( + sessionToken: sessionToken, + auth1Token: auth1Token, + accountID: accountID, + primaryOrgID: primaryOrgID) + } + + private static func fetchPlanStatus( + auth: WindsurfDevinSessionAuth, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> WindsurfGetPlanStatusResponse + { + guard let url = URL(string: self.getPlanStatusURL) else { + throw WindsurfWebFetcherError.apiCallFailed("Invalid GetPlanStatus URL") + } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.httpMethod = "POST" + request.setValue("application/proto", forHTTPHeaderField: "Content-Type") + request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + self.applyWindsurfHeaders(to: &request, auth: auth) + request.httpBody = WindsurfPlanStatusProtoCodec.encodeRequest( + authToken: auth.sessionToken, + includeTopUpStatus: true) + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { + throw WindsurfWebFetcherError.apiCallFailed("Invalid response") + } catch { + throw error + } + + guard response.statusCode == 200 else { + let body = String(data: response.data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let snippet = if let body, !body.isEmpty { + ": \(body.prefix(200))" + } else { + ": " + } + throw WindsurfWebFetcherError.apiCallFailed("HTTP \(response.statusCode)\(snippet)") + } + + do { + return try WindsurfPlanStatusProtoCodec.decodeResponse(response.data) + } catch { + throw WindsurfWebFetcherError.apiCallFailed("Parse error: \(error.localizedDescription)") + } + } + + private static func applyWindsurfHeaders(to request: inout URLRequest, auth: WindsurfDevinSessionAuth) { + request.setValue(self.windsurfOrigin, forHTTPHeaderField: "Origin") + request.setValue(self.windsurfProfileReferer, forHTTPHeaderField: "Referer") + request.setValue(auth.sessionToken, forHTTPHeaderField: "x-auth-token") + request.setValue(auth.sessionToken, forHTTPHeaderField: "x-devin-session-token") + request.setValue(auth.auth1Token, forHTTPHeaderField: "x-devin-auth1-token") + request.setValue(auth.accountID, forHTTPHeaderField: "x-devin-account-id") + request.setValue(auth.primaryOrgID, forHTTPHeaderField: "x-devin-primary-org-id") + } +} + +enum WindsurfPlanStatusProtoCodec { + /// Field numbers come from Windsurf's bundled protobuf metadata in + /// `/Applications/Windsurf.app/.../extension.js` and were re-verified against live browser traffic on 2026-04-17. + struct Request: Equatable { + let authToken: String + let includeTopUpStatus: Bool + } + + static func encodeRequest(authToken: String, includeTopUpStatus: Bool) -> Data { + var data = Data() + self.appendFieldKey(1, wireType: .lengthDelimited, to: &data) + self.appendString(authToken, to: &data) + self.appendFieldKey(2, wireType: .varint, to: &data) + self.appendVarint(includeTopUpStatus ? 1 : 0, to: &data) + return data + } + + static func decodeRequest(_ data: Data) throws -> Request { + var reader = ProtoReader(data: data) + var authToken: String? + var includeTopUpStatus = false + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .lengthDelimited): + authToken = try reader.readString() + case (2, .varint): + includeTopUpStatus = try reader.readVarint() != 0 + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + guard let authToken else { + throw WindsurfProtoError.missingField("auth_token") + } + + return Request(authToken: authToken, includeTopUpStatus: includeTopUpStatus) + } + + static func decodeResponse(_ data: Data) throws -> WindsurfGetPlanStatusResponse { + var reader = ProtoReader(data: data) + var planStatus: WindsurfGetPlanStatusResponse.PlanStatus? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .lengthDelimited): + planStatus = try self.decodePlanStatus(from: reader.readLengthDelimitedData()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse(planStatus: planStatus) + } + + private static func decodePlanStatus(from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus { + var reader = ProtoReader(data: data) + var planInfo: WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo? + var planStart: Date? + var planEnd: Date? + var dailyQuotaRemainingPercent: Int? + var weeklyQuotaRemainingPercent: Int? + var dailyQuotaResetAtUnix: Int64? + var weeklyQuotaResetAtUnix: Int64? + var topUpStatus: WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus? + var gracePeriodStatus: Int? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .lengthDelimited): + planInfo = try self.decodePlanInfo(from: reader.readLengthDelimitedData()) + case (2, .lengthDelimited): + planStart = try self.decodeTimestamp(from: reader.readLengthDelimitedData()) + case (3, .lengthDelimited): + planEnd = try self.decodeTimestamp(from: reader.readLengthDelimitedData()) + case (10, .lengthDelimited): + topUpStatus = try self.decodeTopUpStatus(from: reader.readLengthDelimitedData()) + case (12, .varint): + gracePeriodStatus = try Int(reader.readVarint()) + case (14, .varint): + dailyQuotaRemainingPercent = try Int(reader.readVarint()) + case (15, .varint): + weeklyQuotaRemainingPercent = try Int(reader.readVarint()) + case (17, .varint): + dailyQuotaResetAtUnix = try Int64(reader.readVarint()) + case (18, .varint): + weeklyQuotaResetAtUnix = try Int64(reader.readVarint()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse.PlanStatus( + planInfo: planInfo, + planStart: planStart, + planEnd: planEnd, + dailyQuotaRemainingPercent: dailyQuotaRemainingPercent, + weeklyQuotaRemainingPercent: weeklyQuotaRemainingPercent, + dailyQuotaResetAtUnix: dailyQuotaResetAtUnix, + weeklyQuotaResetAtUnix: weeklyQuotaResetAtUnix, + topUpStatus: topUpStatus, + gracePeriodStatus: gracePeriodStatus) + } + + private static func decodePlanInfo( + from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo + { + var reader = ProtoReader(data: data) + var planName: String? + var teamsTier: Int? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .varint): + teamsTier = try Int(reader.readVarint()) + case (2, .lengthDelimited): + planName = try reader.readString() + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo(planName: planName, teamsTier: teamsTier) + } + + private static func decodeTopUpStatus( + from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus + { + var reader = ProtoReader(data: data) + var topUpTransactionStatus: Int? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .varint): + topUpTransactionStatus = try Int(reader.readVarint()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus( + topUpTransactionStatus: topUpTransactionStatus) + } + + private static func decodeTimestamp(from data: Data) throws -> Date { + var reader = ProtoReader(data: data) + var seconds: Int64 = 0 + var nanos: Int32 = 0 + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .varint): + seconds = try Int64(reader.readVarint()) + case (2, .varint): + nanos = try Int32(reader.readVarint()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + let timeInterval = TimeInterval(seconds) + (TimeInterval(nanos) / 1_000_000_000) + return Date(timeIntervalSince1970: timeInterval) + } + + private static func appendString(_ string: String, to data: inout Data) { + let encoded = Data(string.utf8) + self.appendVarint(UInt64(encoded.count), to: &data) + data.append(encoded) + } + + private static func appendFieldKey(_ fieldNumber: Int, wireType: ProtoWireType, to data: inout Data) { + self.appendVarint(UInt64((fieldNumber << 3) | Int(wireType.rawValue)), to: &data) + } + + private static func appendVarint(_ value: UInt64, to data: inout Data) { + var remaining = value + while remaining >= 0x80 { + data.append(UInt8((remaining & 0x7F) | 0x80)) + remaining >>= 7 + } + data.append(UInt8(remaining)) + } +} + +enum WindsurfProtoError: LocalizedError { + case truncated + case invalidWireType(UInt64) + case invalidUTF8 + case missingField(String) + case unsupportedWireType(ProtoWireType) + case malformedFieldKey + + var errorDescription: String? { + switch self { + case .truncated: + "truncated protobuf payload" + case let .invalidWireType(rawValue): + "invalid wire type \(rawValue)" + case .invalidUTF8: + "invalid UTF-8 string" + case let .missingField(name): + "missing protobuf field \(name)" + case let .unsupportedWireType(type): + "unsupported protobuf wire type \(type.rawValue)" + case .malformedFieldKey: + "malformed protobuf field key" + } + } +} + +enum ProtoWireType: UInt64 { + case varint = 0 + case fixed64 = 1 + case lengthDelimited = 2 + case startGroup = 3 + case endGroup = 4 + case fixed32 = 5 +} + +private struct ProtoField { + let number: Int + let wireType: ProtoWireType +} + +private struct ProtoReader { + private let bytes: [UInt8] + private var index: Int = 0 + + init(data: Data) { + self.bytes = Array(data) + } + + mutating func nextField() throws -> ProtoField? { + guard self.index < self.bytes.count else { return nil } + let key = try self.readVarint() + let number = Int(key >> 3) + guard number > 0 else { + throw WindsurfProtoError.malformedFieldKey + } + guard let wireType = ProtoWireType(rawValue: key & 0x07) else { + throw WindsurfProtoError.invalidWireType(key & 0x07) + } + return ProtoField(number: number, wireType: wireType) + } + + mutating func readVarint() throws -> UInt64 { + var result: UInt64 = 0 + var shift: UInt64 = 0 + + while self.index < self.bytes.count { + let byte = self.bytes[self.index] + self.index += 1 + + result |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { + return result + } + + shift += 7 + if shift >= 64 { + throw WindsurfProtoError.truncated + } + } + + throw WindsurfProtoError.truncated + } + + mutating func readLengthDelimitedData() throws -> Data { + let length = try Int(self.readVarint()) + guard length >= 0, self.index + length <= self.bytes.count else { + throw WindsurfProtoError.truncated + } + + let chunk = Data(self.bytes[self.index..<(self.index + length)]) + self.index += length + return chunk + } + + mutating func readString() throws -> String { + let data = try self.readLengthDelimitedData() + guard let string = String(data: data, encoding: .utf8) else { + throw WindsurfProtoError.invalidUTF8 + } + return string + } + + mutating func skipFieldBody(wireType: ProtoWireType) throws { + switch wireType { + case .varint: + _ = try self.readVarint() + case .fixed64: + guard self.index + 8 <= self.bytes.count else { throw WindsurfProtoError.truncated } + self.index += 8 + case .lengthDelimited: + _ = try self.readLengthDelimitedData() + case .fixed32: + guard self.index + 4 <= self.bytes.count else { throw WindsurfProtoError.truncated } + self.index += 4 + case .startGroup, .endGroup: + throw WindsurfProtoError.unsupportedWireType(wireType) + } + } +} + +#endif diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift index 18d5a15e9b..df14c23922 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift @@ -5,6 +5,7 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable { case bigmodelCN = "bigmodel-cn" private static let quotaPath = "api/monitor/usage/quota/limit" + private static let modelUsagePath = "api/monitor/usage/model-usage" public var displayName: String { switch self { @@ -27,4 +28,17 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable { public var quotaLimitURL: URL { URL(string: self.baseURLString)!.appendingPathComponent(Self.quotaPath) } + + public var modelUsageURL: URL { + URL(string: self.baseURLString)!.appendingPathComponent(Self.modelUsagePath) + } + + public var dashboardURL: URL { + switch self { + case .global: + URL(string: "https://z.ai/manage-apikey/coding-plan/personal/my-plan")! + case .bigmodelCN: + URL(string: "https://bigmodel.cn/coding-plan/personal/usage")! + } + } } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index 430066a109..43554d4ea6 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -1,9 +1,8 @@ -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum ZaiProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .zai, @@ -12,8 +11,8 @@ public enum ZaiProviderDescriptor { displayName: "z.ai", sessionLabel: "Tokens", weeklyLabel: "MCP", - opusLabel: nil, - supportsOpus: false, + opusLabel: "5-hour", + supportsOpus: true, supportsCredits: false, creditsHint: "", toggleTitle: "Show z.ai usage", @@ -21,7 +20,7 @@ public enum ZaiProviderDescriptor { defaultEnabled: false, isPrimaryProvider: false, usesAccountFallback: false, - dashboardURL: "https://z.ai/manage-apikey/subscription", + dashboardURL: ZaiAPIRegion.global.dashboardURL.absoluteString, statusPageURL: nil), branding: ProviderBranding( iconStyle: .zai, @@ -30,43 +29,20 @@ public enum ZaiProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "z.ai cost summary is not supported." }), - fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .api], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ZaiAPIFetchStrategy()] })), + fetchPlan: .apiToken( + strategyID: "zai.api", + resolveToken: { ProviderTokenResolver.zaiToken(environment: $0) }, + missingCredentialsError: { ZaiSettingsError.missingToken }, + loadUsage: { apiKey, context in + let region = context.settings?.zai?.apiRegion ?? .global + return try await ZaiUsageFetcher.fetchUsageWithModelUsage( + apiKey: apiKey, + region: region, + environment: context.env).toUsageSnapshot() + }), cli: ProviderCLIConfig( name: "zai", aliases: ["z.ai"], versionDetector: nil)) } } - -struct ZaiAPIFetchStrategy: ProviderFetchStrategy { - let id: String = "zai.api" - let kind: ProviderFetchKind = .apiToken - - func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil - } - - func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let apiKey = Self.resolveToken(environment: context.env) else { - throw ZaiSettingsError.missingToken - } - let region = context.settings?.zai?.apiRegion ?? .global - let usage = try await ZaiUsageFetcher.fetchUsage( - apiKey: apiKey, - region: region, - environment: context.env) - return self.makeResult( - usage: usage.toUsageSnapshot(), - sourceLabel: "api") - } - - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false - } - - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.zaiToken(environment: environment) - } -} diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift index 4df2223e36..ad385f59e5 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift @@ -24,10 +24,36 @@ public struct ZaiSettingsReader: Sendable { environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { guard let raw = self.cleaned(environment[quotaURLKey]) else { return nil } - if let url = URL(string: raw), url.scheme != nil { - return url + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) + } + + public static func validateEndpointOverrides( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + try self.validateQuotaEndpointOverride(environment: environment) + try self.validateAPIHostEndpointOverride(environment: environment) + } + + public static func validateQuotaEndpointOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + if let raw = self.cleaned(environment[self.quotaURLKey]) { + guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) != nil else { + throw ZaiSettingsError.invalidEndpointOverride(self.quotaURLKey) + } + return + } + + try self.validateAPIHostEndpointOverride(environment: environment) + } + + public static func validateAPIHostEndpointOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) throws + { + guard let raw = self.cleaned(environment[self.apiHostKey]) else { return } + guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw) != nil else { + throw ZaiSettingsError.invalidEndpointOverride(self.apiHostKey) } - return URL(string: "https://\(raw)") } static func cleaned(_ raw: String?) -> String? { @@ -38,8 +64,7 @@ public struct ZaiSettingsReader: Sendable { if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { - value.removeFirst() - value.removeLast() + value = String(value.dropFirst().dropLast()) } value = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -47,13 +72,16 @@ public struct ZaiSettingsReader: Sendable { } } -public enum ZaiSettingsError: LocalizedError, Sendable { +public enum ZaiSettingsError: LocalizedError, Sendable, Equatable { case missingToken + case invalidEndpointOverride(String) public var errorDescription: String? { switch self { case .missingToken: "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." + case let .invalidEndpointOverride(key): + "z.ai endpoint override \(key) must use HTTPS or a bare host." } } } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 1592a61819..c21d105d4d 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -15,6 +15,7 @@ public enum ZaiLimitUnit: Int, Sendable { case days = 1 case hours = 3 case minutes = 5 + case weeks = 6 } /// A single limit entry from the z.ai API @@ -69,6 +70,8 @@ extension ZaiLimitEntry { return self.number * 60 case .days: return self.number * 24 * 60 + case .weeks: + return self.number * 7 * 24 * 60 case .unknown: return nil } @@ -80,6 +83,7 @@ extension ZaiLimitEntry { case .minutes: "minute" case .hours: "hour" case .days: "day" + case .weeks: "week" case .unknown: nil } guard let unitLabel else { return nil } @@ -92,6 +96,10 @@ extension ZaiLimitEntry { return "\(description) window" } + var isMCPMonthlyMarker: Bool { + self.type == .timeLimit && self.unit == .minutes && self.number == 1 + } + private var computedUsedPercent: Double? { guard let limit = self.usage, limit > 0 else { return nil } @@ -129,14 +137,26 @@ public struct ZaiUsageDetail: Sendable, Codable { /// Complete z.ai usage response public struct ZaiUsageSnapshot: Sendable { public let tokenLimit: ZaiLimitEntry? + /// Shorter-window TOKENS_LIMIT (e.g. 5-hour), present only when the API returns two TOKENS_LIMIT entries. + public let sessionTokenLimit: ZaiLimitEntry? public let timeLimit: ZaiLimitEntry? public let planName: String? + public let modelUsage: ZaiModelUsageData? public let updatedAt: Date - public init(tokenLimit: ZaiLimitEntry?, timeLimit: ZaiLimitEntry?, planName: String?, updatedAt: Date) { + public init( + tokenLimit: ZaiLimitEntry?, + sessionTokenLimit: ZaiLimitEntry? = nil, + timeLimit: ZaiLimitEntry?, + planName: String?, + modelUsage: ZaiModelUsageData? = nil, + updatedAt: Date) + { self.tokenLimit = tokenLimit + self.sessionTokenLimit = sessionTokenLimit self.timeLimit = timeLimit self.planName = planName + self.modelUsage = modelUsage self.updatedAt = updatedAt } @@ -150,13 +170,13 @@ extension ZaiUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { let primaryLimit = self.tokenLimit ?? self.timeLimit let secondaryLimit = (self.tokenLimit != nil && self.timeLimit != nil) ? self.timeLimit : nil - let primary = primaryLimit.map { Self.rateWindow(for: $0) } ?? RateWindow( usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = secondaryLimit.map { Self.rateWindow(for: $0) } + let tertiary = self.sessionTokenLimit.map { Self.rateWindow(for: $0) } let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -168,7 +188,7 @@ extension ZaiUsageSnapshot { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: nil, + tertiary: tertiary, providerCost: nil, zaiUsage: self, updatedAt: self.updatedAt, @@ -184,6 +204,9 @@ extension ZaiUsageSnapshot { } private static func resetDescription(for limit: ZaiLimitEntry) -> String? { + if limit.isMCPMonthlyMarker { + return "Monthly" + } if let label = limit.windowLabel { return label } @@ -286,6 +309,21 @@ public struct ZaiUsageFetcher: Sendable { return region.quotaLimitURL } + /// Resolves the canonical dashboard for the effective quota endpoint without opening custom override hosts. + public static func resolveDashboardURL( + region: ZaiAPIRegion, + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + let quotaHost = self.resolveQuotaURL(region: region, environment: environment).host?.lowercased() + if quotaHost == ZaiAPIRegion.global.quotaLimitURL.host?.lowercased() { + return ZaiAPIRegion.global.dashboardURL + } + if quotaHost == ZaiAPIRegion.bigmodelCN.quotaLimitURL.host?.lowercased() { + return ZaiAPIRegion.bigmodelCN.dashboardURL + } + return region.dashboardURL + } + /// Fetches usage stats from z.ai using the provided API key public static func fetchUsage( apiKey: String, @@ -295,6 +333,7 @@ public struct ZaiUsageFetcher: Sendable { guard !apiKey.isEmpty else { throw ZaiUsageError.invalidCredentials } + try ZaiSettingsReader.validateQuotaEndpointOverride(environment: environment) let quotaURL = self.resolveQuotaURL(region: region, environment: environment) @@ -303,16 +342,12 @@ public struct ZaiUsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization") request.setValue("application/json", forHTTPHeaderField: "accept") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw ZaiUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" - Self.log.error("z.ai API returned \(httpResponse.statusCode): \(errorMessage)") - throw ZaiUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)") + Self.log.error("z.ai API returned \(response.statusCode): \(errorMessage)") + throw ZaiUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)") } // Some upstream issues (wrong endpoint/region/proxy) can yield HTTP 200 with an empty body. @@ -364,37 +399,54 @@ public struct ZaiUsageFetcher: Sendable { throw ZaiUsageError.parseFailed("Missing data") } - var tokenLimit: ZaiLimitEntry? + var tokenLimits: [ZaiLimitEntry] = [] var timeLimit: ZaiLimitEntry? for limit in responseData.limits { if let entry = limit.toLimitEntry() { switch entry.type { case .tokensLimit: - tokenLimit = entry + tokenLimits.append(entry) case .timeLimit: timeLimit = entry } } } + // Multiple TOKENS_LIMIT entries: shortest window → sessionTokenLimit (tertiary), + // longest → tokenLimit (primary). + let tokenLimit: ZaiLimitEntry? + let sessionTokenLimit: ZaiLimitEntry? + if tokenLimits.count >= 2 { + let sorted = tokenLimits.sorted { + ($0.windowMinutes ?? Int.max) < ($1.windowMinutes ?? Int.max) + } + sessionTokenLimit = sorted.first + tokenLimit = sorted.last + } else { + tokenLimit = tokenLimits.first + sessionTokenLimit = nil + } + return ZaiUsageSnapshot( tokenLimit: tokenLimit, + sessionTokenLimit: sessionTokenLimit, timeLimit: timeLimit, planName: responseData.planName, + modelUsage: nil, updatedAt: Date()) } private static func quotaURL(baseURLString: String) -> URL? { guard let cleaned = ZaiSettingsReader.cleaned(baseURLString) else { return nil } - if let url = URL(string: cleaned), url.scheme != nil { + if let url = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: cleaned) { if url.path.isEmpty || url.path == "/" { return url.appendingPathComponent(Self.quotaAPIPath) } return url } - guard let base = URL(string: "https://\(cleaned)") else { return nil } + guard let base = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: cleaned) else { return nil } if base.path.isEmpty || base.path == "/" { return base.appendingPathComponent(Self.quotaAPIPath) } @@ -402,6 +454,281 @@ public struct ZaiUsageFetcher: Sendable { } } +// MARK: - Model Usage Data + +/// Per-model hourly token usage from the z.ai model-usage API +public struct ZaiModelUsageData: Sendable { + public let xTime: [String] + public let modelDataList: [ZaiModelDataItem] + + public init(xTime: [String], modelDataList: [ZaiModelDataItem]) { + self.xTime = xTime + self.modelDataList = modelDataList + } + + public var modelNames: [String] { + self.modelDataList.compactMap(\.modelName) + } +} + +public struct ZaiModelDataItem: Sendable { + public let modelName: String? + public let tokensUsage: [Int?] + + public init(modelName: String?, tokensUsage: [Int?]) { + self.modelName = modelName + self.tokensUsage = tokensUsage + } +} + +// MARK: - Hourly Chart Data + +public enum ZaiHourlyRange: Equatable, Sendable { + case today(referenceDate: Date) + case last24h + + public var isToday: Bool { + if case .today = self { return true } + return false + } +} + +public struct ZaiHourlyBar: Sendable { + public let label: String + public let segments: [(model: String, tokens: Int)] + + public init(label: String, segments: [(model: String, tokens: Int)]) { + self.label = label + self.segments = segments + } + + public var totalTokens: Int { + self.segments.reduce(0) { $0 + $1.tokens } + } +} + +public enum ZaiHourlyBars: Sendable { + public static func from(modelData: ZaiModelUsageData, range: ZaiHourlyRange, now: Date = Date()) -> [ZaiHourlyBar] { + let calendar = Calendar.current + let referenceDate: Date = switch range { + case let .today(ref): ref + case .last24h: now + } + + let todayStart = calendar.startOfDay(for: referenceDate) + let cutoff: Date = switch range { + case .today: todayStart + case .last24h: calendar.date(byAdding: .hour, value: -24, to: now) ?? now + } + + var bars: [ZaiHourlyBar] = [] + for (index, timeString) in modelData.xTime.enumerated() { + guard let hourDate = parseHourDate(timeString) else { continue } + + if hourDate < cutoff { continue } + + var segments: [(model: String, tokens: Int)] = [] + for item in modelData.modelDataList { + guard index < item.tokensUsage.count, + let tokenCount = item.tokensUsage[index], tokenCount > 0 + else { continue } + segments.append((model: item.modelName ?? "Unknown", tokens: tokenCount)) + } + + let total = segments.reduce(0) { $0 + $1.tokens } + guard total > 0 else { continue } + + let label = self.formatHourLabel(hourDate: hourDate) + bars.append(ZaiHourlyBar(label: label, segments: segments)) + } + + return bars + } + + public static func parseHourDate(_ string: String) -> Date? { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.date(from: string) + } + + private static func formatHourLabel(hourDate: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: hourDate) + } +} + +// MARK: - Model Usage Fetcher Extension + +extension ZaiUsageFetcher { + /// Fetches hourly model usage data for the last 24 hours + public static func fetchModelUsage( + apiKey: String, + region: ZaiAPIRegion = .global, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ZaiModelUsageData + { + guard !apiKey.isEmpty else { + throw ZaiUsageError.invalidCredentials + } + try ZaiSettingsReader.validateAPIHostEndpointOverride(environment: environment) + + let baseURL: URL = if let host = ZaiSettingsReader.apiHost(environment: environment), + let resolved = Self.modelUsageURL(baseURLString: host) + { + resolved + } else { + region.modelUsageURL + } + + let now = Date() + let calendar = Calendar.current + guard let startDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now)) else { + throw ZaiUsageError.parseFailed("Invalid date calculation") + } + + let startComponents = calendar.dateComponents([.year, .month, .day, .hour], from: startDate) + let endComponents = calendar.dateComponents([.year, .month, .day, .hour], from: now) + let startTime = String( + format: "%04d-%02d-%02d %02d:00:00", + startComponents.year!, + startComponents.month!, + startComponents.day!, + startComponents.hour!) + let endTime = String( + format: "%04d-%02d-%02d %02d:59:59", + endComponents.year!, + endComponents.month!, + endComponents.day!, + endComponents.hour!) + + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + throw ZaiUsageError.networkError("Invalid URL") + } + components.queryItems = [ + URLQueryItem(name: "startTime", value: startTime), + URLQueryItem(name: "endTime", value: endTime), + ] + + guard let requestURL = components.url else { + throw ZaiUsageError.networkError("Invalid URL") + } + + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + Self.log.error("z.ai model-usage API returned \(response.statusCode): \(errorMessage)") + throw ZaiUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)") + } + + guard !data.isEmpty else { return ZaiModelUsageData(xTime: [], modelDataList: []) } + + return try Self.parseModelUsage(from: data) + } + + static func parseModelUsage(from data: Data) throws -> ZaiModelUsageData { + let decoder = JSONDecoder() + let apiResponse = try decoder.decode(ZaiModelUsageAPIResponse.self, from: data) + + guard apiResponse.isSuccess else { + throw ZaiUsageError.apiError(apiResponse.msg) + } + + guard let responseData = apiResponse.data else { + return ZaiModelUsageData(xTime: [], modelDataList: []) + } + + let items = responseData.modelDataList?.map { raw in + ZaiModelDataItem( + modelName: raw.modelName, + tokensUsage: raw.tokensUsage ?? []) + } ?? [] + + return ZaiModelUsageData( + xTime: responseData.xTime ?? [], + modelDataList: items) + } + + /// Fetches required quota data and attaches optional model usage when available. + public static func fetchUsageWithModelUsage( + apiKey: String, + region: ZaiAPIRegion = .global, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ZaiUsageSnapshot + { + try ZaiSettingsReader.validateEndpointOverrides(environment: environment) + let snapshot = try await Self.fetchUsage(apiKey: apiKey, region: region, environment: environment) + let modelUsage: ZaiModelUsageData? + do { + modelUsage = try await Self.fetchModelUsage(apiKey: apiKey, region: region, environment: environment) + } catch { + Self.log.info("z.ai model usage fetch failed (non-fatal): \(error.localizedDescription)") + modelUsage = nil + } + + guard modelUsage != nil else { return snapshot } + + return ZaiUsageSnapshot( + tokenLimit: snapshot.tokenLimit, + sessionTokenLimit: snapshot.sessionTokenLimit, + timeLimit: snapshot.timeLimit, + planName: snapshot.planName, + modelUsage: modelUsage, + updatedAt: snapshot.updatedAt) + } + + private static func modelUsageURL(baseURLString: String) -> URL? { + guard let cleaned = ZaiSettingsReader.cleaned(baseURLString) else { return nil } + let path = "api/monitor/usage/model-usage" + + if let url = URL(string: cleaned), url.scheme != nil { + if url.path.isEmpty || url.path == "/" { + return url.appendingPathComponent(path) + } + return url + } + guard let base = URL(string: "https://\(cleaned)") else { return nil } + if base.path.isEmpty || base.path == "/" { + return base.appendingPathComponent(path) + } + return base + } +} + +// MARK: - Model Usage API Response (private) + +private struct ZaiModelUsageAPIResponse: Decodable { + let code: Int + let msg: String + let data: ZaiModelUsageRawData? + let success: Bool + + var isSuccess: Bool { + self.success && self.code == 200 + } +} + +private struct ZaiModelUsageRawData: Decodable { + let xTime: [String]? + let modelDataList: [ZaiModelDataItemRaw]? + + enum CodingKeys: String, CodingKey { + case xTime = "x_time" + case modelDataList + } +} + +private struct ZaiModelDataItemRaw: Decodable { + let modelName: String? + let tokensUsage: [Int?]? +} + /// Errors that can occur during z.ai usage fetching public enum ZaiUsageError: LocalizedError, Sendable { case invalidCredentials diff --git a/Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift new file mode 100644 index 0000000000..e6f56cb66e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift @@ -0,0 +1,60 @@ +import Foundation + +public enum ZedProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .zed, + metadata: ProviderMetadata( + id: .zed, + displayName: "Zed", + sessionLabel: "Edit predictions", + weeklyLabel: "Billing cycle", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Zed usage", + cliName: "zed", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: nil, + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .zed, + iconResourceName: "ProviderIcon-zed", + color: ProviderColor(red: 8 / 255, green: 78 / 255, blue: 255 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Zed cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [ZedLocalFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "zed", + versionDetector: nil)) + } +} + +struct ZedLocalFetchStrategy: ProviderFetchStrategy { + let id: String = "zed.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + _ = context + let snapshot = try await ZedStatusProbe().fetch() + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "local") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift b/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift new file mode 100644 index 0000000000..4527c992da --- /dev/null +++ b/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift @@ -0,0 +1,554 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - Models + +public struct ZedAuthenticatedUserResponse: Decodable, Equatable, Sendable { + public let user: ZedAuthenticatedUser + public let plan: ZedPlanInfo + + public init(user: ZedAuthenticatedUser, plan: ZedPlanInfo) { + self.user = user + self.plan = plan + } +} + +public struct ZedAuthenticatedUser: Decodable, Equatable, Sendable { + public let id: Int + public let githubLogin: String + public let name: String? + + enum CodingKeys: String, CodingKey { + case id + case githubLogin = "github_login" + case name + } +} + +public struct ZedPlanInfo: Decodable, Equatable, Sendable { + public let planV3: String + public let subscriptionPeriod: ZedSubscriptionPeriod? + public let usage: ZedCurrentUsage + public let hasOverdueInvoices: Bool + + enum CodingKeys: String, CodingKey { + case planV3 = "plan_v3" + case subscriptionPeriod = "subscription_period" + case usage + case hasOverdueInvoices = "has_overdue_invoices" + } +} + +public struct ZedSubscriptionPeriod: Decodable, Equatable, Sendable { + public let startedAt: Date + public let endedAt: Date + + enum CodingKeys: String, CodingKey { + case startedAt = "started_at" + case endedAt = "ended_at" + } +} + +public struct ZedCurrentUsage: Decodable, Equatable, Sendable { + public let editPredictions: ZedUsageData + + enum CodingKeys: String, CodingKey { + case editPredictions = "edit_predictions" + } +} + +public struct ZedUsageData: Decodable, Equatable, Sendable { + public let used: Int + public let limit: ZedUsageLimit +} + +public enum ZedUsageLimit: Equatable, Sendable { + case limited(Int) + case unlimited +} + +extension ZedUsageLimit: Decodable { + public init(from decoder: Decoder) throws { + if let single = try? decoder.singleValueContainer() { + if let string = try? single.decode(String.self), string == "unlimited" { + self = .unlimited + return + } + if let value = try? single.decode(Int.self) { + self = .limited(value) + return + } + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + if let value = try container.decodeIfPresent(Int.self, forKey: .limited) { + self = .limited(value) + return + } + + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unrecognized Zed usage limit")) + } + + private enum CodingKeys: String, CodingKey { + case limited + } +} + +public struct ZedCredentials: Equatable, Sendable { + public let userID: String + public let accessToken: String + + public init(userID: String, accessToken: String) { + self.userID = userID + self.accessToken = accessToken + } + + public var authorizationHeader: String { + "\(self.userID) \(self.accessToken)" + } +} + +public struct ZedUsageSnapshot: Sendable, Equatable { + public let response: ZedAuthenticatedUserResponse + public let updatedAt: Date + + public init(response: ZedAuthenticatedUserResponse, updatedAt: Date = Date()) { + self.response = response + self.updatedAt = updatedAt + } +} + +// MARK: - Errors + +public enum ZedStatusProbeError: LocalizedError, Sendable, Equatable { + case notSupported + case notSignedIn + case keychainUnavailable + case invalidServerURL(String) + case untrustedServerConfiguration + case networkError(String) + case httpError(Int) + case unauthorized + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .notSupported: + "Zed is only supported on macOS." + case .notSignedIn: + "Not signed in to Zed. Sign in from the Zed editor app with GitHub." + case .keychainUnavailable: + "Could not read Zed credentials from the Keychain. Grant CodexBar Keychain access or sign in to Zed again." + case let .invalidServerURL(value): + "Zed server URL is invalid: \(value)" + case .untrustedServerConfiguration: + "Zed custom servers must use HTTPS and store credentials under the same server URL." + case let .networkError(message): + "Zed cloud API request failed: \(message)" + case let .httpError(status): + "Zed cloud API returned HTTP \(status)." + case .unauthorized: + "Zed credentials are invalid or expired. Sign in to Zed again." + case let .parseFailed(message): + "Could not parse Zed account response: \(message)" + } + } +} + +// MARK: - Settings + +public struct ZedClientSettings: Sendable, Equatable { + public let credentialsURL: String? + public let serverURL: String? + + public init(credentialsURL: String?, serverURL: String?) { + self.credentialsURL = credentialsURL + self.serverURL = serverURL + } + + public var keychainServiceURL: String { + let trimmedCredentials = self.credentialsURL?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedCredentials, !trimmedCredentials.isEmpty { + return trimmedCredentials + } + let trimmedServer = self.serverURL?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedServer, !trimmedServer.isEmpty { + return trimmedServer + } + return ZedStatusProbe.defaultKeychainServiceURL + } + + public var cloudAPIURL: URL? { + let trimmedServer = self.serverURL?.trimmingCharacters(in: .whitespacesAndNewlines) + let server = if let trimmedServer, !trimmedServer.isEmpty { + trimmedServer + } else { + ZedStatusProbe.defaultKeychainServiceURL + } + let isTrustedZedServer = server == "https://zed.dev" || server == "https://staging.zed.dev" + let trimmedCredentials = self.credentialsURL?.trimmingCharacters(in: .whitespacesAndNewlines) + if !isTrustedZedServer, + let trimmedCredentials, + !trimmedCredentials.isEmpty, + trimmedCredentials != server + { + return nil + } + let cloudBase = switch server { + case "https://zed.dev", "https://staging.zed.dev": + "https://cloud.zed.dev" + default: + server + } + guard let baseURL = URL(string: cloudBase), + let scheme = baseURL.scheme?.lowercased(), + scheme == "https", + baseURL.host != nil + else { + return nil + } + return baseURL.appendingPathComponent("client/users/me") + } + + public static func load(from url: URL = ZedStatusProbe.defaultSettingsURL) -> ZedClientSettings? { + guard let data = try? Data(contentsOf: url) else { return nil } + struct Payload: Decodable { + let credentialsURL: String? + let serverURL: String? + + enum CodingKeys: String, CodingKey { + case credentialsURL = "credentials_url" + case serverURL = "server_url" + } + } + guard let payload = try? JSONDecoder().decode(Payload.self, from: data) else { return nil } + return ZedClientSettings( + credentialsURL: payload.credentialsURL, + serverURL: payload.serverURL) + } +} + +// MARK: - Credentials + +public protocol ZedCredentialsReading: Sendable { + func loadCredentials(serviceURL: String) throws -> ZedCredentials? +} + +#if os(macOS) +import Security + +public struct ZedKeychainCredentialsReader: ZedCredentialsReading, Sendable { + public init() {} + + public func loadCredentials(serviceURL: String) throws -> ZedCredentials? { + if let credentials = try self.loadInternetPasswordCredentials(server: serviceURL) { + return credentials + } + return try self.loadGenericPasswordCredentials(service: serviceURL) + } + + private func loadInternetPasswordCredentials(server: String) throws -> ZedCredentials? { + var query: [String: Any] = [ + kSecClass as String: kSecClassInternetPassword, + kSecAttrServer as String: server, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + return try self.credentials(from: query) + } + + private func loadGenericPasswordCredentials(service: String) throws -> ZedCredentials? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + return try self.credentials(from: query) + } + + private func credentials(from query: [String: Any]) throws -> ZedCredentials? { + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + break + case errSecItemNotFound: + return nil + case errSecInteractionNotAllowed, errSecAuthFailed, errSecNoAccessForItem: + throw ZedStatusProbeError.keychainUnavailable + default: + throw ZedStatusProbeError.keychainUnavailable + } + + guard let item = result as? [String: Any], + let account = item[kSecAttrAccount as String] as? String, + !account.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + + let tokenData: Data? = if let data = item[kSecValueData as String] as? Data { + data + } else { + nil + } + guard let tokenData, + let accessToken = String(data: tokenData, encoding: .utf8), + !accessToken.isEmpty + else { + return nil + } + + return ZedCredentials(userID: account, accessToken: accessToken) + } +} +#else +public struct ZedKeychainCredentialsReader: ZedCredentialsReading, Sendable { + public init() {} + + public func loadCredentials(serviceURL _: String) throws -> ZedCredentials? { + throw ZedStatusProbeError.notSupported + } +} +#endif + +// MARK: - Probe + +public struct ZedStatusProbe: Sendable { + public static let defaultKeychainServiceURL = "https://zed.dev" + public static let cloudAPIURL = URL(string: "https://cloud.zed.dev/client/users/me")! + + public static var defaultSettingsURL: URL { + let home = FileManager.default.homeDirectoryForCurrentUser + return home + .appendingPathComponent(".config/zed/settings.json") + } + + private static let logger = CodexBarLog.logger(LogCategories.zed) + + private let credentialsReader: any ZedCredentialsReading + private let transport: any ProviderHTTPTransport + private let settingsLoader: @Sendable () -> ZedClientSettings? + + public init( + credentialsReader: any ZedCredentialsReading = ZedKeychainCredentialsReader(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + settingsLoader: @escaping @Sendable () -> ZedClientSettings? = { ZedClientSettings.load() }) + { + self.credentialsReader = credentialsReader + self.transport = transport + self.settingsLoader = settingsLoader + } + + public func fetch() async throws -> ZedUsageSnapshot { + let settings = self.settingsLoader() + let serviceURL = settings?.keychainServiceURL ?? Self.defaultKeychainServiceURL + let cloudAPIURL: URL + if let settings { + guard let configuredURL = settings.cloudAPIURL else { + let serverURL = settings.serverURL ?? "" + guard URL(string: serverURL)?.scheme?.lowercased() == "https" else { + throw ZedStatusProbeError.invalidServerURL(serverURL) + } + throw ZedStatusProbeError.untrustedServerConfiguration + } + cloudAPIURL = configuredURL + } else { + cloudAPIURL = Self.cloudAPIURL + } + guard let credentials = try self.credentialsReader.loadCredentials(serviceURL: serviceURL) else { + throw ZedStatusProbeError.notSignedIn + } + + let response = try await self.fetchAuthenticatedUser(credentials: credentials, apiURL: cloudAPIURL) + return ZedUsageSnapshot(response: response) + } + + private func fetchAuthenticatedUser( + credentials: ZedCredentials, + apiURL: URL) async throws -> ZedAuthenticatedUserResponse + { + var request = URLRequest(url: apiURL) + request.httpMethod = "GET" + request.setValue(credentials.authorizationHeader, forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let httpResponse: ProviderHTTPResponse + do { + httpResponse = try await self.transport.response(for: request) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + Self.logger.debug("Zed cloud API transport failed: \(error.localizedDescription)") + throw ZedStatusProbeError.networkError(error.localizedDescription) + } + + switch httpResponse.statusCode { + case 200: + return try Self.parseResponse(httpResponse.data) + case 401, 403: + throw ZedStatusProbeError.unauthorized + default: + throw ZedStatusProbeError.httpError(httpResponse.statusCode) + } + } + + public static func parseResponse(_ data: Data) throws -> ZedAuthenticatedUserResponse { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + if let date = Self.parseISO8601Date(value) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ISO8601 date: \(value)") + } + do { + return try decoder.decode(ZedAuthenticatedUserResponse.self, from: data) + } catch { + throw ZedStatusProbeError.parseFailed(error.localizedDescription) + } + } + + private static func parseISO8601Date(_ value: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: value) { + return date + } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: value) + } +} + +// MARK: - UsageSnapshot mapping + +extension ZedUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + let plan = self.response.plan + let user = self.response.user + + let primary = Self.makeEditPredictionsWindow( + used: plan.usage.editPredictions.used, + limit: plan.usage.editPredictions.limit) + + let secondary = plan.subscriptionPeriod.map { period in + RateWindow( + usedPercent: Self.billingCycleUsedPercent(startedAt: period.startedAt, endedAt: period.endedAt), + windowMinutes: nil, + resetsAt: period.endedAt, + resetDescription: Self.formatResetDescription(period.endedAt)) + } + + var extraRateWindows: [NamedRateWindow] = [] + if plan.hasOverdueInvoices { + extraRateWindows.append(NamedRateWindow( + id: "zed.overdue-invoices", + title: "Billing", + window: RateWindow( + usedPercent: 100, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Overdue invoices"), + usageKnown: false)) + } + + let identity = ProviderIdentitySnapshot( + providerID: .zed, + accountEmail: user.githubLogin.nilIfEmpty, + accountOrganization: user.name?.nilIfEmpty, + loginMethod: Self.displayPlanName(plan.planV3)) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + extraRateWindows: extraRateWindows.isEmpty ? nil : extraRateWindows, + subscriptionRenewsAt: plan.subscriptionPeriod?.endedAt, + updatedAt: self.updatedAt, + identity: identity) + } + + private static func makeEditPredictionsWindow(used: Int, limit: ZedUsageLimit) -> RateWindow? { + switch limit { + case .unlimited: + return RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Unlimited") + case let .limited(total): + guard total > 0 else { return nil } + let clampedUsed = max(0, min(total, used)) + let usedPercent = Double(clampedUsed) / Double(total) * 100.0 + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(clampedUsed) / \(total) predictions") + } + } + + public static func displayPlanName(_ rawPlan: String) -> String { + switch rawPlan.lowercased() { + case "zed_free": "Zed Free" + case "zed_pro": "Zed Pro" + case "zed_pro_trial": "Zed Pro Trial" + case "zed_student": "Zed Student" + case "zed_business": "Zed Business" + default: + rawPlan + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { word in + word.prefix(1).uppercased() + word.dropFirst().lowercased() + } + .joined(separator: " ") + } + } + + private static func billingCycleUsedPercent(startedAt: Date, endedAt: Date) -> Double { + let now = Date() + let total = endedAt.timeIntervalSince(startedAt) + guard total > 0 else { return 0 } + let elapsed = now.timeIntervalSince(startedAt) + return max(0, min(100, elapsed / total * 100)) + } + + private static func formatResetDescription(_ date: Date) -> String? { + let now = Date() + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "Cycle ended" } + + let hours = Int(interval / 3600) + let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) + + if hours > 24 { + let days = hours / 24 + let remainingHours = hours % 24 + return "Cycle ends in \(days)d \(remainingHours)h" + } else if hours > 0 { + return "Cycle ends in \(hours)h \(minutes)m" + } else { + return "Cycle ends in \(minutes)m" + } + } +} + +extension String { + fileprivate var nilIfEmpty: String? { + self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index 378ad39392..37ec660a97 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -12,6 +12,7 @@ public struct TokenAccountSupport: Sendable { public let injection: TokenAccountInjection public let requiresManualCookieSource: Bool public let cookieName: String? + public let environmentKeysToScrub: [String] public init( title: String, @@ -19,7 +20,8 @@ public struct TokenAccountSupport: Sendable { placeholder: String, injection: TokenAccountInjection, requiresManualCookieSource: Bool, - cookieName: String?) + cookieName: String?, + environmentKeysToScrub: [String] = []) { self.title = title self.subtitle = subtitle @@ -27,6 +29,7 @@ public struct TokenAccountSupport: Sendable { self.injection = injection self.requiresManualCookieSource = requiresManualCookieSource self.cookieName = cookieName + self.environmentKeysToScrub = environmentKeysToScrub } } @@ -42,15 +45,42 @@ public enum TokenAccountSupportCatalog { return [key: token] case .cookieHeader: if provider == .claude, - let normalized = self.normalizedClaudeOAuthToken(token), - self.isClaudeOAuthToken(normalized) + case let route = ClaudeCredentialRouting.resolve(tokenAccountToken: token, manualCookieHeader: nil) { - return [ClaudeOAuthCredentialsStore.environmentTokenKey: normalized] + switch route { + case let .oauth(accessToken): + return [ClaudeOAuthCredentialsStore.environmentTokenKey: accessToken] + case let .adminAPIKey(apiKey): + return [ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey: apiKey] + case .none, .webCookie: + break + } } return nil } } + public static func scrubEnvironmentForSelectedAccount( + _ environment: inout [String: String], + provider: UsageProvider, + token _: String) + { + guard let support = self.support(for: provider) else { return } + switch support.injection { + case let .environment(key): + environment.removeValue(forKey: key) + for key in support.environmentKeysToScrub { + environment.removeValue(forKey: key) + } + case .cookieHeader: + guard provider == .claude else { return } + environment.removeValue(forKey: ClaudeOAuthCredentialsStore.environmentTokenKey) + for key in ClaudeAdminAPISettingsReader.apiKeyEnvironmentKeys { + environment.removeValue(forKey: key) + } + } + } + public static func normalizedCookieHeader(for provider: UsageProvider, token: String) -> String { guard let support = self.support(for: provider) else { return token.trimmingCharacters(in: .whitespacesAndNewlines) @@ -59,23 +89,7 @@ public enum TokenAccountSupportCatalog { } public static func isClaudeOAuthToken(_ token: String) -> Bool { - guard let trimmed = self.normalizedClaudeOAuthToken(token) else { return false } - let lower = trimmed.lowercased() - if lower.contains("cookie:") || trimmed.contains("=") { - return false - } - return lower.hasPrefix("sk-ant-oat") - } - - private static func normalizedClaudeOAuthToken(_ token: String) -> String? { - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let lower = trimmed.lowercased() - if lower.hasPrefix("bearer ") { - return trimmed.dropFirst("bearer ".count) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - return trimmed + ClaudeCredentialRouting.resolve(tokenAccountToken: token, manualCookieHeader: nil).isOAuth } public static func normalizedCookieHeader(_ token: String, support: TokenAccountSupport) -> String { diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 2a1d0f1d46..4db3a50bca 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -2,13 +2,35 @@ import Foundation extension TokenAccountSupportCatalog { static let supportByProvider: [UsageProvider: TokenAccountSupport] = [ + .openai: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple OpenAI API keys.", + placeholder: "sk-admin-...", + injection: .environment(key: OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil, + environmentKeysToScrub: [OpenAIAPISettingsReader.projectIDEnvironmentKey]), .claude: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store Claude sessionKey cookies or OAuth access tokens.", - placeholder: "Paste sessionKey or OAuth token…", + title: "Claude credentials", + subtitle: "Store Claude sessionKey cookies, OAuth tokens, or Anthropic Admin API keys.", + placeholder: "Paste sessionKey, OAuth token, or sk-ant-admin…", injection: .cookieHeader, requiresManualCookieSource: true, cookieName: "sessionKey"), + .deepseek: TokenAccountSupport( + title: "API tokens", + subtitle: "Store multiple DeepSeek API keys.", + placeholder: "Paste API key…", + injection: .environment(key: DeepSeekSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .antigravity: TokenAccountSupport( + title: "Google accounts", + subtitle: "Store multiple Antigravity Google OAuth accounts for quick switching.", + placeholder: "Antigravity OAuth credentials JSON", + injection: .environment(key: AntigravityOAuthCredentialsStore.environmentCredentialsKey), + requiresManualCookieSource: false, + cookieName: nil), .zai: TokenAccountSupport( title: "API tokens", subtitle: "Stored in the CodexBar config file.", @@ -30,13 +52,20 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), - .factory: TokenAccountSupport( + .opencodego: TokenAccountSupport( title: "Session tokens", - subtitle: "Store multiple Factory Cookie headers.", + subtitle: "Store multiple OpenCode Go Cookie headers.", placeholder: "Cookie: …", injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .factory: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Factory Cookie or Authorization headers.", + placeholder: "Cookie: … or Authorization: Bearer …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), .minimax: TokenAccountSupport( title: "Session tokens", subtitle: "Store multiple MiniMax Cookie headers.", @@ -44,6 +73,13 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .manus: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Manus session_id cookies.", + placeholder: "session_id=…", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: "session_id"), .augment: TokenAccountSupport( title: "Session tokens", subtitle: "Store multiple Augment Cookie headers.", @@ -58,5 +94,68 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .abacus: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Abacus AI Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), + .mistral: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Mistral Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), + .copilot: TokenAccountSupport( + title: "GitHub accounts", + subtitle: "Sign in with multiple GitHub accounts via OAuth.", + placeholder: "Paste GitHub token…", + injection: .environment(key: "COPILOT_API_TOKEN"), + requiresManualCookieSource: false, + cookieName: nil), + .venice: TokenAccountSupport( + title: "API tokens", + subtitle: "Store multiple Venice API keys.", + placeholder: "Paste API key…", + injection: .environment(key: VeniceSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .elevenlabs: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple ElevenLabs API keys.", + placeholder: "Paste API key…", + injection: .environment(key: ElevenLabsSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .groq: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple Groq API keys.", + placeholder: "Paste Groq API key…", + injection: .environment(key: GroqSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .llmproxy: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple LLM Proxy API keys.", + placeholder: "Paste proxy API key…", + injection: .environment(key: LLMProxySettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .litellm: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple LiteLLM API keys.", + placeholder: "Paste LiteLLM API key…", + injection: .environment(key: LiteLLMSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .stepfun: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple StepFun Oasis-Token values.", + placeholder: "Oasis-Token=…", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), ] } diff --git a/Sources/CodexBarCore/TokenAccounts.swift b/Sources/CodexBarCore/TokenAccounts.swift index 519386aec1..73110d28de 100644 --- a/Sources/CodexBarCore/TokenAccounts.swift +++ b/Sources/CodexBarCore/TokenAccounts.swift @@ -6,18 +6,53 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { public let token: String public let addedAt: TimeInterval public let lastUsed: TimeInterval? + /// Stable provider-specific identity (e.g. GitHub `login`) used for + /// re-auth deduplication. Optional so legacy accounts keep working. + public let externalIdentifier: String? + /// Optional provider-specific organization/workspace target. Claude web + /// sessionKey accounts use this to disambiguate linked Anthropic emails. + public let organizationID: String? - public init(id: UUID, label: String, token: String, addedAt: TimeInterval, lastUsed: TimeInterval?) { + enum CodingKeys: String, CodingKey { + case id + case label + case token + case addedAt + case lastUsed + case externalIdentifier + case organizationID = "organizationId" + } + + public init( + id: UUID, + label: String, + token: String, + addedAt: TimeInterval, + lastUsed: TimeInterval?, + externalIdentifier: String? = nil, + organizationID: String? = nil) + { self.id = id self.label = label self.token = token self.addedAt = addedAt self.lastUsed = lastUsed + self.externalIdentifier = externalIdentifier + self.organizationID = organizationID } public var displayName: String { self.label } + + public var sanitizedOrganizationID: String? { + Self.clean(self.organizationID) + } + + private static func clean(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty ?? true) ? nil : trimmed + } } public struct ProviderTokenAccountData: Codable, Sendable { diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index f2370e9cac..ed82715c57 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -6,17 +6,87 @@ public struct RateWindow: Codable, Equatable, Sendable { public let resetsAt: Date? /// Optional textual reset description (used by Claude CLI UI scrape). public let resetDescription: String? + /// Optional percent restored on the next regeneration tick for providers with rolling recovery. + public let nextRegenPercent: Double? - public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, resetDescription: String?) { + public init( + usedPercent: Double, + windowMinutes: Int?, + resetsAt: Date?, + resetDescription: String?, + nextRegenPercent: Double? = nil) + { self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.nextRegenPercent = nextRegenPercent } public var remainingPercent: Double { max(0, 100 - self.usedPercent) } + + public func backfillingResetTime(from cached: RateWindow?, now: Date = .init()) -> RateWindow { + if self.resetsAt != nil { return self } + guard let cachedReset = cached?.resetsAt, cachedReset > now else { return self } + let windowMinutes = if let windowMinutes = self.windowMinutes, windowMinutes > 0 { + windowMinutes + } else { + cached?.windowMinutes + } + return RateWindow( + usedPercent: self.usedPercent, + windowMinutes: windowMinutes, + resetsAt: cachedReset, + resetDescription: self.resetDescription ?? cached?.resetDescription, + nextRegenPercent: self.nextRegenPercent) + } +} + +public struct NamedRateWindow: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let window: RateWindow + /// Whether `window.usedPercent` reflects known quota usage. + /// + /// Some providers expose reset metadata for a named quota window before + /// they expose remaining usage. Keep those windows visible for reset/debug + /// context, but mark them so clients do not render `usedPercent` as a real + /// exhausted quota. Missing values decode as `true` for older cached payloads. + public let usageKnown: Bool + + public init(id: String, title: String, window: RateWindow, usageKnown: Bool = true) { + self.id = id + self.title = title + self.window = window + self.usageKnown = usageKnown + } + + private enum CodingKeys: String, CodingKey { + case id + case title + case window + case usageKnown + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.title = try container.decode(String.self, forKey: .title) + self.window = try container.decode(RateWindow.self, forKey: .window) + self.usageKnown = try container.decodeIfPresent(Bool.self, forKey: .usageKnown) ?? true + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.title, forKey: .title) + try container.encode(self.window, forKey: .window) + if !self.usageKnown { + try container.encode(false, forKey: .usageKnown) + } + } } public struct ProviderIdentitySnapshot: Codable, Sendable { @@ -47,26 +117,66 @@ public struct ProviderIdentitySnapshot: Codable, Sendable { } } +public enum UsageDataConfidence: String, Codable, Equatable, Sendable { + case exact + case estimated + case percentOnly + case unknown +} + public struct UsageSnapshot: Codable, Sendable { public let primary: RateWindow? public let secondary: RateWindow? public let tertiary: RateWindow? + public let extraRateWindows: [NamedRateWindow]? public let providerCost: ProviderCostSnapshot? + public let kiroUsage: KiroUsageDetails? + public let ampUsage: AmpUsageDetails? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? + public let deepseekUsage: DeepSeekUsageSummary? + public let mimoUsage: MiMoUsageSnapshot? public let openRouterUsage: OpenRouterUsageSnapshot? + public let openAIAPIUsage: OpenAIAPIUsageSnapshot? + public let codexResetCredits: CodexRateLimitResetCreditsSnapshot? + public let claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? + public let mistralUsage: MistralUsageSnapshot? + public let deepgramUsage: DeepgramUsageSnapshot? + public let poeUsage: PoeUsageHistorySnapshot? public let cursorRequests: CursorRequestUsage? + /// Live-only marker for optional Command Code subscription lookup failure. + public let commandCodeSubscriptionEnrichmentUnavailable: Bool + /// Live-only marker that Command Code returned a recognized subscription plan. + public let commandCodeHasSubscriptionPlan: Bool + /// Live-only marker that Command Code's monthly grant has no remaining credits. + public let commandCodeMonthlyGrantDepleted: Bool + public let subscriptionExpiresAt: Date? + public let subscriptionRenewsAt: Date? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? + public let dataConfidence: UsageDataConfidence private enum CodingKeys: String, CodingKey { case primary case secondary case tertiary + case extraRateWindows case providerCost + case kiroUsage + case ampUsage + case mimoUsage case openRouterUsage + case openAIAPIUsage + case codexResetCredits + case claudeAdminAPIUsage + case mistralUsage + case deepgramUsage + case poeUsage + case subscriptionExpiresAt + case subscriptionRenewsAt case updatedAt case identity + case dataConfidence case accountEmail case accountOrganization case loginMethod @@ -76,24 +186,72 @@ public struct UsageSnapshot: Codable, Sendable { primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow? = nil, + extraRateWindows: [NamedRateWindow]? = nil, + kiroUsage: KiroUsageDetails? = nil, + ampUsage: AmpUsageDetails? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, + deepseekUsage: DeepSeekUsageSummary? = nil, + mimoUsage: MiMoUsageSnapshot? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, + openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, + codexResetCredits: CodexRateLimitResetCreditsSnapshot? = nil, + claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? = nil, + mistralUsage: MistralUsageSnapshot? = nil, + deepgramUsage: DeepgramUsageSnapshot? = nil, + poeUsage: PoeUsageHistorySnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, + commandCodeSubscriptionEnrichmentUnavailable: Bool = false, + commandCodeHasSubscriptionPlan: Bool = false, + commandCodeMonthlyGrantDepleted: Bool = false, + subscriptionExpiresAt: Date? = nil, + subscriptionRenewsAt: Date? = nil, updatedAt: Date, - identity: ProviderIdentitySnapshot? = nil) + identity: ProviderIdentitySnapshot? = nil, + dataConfidence: UsageDataConfidence = .unknown) { self.primary = primary self.secondary = secondary self.tertiary = tertiary + self.extraRateWindows = extraRateWindows + self.kiroUsage = kiroUsage + self.ampUsage = ampUsage self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage + self.deepseekUsage = deepseekUsage + self.mimoUsage = mimoUsage self.openRouterUsage = openRouterUsage + self.openAIAPIUsage = openAIAPIUsage + self.codexResetCredits = codexResetCredits + self.claudeAdminAPIUsage = claudeAdminAPIUsage + self.mistralUsage = mistralUsage + self.deepgramUsage = deepgramUsage + self.poeUsage = poeUsage self.cursorRequests = cursorRequests + self.commandCodeSubscriptionEnrichmentUnavailable = commandCodeSubscriptionEnrichmentUnavailable + self.commandCodeHasSubscriptionPlan = commandCodeHasSubscriptionPlan + self.commandCodeMonthlyGrantDepleted = commandCodeMonthlyGrantDepleted + self.subscriptionExpiresAt = subscriptionExpiresAt + self.subscriptionRenewsAt = subscriptionRenewsAt self.updatedAt = updatedAt self.identity = identity + self.dataConfidence = dataConfidence + } + + public func with(extraRateWindows: [NamedRateWindow]?) -> UsageSnapshot { + self.replacing(extraRateWindows: .value(extraRateWindows)) + } + + public func withCodexResetCredits(_ resetCredits: CodexRateLimitResetCreditsSnapshot?) -> UsageSnapshot { + self.replacing(codexResetCredits: .value(resetCredits)) + } + + public func with(primary: RateWindow?, secondary: RateWindow?) -> UsageSnapshot { + self.replacing( + primary: .value(primary), + secondary: .value(secondary)) } public init(from decoder: Decoder) throws { @@ -101,12 +259,37 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) self.secondary = try container.decodeIfPresent(RateWindow.self, forKey: .secondary) self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary) + self.extraRateWindows = try container.decodeIfPresent([NamedRateWindow].self, forKey: .extraRateWindows) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) + self.kiroUsage = try container.decodeIfPresent(KiroUsageDetails.self, forKey: .kiroUsage) + self.ampUsage = try container.decodeIfPresent(AmpUsageDetails.self, forKey: .ampUsage) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time + self.deepseekUsage = nil // Not persisted, fetched fresh each time + self.mimoUsage = try container.decodeIfPresent(MiMoUsageSnapshot.self, forKey: .mimoUsage) self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) + self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) + self.codexResetCredits = try container.decodeIfPresent( + CodexRateLimitResetCreditsSnapshot.self, + forKey: .codexResetCredits) + self.claudeAdminAPIUsage = try container.decodeIfPresent( + ClaudeAdminAPIUsageSnapshot.self, + forKey: .claudeAdminAPIUsage) + self.mistralUsage = try container.decodeIfPresent(MistralUsageSnapshot.self, forKey: .mistralUsage) + self.deepgramUsage = try container.decodeIfPresent(DeepgramUsageSnapshot.self, forKey: .deepgramUsage) + self.poeUsage = try container.decodeIfPresent(PoeUsageHistorySnapshot.self, forKey: .poeUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time + self.commandCodeSubscriptionEnrichmentUnavailable = false // Live-only fetch state + self.commandCodeHasSubscriptionPlan = false // Live-only fetch state + self.commandCodeMonthlyGrantDepleted = false // Live-only fetch state + self.subscriptionExpiresAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionExpiresAt) + self.subscriptionRenewsAt = try container.decodeIfPresent(Date.self, forKey: .subscriptionRenewsAt) self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) + if let dataConfidence = try container.decodeIfPresent(String.self, forKey: .dataConfidence) { + self.dataConfidence = UsageDataConfidence(rawValue: dataConfidence) ?? .unknown + } else { + self.dataConfidence = .unknown + } if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { self.identity = identity } else { @@ -131,10 +314,25 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.primary, forKey: .primary) try container.encode(self.secondary, forKey: .secondary) try container.encode(self.tertiary, forKey: .tertiary) + try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) + try container.encodeIfPresent(self.kiroUsage, forKey: .kiroUsage) + try container.encodeIfPresent(self.ampUsage, forKey: .ampUsage) + try container.encodeIfPresent(self.mimoUsage, forKey: .mimoUsage) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) + try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage) + try container.encodeIfPresent(self.codexResetCredits, forKey: .codexResetCredits) + try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage) + try container.encodeIfPresent(self.mistralUsage, forKey: .mistralUsage) + try container.encodeIfPresent(self.deepgramUsage, forKey: .deepgramUsage) + try container.encodeIfPresent(self.poeUsage, forKey: .poeUsage) + try container.encodeIfPresent(self.subscriptionExpiresAt, forKey: .subscriptionExpiresAt) + try container.encodeIfPresent(self.subscriptionRenewsAt, forKey: .subscriptionRenewsAt) try container.encode(self.updatedAt, forKey: .updatedAt) try container.encodeIfPresent(self.identity, forKey: .identity) + if self.dataConfidence != .unknown { + try container.encode(self.dataConfidence, forKey: .dataConfidence) + } try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail) try container.encodeIfPresent(self.identity?.accountOrganization, forKey: .accountOrganization) try container.encodeIfPresent(self.identity?.loginMethod, forKey: .loginMethod) @@ -145,20 +343,51 @@ public struct UsageSnapshot: Codable, Sendable { return identity } + public func automaticPerplexityWindow() -> RateWindow? { + let fallbackWindows = self.orderedPerplexityFallbackWindows() + guard let primary = self.primary else { + return fallbackWindows.first + } + if primary.remainingPercent > 0 || fallbackWindows.isEmpty { + return primary + } + return fallbackWindows.first + } + + public func orderedPerplexityDisplayWindows() -> [RateWindow] { + let fallbackWindows = self.orderedPerplexityFallbackWindows() + guard let primary = self.primary else { + return fallbackWindows + } + if primary.remainingPercent > 0 || fallbackWindows.isEmpty { + return [primary] + fallbackWindows + } + return fallbackWindows + [primary] + } + public func switcherWeeklyWindow(for provider: UsageProvider, showUsed: Bool) -> RateWindow? { switch provider { case .factory: // Factory prefers secondary window return self.secondary ?? self.primary + case .perplexity: + return self.automaticPerplexityWindow() case .cursor: - // Cursor: fall back to On-Demand when Plan is exhausted (only in "show remaining" mode). - // In "show used" mode, keep showing primary so 100% used Plan is visible. + // Cursor: fall back to on-demand budget when the included plan is exhausted (only in + // "show remaining" mode). The secondary/tertiary lanes are Total/Auto/API breakdowns, + // not extra capacity, so they should not replace the remaining paid quota indicator. if !showUsed, let primary = self.primary, primary.remainingPercent <= 0, - let secondary = self.secondary + let providerCost = self.providerCost, + providerCost.limit > 0 { - return secondary + let usedPercent = max(0, min(100, (providerCost.used / providerCost.limit) * 100)) + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: providerCost.resetsAt, + resetDescription: nil) } return self.primary ?? self.secondary default: @@ -178,19 +407,21 @@ public struct UsageSnapshot: Codable, Sendable { self.identity(for: provider)?.loginMethod } - /// Keep this initializer-style copy in sync with UsageSnapshot fields so relabeling/scoping never drops data. + public var hasRateLimitWindows: Bool { + self.primary != nil || self.secondary != nil || self.tertiary != nil || + !(self.extraRateWindows?.isEmpty ?? true) + } + + public func rateLimitsUnavailable(for provider: UsageProvider) -> Bool { + UsageLimitsAvailability.resolve(provider: provider, snapshot: self).isUnavailable + } + public func withIdentity(_ identity: ProviderIdentitySnapshot?) -> UsageSnapshot { - UsageSnapshot( - primary: self.primary, - secondary: self.secondary, - tertiary: self.tertiary, - providerCost: self.providerCost, - zaiUsage: self.zaiUsage, - minimaxUsage: self.minimaxUsage, - openRouterUsage: self.openRouterUsage, - cursorRequests: self.cursorRequests, - updatedAt: self.updatedAt, - identity: identity) + self.replacing(identity: .value(identity)) + } + + public func withDataConfidence(_ dataConfidence: UsageDataConfidence) -> UsageSnapshot { + self.replacing(dataConfidence: .value(dataConfidence)) } public func scoped(to provider: UsageProvider) -> UsageSnapshot { @@ -199,18 +430,117 @@ public struct UsageSnapshot: Codable, Sendable { if scopedIdentity.providerID == identity.providerID { return self } return self.withIdentity(scopedIdentity) } + + public func backfillingResetTimes(from cached: UsageSnapshot?, now: Date = .init()) -> UsageSnapshot { + guard let cached else { return self } + guard Self.identitiesMatch(self.identity, cached.identity) else { return self } + let primary = self.primary?.backfillingResetTime(from: cached.primary, now: now) + let secondary = self.secondary?.backfillingResetTime(from: cached.secondary, now: now) + let tertiary = self.tertiary?.backfillingResetTime(from: cached.tertiary, now: now) + if primary == self.primary, secondary == self.secondary, tertiary == self.tertiary { + return self + } + return self.replacing( + primary: .value(primary), + secondary: .value(secondary), + tertiary: .value(tertiary)) + } + + private func orderedPerplexityFallbackWindows() -> [RateWindow] { + let fallbackWindows = [self.tertiary, self.secondary].compactMap(\.self) + let usableFallback = fallbackWindows.filter { $0.remainingPercent > 0 } + let exhaustedFallback = fallbackWindows.filter { $0.remainingPercent <= 0 } + return usableFallback + exhaustedFallback + } + + private static func identitiesMatch(_ lhs: ProviderIdentitySnapshot?, _ rhs: ProviderIdentitySnapshot?) -> Bool { + if lhs == nil, rhs == nil { return true } + guard let lhs, let rhs else { return false } + let lhsEmail = lhs.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + let rhsEmail = rhs.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + if let lhsEmail, let rhsEmail, !lhsEmail.isEmpty, !rhsEmail.isEmpty { + return lhsEmail == rhsEmail + } + return true + } + + private enum Replacement { + case unchanged + case value(Value) + + func resolving(_ current: Value) -> Value { + switch self { + case .unchanged: current + case let .value(value): value + } + } + } + + private func replacing( + primary: Replacement = .unchanged, + secondary: Replacement = .unchanged, + tertiary: Replacement = .unchanged, + extraRateWindows: Replacement<[NamedRateWindow]?> = .unchanged, + codexResetCredits: Replacement = .unchanged, + identity: Replacement = .unchanged, + dataConfidence: Replacement = .unchanged) -> UsageSnapshot + { + UsageSnapshot( + primary: primary.resolving(self.primary), + secondary: secondary.resolving(self.secondary), + tertiary: tertiary.resolving(self.tertiary), + extraRateWindows: extraRateWindows.resolving(self.extraRateWindows), + kiroUsage: self.kiroUsage, + ampUsage: self.ampUsage, + providerCost: self.providerCost, + zaiUsage: self.zaiUsage, + minimaxUsage: self.minimaxUsage, + deepseekUsage: self.deepseekUsage, + mimoUsage: self.mimoUsage, + openRouterUsage: self.openRouterUsage, + openAIAPIUsage: self.openAIAPIUsage, + codexResetCredits: codexResetCredits.resolving(self.codexResetCredits), + claudeAdminAPIUsage: self.claudeAdminAPIUsage, + mistralUsage: self.mistralUsage, + deepgramUsage: self.deepgramUsage, + poeUsage: self.poeUsage, + cursorRequests: self.cursorRequests, + commandCodeSubscriptionEnrichmentUnavailable: self.commandCodeSubscriptionEnrichmentUnavailable, + commandCodeHasSubscriptionPlan: self.commandCodeHasSubscriptionPlan, + commandCodeMonthlyGrantDepleted: self.commandCodeMonthlyGrantDepleted, + subscriptionExpiresAt: self.subscriptionExpiresAt, + subscriptionRenewsAt: self.subscriptionRenewsAt, + updatedAt: self.updatedAt, + identity: identity.resolving(self.identity), + dataConfidence: dataConfidence.resolving(self.dataConfidence)) + } } public struct AccountInfo: Equatable, Sendable { public let email: String? public let plan: String? + public var hasIdentity: Bool { + self.email?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false || + self.plan?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + public init(email: String?, plan: String?) { self.email = email self.plan = plan } } +public struct CodexCLIAccountSnapshot: Sendable { + public let usage: UsageSnapshot? + public let credits: CreditsSnapshot? + + public init(usage: UsageSnapshot?, credits: CreditsSnapshot?) { + self.usage = usage + self.credits = credits + } +} + public enum UsageError: LocalizedError, Sendable { case noSessions case noRateLimitsFound @@ -226,6 +556,49 @@ public enum UsageError: LocalizedError, Sendable { "Could not parse Codex session log." } } + + public static func isNoRateLimitsFoundDescription(_ text: String?) -> Bool { + text?.trimmingCharacters(in: .whitespacesAndNewlines) == UsageError.noRateLimitsFound.errorDescription + } +} + +public enum UsageLimitsAvailability: Equatable, Sendable { + case available + case unavailable + + public var isUnavailable: Bool { + self == .unavailable + } + + public static func resolve( + provider: UsageProvider, + snapshot: UsageSnapshot?, + account: AccountInfo? = nil, + lastErrorDescription: String? = nil) -> Self + { + if provider == .doubao || provider == .antigravity { + guard let snapshot, + snapshot.identity(for: provider) != nil + else { + return .available + } + return snapshot.hasRateLimitWindows ? .available : .unavailable + } + + guard provider == .codex else { return .available } + + if let snapshot { + guard snapshot.identity(for: provider) != nil else { return .available } + return snapshot.hasRateLimitWindows ? .available : .unavailable + } + + guard UsageError.isNoRateLimitsFoundDescription(lastErrorDescription), + account?.hasIdentity == true + else { + return .available + } + return .unavailable + } } // MARK: - Codex RPC client (local process) @@ -272,6 +645,7 @@ private struct RPCRateLimitSnapshot: Decodable, Encodable { let primary: RPCRateLimitWindow? let secondary: RPCRateLimitWindow? let credits: RPCCreditsSnapshot? + let planType: String? } private struct RPCRateLimitWindow: Decodable, Encodable { @@ -286,10 +660,25 @@ private struct RPCCreditsSnapshot: Decodable, Encodable { let balance: String? } -private enum RPCWireError: Error, LocalizedError { +private struct RPCRateLimitsErrorBody: Decodable { + let email: String? + let planType: String? + let rateLimit: CodexUsageResponse.RateLimitDetails? + let credits: CodexUsageResponse.CreditDetails? + + enum CodingKeys: String, CodingKey { + case email + case planType = "plan_type" + case rateLimit = "rate_limit" + case credits + } +} + +enum RPCWireError: Error, LocalizedError { case startFailed(String) case requestFailed(String) case malformed(String) + case timeout(method: String) var errorDescription: String? { switch self { @@ -299,10 +688,19 @@ private enum RPCWireError: Error, LocalizedError { "Codex connection failed: \(message)" case let .malformed(message): "Codex returned invalid data: \(message)" + case let .timeout(method): + "Codex RPC timed out waiting for `\(method)` reply." } } } +typealias CodexExecutableResolver = @Sendable (_ environment: [String: String], _ executable: String) -> String? + +let defaultCodexExecutableResolver: CodexExecutableResolver = { environment, executable in + BinaryLocator.resolveCodexBinary(env: environment) + ?? TTYCommandRunner.which(executable) +} + /// RPC helper used on background tasks; safe because we confine it to the owning task. private final class CodexRPCClient: @unchecked Sendable { private static let log = CodexBarLog.logger(LogCategories.codexRPC) @@ -313,6 +711,8 @@ private final class CodexRPCClient: @unchecked Sendable { private let stdoutLineStream: AsyncStream private let stdoutLineContinuation: AsyncStream.Continuation private var nextID = 1 + private let initializeTimeoutSeconds: TimeInterval + private let requestTimeoutSeconds: TimeInterval private final class LineBuffer: @unchecked Sendable { private let lock = NSLock() @@ -343,23 +743,27 @@ private final class CodexRPCClient: @unchecked Sendable { init( executable: String = "codex", - arguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"]) throws + arguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"], + environment: [String: String] = ProcessInfo.processInfo.environment, + initializeTimeoutSeconds: TimeInterval = 8.0, + requestTimeoutSeconds: TimeInterval = 3.0, + resolveExecutable: CodexExecutableResolver = defaultCodexExecutableResolver) throws { + self.initializeTimeoutSeconds = initializeTimeoutSeconds + self.requestTimeoutSeconds = requestTimeoutSeconds var stdoutContinuation: AsyncStream.Continuation! self.stdoutLineStream = AsyncStream { continuation in stdoutContinuation = continuation } self.stdoutLineContinuation = stdoutContinuation - let resolvedExec = BinaryLocator.resolveCodexBinary() - ?? TTYCommandRunner.which(executable) + let resolvedExec = resolveExecutable(environment, executable) guard let resolvedExec else { Self.log.warning("Codex RPC binary not found", metadata: ["binary": executable]) - throw RPCWireError.startFailed( - "Codex CLI not found. Install with `npm i -g @openai/codex` (or bun) then relaunch CodexBar.") + throw CodexStatusProbeError.codexNotInstalled } - var env = ProcessInfo.processInfo.environment + var env = environment env["PATH"] = PathBuilder.effectivePATH( purposes: [.rpc, .nodeTooling], env: env) @@ -371,12 +775,19 @@ private final class CodexRPCClient: @unchecked Sendable { self.process.standardOutput = self.stdoutPipe self.process.standardError = self.stderrPipe + if let message = CodexCLILaunchGate.shared.backgroundSkipMessage(binary: resolvedExec) { + Self.log.warning("Codex RPC launch skipped after recent launch failure", metadata: ["binary": resolvedExec]) + throw RPCWireError.startFailed(message) + } + do { try self.process.run() Self.log.debug("Codex RPC started", metadata: ["binary": resolvedExec]) } catch { - Self.log.warning("Codex RPC failed to start", metadata: ["error": error.localizedDescription]) - throw RPCWireError.startFailed(error.localizedDescription) + let message = error.localizedDescription + let throttled = CodexCLILaunchGate.shared.recordLaunchFailure(binary: resolvedExec, message: message) + Self.log.warning("Codex RPC failed to start", metadata: ["error": message]) + throw RPCWireError.startFailed(throttled ?? message) } let stdoutHandle = self.stdoutPipe.fileHandleForReading @@ -416,7 +827,8 @@ private final class CodexRPCClient: @unchecked Sendable { func initialize(clientName: String, clientVersion: String) async throws { _ = try await self.request( method: "initialize", - params: ["clientInfo": ["name": clientName, "version": clientVersion]]) + params: ["clientInfo": ["name": clientName, "version": clientVersion]], + timeout: self.initializeTimeoutSeconds) try self.sendNotification(method: "initialized") } @@ -439,26 +851,72 @@ private final class CodexRPCClient: @unchecked Sendable { // MARK: - JSON-RPC helpers - private func request(method: String, params: [String: Any]? = nil) async throws -> [String: Any] { + private struct SendableJSONMessage: @unchecked Sendable { + let value: [String: Any] + } + + private func request( + method: String, + params: [String: Any]? = nil, + timeout: TimeInterval? = nil) async throws -> [String: Any] + { let id = self.nextID self.nextID += 1 try self.sendRequest(id: id, method: method, params: params) - while true { - let message = try await self.readNextMessage() + let resolvedTimeout = timeout ?? self.requestTimeoutSeconds + let wrapped = try await self.withTimeout(seconds: resolvedTimeout, method: method) { + while true { + let message = try await self.readNextMessage() - if message["id"] == nil, let methodName = message["method"] as? String { - Self.debugWriteStderr("[codex notify] \(methodName)\n") - continue - } + if message["id"] == nil, let methodName = message["method"] as? String { + Self.debugWriteStderr("[codex notify] \(methodName)\n") + continue + } - guard let messageID = self.jsonID(message["id"]), messageID == id else { continue } + guard let messageID = self.jsonID(message["id"]), messageID == id else { continue } + + if let error = message["error"] as? [String: Any], let messageText = error["message"] as? String { + throw RPCWireError.requestFailed(messageText) + } - if let error = message["error"] as? [String: Any], let messageText = error["message"] as? String { - throw RPCWireError.requestFailed(messageText) + return SendableJSONMessage(value: message) + } + } + return wrapped.value + } + + private func withTimeout( + seconds: TimeInterval, + method: String, + body: @escaping @Sendable () async throws -> T) async throws -> T + { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await body() + } + group.addTask { [weak self] in + try await Task.sleep(for: .seconds(seconds)) + self?.terminateProcessForTimeout(method: method) + throw RPCWireError.timeout(method: method) + } + do { + guard let result = try await group.next() else { + throw RPCWireError.timeout(method: method) + } + group.cancelAll() + return result + } catch { + group.cancelAll() + throw error } + } + } - return message + private func terminateProcessForTimeout(method: String) { + if self.process.isRunning { + Self.log.warning("Codex RPC timed out on `\(method)`; terminating process") + self.process.terminate() } } @@ -514,118 +972,110 @@ private final class CodexRPCClient: @unchecked Sendable { public struct UsageFetcher: Sendable { private let environment: [String: String] + private let initializeTimeoutSeconds: TimeInterval + private let requestTimeoutSeconds: TimeInterval + private let codexExecutableResolver: CodexExecutableResolver + private let codexArguments: [String] public init(environment: [String: String] = ProcessInfo.processInfo.environment) { self.environment = environment + self.initializeTimeoutSeconds = 8.0 + self.requestTimeoutSeconds = 3.0 + self.codexExecutableResolver = defaultCodexExecutableResolver + self.codexArguments = ["-s", "read-only", "-a", "untrusted", "app-server"] LoginShellPathCache.shared.captureOnce() } - public func loadLatestUsage(keepCLISessionsAlive: Bool = false) async throws -> UsageSnapshot { - try await self.withFallback( - primary: self.loadRPCUsage, - secondary: { try await self.loadTTYUsage(keepCLISessionsAlive: keepCLISessionsAlive) }) + init( + environment: [String: String], + initializeTimeoutSeconds: TimeInterval, + requestTimeoutSeconds: TimeInterval, + codexArguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"], + codexExecutableResolver: @escaping CodexExecutableResolver = defaultCodexExecutableResolver) + { + self.environment = environment + self.initializeTimeoutSeconds = initializeTimeoutSeconds + self.requestTimeoutSeconds = requestTimeoutSeconds + self.codexExecutableResolver = codexExecutableResolver + self.codexArguments = codexArguments + LoginShellPathCache.shared.captureOnce() } - private func loadRPCUsage() async throws -> UsageSnapshot { - let rpc = try CodexRPCClient() - defer { rpc.shutdown() } - - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - // The app-server answers on a single stdout stream, so keep requests - // serialized to avoid starving one reader when multiple awaiters race - // for the same pipe. - let limits = try await rpc.fetchRateLimits().rateLimits - let account = try? await rpc.fetchAccount() - - guard let primary = Self.makeWindow(from: limits.primary), - let secondary = Self.makeWindow(from: limits.secondary) - else { + public func loadLatestUsage(keepCLISessionsAlive: Bool = false) async throws -> UsageSnapshot { + _ = keepCLISessionsAlive + guard let usage = try await self.loadLatestCLIAccountSnapshot().usage else { throw UsageError.noRateLimitsFound } - - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: account?.account.flatMap { details in - if case let .chatgpt(email, _) = details { email } else { nil } - }, - accountOrganization: nil, - loginMethod: account?.account.flatMap { details in - if case let .chatgpt(_, plan) = details { plan } else { nil } - }) - return UsageSnapshot( - primary: primary, - secondary: secondary, - tertiary: nil, - updatedAt: Date(), - identity: identity) + return usage } - private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { - let status = try await CodexStatusProbe(keepCLISessionsAlive: keepCLISessionsAlive).fetch() - guard let fiveLeft = status.fiveHourPercentLeft, let weekLeft = status.weeklyPercentLeft else { - throw UsageError.noRateLimitsFound + public func loadLatestCLIAccountSnapshot() async throws -> CodexCLIAccountSnapshot { + let rpc = try CodexRPCClient( + arguments: self.codexArguments, + environment: self.environment, + initializeTimeoutSeconds: self.initializeTimeoutSeconds, + requestTimeoutSeconds: self.requestTimeoutSeconds, + resolveExecutable: self.codexExecutableResolver) + defer { rpc.shutdown() } + do { + try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") + // The app-server answers on a single stdout stream, so keep requests + // serialized to avoid starving one reader when multiple awaiters race + // for the same pipe. + let limits = try await rpc.fetchRateLimits().rateLimits + let account = try? await rpc.fetchAccount() + let rateLimitsPlan = Self.normalizedCodexAccountField(limits.planType) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account?.account.flatMap { details in + if case let .chatgpt(email, _) = details { email } else { nil } + }, + accountOrganization: nil, + loginMethod: account?.account.flatMap { details in + if case let .chatgpt(_, plan) = details { plan } else { nil } + } ?? rateLimitsPlan) + let credits = Self.makeCredits(from: limits.credits) + let shouldReturnUnavailableUsage = credits == nil || rateLimitsPlan != nil + let usage = CodexReconciledState.fromCLI( + primary: Self.makeWindow(from: limits.primary), + secondary: Self.makeWindow(from: limits.secondary), + identity: identity)? + .toUsageSnapshot() + ?? (shouldReturnUnavailableUsage ? Self.emptyCodexUsageSnapshotIfIdentified(identity: identity) : nil) + guard usage != nil || credits != nil else { + throw UsageError.noRateLimitsFound + } + return CodexCLIAccountSnapshot( + usage: usage, + credits: credits) + } catch { + let usage = Self.recoverUsageFromRPCError(error) + let credits = Self.recoverCreditsFromRPCError(error) + if usage != nil || credits != nil { + return CodexCLIAccountSnapshot( + usage: usage, + credits: credits) + } + throw error } - - let primary = RateWindow( - usedPercent: max(0, 100 - Double(fiveLeft)), - windowMinutes: 300, - resetsAt: nil, - resetDescription: status.fiveHourResetDescription) - let secondary = RateWindow( - usedPercent: max(0, 100 - Double(weekLeft)), - windowMinutes: 10080, - resetsAt: nil, - resetDescription: status.weeklyResetDescription) - - return UsageSnapshot( - primary: primary, - secondary: secondary, - tertiary: nil, - updatedAt: Date(), - identity: nil) } public func loadLatestCredits(keepCLISessionsAlive: Bool = false) async throws -> CreditsSnapshot { - try await self.withFallback( - primary: self.loadRPCCredits, - secondary: { try await self.loadTTYCredits(keepCLISessionsAlive: keepCLISessionsAlive) }) - } - - private func loadRPCCredits() async throws -> CreditsSnapshot { - let rpc = try CodexRPCClient() - defer { rpc.shutdown() } - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - let limits = try await rpc.fetchRateLimits().rateLimits - guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } - let remaining = Self.parseCredits(credits.balance) - return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) - } - - private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { - let status = try await CodexStatusProbe(keepCLISessionsAlive: keepCLISessionsAlive).fetch() - guard let credits = status.credits else { throw UsageError.noRateLimitsFound } - return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) - } - - private func withFallback( - primary: @escaping () async throws -> T, - secondary: @escaping () async throws -> T) async throws -> T - { - do { - return try await primary() - } catch let primaryError { - do { - return try await secondary() - } catch { - // Preserve the original failure so callers see the primary path error. - throw primaryError - } + _ = keepCLISessionsAlive + guard let credits = try await self.loadLatestCLIAccountSnapshot().credits else { + throw UsageError.noRateLimitsFound } + return credits } public func debugRawRateLimits() async -> String { do { - let rpc = try CodexRPCClient() + let rpc = try CodexRPCClient( + arguments: self.codexArguments, + environment: self.environment, + initializeTimeoutSeconds: self.initializeTimeoutSeconds, + requestTimeoutSeconds: self.requestTimeoutSeconds, + resolveExecutable: self.codexExecutableResolver) defer { rpc.shutdown() } try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") let limits = try await rpc.fetchRateLimits() @@ -637,30 +1087,30 @@ public struct UsageFetcher: Sendable { } public func loadAccountInfo() -> AccountInfo { - // Keep using auth.json for quick startup (non-blocking, no RPC spin-up required). - let authURL = URL(fileURLWithPath: self.environment["CODEX_HOME"] ?? "\(NSHomeDirectory())/.codex") - .appendingPathComponent("auth.json") - guard let data = try? Data(contentsOf: authURL), - let auth = try? JSONDecoder().decode(AuthFile.self, from: data), - let idToken = auth.tokens?.idToken - else { - return AccountInfo(email: nil, plan: nil) - } + let account = self.loadAuthBackedCodexAccount() + return AccountInfo(email: account.email, plan: account.plan) + } - guard let payload = UsageFetcher.parseJWT(idToken) else { - return AccountInfo(email: nil, plan: nil) + public func loadAuthBackedCodexAccount() -> CodexAuthBackedAccount { + guard let credentials = try? CodexOAuthCredentialsStore.load(env: self.environment) else { + return CodexAuthBackedAccount(identity: .unresolved, email: nil, plan: nil) } - let authDict = payload["https://api.openai.com/auth"] as? [String: Any] - let profileDict = payload["https://api.openai.com/profile"] as? [String: Any] - - let plan = (authDict?["chatgpt_plan_type"] as? String) - ?? (payload["chatgpt_plan_type"] as? String) - - let email = (payload["email"] as? String) - ?? (profileDict?["email"] as? String) - - return AccountInfo(email: email, plan: plan) + let payload = credentials.idToken.flatMap(Self.parseJWT) + let authDict = payload?["https://api.openai.com/auth"] as? [String: Any] + let profileDict = payload?["https://api.openai.com/profile"] as? [String: Any] + + let email = Self.normalizedCodexAccountField( + (payload?["email"] as? String) ?? (profileDict?["email"] as? String)) + let plan = Self.normalizedCodexAccountField( + (authDict?["chatgpt_plan_type"] as? String) ?? (payload?["chatgpt_plan_type"] as? String)) + let accountId = Self.normalizedCodexAccountField( + credentials.accountId + ?? (authDict?["chatgpt_account_id"] as? String) + ?? (payload?["chatgpt_account_id"] as? String)) + let identity = CodexIdentityResolver.resolve(accountId: accountId, email: email) + + return CodexAuthBackedAccount(identity: identity, email: email, plan: plan) } // MARK: - Helpers @@ -676,11 +1126,133 @@ public struct UsageFetcher: Sendable { resetDescription: resetDescription) } + private static func makeWindow(from response: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { + guard let response else { return nil } + let resetsAtDate = Date(timeIntervalSince1970: TimeInterval(response.resetAt)) + return RateWindow( + usedPercent: Double(response.usedPercent), + windowMinutes: response.limitWindowSeconds / 60, + resetsAt: resetsAtDate, + resetDescription: UsageFormatter.resetDescription(from: resetsAtDate)) + } + + private static func makeTTYWindow( + percentLeft: Int?, + windowMinutes: Int, + resetsAt: Date?, + resetDescription: String?) -> RateWindow? + { + guard let percentLeft else { return nil } + return RateWindow( + usedPercent: max(0, 100 - Double(percentLeft)), + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: resetDescription) + } + private static func parseCredits(_ balance: String?) -> Double { guard let balance, let val = Double(balance) else { return 0 } return val } + private static func makeCredits(from rpc: RPCCreditsSnapshot?) -> CreditsSnapshot? { + guard let rpc else { return nil } + return CreditsSnapshot(remaining: self.parseCredits(rpc.balance), events: [], updatedAt: Date()) + } + + private static func emptyCodexUsageSnapshotIfIdentified(identity: ProviderIdentitySnapshot) -> UsageSnapshot? { + guard identity.accountEmail != nil || identity.loginMethod != nil else { return nil } + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: identity) + } + + private static func recoverUsageFromRPCError(_ error: Error) -> UsageSnapshot? { + guard let body = self.decodeRateLimitsErrorBody(from: error) else { return nil } + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: self.normalizedCodexAccountField(body.email), + accountOrganization: nil, + loginMethod: self.normalizedCodexAccountField(body.planType)) + guard let state = CodexReconciledState.fromCLI( + primary: self.makeWindow(from: body.rateLimit?.primaryWindow), + secondary: self.makeWindow(from: body.rateLimit?.secondaryWindow), + identity: identity) + else { + return nil + } + if body.rateLimit?.hasWindowDecodeFailure == true, + state.session == nil + { + return nil + } + return state.toUsageSnapshot() + } + + private static func recoverCreditsFromRPCError(_ error: Error) -> CreditsSnapshot? { + guard let credits = self.decodeRateLimitsErrorBody(from: error)?.credits else { return nil } + guard let remaining = credits.balance else { return nil } + return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) + } + + private static func decodeRateLimitsErrorBody(from error: Error) -> RPCRateLimitsErrorBody? { + guard case let RPCWireError.requestFailed(message) = error else { return nil } + guard let json = self.extractJSONObject(after: "body=", in: message) else { return nil } + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(RPCRateLimitsErrorBody.self, from: data) + } + + private static func extractJSONObject(after marker: String, in text: String) -> String? { + guard let markerRange = text.range(of: marker) else { return nil } + let suffix = text[markerRange.upperBound...] + guard let start = suffix.firstIndex(of: "{") else { return nil } + + var depth = 0 + var inString = false + var isEscaped = false + + for index in suffix[start...].indices { + let character = suffix[index] + + if inString { + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + continue + } + + switch character { + case "\"": + inString = true + case "{": + depth += 1 + case "}": + depth -= 1 + if depth == 0 { + return String(suffix[start...index]) + } + default: + break + } + } + + return nil + } + + private static func normalizedCodexAccountField(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + public static func parseJWT(_ token: String) -> [String: Any]? { let parts = token.split(separator: ".") guard parts.count >= 2 else { return nil } @@ -698,8 +1270,68 @@ public struct UsageFetcher: Sendable { } } -/// Minimal auth.json struct preserved from previous implementation -private struct AuthFile: Decodable { - struct Tokens: Decodable { let idToken: String? } - let tokens: Tokens? +#if DEBUG +extension UsageFetcher { + static func _mapCodexRPCLimitsForTesting( + primary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, + secondary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, + planType: String? = nil) throws -> UsageSnapshot + { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.normalizedCodexAccountField(planType)) + guard let state = CodexReconciledState.fromCLI( + primary: primary.map(self.makeTestingWindow), + secondary: secondary.map(self.makeTestingWindow), + identity: identity) + else { + if let usage = self.emptyCodexUsageSnapshotIfIdentified(identity: identity) { + return usage + } + throw UsageError.noRateLimitsFound + } + return state.toUsageSnapshot() + } + + static func _mapCodexStatusForTesting(_ status: CodexStatusSnapshot) throws -> UsageSnapshot { + guard let state = CodexReconciledState.fromCLI( + primary: self.makeTTYWindow( + percentLeft: status.fiveHourPercentLeft, + windowMinutes: 300, + resetsAt: status.fiveHourResetsAt, + resetDescription: status.fiveHourResetDescription), + secondary: self.makeTTYWindow( + percentLeft: status.weeklyPercentLeft, + windowMinutes: 10080, + resetsAt: status.weeklyResetsAt, + resetDescription: status.weeklyResetDescription), + identity: nil) + else { + throw UsageError.noRateLimitsFound + } + return state.toUsageSnapshot() + } + + public static func _recoverCodexRPCUsageFromErrorForTesting(_ message: String) -> UsageSnapshot? { + self.recoverUsageFromRPCError(RPCWireError.requestFailed(message)) + } + + public static func _recoverCodexRPCCreditsFromErrorForTesting(_ message: String) -> CreditsSnapshot? { + self.recoverCreditsFromRPCError(RPCWireError.requestFailed(message)) + } + + private static func makeTestingWindow( + _ value: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)) + -> RateWindow + { + let resetsAt = value.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + return RateWindow( + usedPercent: value.usedPercent, + windowMinutes: value.windowMinutes, + resetsAt: resetsAt, + resetDescription: resetsAt.map { UsageFormatter.resetDescription(from: $0) }) + } } +#endif diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 226d435698..e6bcc6a62f 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -6,10 +6,84 @@ public enum ResetTimeDisplayStyle: String, Codable, Sendable { } public enum UsageFormatter { + private final class BundleToken {} + + private static let localizationLock = NSLock() + private nonisolated(unsafe) static var localizationProvider: (@Sendable (String) -> String)? + private nonisolated(unsafe) static var localeProvider: (@Sendable () -> Locale)? + + public static func setLocalizationProvider(_ provider: @escaping @Sendable (String) -> String) { + self.localizationLock.lock() + self.localizationProvider = provider + self.localizationLock.unlock() + } + + public static func clearLocalizationProvider() { + self.localizationLock.lock() + self.localizationProvider = nil + self.localizationLock.unlock() + } + + public static func setLocaleProvider(_ provider: @escaping @Sendable () -> Locale) { + self.localizationLock.lock() + self.localeProvider = provider + self.localizationLock.unlock() + } + + public static func clearLocaleProvider() { + self.localizationLock.lock() + self.localeProvider = nil + self.localizationLock.unlock() + } + + private static func currentLocale() -> Locale { + self.localizationLock.lock() + let provider = self.localeProvider + self.localizationLock.unlock() + return provider?() ?? Locale(identifier: "en_US_POSIX") + } + + private static func localized(_ key: String) -> String { + self.localizationLock.lock() + let provider = self.localizationProvider + self.localizationLock.unlock() + if let provider { + return provider(key) + } + let coreBundle = Bundle(for: BundleToken.self) + let coreValue = NSLocalizedString(key, tableName: "Localizable", bundle: coreBundle, value: key, comment: "") + if coreValue != key { return coreValue } + + let mainValue = NSLocalizedString(key, tableName: "Localizable", bundle: .main, value: key, comment: "") + if mainValue != key { return mainValue } + + switch key { + case "usage_percent_suffix_left": return "left" + case "usage_percent_suffix_used": return "used" + case "reset_tomorrow_format": return "tomorrow, %@" + case "byte_unit_byte": return "byte" + case "byte_unit_bytes": return "bytes" + case "byte_unit_kilobyte": return "kilobyte" + case "byte_unit_kilobytes": return "kilobytes" + case "byte_unit_megabyte": return "megabyte" + case "byte_unit_megabytes": return "megabytes" + case "byte_unit_gigabyte": return "gigabyte" + case "byte_unit_gigabytes": return "gigabytes" + default: return key + } + } + + private static func localized(_ key: String, _ args: CVarArg...) -> String { + let format = self.localized(key) + return String(format: format, locale: self.currentLocale(), arguments: args) + } + public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { let percent = showUsed ? used : remaining let clamped = min(100, max(0, percent)) - let suffix = showUsed ? "used" : "left" + let suffix = showUsed + ? self.localized("usage_percent_suffix_used") + : self.localized("usage_percent_suffix_left") return String(format: "%.0f%% %@", clamped, suffix) } @@ -37,14 +111,15 @@ public enum UsageFormatter { // Human-friendly phrasing: today / tomorrow / date+time. let calendar = Calendar.current if calendar.isDate(date, inSameDayAs: now) { - return date.formatted(date: .omitted, time: .shortened) + return date.formatted(.dateTime.hour().minute().locale(self.currentLocale())) } if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(date, inSameDayAs: tomorrow) { - return "tomorrow, \(date.formatted(date: .omitted, time: .shortened))" + let timeStr = date.formatted(.dateTime.hour().minute().locale(self.currentLocale())) + return self.localized("reset_tomorrow_format", timeStr) } - return date.formatted(date: .abbreviated, time: .shortened) + return date.formatted(.dateTime.month(.abbreviated).day().hour().minute().locale(self.currentLocale())) } public static func resetLine( @@ -53,17 +128,30 @@ public enum UsageFormatter { now: Date = .init()) -> String? { if let date = window.resetsAt { - let text = style == .countdown - ? self.resetCountdownDescription(from: date, now: now) - : self.resetDescription(from: date, now: now) - return "Resets \(text)" + if style == .countdown { + let countdown = self.resetCountdownDescription(from: date, now: now) + if countdown == "now" { + return self.localized("Resets now") + } + if countdown.hasPrefix("in ") { + return self.localized("Resets in %@", String(countdown.dropFirst(3))) + } + return self.localized("Resets %@", countdown) + } + let text = self.resetDescription(from: date, now: now) + return self.localized("Resets %@", text) } if let desc = window.resetDescription { let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - if trimmed.lowercased().hasPrefix("resets") { return trimmed } - return "Resets \(trimmed)" + if trimmed.lowercased().hasPrefix("resets in ") { + return self.localized("Resets in %@", String(trimmed.dropFirst("Resets in ".count))) + } + if trimmed.lowercased().hasPrefix("resets ") { + return self.localized("Resets %@", String(trimmed.dropFirst("Resets ".count))) + } + return self.localized("Resets %@", trimmed) } return nil } @@ -71,24 +159,27 @@ public enum UsageFormatter { public static func updatedString(from date: Date, now: Date = .init()) -> String { let delta = now.timeIntervalSince(date) if abs(delta) < 60 { - return "Updated just now" + return self.localized("Updated just now") } if let hours = Calendar.current.dateComponents([.hour], from: date, to: now).hour, hours < 24 { #if os(macOS) let rel = RelativeDateTimeFormatter() + rel.locale = self.currentLocale() rel.unitsStyle = .abbreviated - return "Updated \(rel.localizedString(for: date, relativeTo: now))" + return self.localized("Updated %@", rel.localizedString(for: date, relativeTo: now)) #else let seconds = max(0, Int(now.timeIntervalSince(date))) if seconds < 3600 { let minutes = max(1, seconds / 60) - return "Updated \(minutes)m ago" + return self.localized("Updated %@m ago", String(minutes)) } let wholeHours = max(1, seconds / 3600) - return "Updated \(wholeHours)h ago" + return self.localized("Updated %@h ago", String(wholeHours)) #endif } else { - return "Updated \(date.formatted(date: .omitted, time: .shortened))" + return self.localized( + "Updated %@", + date.formatted(.dateTime.hour().minute().locale(self.currentLocale()))) } } @@ -99,7 +190,15 @@ public enum UsageFormatter { // Use explicit locale for consistent formatting on all systems number.locale = Locale(identifier: "en_US_POSIX") let formatted = number.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) - return "\(formatted) left" + return self.localized("%@ left", formatted) + } + + public static func kiroCreditNumber(_ value: Double) -> String { + let rounded = value.rounded() + if abs(value - rounded) < 0.005 { + return String(format: "%.0f", rounded) + } + return String(format: "%.2f", value) } /// Formats a USD value with proper negative handling and thousand separators. @@ -108,6 +207,18 @@ public enum UsageFormatter { value.formatted(.currency(code: "USD").locale(Locale(identifier: "en_US"))) } + public static let costEstimateHint = "Estimated from local logs · may differ from your bill" + + public static func costEstimateHint(provider: UsageProvider) -> String { + switch provider { + case .claude: + "Estimated from local Claude logs at API rates; token totals include cache read/write tokens " + + "and may differ from Claude Code /status." + default: + self.costEstimateHint + } + } + /// Formats a currency value with the specified currency code. /// Uses FormatStyle with explicit en_US locale to ensure consistent formatting /// regardless of the user's system locale (e.g., pt-BR users see $54.72 not US$ 54,72). @@ -138,11 +249,50 @@ public enum UsageFormatter { return "\(sign)\(formatted)\(unit.suffix)" } - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.usesGroupingSeparator = true - formatter.locale = Locale(identifier: "en_US_POSIX") - return formatter.string(from: NSNumber(value: value)) ?? "\(value)" + return "\(value)" + } + + public static func byteCountString(_ bytes: Int64) -> String { + let sign = bytes < 0 ? "-" : "" + let absBytes = Double(bytes.magnitude) + let units: [(threshold: Double, divisor: Double, suffix: String)] = [ + (1024 * 1024 * 1024, 1024 * 1024 * 1024, "GB"), + (1024 * 1024, 1024 * 1024, "MB"), + (1024, 1024, "KB"), + ] + + for unit in units where absBytes >= unit.threshold { + let scaled = absBytes / unit.divisor + let format = scaled >= 10 || scaled.rounded(.towardZero) == scaled ? "%.0f" : "%.1f" + let formatted = String(format: format, scaled) + return "\(sign)\(formatted) \(unit.suffix)" + } + + return "\(bytes) B" + } + + /// Same magnitudes as `byteCountString`, but spelled out ("megabytes" instead of "MB"). + public static func byteCountStringLong(_ bytes: Int64) -> String { + let sign = bytes < 0 ? "-" : "" + let absBytes = Double(bytes.magnitude) + let units: [(threshold: Double, divisor: Double, singularKey: String, pluralKey: String)] = [ + (1024 * 1024 * 1024, 1024 * 1024 * 1024, "byte_unit_gigabyte", "byte_unit_gigabytes"), + (1024 * 1024, 1024 * 1024, "byte_unit_megabyte", "byte_unit_megabytes"), + (1024, 1024, "byte_unit_kilobyte", "byte_unit_kilobytes"), + ] + + for unit in units where absBytes >= unit.threshold { + let scaled = absBytes / unit.divisor + let format = scaled >= 10 || scaled.rounded(.towardZero) == scaled ? "%.0f" : "%.1f" + let formatted = String(format: format, locale: self.currentLocale(), scaled) + let displayScale = format == "%.0f" ? 1.0 : 10.0 + let displayedValue = (scaled * displayScale).rounded() / displayScale + let word = self.localized(displayedValue == 1 ? unit.singularKey : unit.pluralKey) + return "\(sign)\(formatted) \(word)" + } + + let word = self.localized(bytes.magnitude == 1 ? "byte_unit_byte" : "byte_unit_bytes") + return "\(bytes) \(word)" } public static func creditEventSummary(_ event: CreditEvent) -> String { @@ -206,6 +356,26 @@ public enum UsageFormatter { return cleaned.isEmpty ? raw : cleaned } + public static func modelCostDetail( + _ model: String, + costUSD: Double?, + totalTokens: Int? = nil, + currencyCode: String = "USD") -> String? + { + let costDetail: String? = if let label = CostUsagePricing.codexDisplayLabel(model: model) { + label + } else if let costUSD { + self.currencyString(costUSD, currencyCode: currencyCode) + } else { + nil + } + + let tokenDetail = totalTokens.map(self.tokenCountString) + let parts = [costDetail, tokenDetail].compactMap(\.self) + guard !parts.isEmpty else { return nil } + return parts.joined(separator: " · ") + } + /// Cleans a provider plan string: strip ANSI/bracket noise, drop boilerplate words, collapse whitespace, and /// ensure a leading capital if the result starts lowercase. public static func cleanPlanName(_ text: String) -> String { diff --git a/Sources/CodexBarCore/UsagePace.swift b/Sources/CodexBarCore/UsagePace.swift index 2c93f0a474..c55b54c890 100644 --- a/Sources/CodexBarCore/UsagePace.swift +++ b/Sources/CodexBarCore/UsagePace.swift @@ -40,7 +40,9 @@ public struct UsagePace: Sendable { public static func weekly( window: RateWindow, now: Date = .init(), - defaultWindowMinutes: Int = 10080) -> UsagePace? + defaultWindowMinutes: Int = 10080, + workDays: Int? = nil, + calendar: Calendar = .current) -> UsagePace? { guard let resetsAt = window.resetsAt else { return nil } let minutes = window.windowMinutes ?? defaultWindowMinutes @@ -51,7 +53,20 @@ public struct UsagePace: Sendable { guard timeUntilReset > 0 else { return nil } guard timeUntilReset <= duration else { return nil } let elapsed = (duration - timeUntilReset).clamped(to: 0...duration) - let expected = ((elapsed / duration) * 100).clamped(to: 0...100) + let workdayProgress: WorkdayProgress? = if let workDays, workDays >= 2, workDays < 7, + minutes == 10080 + { + Self.workdayProgress( + now: now, + duration: duration, + resetsAt: resetsAt, + workDays: workDays, + calendar: calendar) + } else { + nil + } + let expected = workdayProgress?.expectedUsedPercent + ?? ((elapsed / duration) * 100).clamped(to: 0...100) let actual = window.usedPercent.clamped(to: 0...100) if elapsed == 0, actual > 0 { return nil @@ -62,18 +77,29 @@ public struct UsagePace: Sendable { var etaSeconds: TimeInterval? var willLastToReset = false - if elapsed > 0, actual > 0 { - let rate = actual / elapsed + let paceElapsed = workdayProgress?.elapsedSeconds ?? elapsed + if actual >= 100 { + etaSeconds = 0 + } else if paceElapsed > 0, actual > 0 { + let rate = actual / paceElapsed if rate > 0 { - let remaining = max(0, 100 - actual) + let remaining = 100 - actual let candidate = remaining / rate - if candidate >= timeUntilReset { + let effectiveTimeUntilReset = workdayProgress?.remainingSeconds ?? timeUntilReset + if candidate >= effectiveTimeUntilReset { willLastToReset = true + } else if let workDays = workdayProgress?.workDays { + etaSeconds = Self.wallClockInterval( + from: now, + to: resetsAt, + consumingWorkSeconds: candidate, + workDays: workDays, + calendar: calendar) } else { etaSeconds = candidate } } - } else if elapsed > 0, actual == 0 { + } else if paceElapsed > 0, actual == 0 { willLastToReset = true } @@ -107,6 +133,101 @@ public struct UsagePace: Sendable { runOutProbability: runOutProbability) } + private struct WorkdayProgress { + let workDays: Int + let totalSeconds: TimeInterval + let elapsedSeconds: TimeInterval + let remainingSeconds: TimeInterval + + var expectedUsedPercent: Double { + ((self.elapsedSeconds / self.totalSeconds) * 100).clamped(to: 0...100) + } + } + + /// Splits the weekly window at local day boundaries so reset offsets do not shift weekday classification. + private static func workdayProgress( + now: Date, + duration: TimeInterval, + resetsAt: Date, + workDays: Int, + calendar: Calendar) -> WorkdayProgress? + { + let windowStart = resetsAt.addingTimeInterval(-duration) + + var totalWorkSeconds: TimeInterval = 0 + var elapsedWorkSeconds: TimeInterval = 0 + var remainingWorkSeconds: TimeInterval = 0 + + var cursor = windowStart + while cursor < resetsAt { + guard let startOfNextDay = Self.nextDayBoundary(after: cursor, calendar: calendar), + startOfNextDay > cursor + else { + return nil + } + let sliceEnd = min(startOfNextDay, resetsAt) + + if Self.isWorkday(cursor, calendar: calendar, workDays: workDays) { + let sliceDuration = sliceEnd.timeIntervalSince(cursor) + totalWorkSeconds += sliceDuration + if now > cursor { + elapsedWorkSeconds += min(now, sliceEnd).timeIntervalSince(cursor) + } + if now < sliceEnd { + remainingWorkSeconds += sliceEnd.timeIntervalSince(max(now, cursor)) + } + } + cursor = sliceEnd + } + + guard totalWorkSeconds > 0 else { return nil } + return WorkdayProgress( + workDays: workDays, + totalSeconds: totalWorkSeconds, + elapsedSeconds: elapsedWorkSeconds, + remainingSeconds: remainingWorkSeconds) + } + + private static func wallClockInterval( + from now: Date, + to resetsAt: Date, + consumingWorkSeconds requiredWorkSeconds: TimeInterval, + workDays: Int, + calendar: Calendar) -> TimeInterval? + { + guard requiredWorkSeconds > 0 else { return 0 } + + var remaining = requiredWorkSeconds + var cursor = now + while cursor < resetsAt { + guard let startOfNextDay = Self.nextDayBoundary(after: cursor, calendar: calendar), + startOfNextDay > cursor + else { + return nil + } + let sliceEnd = min(startOfNextDay, resetsAt) + if Self.isWorkday(cursor, calendar: calendar, workDays: workDays) { + let available = sliceEnd.timeIntervalSince(cursor) + if remaining <= available { + return cursor.addingTimeInterval(remaining).timeIntervalSince(now) + } + remaining -= available + } + cursor = sliceEnd + } + return nil + } + + private static func nextDayBoundary(after date: Date, calendar: Calendar) -> Date? { + calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: date)) + } + + private static func isWorkday(_ date: Date, calendar: Calendar, workDays: Int) -> Bool { + let weekday = calendar.component(.weekday, from: date) + let isoWeekday = weekday == 1 ? 7 : weekday - 1 + return isoWeekday <= workDays + } + private static func stage(for delta: Double) -> Stage { let absDelta = abs(delta) if absDelta <= 2 { return .onTrack } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index e56131a416..40c2d8c6f4 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -1,6 +1,21 @@ import Foundation enum CostUsageCacheIO { + private static let compatibleCodexProducerKeys: Set = [ + "codex:cu:p3c27f997569eb3c5", + ] + + private static func artifactVersion(for provider: UsageProvider) -> Int { + switch provider { + case .codex: + 8 + case .claude, .vertexai: + 4 + default: + 1 + } + } + private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! return root.appendingPathComponent("CodexBar", isDirectory: true) @@ -8,44 +23,95 @@ enum CostUsageCacheIO { static func cacheFileURL(provider: UsageProvider, cacheRoot: URL? = nil) -> URL { let root = cacheRoot ?? self.defaultCacheRoot() + let artifactVersion = self.artifactVersion(for: provider) return root .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("\(provider.rawValue)-v1.json", isDirectory: false) + .appendingPathComponent("\(provider.rawValue)-v\(artifactVersion).json", isDirectory: false) } - static func load(provider: UsageProvider, cacheRoot: URL? = nil) -> CostUsageCache { + static func load( + provider: UsageProvider, + cacheRoot: URL? = nil, + producerKey: String? = nil) -> CostUsageCache + { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) - if let decoded = self.loadCache(at: url) { return decoded } + let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) + let compatibleProducerKeys = producerKey == nil && provider == .codex + ? self.compatibleCodexProducerKeys + : [] + if let decoded = self.loadCache( + at: url, + expectedProducerKey: expectedProducerKey, + compatibleProducerKeys: compatibleProducerKeys) + { + return decoded + } return CostUsageCache() } - private static func loadCache(at url: URL) -> CostUsageCache? { + private static func loadCache( + at url: URL, + expectedProducerKey: String?, + compatibleProducerKeys: Set) -> CostUsageCache? + { guard let data = try? Data(contentsOf: url) else { return nil } guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data) else { return nil } guard decoded.version == 1 else { return nil } + if let expectedProducerKey { + guard decoded.producerKey == expectedProducerKey + || decoded.producerKey.map(compatibleProducerKeys.contains) == true + else { return nil } + } return decoded } - static func save(provider: UsageProvider, cache: CostUsageCache, cacheRoot: URL? = nil) { + static func save( + provider: UsageProvider, + cache: CostUsageCache, + cacheRoot: URL? = nil, + producerKey: String? = nil) + { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var cache = cache + cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) + let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) let data = (try? JSONEncoder().encode(cache)) ?? Data() do { try data.write(to: tmp, options: [.atomic]) - _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } else { + try FileManager.default.moveItem(at: tmp, to: url) + } } catch { try? FileManager.default.removeItem(at: tmp) } } + + static func currentProducerKey( + provider: UsageProvider, + parserHash: String = CodexParserHash.value) -> String? + { + guard provider == .codex else { return nil } + return "\(provider.rawValue):cu:p\(parserHash)" + } } -struct CostUsageCache: Codable, Sendable { +struct CostUsageCache: Codable { var version: Int = 1 + var producerKey: String? var lastScanUnixMs: Int64 = 0 + var scanSinceKey: String? + var scanUntilKey: String? + var codexPricingKey: String? + var codexPriorityMetadataKey: String? + var codexPriorityTurnKeys: [String: String]? + var codexPriorityTurnIDsByDay: [String: [String]]? /// filePath -> file usage var files: [String: CostUsageFileUsage] = [:] @@ -57,17 +123,31 @@ struct CostUsageCache: Codable, Sendable { var roots: [String: Int64]? } -struct CostUsageFileUsage: Codable, Sendable { +struct CostUsageFileUsage: Codable { var mtimeUnixMs: Int64 var size: Int64 var days: [String: [String: [Int]]] var parsedBytes: Int64? var lastModel: String? var lastTotals: CostUsageCodexTotals? + var lastCountedTotals: CostUsageCodexTotals? + var lastRawTotalsBaseline: CostUsageCodexTotals? + var hasDivergentTotals: Bool? + var lastCodexTurnID: String? var sessionId: String? + var forkedFromId: String? + var codexCostNanos: [String: [String: Int64]]? + var codexPrioritySurchargeNanos: [String: [String: Int64]]? + var codexStandardCostNanos: [String: [String: Int64]]? + var codexPriorityCostNanos: [String: [String: Int64]]? + var codexStandardTokens: [String: [String: Int]]? + var codexPriorityTokens: [String: [String: Int]]? + var codexTurnIDs: [String]? + var codexRows: [CostUsageScanner.CodexUsageRow]? + var claudeRows: [CostUsageScanner.ClaudeUsageRow]? } -struct CostUsageCodexTotals: Codable, Sendable { +struct CostUsageCodexTotals: Codable { var input: Int var cached: Int var output: Int diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift index 5105f97593..35f58b446a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift @@ -1,7 +1,7 @@ import Foundation enum CostUsageJsonl { - struct Line: Sendable { + struct Line { let bytes: Data let wasTruncated: Bool } @@ -14,6 +14,25 @@ enum CostUsageJsonl { prefixBytes: Int, onLine: (Line) -> Void) throws -> Int64 + { + try self.scan( + fileURL: fileURL, + offset: offset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + checkCancellation: nil, + onLine: onLine) + } + + @discardableResult + static func scan( + fileURL: URL, + offset: Int64 = 0, + maxLineBytes: Int, + prefixBytes: Int, + checkCancellation: (() throws -> Void)? = nil, + onLine: (Line) -> Void) throws + -> Int64 { let handle = try FileHandle(forReadingFrom: fileURL) defer { try? handle.close() } @@ -29,16 +48,18 @@ enum CostUsageJsonl { var truncated = false var bytesRead: Int64 = 0 - func appendSegment(_ segment: Data.SubSequence) { - guard !segment.isEmpty else { return } - lineBytes += segment.count - guard !truncated else { return } + func appendSegment(_ bytes: UnsafePointer, count: Int) { + guard count > 0 else { return } + lineBytes += count + if current.count < prefixBytes { + let appendCount = min(prefixBytes - current.count, count) + if appendCount > 0 { + current.append(bytes, count: appendCount) + } + } if lineBytes > maxLineBytes || lineBytes > prefixBytes { truncated = true - current.removeAll(keepingCapacity: true) - return } - current.append(contentsOf: segment) } func flushLine() { @@ -51,22 +72,36 @@ enum CostUsageJsonl { } while true { - let chunk = try handle.read(upToCount: 256 * 1024) ?? Data() - if chunk.isEmpty { - flushLine() - break - } + try checkCancellation?() + let reachedEOF = try autoreleasepool { + let chunk = try handle.read(upToCount: 256 * 1024) ?? Data() + if chunk.isEmpty { + flushLine() + return true + } - bytesRead += Int64(chunk.count) - var segmentStart = chunk.startIndex - while let nl = chunk[segmentStart...].firstIndex(of: 0x0A) { - appendSegment(chunk[segmentStart.. String { + var parts = ["priorityInputTokenLimit=\(self.codexPriorityInputTokenLimit)"] + for model in self.codex.keys.sorted() { + guard let pricing = self.codex[model] else { continue } + parts.append([ + "model=\(model)", + self.optionalPricingFingerprint(pricing.inputCostPerToken), + self.optionalPricingFingerprint(pricing.outputCostPerToken), + self.optionalPricingFingerprint(pricing.cacheReadInputCostPerToken), + pricing.displayLabel ?? "nil", + pricing.thresholdTokens.map(String.init) ?? "nil", + self.optionalPricingFingerprint(pricing.inputCostPerTokenAboveThreshold), + self.optionalPricingFingerprint(pricing.outputCostPerTokenAboveThreshold), + self.optionalPricingFingerprint(pricing.cacheReadInputCostPerTokenAboveThreshold), + self.optionalPricingFingerprint(pricing.priorityInputCostPerToken), + self.optionalPricingFingerprint(pricing.priorityOutputCostPerToken), + self.optionalPricingFingerprint(pricing.priorityCacheReadInputCostPerToken), + ].joined(separator: "|")) + } + return parts.joined(separator: "\n") + } + + private static func optionalPricingFingerprint(_ value: Double?) -> String { + guard let value else { return "nil" } + return String(format: "%.17g", value) + } + private static let claude: [String: ClaudePricing] = [ + "claude-fable-5": ClaudePricing( + inputCostPerToken: 1e-5, + outputCostPerToken: 5e-5, + cacheCreationInputCostPerToken: 1.25e-5, + cacheReadInputCostPerToken: 1e-6, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-haiku-4-5-20251001": ClaudePricing( inputCostPerToken: 1e-6, outputCostPerToken: 5e-6, @@ -112,6 +283,26 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: nil, cacheCreationInputCostPerTokenAboveThreshold: nil, cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-opus-4-7": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-opus-4-8": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, @@ -122,6 +313,16 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: 2.25e-5, cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6, cacheReadInputCostPerTokenAboveThreshold: 6e-7), + "claude-sonnet-4-6": ClaudePricing( + inputCostPerToken: 3e-6, + outputCostPerToken: 1.5e-5, + cacheCreationInputCostPerToken: 3.75e-6, + cacheReadInputCostPerToken: 3e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5-20250929": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, @@ -164,18 +365,57 @@ enum CostUsagePricing { cacheReadInputCostPerTokenAboveThreshold: 6e-7), ] + private static let claudeFullContextStandardPricingCutoff = Date(timeIntervalSince1970: 1_773_360_000) + private static let claudeHistoricalLongContext: [String: ClaudePricing] = [ + "claude-opus-4-6": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 1e-5, + outputCostPerTokenAboveThreshold: 3.75e-5, + cacheCreationInputCostPerTokenAboveThreshold: 1.25e-5, + cacheReadInputCostPerTokenAboveThreshold: 1e-6), + "claude-sonnet-4-6": ClaudePricing( + inputCostPerToken: 3e-6, + outputCostPerToken: 1.5e-5, + cacheCreationInputCostPerToken: 3.75e-6, + cacheReadInputCostPerToken: 3e-7, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 6e-6, + outputCostPerTokenAboveThreshold: 2.25e-5, + cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6, + cacheReadInputCostPerTokenAboveThreshold: 6e-7), + ] + + private static let codexModelsDevProviderID = "openai" + private static let claudeModelsDevProviderID = "anthropic" + static func normalizeCodexModel(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("openai/") { trimmed = String(trimmed.dropFirst("openai/".count)) } - if let codexRange = trimmed.range(of: "-codex") { - let base = String(trimmed[.. String? { + let key = self.normalizeCodexModel(model) + return self.codex[key]?.displayLabel + } + static func normalizeClaudeModel(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("anthropic.") { @@ -205,14 +445,110 @@ enum CostUsagePricing { return trimmed } - static func codexCostUSD(model: String, inputTokens: Int, cachedInputTokens: Int, outputTokens: Int) -> Double? { + static func codexCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? + { let key = self.normalizeCodexModel(model) + if let lookup = self.modelsDevLookup( + providerID: self.codexModelsDevProviderID, + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.codexCostUSD( + pricing: lookup.pricing, + thresholdTokens: self.codex[key]?.thresholdTokens, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + guard let pricing = self.codex[key] else { return nil } + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + + static func codexPriorityCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int = 0, + outputTokens: Int) -> Double? + { + let key = self.normalizeCodexModel(model) + guard let pricing = self.codex[key], + let priorityInputCostPerToken = pricing.priorityInputCostPerToken, + let priorityOutputCostPerToken = pricing.priorityOutputCostPerToken + else { return nil } + if max(0, inputTokens) > self.codexPriorityInputTokenLimit { + return nil + } + + let priorityPricing = CodexPricing( + inputCostPerToken: priorityInputCostPerToken, + outputCostPerToken: priorityOutputCostPerToken, + cacheReadInputCostPerToken: pricing.priorityCacheReadInputCostPerToken, + displayLabel: nil) + return self.codexCostUSD( + pricing: priorityPricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + + private static func codexCostUSD( + pricing: CodexPricing, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int) -> Double + { let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) let nonCached = max(0, inputTokens - cached) - return Double(nonCached) * pricing.inputCostPerToken - + Double(cached) * pricing.cacheReadInputCostPerToken - + Double(max(0, outputTokens)) * pricing.outputCostPerToken + let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken + + let usesLongContextRates = pricing.thresholdTokens.map { max(0, inputTokens) > $0 } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cachedInputRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? cachedRate + : cachedRate + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + + return (Double(nonCached) * inputRate) + + (Double(cached) * cachedInputRate) + + (Double(max(0, outputTokens)) * outputRate) + } + + private static func codexCostUSD( + pricing: ModelsDevPricingInfo, + thresholdTokens: Int? = nil, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int) -> Double + { + self.codexCostUSD( + pricing: CodexPricing( + inputCostPerToken: pricing.inputCostPerToken, + outputCostPerToken: pricing.outputCostPerToken, + cacheReadInputCostPerToken: pricing.cacheReadInputCostPerToken, + displayLabel: nil, + thresholdTokens: thresholdTokens ?? pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) } static func claudeCostUSD( @@ -220,37 +556,113 @@ enum CostUsagePricing { inputTokens: Int, cacheReadInputTokens: Int, cacheCreationInputTokens: Int, - outputTokens: Int) -> Double? + cacheCreationInputTokens1h: Int = 0, + outputTokens: Int, + pricingDate: Date? = nil, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? { + let tokens = ClaudeCostTokens( + input: inputTokens, + cacheRead: cacheReadInputTokens, + cacheCreation: cacheCreationInputTokens, + cacheCreation1h: cacheCreationInputTokens1h, + output: outputTokens) let key = self.normalizeClaudeModel(model) + if let pricingDate, + let historicalPricing = self.claudeHistoricalLongContext[key], + let currentPricing = self.claude[key] + { + return self.claudeCostUSD( + pricing: pricingDate < self.claudeFullContextStandardPricingCutoff + ? historicalPricing + : currentPricing, + tokens: tokens) + } + if let lookup = self.modelsDevLookup( + providerID: self.claudeModelsDevProviderID, + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.claudeCostUSD( + pricing: lookup.pricing, + tokens: tokens) + } + guard let pricing = self.claude[key] else { return nil } + return self.claudeCostUSD( + pricing: pricing, + tokens: tokens) + } + + private static func claudeCostUSD( + pricing: ClaudePricing, + tokens: ClaudeCostTokens) -> Double + { + let input = max(0, tokens.input) + let cacheRead = max(0, tokens.cacheRead) + let cacheCreationTotal = max(0, tokens.cacheCreation) + let cacheCreation1h = min(max(0, tokens.cacheCreation1h), cacheCreationTotal) + let cacheCreation5m = cacheCreationTotal - cacheCreation1h + let usesLongContextRates = pricing.thresholdTokens.map { + input + cacheRead + cacheCreationTotal > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken + : pricing.cacheReadInputCostPerToken + let cacheCreation5mRate = usesLongContextRates + ? pricing.cacheCreationInputCostPerTokenAboveThreshold ?? pricing.cacheCreationInputCostPerToken + : pricing.cacheCreationInputCostPerToken + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + + return Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(cacheCreation5m) * cacheCreation5mRate + + Double(cacheCreation1h) * inputRate * 2 + + Double(max(0, tokens.output)) * outputRate + } + + private static func claudeCostUSD( + pricing: ModelsDevPricingInfo, + tokens: ClaudeCostTokens) -> Double + { + self.claudeCostUSD( + pricing: ClaudePricing( + inputCostPerToken: pricing.inputCostPerToken, + outputCostPerToken: pricing.outputCostPerToken, + cacheCreationInputCostPerToken: pricing.cacheCreationInputCostPerToken ?? pricing.inputCostPerToken, + cacheReadInputCostPerToken: pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken, + thresholdTokens: pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, + cacheCreationInputCostPerTokenAboveThreshold: pricing.cacheCreationInputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), + tokens: tokens) + } - func tiered(_ tokens: Int, base: Double, above: Double?, threshold: Int?) -> Double { - guard let threshold, let above else { return Double(tokens) * base } - let below = min(tokens, threshold) - let over = max(tokens - threshold, 0) - return Double(below) * base + Double(over) * above + static func modelsDevCatalog(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCatalog? { + ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog + } + + private static func modelsDevLookup( + providerID: String, + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ModelsDevPricingLookup? + { + if let catalog { + return catalog.pricing(providerID: providerID, modelID: model) } - return tiered( - max(0, inputTokens), - base: pricing.inputCostPerToken, - above: pricing.inputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, cacheReadInputTokens), - base: pricing.cacheReadInputCostPerToken, - above: pricing.cacheReadInputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, cacheCreationInputTokens), - base: pricing.cacheCreationInputCostPerToken, - above: pricing.cacheCreationInputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) - + tiered( - max(0, outputTokens), - base: pricing.outputCostPerToken, - above: pricing.outputCostPerTokenAboveThreshold, - threshold: pricing.thresholdTokens) + return ModelsDevPricingPipeline.lookup( + providerID: providerID, + modelID: model, + cacheRoot: cacheRoot) } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift new file mode 100644 index 0000000000..0e7ea82ce7 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -0,0 +1,1212 @@ +import Foundation +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#else +import Darwin +#endif + +extension CostUsageScanner { + static func codexRowsByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: [CodexUsageRow]]] + { + var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] + for usage in cache.files.values { + for row in usage.codexRows ?? [] { + guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) + else { continue } + rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) + } + } + return rowsByDayModel + } + + static func codexRowsByDayModel( + rows: [CodexUsageRow], + range: CostUsageDayRange) -> [String: [String: [CodexUsageRow]]] + { + var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] + for row in rows { + guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) + else { continue } + rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) + } + return rowsByDayModel + } + + static func codexCostNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int64]] + { + self.codexNanosByDayModel(cache: cache, range: range) { $0.codexCostNanos } + } + + static func codexPrioritySurchargeNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int64]] + { + self.codexNanosByDayModel(cache: cache, range: range) { $0.codexPrioritySurchargeNanos } + } + + static func codexStandardCostNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int64]] + { + self.codexNanosByDayModel(cache: cache, range: range) { $0.codexStandardCostNanos } + } + + static func codexPriorityCostNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int64]] + { + self.codexNanosByDayModel(cache: cache, range: range) { $0.codexPriorityCostNanos } + } + + static func codexStandardTokensByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int]] + { + self.codexIntByDayModel(cache: cache, range: range) { $0.codexStandardTokens } + } + + static func codexPriorityTokensByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int]] + { + self.codexIntByDayModel(cache: cache, range: range) { $0.codexPriorityTokens } + } + + static func codexNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange, + keyPath: (CostUsageFileUsage) -> [String: [String: Int64]]?) -> [String: [String: Int64]] + { + var out: [String: [String: Int64]] = [:] + for usage in cache.files.values { + for (day, models) in keyPath(usage) ?? [:] { + guard CostUsageDayRange.isInRange(dayKey: day, since: range.sinceKey, until: range.untilKey) + else { continue } + for (model, value) in models { + out[day, default: [:]][model, default: 0] += value + } + } + } + return out + } + + static func codexIntByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange, + keyPath: (CostUsageFileUsage) -> [String: [String: Int]]?) -> [String: [String: Int]] + { + var out: [String: [String: Int]] = [:] + for usage in cache.files.values { + for (day, models) in keyPath(usage) ?? [:] { + guard CostUsageDayRange.isInRange(dayKey: day, since: range.sinceKey, until: range.untilKey) + else { continue } + for (model, value) in models { + out[day, default: [:]][model, default: 0] += value + } + } + } + return out + } + + static func codexRowsCostUSD( + rows: [CodexUsageRow], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + var total: Double = 0 + var seen = false + for row in rows { + guard let cost = CostUsagePricing.codexCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } + total += cost + seen = true + } + return seen ? total : nil + } + + static func codexPrioritySurchargeUSD( + rows: [CodexUsageRow], + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + var total: Double = 0 + var seen = false + for row in rows { + guard let turnID = row.turnID, let priorityMetadata = priorityTurns[turnID] else { continue } + let pricedModel = Self.codexPriorityPricingModel(for: row, priorityMetadata: priorityMetadata) + guard let baseCost = CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot), + let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + else { continue } + total += max(priorityCost - baseCost, 0) + seen = true + } + return seen ? total : nil + } + + private static func codexPriorityPricingModel( + for row: CodexUsageRow, + priorityMetadata: CodexPriorityTurnMetadata) -> String + { + guard let model = priorityMetadata.model, + CostUsagePricing.codexPriorityCostUSD( + model: model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) != nil + else { return row.model } + return model + } + + struct CodexRowCostBreakdown { + var standardCostUSD: Double = 0 + var priorityCostUSD: Double = 0 + var standardTokens: Int = 0 + var priorityTokens: Int = 0 + var sawStandardCost = false + var sawPriorityCost = false + + var optionalStandardCostUSD: Double? { + self.sawStandardCost ? self.standardCostUSD : nil + } + + var optionalPriorityCostUSD: Double? { + self.sawPriorityCost ? self.priorityCostUSD : nil + } + + var optionalStandardTokens: Int? { + self.standardTokens > 0 ? self.standardTokens : nil + } + + var optionalPriorityTokens: Int? { + self.priorityTokens > 0 ? self.priorityTokens : nil + } + + var totalCostUSD: Double? { + guard self.sawStandardCost || self.sawPriorityCost else { return nil } + return self.standardCostUSD + self.priorityCostUSD + } + + var hasModeSplit: Bool { + self.sawPriorityCost || self.priorityTokens > 0 + } + } + + static func codexRowCostBreakdown( + rows: [CodexUsageRow], + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> CodexRowCostBreakdown + { + var breakdown = CodexRowCostBreakdown() + for row in rows { + let tokenCount = row.input + row.output + let priorityMetadata = row.turnID.flatMap { priorityTurns[$0] } + let isPriority = priorityMetadata != nil + if isPriority { + breakdown.priorityTokens += tokenCount + } else { + breakdown.standardTokens += tokenCount + } + let pricedModel = priorityMetadata.map { Self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } + ?? row.model + + let baseCost = CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + if isPriority, let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + { + breakdown.priorityCostUSD += max(priorityCost, baseCost ?? priorityCost) + breakdown.sawPriorityCost = true + } else if isPriority, let baseCost { + breakdown.priorityCostUSD += baseCost + breakdown.sawPriorityCost = true + } else if let baseCost { + breakdown.standardCostUSD += baseCost + breakdown.sawStandardCost = true + } + } + return breakdown + } + + // MARK: - File cache construction + + static func makeFileUsage( + mtimeUnixMs: Int64, + size: Int64, + days: [String: [String: [Int]]], + parsedBytes: Int64?, + lastModel: String? = nil, + lastTotals: CostUsageCodexTotals? = nil, + lastCountedTotals: CostUsageCodexTotals? = nil, + lastRawTotalsBaseline: CostUsageCodexTotals? = nil, + hasDivergentTotals: Bool? = nil, + lastCodexTurnID: String? = nil, + sessionId: String? = nil, + forkedFromId: String? = nil, + codexCostNanos: [String: [String: Int64]]? = nil, + codexPrioritySurchargeNanos: [String: [String: Int64]]? = nil, + codexStandardCostNanos: [String: [String: Int64]]? = nil, + codexPriorityCostNanos: [String: [String: Int64]]? = nil, + codexStandardTokens: [String: [String: Int]]? = nil, + codexPriorityTokens: [String: [String: Int]]? = nil, + codexTurnIDs: [String]? = nil, + codexRows: [CodexUsageRow]? = nil, + claudeRows: [ClaudeUsageRow]? = nil) -> CostUsageFileUsage + { + CostUsageFileUsage( + mtimeUnixMs: mtimeUnixMs, + size: size, + days: days, + parsedBytes: parsedBytes, + lastModel: lastModel, + lastTotals: lastTotals, + lastCountedTotals: lastCountedTotals, + lastRawTotalsBaseline: lastRawTotalsBaseline, + hasDivergentTotals: hasDivergentTotals, + lastCodexTurnID: lastCodexTurnID, + sessionId: sessionId, + forkedFromId: forkedFromId, + codexCostNanos: codexCostNanos, + codexPrioritySurchargeNanos: codexPrioritySurchargeNanos, + codexStandardCostNanos: codexStandardCostNanos, + codexPriorityCostNanos: codexPriorityCostNanos, + codexStandardTokens: codexStandardTokens, + codexPriorityTokens: codexPriorityTokens, + codexTurnIDs: codexTurnIDs, + codexRows: codexRows, + claudeRows: claudeRows) + } + + static func needsCodexCostCache(_ usage: CostUsageFileUsage) -> Bool { + !(usage.codexRows?.isEmpty ?? true) + && (usage.codexCostNanos == nil || self.needsCodexModeSplitCache(usage)) + } + + static func needsCodexCostCache(_ usage: CostUsageFileUsage, range: CostUsageDayRange) -> Bool { + guard let rows = usage.codexRows, !rows.isEmpty else { return false } + return rows.contains { + CostUsageDayRange.isInRange(dayKey: $0.day, since: range.sinceKey, until: range.untilKey) + } && (usage.codexCostNanos == nil || Self.needsCodexModeSplitCache(usage)) + } + + static func needsCodexModeSplitCache(_ usage: CostUsageFileUsage) -> Bool { + usage.codexStandardCostNanos == nil + || usage.codexPriorityCostNanos == nil + || usage.codexStandardTokens == nil + || usage.codexPriorityTokens == nil + } + + static func codexFileUsageWithCostCache( + _ usage: CostUsageFileUsage, + context: CodexFileScanContext) -> CostUsageFileUsage + { + guard let rows = usage.codexRows, !rows.isEmpty else { return usage } + var migratedRows: [CodexUsageRow] = [] + var retainedRows: [CodexUsageRow] = [] + for row in rows { + if CostUsageDayRange.isInRange( + dayKey: row.day, + since: context.range.scanSinceKey, + until: context.range.scanUntilKey) + { + migratedRows.append(row) + } else { + retainedRows.append(row) + } + } + guard !migratedRows.isEmpty else { return usage } + + let splitMaps = Self.codexModeSplitMaps( + rows: migratedRows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + var updated = usage + updated.codexCostNanos = Self.mergeMissingCostMaps( + usage.codexCostNanos, + Self.codexCostNanos( + rows: migratedRows, + range: context.range, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + updated.codexPrioritySurchargeNanos = Self.mergeMissingCostMaps( + usage.codexPrioritySurchargeNanos, + Self.codexPrioritySurchargeNanos( + rows: migratedRows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + updated.codexStandardCostNanos = Self.mergeMissingCostMaps( + usage.codexStandardCostNanos, + splitMaps.standardCostNanos) + updated.codexPriorityCostNanos = Self.mergeMissingCostMaps( + usage.codexPriorityCostNanos, + splitMaps.priorityCostNanos) + updated.codexStandardTokens = Self.mergeMissingIntMaps( + usage.codexStandardTokens, + splitMaps.standardTokens) + updated.codexPriorityTokens = Self.mergeMissingIntMaps( + usage.codexPriorityTokens, + splitMaps.priorityTokens) + updated.codexTurnIDs = Self.mergeCodexTurnIDs(usage.codexTurnIDs, rows: migratedRows) + updated.codexRows = retainedRows.isEmpty ? nil : retainedRows + return updated + } + + static func codexMergedCostMap( + _ existing: [String: [String: Int64]]?, + deltaRows: [CodexUsageRow], + context: CodexFileScanContext) -> [String: [String: Int64]]? + { + self.mergeCostMaps( + existing, + self.codexCostNanos( + rows: deltaRows, + range: context.range, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + } + + static func codexMergedPrioritySurchargeMap( + _ existing: [String: [String: Int64]]?, + deltaRows: [CodexUsageRow], + context: CodexFileScanContext) -> [String: [String: Int64]]? + { + self.mergeCostMaps( + existing, + self.codexPrioritySurchargeNanos( + rows: deltaRows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + } + + static func codexCostNanos( + rows: [CodexUsageRow], + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> [String: [String: Int64]]? + { + let rowsByDayModel = Self.codexRowsByDayModel(rows: rows, range: range) + var out: [String: [String: Int64]] = [:] + for (day, models) in rowsByDayModel { + for (model, rows) in models { + guard let cost = Self.codexRowsCostUSD( + rows: rows, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } + out[day, default: [:]][model] = Int64((cost * Self.costScale).rounded()) + } + } + return out.isEmpty ? nil : out + } + + static func codexPrioritySurchargeNanos( + rows: [CodexUsageRow], + range: CostUsageDayRange, + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> [String: [String: Int64]]? + { + guard !priorityTurns.isEmpty else { return nil } + let rowsByDayModel = Self.codexRowsByDayModel(rows: rows, range: range) + var out: [String: [String: Int64]] = [:] + for (day, models) in rowsByDayModel { + for (model, rows) in models { + guard let surcharge = Self.codexPrioritySurchargeUSD( + rows: rows, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } + out[day, default: [:]][model] = Int64((surcharge * Self.costScale).rounded()) + } + } + return out.isEmpty ? nil : out + } + + static func codexModeSplitMaps( + rows: [CodexUsageRow], + range: CostUsageDayRange, + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> ( + standardCostNanos: [String: [String: Int64]]?, + priorityCostNanos: [String: [String: Int64]]?, + standardTokens: [String: [String: Int]]?, + priorityTokens: [String: [String: Int]]?) + { + var standardCostNanos: [String: [String: Int64]] = [:] + var priorityCostNanos: [String: [String: Int64]] = [:] + var standardTokens: [String: [String: Int]] = [:] + var priorityTokens: [String: [String: Int]] = [:] + + for row in rows { + guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) + else { continue } + + let tokenCount = row.input + row.output + let priorityMetadata = row.turnID.flatMap { priorityTurns[$0] } + let pricedModel = priorityMetadata.map { Self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } + ?? row.model + let isPriority = priorityMetadata != nil + + if isPriority { + priorityTokens[row.day, default: [:]][row.model, default: 0] += tokenCount + } else { + standardTokens[row.day, default: [:]][row.model, default: 0] += tokenCount + } + + let baseCost = CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + + if isPriority, let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + { + priorityCostNanos[row.day, default: [:]][row.model, default: 0] += Int64( + (max(priorityCost, baseCost ?? priorityCost) * Self.costScale).rounded()) + } else if isPriority, let baseCost { + priorityCostNanos[row.day, default: [:]][row.model, default: 0] += Int64( + (baseCost * Self.costScale).rounded()) + } else if let baseCost { + standardCostNanos[row.day, default: [:]][row.model, default: 0] += Int64( + (baseCost * Self.costScale).rounded()) + } + } + + return ( + standardCostNanos.isEmpty ? nil : standardCostNanos, + priorityCostNanos.isEmpty ? nil : priorityCostNanos, + standardTokens.isEmpty ? nil : standardTokens, + priorityTokens.isEmpty ? nil : priorityTokens) + } + + static func codexTurnIDs(rows: [CodexUsageRow]) -> [String]? { + let ids = Set(rows.compactMap(\.turnID)) + return ids.sorted() + } + + static func mergeCodexTurnIDs(_ existing: [String]?, rows: [CodexUsageRow]) -> [String]? { + var ids = Set(existing ?? []) + ids.formUnion(rows.compactMap(\.turnID)) + return ids.sorted() + } + + static func mergeCostMaps( + _ existing: [String: [String: Int64]]?, + _ delta: [String: [String: Int64]]?) -> [String: [String: Int64]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models { + out[day, default: [:]][model, default: 0] += value + } + } + return out.isEmpty ? nil : out + } + + static func mergeMissingCostMaps( + _ existing: [String: [String: Int64]]?, + _ delta: [String: [String: Int64]]?) -> [String: [String: Int64]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models where out[day]?[model] == nil { + out[day, default: [:]][model] = value + } + } + return out.isEmpty ? nil : out + } + + static func mergeIntMaps( + _ existing: [String: [String: Int]]?, + _ delta: [String: [String: Int]]?) -> [String: [String: Int]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models { + out[day, default: [:]][model, default: 0] += value + } + } + return out.isEmpty ? nil : out + } + + static func mergeMissingIntMaps( + _ existing: [String: [String: Int]]?, + _ delta: [String: [String: Int]]?) -> [String: [String: Int]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models where out[day]?[model] == nil { + out[day, default: [:]][model] = value + } + } + return out.isEmpty ? nil : out + } + + static func costMapOutsideScanWindow( + _ map: [String: [String: Int64]]?, + range: CostUsageDayRange) -> [String: [String: Int64]]? + { + let filtered = (map ?? [:]).filter { + !CostUsageDayRange.isInRange(dayKey: $0.key, since: range.scanSinceKey, until: range.scanUntilKey) + } + return filtered.isEmpty ? nil : filtered + } + + static func intMapOutsideScanWindow( + _ map: [String: [String: Int]]?, + range: CostUsageDayRange) -> [String: [String: Int]]? + { + let filtered = (map ?? [:]).filter { + !CostUsageDayRange.isInRange(dayKey: $0.key, since: range.scanSinceKey, until: range.scanUntilKey) + } + return filtered.isEmpty ? nil : filtered + } + + // MARK: - File scan orchestration + + struct CodexFileMetadata { + let path: String + let mtimeUnixMs: Int64 + let size: Int64 + let fileId: String? + } + + struct CodexFileScanInput { + let fileURL: URL + let metadata: CodexFileMetadata + let cached: CostUsageFileUsage? + } + + static func codexFileMetadata(fileURL: URL) -> CodexFileMetadata { + let path = fileURL.path + var info = stat() + guard path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { + return CodexFileMetadata(path: path, mtimeUnixMs: 0, size: 0, fileId: nil) + } + #if os(Linux) + let modifiedSeconds = Int64(info.st_mtim.tv_sec) + let modifiedNanoseconds = Int64(info.st_mtim.tv_nsec) + #else + let modifiedSeconds = Int64(info.st_mtimespec.tv_sec) + let modifiedNanoseconds = Int64(info.st_mtimespec.tv_nsec) + #endif + return CodexFileMetadata( + path: path, + mtimeUnixMs: modifiedSeconds * 1000 + modifiedNanoseconds / 1_000_000, + size: Int64(info.st_size), + fileId: "\(info.st_dev):\(info.st_ino)") + } + + static func dropCachedCodexFile( + path: String, + cached: CostUsageFileUsage?, + cache: inout CostUsageCache) + { + if let cached { + self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) + } + cache.files.removeValue(forKey: path) + } + + static func rememberScannedCodexFile( + fileURL: URL, + metadata: CodexFileMetadata, + sessionId: String?, + context: CodexFileScanContext, + state: inout CodexScanState) + { + if let sessionId { + state.seenSessionIds.insert(sessionId) + context.resources.fileIndex.remember(fileURL: fileURL, sessionId: sessionId) + } + if let fileId = metadata.fileId { + state.seenFileIds.insert(fileId) + } + } + + static func keepCachedCodexFileIfFresh( + input: CodexFileScanInput, + context: CodexFileScanContext, + cache: inout CostUsageCache, + state: inout CodexScanState) -> Bool + { + guard let cached = input.cached else { return false } + let needsSessionId = cached.sessionId == nil + guard cached.mtimeUnixMs == input.metadata.mtimeUnixMs, + cached.size == input.metadata.size, + !needsSessionId, + !context.forceFullScan + else { return false } + + guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } + + if Self.needsCodexCostCache(cached, range: context.range) { + cache.files[input.metadata.path] = Self.codexFileUsageWithCostCache(cached, context: context) + } + Self.rememberScannedCodexFile( + fileURL: input.fileURL, + metadata: input.metadata, + sessionId: cached.sessionId, + context: context, + state: &state) + return true + } + + static func cachedCodexFileNeedsPriorityRescan( + _ cached: CostUsageFileUsage, + context: CodexFileScanContext) -> Bool + { + if cached.codexTurnIDs == nil { + return context.requiresTurnIDCache + } + guard !context.changedPriorityTurnIDs.isEmpty else { return false } + return !(Set(cached.codexTurnIDs ?? []).isDisjoint(with: context.changedPriorityTurnIDs)) + } + + static func appendCodexFileIncrementIfPossible( + input: CodexFileScanInput, + context: CodexFileScanContext, + cache: inout CostUsageCache, + state: inout CodexScanState) throws -> Bool + { + try context.checkCancellation?() + guard let cached = input.cached, cached.sessionId != nil, !context.forceFullScan else { return false } + guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } + let startOffset = cached.parsedBytes ?? cached.size + let initialCountedTotals = cached.lastCountedTotals ?? cached.lastTotals + let initialRawTotalsBaseline = cached.lastRawTotalsBaseline ?? cached.lastTotals + let canIncremental = input.metadata.size > cached.size && startOffset > 0 + && startOffset <= input.metadata.size + && initialCountedTotals != nil + && cached.forkedFromId == nil + guard canIncremental else { return false } + + let delta = try Self.parseCodexFileCancellable( + fileURL: input.fileURL, + range: context.range, + startOffset: startOffset, + initialModel: cached.lastModel, + initialTotals: initialCountedTotals, + initialRawTotalsBaseline: initialRawTotalsBaseline, + initialHasDivergentTotals: cached.hasDivergentTotals ?? (cached.lastTotals == nil), + initialCodexTurnID: cached.lastCodexTurnID, + checkCancellation: context.checkCancellation) + if delta.forkedFromId != nil { + return false + } + let sessionId = delta.sessionId ?? cached.sessionId + if let sessionId, state.seenSessionIds.contains(sessionId) { + Self.dropCachedCodexFile(path: input.metadata.path, cached: cached, cache: &cache) + return true + } + + let migratedCached = Self.codexFileUsageWithCostCache(cached, context: context) + if !delta.days.isEmpty { + Self.applyFileDays(cache: &cache, fileDays: delta.days, sign: 1) + } + + var mergedDays = migratedCached.days + Self.mergeFileDays(existing: &mergedDays, delta: delta.days) + let splitMaps = Self.codexModeSplitMaps( + rows: delta.rows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + cache.files[input.metadata.path] = Self.makeFileUsage( + mtimeUnixMs: input.metadata.mtimeUnixMs, + size: input.metadata.size, + days: mergedDays, + parsedBytes: delta.parsedBytes, + lastModel: delta.lastModel, + lastTotals: delta.lastTotals, + lastCountedTotals: delta.lastCountedTotals, + lastRawTotalsBaseline: delta.lastRawTotalsBaseline, + hasDivergentTotals: delta.hasDivergentTotals, + lastCodexTurnID: delta.lastCodexTurnID, + sessionId: sessionId, + forkedFromId: delta.forkedFromId ?? migratedCached.forkedFromId, + codexCostNanos: Self.codexMergedCostMap( + migratedCached.codexCostNanos, + deltaRows: delta.rows, + context: context), + codexPrioritySurchargeNanos: Self.codexMergedPrioritySurchargeMap( + migratedCached.codexPrioritySurchargeNanos, + deltaRows: delta.rows, + context: context), + codexStandardCostNanos: Self.mergeCostMaps( + migratedCached.codexStandardCostNanos, + splitMaps.standardCostNanos), + codexPriorityCostNanos: Self.mergeCostMaps( + migratedCached.codexPriorityCostNanos, + splitMaps.priorityCostNanos), + codexStandardTokens: Self.mergeIntMaps( + migratedCached.codexStandardTokens, + splitMaps.standardTokens), + codexPriorityTokens: Self.mergeIntMaps( + migratedCached.codexPriorityTokens, + splitMaps.priorityTokens), + codexTurnIDs: Self.mergeCodexTurnIDs(migratedCached.codexTurnIDs, rows: delta.rows), + codexRows: migratedCached.codexRows) + Self.rememberScannedCodexFile( + fileURL: input.fileURL, + metadata: input.metadata, + sessionId: sessionId, + context: context, + state: &state) + return true + } + + static func rescanCodexFile( + input: CodexFileScanInput, + context: CodexFileScanContext, + cache: inout CostUsageCache, + state: inout CodexScanState) throws + { + try context.checkCancellation?() + if let cached = input.cached { + self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) + } + let migratedCached = input.cached.map { Self.codexFileUsageWithCostCache($0, context: context) } + var usageDays = context.dropDeferredCodexRows + ? [:] + : Self.fileDaysOutsideScanWindow(migratedCached?.days ?? [:], range: context.range) + + let parsed = try Self.parseCodexFileCancellable( + fileURL: input.fileURL, + range: context.range, + inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:), + checkCancellation: context.checkCancellation) + let sessionId = parsed.sessionId ?? input.cached?.sessionId + if let sessionId, state.seenSessionIds.contains(sessionId) { + cache.files.removeValue(forKey: input.metadata.path) + return + } + Self.mergeFileDays(existing: &usageDays, delta: parsed.days) + let splitMaps = Self.codexModeSplitMaps( + rows: parsed.rows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + + cache.files[input.metadata.path] = Self.makeFileUsage( + mtimeUnixMs: input.metadata.mtimeUnixMs, + size: input.metadata.size, + days: usageDays, + parsedBytes: parsed.parsedBytes, + lastModel: parsed.lastModel, + lastTotals: parsed.lastTotals, + lastCountedTotals: parsed.lastCountedTotals, + lastRawTotalsBaseline: parsed.lastRawTotalsBaseline, + hasDivergentTotals: parsed.hasDivergentTotals, + lastCodexTurnID: parsed.lastCodexTurnID, + sessionId: sessionId, + forkedFromId: parsed.forkedFromId, + codexCostNanos: Self.mergeCostMaps( + context.dropDeferredCodexRows + ? nil + : Self.costMapOutsideScanWindow(migratedCached?.codexCostNanos, range: context.range), + Self.codexCostNanos( + rows: parsed.rows, + range: context.range, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)), + codexPrioritySurchargeNanos: Self.mergeCostMaps( + context.dropDeferredCodexRows + ? nil + : Self.costMapOutsideScanWindow(migratedCached?.codexPrioritySurchargeNanos, range: context.range), + Self.codexPrioritySurchargeNanos( + rows: parsed.rows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)), + codexStandardCostNanos: Self.mergeCostMaps( + context.dropDeferredCodexRows + ? nil + : Self.costMapOutsideScanWindow(migratedCached?.codexStandardCostNanos, range: context.range), + splitMaps.standardCostNanos), + codexPriorityCostNanos: Self.mergeCostMaps( + context.dropDeferredCodexRows + ? nil + : Self.costMapOutsideScanWindow(migratedCached?.codexPriorityCostNanos, range: context.range), + splitMaps.priorityCostNanos), + codexStandardTokens: Self.mergeIntMaps( + context.dropDeferredCodexRows + ? nil + : Self.intMapOutsideScanWindow(migratedCached?.codexStandardTokens, range: context.range), + splitMaps.standardTokens), + codexPriorityTokens: Self.mergeIntMaps( + context.dropDeferredCodexRows + ? nil + : Self.intMapOutsideScanWindow(migratedCached?.codexPriorityTokens, range: context.range), + splitMaps.priorityTokens), + codexTurnIDs: context.dropDeferredCodexRows + ? Self.codexTurnIDs(rows: parsed.rows) + : Self.mergeCodexTurnIDs(migratedCached?.codexTurnIDs, rows: parsed.rows), + codexRows: context.dropDeferredCodexRows ? nil : migratedCached?.codexRows) + Self.applyFileDays(cache: &cache, fileDays: cache.files[input.metadata.path]?.days ?? [:], sign: 1) + Self.rememberScannedCodexFile( + fileURL: input.fileURL, + metadata: input.metadata, + sessionId: sessionId, + context: context, + state: &state) + } + + static func mergeFileDays( + existing: inout [String: [String: [Int]]], + delta: [String: [String: [Int]]]) + { + for (day, models) in delta { + var dayModels = existing[day] ?? [:] + for (model, packed) in models { + let existingPacked = dayModels[model] ?? [] + let merged = self.addPacked(a: existingPacked, b: packed, sign: 1) + if merged.allSatisfy({ $0 == 0 }) { + dayModels.removeValue(forKey: model) + } else { + dayModels[model] = merged + } + } + + if dayModels.isEmpty { + existing.removeValue(forKey: day) + } else { + existing[day] = dayModels + } + } + } + + static func fileDaysOutsideScanWindow( + _ days: [String: [String: [Int]]], + range: CostUsageDayRange) -> [String: [String: [Int]]] + { + days.filter { + !CostUsageDayRange.isInRange(dayKey: $0.key, since: range.scanSinceKey, until: range.scanUntilKey) + } + } + + static func applyFileDays(cache: inout CostUsageCache, fileDays: [String: [String: [Int]]], sign: Int) { + for (day, models) in fileDays { + var dayModels = cache.days[day] ?? [:] + for (model, packed) in models { + let existing = dayModels[model] ?? [] + let merged = self.addPacked(a: existing, b: packed, sign: sign) + if merged.allSatisfy({ $0 == 0 }) { + dayModels.removeValue(forKey: model) + } else { + dayModels[model] = merged + } + } + + if dayModels.isEmpty { + cache.days.removeValue(forKey: day) + } else { + cache.days[day] = dayModels + } + } + } + + static func pruneDays(cache: inout CostUsageCache, sinceKey: String, untilKey: String) { + for key in cache.days.keys where !CostUsageDayRange.isInRange(dayKey: key, since: sinceKey, until: untilKey) { + cache.days.removeValue(forKey: key) + } + } + + static func pruneForceRescanFilesOutsideWindow( + cache: inout CostUsageCache, + range: CostUsageDayRange, + isForceRescan: Bool) + { + guard isForceRescan else { return } + for key in cache.files.keys { + guard let old = cache.files[key] else { continue } + guard !old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + else { continue } + Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) + cache.files.removeValue(forKey: key) + } + } + + static func requestedWindowExpandsCache(range: CostUsageDayRange, cache: CostUsageCache) -> Bool { + guard let cachedSince = cache.scanSinceKey, + let cachedUntil = cache.scanUntilKey + else { + return cache.lastScanUnixMs != 0 || !cache.files.isEmpty || !cache.days.isEmpty + } + return range.scanSinceKey < cachedSince || range.scanUntilKey > cachedUntil + } + + static func addPacked(a: [Int], b: [Int], sign: Int) -> [Int] { + let len = max(a.count, b.count) + var out: [Int] = Array(repeating: 0, count: len) + for idx in 0.. CostUsageDailyReport + { + var entries: [CostUsageDailyReport.Entry] = [] + var totalInput = 0 + var totalOutput = 0 + var totalTokens = 0 + var totalCost: Double = 0 + var costSeen = false + + let dayKeys = cache.days.keys.sorted().filter { + CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) + } + let costNanosByDayModel = self.codexCostNanosByDayModel(cache: cache, range: range) + let prioritySurchargeNanosByDayModel = self.codexPrioritySurchargeNanosByDayModel(cache: cache, range: range) + let standardCostNanosByDayModel = self.codexStandardCostNanosByDayModel(cache: cache, range: range) + let priorityCostNanosByDayModel = self.codexPriorityCostNanosByDayModel(cache: cache, range: range) + let standardTokensByDayModel = self.codexStandardTokensByDayModel(cache: cache, range: range) + let priorityTokensByDayModel = self.codexPriorityTokensByDayModel(cache: cache, range: range) + + let hasCodexRows = cache.files.values.contains { + !($0.codexRows?.isEmpty ?? true) + } + let rowsByDayModel = hasCodexRows ? self.codexRowsByDayModel(cache: cache, range: range) : [:] + + for day in dayKeys { + guard let models = cache.days[day] else { continue } + let modelNames = models.keys.sorted() + + var dayInput = 0 + var dayOutput = 0 + var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] + var dayCost: Double = 0 + var dayCostSeen = false + + for model in modelNames { + let packed = models[model] ?? [0, 0, 0] + let input = packed[safe: 0] ?? 0 + let cached = packed[safe: 1] ?? 0 + let output = packed[safe: 2] ?? 0 + let totalTokens = input + output + + dayInput += input + dayOutput += output + + let rows = rowsByDayModel[day]?[model] + let rowCostBreakdown = rows.map { + self.codexRowCostBreakdown( + rows: $0, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + let cachedBaseCost = costNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } + let rowTotalCost = cachedBaseCost == nil ? rowCostBreakdown?.totalCostUSD : nil + let standardCost = standardCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardCostUSD : nil) + let priorityCost = priorityCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityCostUSD : nil) + let splitTotalCost: Double? = if standardCost != nil || priorityCost != nil { + (standardCost ?? 0) + (priorityCost ?? 0) + } else { + nil + } + var cost = splitTotalCost + ?? cachedBaseCost + ?? rowTotalCost + ?? CostUsagePricing.codexCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + if splitTotalCost == nil, + let surchargeNanos = prioritySurchargeNanosByDayModel[day]?[model], + cachedBaseCost != nil + { + cost = (cost ?? 0) + (Double(surchargeNanos) / Self.costScale) + } else if splitTotalCost == nil, + rowTotalCost == nil, + !priorityTurns.isEmpty, + let rows, + let surcharge = self.codexPrioritySurchargeUSD( + rows: rows, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + cost = (cost ?? 0) + surcharge + } + let standardModeTokens = standardTokensByDayModel[day]?[model] + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardTokens : nil) + let priorityModeTokens = priorityTokensByDayModel[day]?[model] + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityTokens : nil) + let hasModeSplit = priorityCost != nil || priorityModeTokens != nil + breakdown.append( + CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: totalTokens, + standardCostUSD: hasModeSplit ? standardCost : nil, + priorityCostUSD: hasModeSplit ? priorityCost : nil, + standardTokens: hasModeSplit ? standardModeTokens : nil, + priorityTokens: hasModeSplit ? priorityModeTokens : nil)) + if let cost { + dayCost += cost + dayCostSeen = true + } + } + + let dayTotal = dayInput + dayOutput + let entryCost = dayCostSeen ? dayCost : nil + entries.append(CostUsageDailyReport.Entry( + date: day, + inputTokens: dayInput, + outputTokens: dayOutput, + totalTokens: dayTotal, + costUSD: entryCost, + modelsUsed: modelNames, + modelBreakdowns: Self.sortedModelBreakdowns(breakdown))) + + totalInput += dayInput + totalOutput += dayOutput + totalTokens += dayTotal + if let entryCost { + totalCost += entryCost + costSeen = true + } + } + + let summary: CostUsageDailyReport.Summary? = entries.isEmpty + ? nil + : CostUsageDailyReport.Summary( + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + totalTokens: totalTokens, + totalCostUSD: costSeen ? totalCost : nil) + + return CostUsageDailyReport(data: entries, summary: summary) + } + + static func sortedModelBreakdowns(_ breakdowns: [CostUsageDailyReport.ModelBreakdown]) + -> [CostUsageDailyReport.ModelBreakdown] + { + breakdowns.sorted { lhs, rhs in + let lhsCost = lhs.costUSD ?? -1 + let rhsCost = rhs.costUSD ?? -1 + if lhsCost != rhsCost { + return lhsCost > rhsCost + } + + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } + + return lhs.modelName > rhs.modelName + } + } + + static func parseDayKey(_ key: String) -> Date? { + let parts = key.split(separator: "-") + guard parts.count == 3 else { return nil } + guard + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + + var comps = DateComponents() + comps.calendar = Calendar.current + comps.timeZone = TimeZone.current + comps.year = year + comps.month = month + comps.day = day + comps.hour = 12 + return comps.date + } +} + +extension Data { + func containsAscii(_ needle: String) -> Bool { + guard let n = needle.data(using: .utf8) else { return false } + return self.range(of: n) != nil + } +} + +extension [Int] { + subscript(safe index: Int) -> Int? { + if index < 0 { return nil } + if index >= self.count { return nil } + return self[index] + } +} + +extension [UInt8] { + subscript(safe index: Int) -> UInt8? { + if index < 0 { return nil } + if index >= self.count { return nil } + return self[index] + } +} + +extension CostUsageFileUsage { + func touchesCodexScanWindow(sinceKey: String, untilKey: String) -> Bool { + self.days.keys.contains { + CostUsageScanner.CostUsageDayRange.isInRange(dayKey: $0, since: sinceKey, until: untilKey) + } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 5e32060b00..83730d3b16 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -3,6 +3,27 @@ import Foundation extension CostUsageScanner { // MARK: - Claude + private struct ClaudeTokens { + let input: Int + let cacheRead: Int + let cacheCreate: Int + let cacheCreate1h: Int + let output: Int + let costNanos: Int + let costPriced: Bool + } + + private struct ClaudeDayModelKey: Hashable { + let day: String + let model: String + } + + private struct ClaudeRepricedCost { + var total: Double = 0 + var sampleCount: Int = 0 + var unresolved = false + } + private static func defaultClaudeProjectsRoots(options: Options) -> [URL] { if let override = options.claudeProjectsRoots { return override } @@ -35,105 +56,308 @@ extension CostUsageScanner { fileURL: URL, range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter, - startOffset: Int64 = 0) -> ClaudeParseResult + startOffset: Int64 = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult { - var days: [String: [String: [Int]]] = [:] - // Track seen message+request IDs to deduplicate streaming chunks within a JSONL file. - // Claude emits multiple lines per message with cumulative usage, so we only count once. - var seenKeys: Set = [] - - struct ClaudeTokens: Sendable { - let input: Int - let cacheRead: Int - let cacheCreate: Int - let output: Int - let costNanos: Int - } + ( + try? self.parseClaudeFileCancellable( + fileURL: fileURL, + range: range, + providerFilter: providerFilter, + startOffset: startOffset, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot, + checkCancellation: nil)) ?? ClaudeParseResult(days: [:], rows: [], parsedBytes: startOffset) + } - func add(dayKey: String, model: String, tokens: ClaudeTokens) { + static func parseClaudeFileCancellable( + fileURL: URL, + range: CostUsageDayRange, + providerFilter: ClaudeLogProviderFilter, + startOffset: Int64 = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + checkCancellation: CancellationCheck? = nil) throws -> ClaudeParseResult + { + func add(dayKey: String, model: String, tokens: ClaudeTokens, days: inout [String: [String: [Int]]]) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) else { return } let normModel = CostUsagePricing.normalizeClaudeModel(model) var dayModels = days[dayKey] ?? [:] - var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0] + var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0, 0, 0, 0] packed[0] = (packed[safe: 0] ?? 0) + tokens.input packed[1] = (packed[safe: 1] ?? 0) + tokens.cacheRead packed[2] = (packed[safe: 2] ?? 0) + tokens.cacheCreate packed[3] = (packed[safe: 3] ?? 0) + tokens.output packed[4] = (packed[safe: 4] ?? 0) + tokens.costNanos + packed[5] = (packed[safe: 5] ?? 0) + 1 + packed[6] = (packed[safe: 6] ?? 0) + (tokens.costPriced ? 1 : 0) + packed[7] = (packed[safe: 7] ?? 0) + tokens.cacheCreate1h dayModels[normModel] = packed days[dayKey] = dayModels } + func toInt(_ v: Any?) -> Int { + if let n = v as? NSNumber { return n.intValue } + return 0 + } + + func toBool(_ value: Any?) -> Bool { + if let bool = value as? Bool { return bool } + if let number = value as? NSNumber { return number.boolValue } + return false + } + + let pathRole = Self.claudePathRole(fileURL: fileURL) + var keyedRows: [String: ClaudeUsageRow] = [:] + var unkeyedRows: [ClaudeUsageRow] = [] + let maxLineBytes = 512 * 1024 // Keep the full line so usage at the tail isn't dropped on large tool outputs. let prefixBytes = maxLineBytes let costScale = 1_000_000_000.0 - let parsedBytes = (try? CostUsageJsonl.scan( - fileURL: fileURL, - offset: startOffset, - maxLineBytes: maxLineBytes, - prefixBytes: prefixBytes, - onLine: { line in - guard !line.bytes.isEmpty else { return } - guard !line.wasTruncated else { return } - guard line.bytes.containsAscii(#""type":"assistant""#) else { return } - guard line.bytes.containsAscii(#""usage""#) else { return } - - guard - let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], - let type = obj["type"] as? String, - type == "assistant" - else { return } - guard Self.matchesClaudeProviderFilter(obj: obj, filter: providerFilter) else { return } - - guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return } - - guard let message = obj["message"] as? [String: Any] else { return } - guard let model = message["model"] as? String else { return } - guard let usage = message["usage"] as? [String: Any] else { return } - - // Deduplicate by message.id + requestId (streaming chunks have same usage). - let messageId = message["id"] as? String - let requestId = obj["requestId"] as? String - if let messageId, let requestId { - let key = "\(messageId):\(requestId)" - if seenKeys.contains(key) { return } - seenKeys.insert(key) + let parsedBytes: Int64 + do { + parsedBytes = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: startOffset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + checkCancellation: checkCancellation, + onLine: { line in + guard !line.bytes.isEmpty else { return } + guard !line.wasTruncated else { return } + guard line.bytes.containsAscii(#""type":"assistant""#) else { return } + guard line.bytes.containsAscii(#""usage""#) else { return } + + autoreleasepool { + guard + let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], + let type = obj["type"] as? String, + type == "assistant" + else { return } + guard Self.matchesClaudeProviderFilter(obj: obj, filter: providerFilter) else { return } + + guard let tsText = obj["timestamp"] as? String, let timestamp = Self.dateFromTimestamp(tsText) + else { return } + guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + else { return } + + guard let message = obj["message"] as? [String: Any] else { return } + guard let model = message["model"] as? String else { return } + guard let usage = message["usage"] as? [String: Any] else { return } + + let input = max(0, toInt(usage["input_tokens"])) + let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"])) + let cacheCreate1h = Self.claudeOneHourCacheCreationTokens( + usage: usage, + total: cacheCreate) + let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) + let output = max(0, toInt(usage["output_tokens"])) + if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } + + let cost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: input, + cacheReadInputTokens: cacheRead, + cacheCreationInputTokens: cacheCreate, + cacheCreationInputTokens1h: cacheCreate1h, + outputTokens: output, + pricingDate: timestamp, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 + let tokens = ClaudeTokens( + input: input, + cacheRead: cacheRead, + cacheCreate: cacheCreate, + cacheCreate1h: cacheCreate1h, + output: output, + costNanos: costNanos, + costPriced: cost != nil) + + guard CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + else { return } + + let messageId = message["id"] as? String + let requestId = obj["requestId"] as? String + let sessionId = obj["sessionId"] as? String + ?? obj["session_id"] as? String + ?? (obj["metadata"] as? [String: Any])?["sessionId"] as? String + ?? (message["metadata"] as? [String: Any])?["sessionId"] as? String + let normalizedModel = CostUsagePricing.normalizeClaudeModel(model) + let row = ClaudeUsageRow( + dayKey: dayKey, + model: normalizedModel, + sessionId: sessionId, + messageId: messageId, + requestId: requestId, + timestampUnixMs: Int64((timestamp.timeIntervalSince1970 * 1000).rounded()), + isSidechain: toBool(obj["isSidechain"]), + pathRole: pathRole, + input: tokens.input, + cacheRead: tokens.cacheRead, + cacheCreate: tokens.cacheCreate, + cacheCreate1h: tokens.cacheCreate1h, + output: tokens.output, + costNanos: tokens.costNanos, + costPriced: tokens.costPriced) + + // Streaming chunks share message.id + requestId inside a file. + // Keep overwriting so the final cumulative chunk wins. + if let messageId, let requestId { + let key = "\(messageId):\(requestId)" + keyedRows[key] = row + } else { + // Older logs omit IDs; treat each line as distinct to avoid dropping usage. + unkeyedRows.append(row) + } + } + }) + } catch is CancellationError { + throw CancellationError() + } catch { + parsedBytes = startOffset + } + + let rows = keyedRows.keys.sorted().compactMap { keyedRows[$0] } + unkeyedRows + var days: [String: [String: [Int]]] = [:] + for row in rows { + let tokens = ClaudeTokens( + input: row.input, + cacheRead: row.cacheRead, + cacheCreate: row.cacheCreate, + cacheCreate1h: row.cacheCreate1h ?? 0, + output: row.output, + costNanos: row.costNanos, + costPriced: row.costPriced ?? (row.costNanos > 0)) + add(dayKey: row.dayKey, model: row.model, tokens: tokens, days: &days) + } + + return ClaudeParseResult(days: days, rows: rows, parsedBytes: parsedBytes) + } + + private static func claudeOneHourCacheCreationTokens(usage: [String: Any], total: Int) -> Int { + guard let cacheCreation = usage["cache_creation"] as? [String: Any] else { return 0 } + let tokens = (cacheCreation["ephemeral_1h_input_tokens"] as? NSNumber)?.intValue ?? 0 + return min(total, max(0, tokens)) + } + + private static func claudePathRole(fileURL: URL) -> ClaudePathRole { + fileURL.path.contains("/subagents/") ? .subagent : .parent + } + + private static func claudeCanonicalRowKey(_ row: ClaudeUsageRow) -> String? { + guard let messageId = row.messageId, let requestId = row.requestId else { + return nil + } + return "\(messageId):\(requestId)" + } + + private static func mergeClaudeRows(existing: [ClaudeUsageRow], delta: [ClaudeUsageRow]) -> [ClaudeUsageRow] { + var keyedRows: [String: ClaudeUsageRow] = [:] + var unkeyedRows: [ClaudeUsageRow] = [] + + for row in existing { + if let key = Self.claudeInFileKey(row) { + keyedRows[key] = row + } else { + unkeyedRows.append(row) + } + } + for row in delta { + if let key = Self.claudeInFileKey(row) { + keyedRows[key] = row + } else { + unkeyedRows.append(row) + } + } + + return keyedRows.keys.sorted().compactMap { keyedRows[$0] } + unkeyedRows + } + + private static func claudeInFileKey(_ row: ClaudeUsageRow) -> String? { + guard let messageId = row.messageId, let requestId = row.requestId else { return nil } + return "\(messageId):\(requestId)" + } + + private static func claudeRowWins( + lhs: (path: String, row: ClaudeUsageRow), + rhs: (path: String, row: ClaudeUsageRow)) -> Bool + { + if lhs.row.isSidechain != rhs.row.isSidechain { + return rhs.row.isSidechain + } + if lhs.row.pathRole != rhs.row.pathRole { + return rhs.row.pathRole == .subagent + } + return lhs.path < rhs.path + } + + private static func reconciledClaudeRows(cache: CostUsageCache) -> [ClaudeUsageRow] { + var rows: [ClaudeUsageRow] = [] + var winners: [String: (path: String, row: ClaudeUsageRow)] = [:] + + for path in cache.files.keys.sorted() { + guard let fileRows = cache.files[path]?.claudeRows else { continue } + for row in fileRows { + guard let canonicalKey = Self.claudeCanonicalRowKey(row) else { + rows.append(row) + continue + } + let candidate = (path: path, row: row) + if let existing = winners[canonicalKey] { + if Self.claudeRowWins(lhs: candidate, rhs: existing) { + winners[canonicalKey] = candidate + } } else { - // Older logs omit IDs; treat each line as distinct to avoid dropping usage. + winners[canonicalKey] = candidate } + } + } - func toInt(_ v: Any?) -> Int { - if let n = v as? NSNumber { return n.intValue } - return 0 - } + rows.append(contentsOf: winners.keys.sorted().compactMap { winners[$0]?.row }) + return rows + } + + private static func rebuildClaudeDays(cache: inout CostUsageCache) { + var days: [String: [String: [Int]]] = [:] - let input = max(0, toInt(usage["input_tokens"])) - let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"])) - let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) - let output = max(0, toInt(usage["output_tokens"])) - if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } - - let cost = CostUsagePricing.claudeCostUSD( - model: model, - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreate, - outputTokens: output) - let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 - let tokens = ClaudeTokens( - input: input, - cacheRead: cacheRead, - cacheCreate: cacheCreate, - output: output, - costNanos: costNanos) - add(dayKey: dayKey, model: model, tokens: tokens) - })) ?? startOffset - - return ClaudeParseResult(days: days, parsedBytes: parsedBytes) + for row in Self.reconciledClaudeRows(cache: cache) { + var dayModels = days[row.dayKey] ?? [:] + var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0, 0] + packed[0] = (packed[safe: 0] ?? 0) + row.input + packed[1] = (packed[safe: 1] ?? 0) + row.cacheRead + packed[2] = (packed[safe: 2] ?? 0) + row.cacheCreate + packed[3] = (packed[safe: 3] ?? 0) + row.output + packed[4] = (packed[safe: 4] ?? 0) + row.costNanos + packed[5] = (packed[safe: 5] ?? 0) + 1 + packed[6] = (packed[safe: 6] ?? 0) + ((row.costPriced ?? (row.costNanos > 0)) ? 1 : 0) + packed[7] = (packed[safe: 7] ?? 0) + (row.cacheCreate1h ?? 0) + dayModels[row.model] = packed + days[row.dayKey] = dayModels + } + + cache.days = days + } + + private static func makeClaudeFileUsage( + mtimeMs: Int64, + size: Int64, + rows: [ClaudeUsageRow], + parsedBytes: Int64?) -> CostUsageFileUsage + { + makeFileUsage( + mtimeUnixMs: mtimeMs, + size: size, + days: [:], + parsedBytes: parsedBytes, + claudeRows: rows) } private static let vertexProviderKeys: Set = [ @@ -260,17 +484,31 @@ extension CostUsageScanner { private final class ClaudeScanState { var cache: CostUsageCache - var rootCache: [String: Int64] var touched: Set let range: CostUsageDayRange let providerFilter: ClaudeLogProviderFilter - - init(cache: CostUsageCache, range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter) { + let forceFullScan: Bool + let modelsDevCatalog: ModelsDevCatalog? + let modelsDevCacheRoot: URL? + let checkCancellation: CancellationCheck? + + init( + cache: CostUsageCache, + range: CostUsageDayRange, + providerFilter: ClaudeLogProviderFilter, + forceFullScan: Bool, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?, + checkCancellation: CancellationCheck?) + { self.cache = cache - self.rootCache = cache.roots ?? [:] self.touched = [] self.range = range self.providerFilter = providerFilter + self.forceFullScan = forceFullScan + self.modelsDevCatalog = modelsDevCatalog + self.modelsDevCacheRoot = modelsDevCacheRoot + self.checkCancellation = checkCancellation } } @@ -278,119 +516,87 @@ extension CostUsageScanner { url: URL, size: Int64, mtimeMs: Int64, - state: ClaudeScanState) + state: ClaudeScanState) throws { + try state.checkCancellation?() let path = url.path state.touched.insert(path) if let cached = state.cache.files[path], cached.mtimeUnixMs == mtimeMs, - cached.size == size + cached.size == size, + !state.forceFullScan { return } - if let cached = state.cache.files[path] { + if let cached = state.cache.files[path], !state.forceFullScan { let startOffset = cached.parsedBytes ?? cached.size let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size + && cached.claudeRows != nil if canIncremental { - let delta = Self.parseClaudeFile( + let delta = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, providerFilter: state.providerFilter, - startOffset: startOffset) - if !delta.days.isEmpty { - Self.applyFileDays(cache: &state.cache, fileDays: delta.days, sign: 1) - } - - var mergedDays = cached.days - Self.mergeFileDays(existing: &mergedDays, delta: delta.days) - state.cache.files[path] = Self.makeFileUsage( - mtimeUnixMs: mtimeMs, + startOffset: startOffset, + modelsDevCatalog: state.modelsDevCatalog, + modelsDevCacheRoot: state.modelsDevCacheRoot, + checkCancellation: state.checkCancellation) + let mergedRows = Self.mergeClaudeRows(existing: cached.claudeRows ?? [], delta: delta.rows) + state.cache.files[path] = Self.makeClaudeFileUsage( + mtimeMs: mtimeMs, size: size, - days: mergedDays, + rows: mergedRows, parsedBytes: delta.parsedBytes) return } - - Self.applyFileDays(cache: &state.cache, fileDays: cached.days, sign: -1) } - let parsed = Self.parseClaudeFile( + let parsed = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, - providerFilter: state.providerFilter) - let usage = Self.makeFileUsage( - mtimeUnixMs: mtimeMs, + providerFilter: state.providerFilter, + modelsDevCatalog: state.modelsDevCatalog, + modelsDevCacheRoot: state.modelsDevCacheRoot, + checkCancellation: state.checkCancellation) + let usage = Self.makeClaudeFileUsage( + mtimeMs: mtimeMs, size: size, - days: parsed.days, + rows: parsed.rows, parsedBytes: parsed.parsedBytes) state.cache.files[path] = usage - Self.applyFileDays(cache: &state.cache, fileDays: usage.days, sign: 1) } private static func scanClaudeRoot( root: URL, - state: ClaudeScanState) + state: ClaudeScanState) throws { + try state.checkCancellation?() let rootPath = root.path let rootCandidates = Self.claudeRootCandidates(for: rootPath) let prefixes = Set(rootCandidates).map { path in path.hasSuffix("/") ? path : "\(path)/" } let rootExists = rootCandidates.contains { FileManager.default.fileExists(atPath: $0) } - let canonicalRootPath = rootCandidates.first(where: { - FileManager.default.fileExists(atPath: $0) - }) ?? rootPath guard rootExists else { let stale = state.cache.files.keys.filter { path in prefixes.contains(where: { path.hasPrefix($0) }) } for path in stale { - if let old = state.cache.files[path] { - Self.applyFileDays(cache: &state.cache, fileDays: old.days, sign: -1) - } state.cache.files.removeValue(forKey: path) } - for candidate in rootCandidates { - state.rootCache.removeValue(forKey: candidate) - } - return - } - - let rootAttrs = (try? FileManager.default.attributesOfItem(atPath: canonicalRootPath)) ?? [:] - let rootMtime = (rootAttrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let rootMtimeMs = Int64(rootMtime * 1000) - let cachedRootMtime = rootCandidates.compactMap { state.rootCache[$0] }.first - let canSkipEnumeration = cachedRootMtime == rootMtimeMs && rootMtimeMs > 0 - - if canSkipEnumeration { - let cachedPaths = state.cache.files.keys.filter { path in - prefixes.contains(where: { path.hasPrefix($0) }) - } - for path in cachedPaths { - guard FileManager.default.fileExists(atPath: path) else { - if let old = state.cache.files[path] { - Self.applyFileDays(cache: &state.cache, fileDays: old.days, sign: -1) - } - state.cache.files.removeValue(forKey: path) - continue - } - let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] - let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 - if size <= 0 { continue } - let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let mtimeMs = Int64(mtime * 1000) - Self.processClaudeFile( - url: URL(fileURLWithPath: path), - size: size, - mtimeMs: mtimeMs, - state: state) - } return } + // Always enumerate the directory tree. The per-file mtime/size cache in + // processClaudeFile already skips unchanged files, so the only cost here is + // the directory walk itself. The previous root-mtime optimization skipped + // enumeration entirely when the root directory mtime was unchanged, but on + // POSIX systems a directory mtime only updates for direct child changes — + // not for files created or modified inside subdirectories. This caused new + // session logs to go undetected until the cache was manually cleared. let keys: [URLResourceKey] = [ .isRegularFileKey, .contentModificationDateKey, @@ -404,6 +610,7 @@ extension CostUsageScanner { else { return } for case let url as URL in enumerator { + try state.checkCancellation?() guard url.pathExtension.lowercased() == "jsonl" else { continue } guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue } guard values.isRegularFile == true else { continue } @@ -412,32 +619,33 @@ extension CostUsageScanner { let mtime = values.contentModificationDate?.timeIntervalSince1970 ?? 0 let mtimeMs = Int64(mtime * 1000) - Self.processClaudeFile( + try Self.processClaudeFile( url: url, size: size, mtimeMs: mtimeMs, state: state) } - if rootMtimeMs > 0 { - state.rootCache[canonicalRootPath] = rootMtimeMs - for candidate in rootCandidates where candidate != canonicalRootPath { - state.rootCache.removeValue(forKey: candidate) - } - } + // Root mtime caching removed — see comment above. } static func loadClaudeDaily( provider: UsageProvider, range: CostUsageDayRange, now: Date, - options: Options) -> CostUsageDailyReport + options: Options, + checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { var cache = CostUsageCacheIO.load(provider: provider, cacheRoot: options.cacheRoot) let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) - let shouldRefresh = refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs + let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache) + let shouldRefresh = options.forceRescan + || windowExpanded + || refreshMs == 0 + || cache.lastScanUnixMs == 0 + || nowMs - cache.lastScanUnixMs > refreshMs let roots = self.defaultClaudeProjectsRoots(options: options) let providerFilter = options.claudeLogProviderFilter @@ -445,39 +653,57 @@ extension CostUsageScanner { var touched: Set = [] if shouldRefresh { + try checkCancellation?() if options.forceRescan { cache = CostUsageCache() } - let scanState = ClaudeScanState(cache: cache, range: range, providerFilter: providerFilter) + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + let scanState = ClaudeScanState( + cache: cache, + range: range, + providerFilter: providerFilter, + forceFullScan: options.forceRescan || windowExpanded, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot, + checkCancellation: checkCancellation) for root in roots { - Self.scanClaudeRoot( + try Self.scanClaudeRoot( root: root, state: scanState) } + try checkCancellation?() cache = scanState.cache touched = scanState.touched - cache.roots = scanState.rootCache.isEmpty ? nil : scanState.rootCache + cache.roots = nil for key in cache.files.keys where !touched.contains(key) { - if let old = cache.files[key] { - Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) - } cache.files.removeValue(forKey: key) } + Self.rebuildClaudeDays(cache: &cache) Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + cache.scanSinceKey = range.scanSinceKey + cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs + try checkCancellation?() CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) } - return Self.buildClaudeReportFromCache(cache: cache, range: range) + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + return Self.buildClaudeReportFromCache( + cache: cache, + range: range, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot) } private static func buildClaudeReportFromCache( cache: CostUsageCache, - range: CostUsageDayRange) -> CostUsageDailyReport + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport { var entries: [CostUsageDailyReport.Entry] = [] var totalInput = 0 @@ -488,6 +714,41 @@ extension CostUsageScanner { var totalCost: Double = 0 var costSeen = false let costScale = 1_000_000_000.0 + var repricedCosts: [ClaudeDayModelKey: ClaudeRepricedCost] = [:] + + for row in Self.reconciledClaudeRows(cache: cache) { + let key = ClaudeDayModelKey(day: row.dayKey, model: row.model) + var aggregate = repricedCosts[key] ?? ClaudeRepricedCost() + aggregate.sampleCount += 1 + let isPriced = row.costPriced ?? (row.costNanos > 0) + let currentPricingCost = CostUsagePricing.claudeCostUSD( + model: row.model, + inputTokens: row.input, + cacheReadInputTokens: row.cacheRead, + cacheCreationInputTokens: row.cacheCreate, + cacheCreationInputTokens1h: row.cacheCreate1h ?? 0, + outputTokens: row.output, + pricingDate: row.timestampUnixMs.map { + Date(timeIntervalSince1970: Double($0) / 1000) + }, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + let cost: Double? = if isPriced, row.costNanos == 0 { + 0 + } else if let currentPricingCost { + currentPricingCost + } else if isPriced { + Double(row.costNanos) / costScale + } else { + nil + } + if let cost { + aggregate.total += cost + } else { + aggregate.unresolved = true + } + repricedCosts[key] = aggregate + } let dayKeys = cache.days.keys.sorted().filter { CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) @@ -512,7 +773,8 @@ extension CostUsageScanner { let cacheRead = packed[safe: 1] ?? 0 let cacheCreate = packed[safe: 2] ?? 0 let output = packed[safe: 3] ?? 0 - let cachedCost = packed[safe: 4] ?? 0 + let sampleCount = packed[safe: 5] ?? 0 + let totalTokens = input + cacheRead + cacheCreate + output // Cache tokens are tracked separately; totalTokens includes input + cache. dayInput += input @@ -520,23 +782,28 @@ extension CostUsageScanner { dayCacheCreate += cacheCreate dayOutput += output - let cost = cachedCost > 0 - ? Double(cachedCost) / costScale - : CostUsagePricing.claudeCostUSD( - model: model, - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreate, - outputTokens: output) - breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost)) + let repricedCost = repricedCosts[ClaudeDayModelKey(day: day, model: model)] + let currentPricingCost: Double? = if let repricedCost, + repricedCost.sampleCount == sampleCount, + !repricedCost.unresolved + { + repricedCost.total + } else { + nil + } + let cost = currentPricingCost + breakdown.append( + CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: totalTokens)) if let cost { dayCost += cost dayCostSeen = true } } - breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) } - let top = Array(breakdown.prefix(3)) + let sortedBreakdown = Self.sortedModelBreakdowns(breakdown) let dayTotal = dayInput + dayCacheRead + dayCacheCreate + dayOutput let entryCost = dayCostSeen ? dayCost : nil @@ -549,7 +816,7 @@ extension CostUsageScanner { totalTokens: dayTotal, costUSD: entryCost, modelsUsed: modelNames, - modelBreakdowns: top)) + modelBreakdowns: sortedBreakdown)) totalInput += dayInput totalOutput += dayOutput diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift new file mode 100644 index 0000000000..958e45092c --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexFastJSON.swift @@ -0,0 +1,255 @@ +import Foundation + +extension CostUsageScanner { + static func extractJSONByteStringField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int) -> String? + { + self.extractJSONByteField(field, from: bytes, in: range, atDepth: targetDepth) { valueIndex in + guard let parsed = parseJSONByteStringRange(in: bytes, index: &valueIndex, limit: range.upperBound), + parsed.range.lowerBound < parsed.range.upperBound + else { return nil } + if parsed.hasEscapes { + return self.decodeEscapedJSONByteString(from: bytes, in: parsed.range) + } + return String(bytes: bytes[parsed.range], encoding: .utf8) + } + } + + static func extractJSONByteObjectField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int) -> Range? + { + self.extractJSONByteField(field, from: bytes, in: range, atDepth: targetDepth) { valueIndex in + self.parseJSONByteObjectRange(in: bytes, index: &valueIndex, limit: range.upperBound) + } + } + + static func extractJSONByteIntField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int) -> Int? + { + self.extractJSONByteField(field, from: bytes, in: range, atDepth: targetDepth) { valueIndex in + self.parseJSONByteInt(in: bytes, index: &valueIndex, limit: range.upperBound) + } + } + + private static func extractJSONByteField( + _ field: [UInt8], + from bytes: UnsafeBufferPointer, + in range: Range, + atDepth targetDepth: Int, + parseValue: (inout Int) -> T?) -> T? + { + var index = range.lowerBound + var depth = 0 + + while index < range.upperBound { + switch bytes[index] { + case 0x7B: // { + depth += 1 + index += 1 + case 0x7D: // } + depth -= 1 + index += 1 + case 0x22: // " + var valueIndex = index + guard let key = parseJSONByteStringRange(in: bytes, index: &valueIndex, limit: range.upperBound) + else { return nil } + index = valueIndex + guard depth == targetDepth, + !key.hasEscapes, + self.byteRange(bytes, key.range, equals: field) + else { continue } + + self.skipJSONByteWhitespace(in: bytes, index: &valueIndex, limit: range.upperBound) + guard valueIndex < range.upperBound, bytes[valueIndex] == 0x3A else { continue } // : + + valueIndex += 1 + self.skipJSONByteWhitespace(in: bytes, index: &valueIndex, limit: range.upperBound) + if let value = parseValue(&valueIndex) { + return value + } + default: + index += 1 + } + } + + return nil + } + + private static func parseJSONByteStringRange( + in bytes: UnsafeBufferPointer, + index: inout Int, + limit: Int) -> (range: Range, hasEscapes: Bool)? + { + guard index < limit, bytes[index] == 0x22 else { return nil } // " + index += 1 + let start = index + var hasEscapes = false + + while index < limit { + switch bytes[index] { + case 0x5C: // \ + hasEscapes = true + index += 2 + case 0x22: // " + let end = index + index += 1 + return (start.., + index: inout Int, + limit: Int) -> Range? + { + guard index < limit, bytes[index] == 0x7B else { return nil } // { + let start = index + var depth = 0 + + while index < limit { + switch bytes[index] { + case 0x22: // " + guard self.parseJSONByteStringRange(in: bytes, index: &index, limit: limit) != nil else { + return nil + } + case 0x7B: // { + depth += 1 + index += 1 + case 0x7D: // } + depth -= 1 + index += 1 + if depth == 0 { + return start.., + index: inout Int, + limit: Int) -> Int? + { + var sign = 1 + if index < limit, bytes[index] == 0x2D { // - + sign = -1 + index += 1 + } + + var value = 0 + var sawDigit = false + while index < limit { + let byte = bytes[index] + guard byte >= 0x30, byte <= 0x39 else { break } + sawDigit = true + let digit = Int(byte - 0x30) + let multiplied = value.multipliedReportingOverflow(by: 10) + if multiplied.overflow { return nil } + let added = multiplied.partialValue.addingReportingOverflow(digit) + if added.overflow { return nil } + value = added.partialValue + index += 1 + } + return sawDigit ? (sign == -1 ? -value : value) : nil + } + + private static func skipJSONByteWhitespace( + in bytes: UnsafeBufferPointer, + index: inout Int, + limit: Int) + { + while index < limit { + switch bytes[index] { + case 0x20, 0x09, 0x0A, 0x0D: + index += 1 + default: + return + } + } + } + + private static func decodeEscapedJSONByteString( + from bytes: UnsafeBufferPointer, + in range: Range) -> String? + { + var out: [UInt8] = [] + out.reserveCapacity(range.count) + var index = range.lowerBound + while index < range.upperBound { + let byte = bytes[index] + guard byte == 0x5C else { // \ + out.append(byte) + index += 1 + continue + } + + index += 1 + guard index < range.upperBound else { return nil } + switch bytes[index] { + case 0x22, 0x5C, 0x2F: // ", \, / + out.append(bytes[index]) + case 0x62: // b + out.append(0x08) + case 0x66: // f + out.append(0x0C) + case 0x6E: // n + out.append(0x0A) + case 0x72: // r + out.append(0x0D) + case 0x74: // t + out.append(0x09) + case 0x75: // u + return self.decodeJSONStringViaFoundation(from: bytes, in: range) + default: + return nil + } + index += 1 + } + + return String(bytes: out, encoding: .utf8) + } + + private static func decodeJSONStringViaFoundation( + from bytes: UnsafeBufferPointer, + in range: Range) -> String? + { + var data = Data([0x22]) + data.append(UnsafeBufferPointer(rebasing: bytes[range])) + data.append(0x22) + return (try? JSONSerialization.jsonObject(with: data)) as? String + } + + private static func byteRange( + _ bytes: UnsafeBufferPointer, + _ range: Range, + equals field: [UInt8]) -> Bool + { + guard range.count == field.count else { return false } + var index = range.lowerBound + var fieldIndex = 0 + while index < range.upperBound { + guard bytes[index] == field[fieldIndex] else { return false } + index += 1 + fieldIndex += 1 + } + return true + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift new file mode 100644 index 0000000000..088af5b728 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift @@ -0,0 +1,653 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#endif + +extension CostUsageScanner { + struct CodexPriorityTurnMetadata: Codable, Equatable { + var threadID: String? + var turnID: String + var model: String? + var timestamp: String? + } + + private static let requestMarker = "websocket request:" + + static func defaultCodexPriorityDatabaseURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("logs_2.sqlite", isDirectory: false) + } + + #if canImport(SQLite3) + /// Accumulated priority-turn state for one trace database. The `logs` table uses an + /// `INTEGER PRIMARY KEY AUTOINCREMENT` id, so rowids are monotonic and never + /// reused. Codex prunes old rows in place, so source row IDs are retained and cheaply + /// revalidated before each incremental scan. + struct CodexPriorityTurnsMemoState { + var observationID: UInt64 + var coverageSinceEpoch: Int64 + var lastRowID: Int64 + var fileIdentity: UInt64? + var turns: [String: CodexPriorityTurnMetadata] + var requestSourcesByTurnID: [String: [Int64: CodexPriorityTurnMetadata]] + var priorityCompletedModelsByTurnID: [String: [Int64: String]] + var completedModelsByTurnID: [String: [Int64: String]] + var completedTurnIDInsertionOrder: [String] + var completedTurnIDInsertionOrderStartIndex: Int + } + + /// Completion models for known priority turns are retained with those turns. Completions + /// seen before their request are pending and may belong to non-priority turns, so that + /// separate map is bounded to keep memory constant while preserving ordering. + static let codexPriorityCompletedModelRetentionLimit = 4096 + + private final class CodexPriorityLockedState: @unchecked Sendable { + private let lock = NSLock() + private var state: State + + init(_ state: State) { + self.state = state + } + + func withLock(_ body: (inout State) throws -> Result) rethrows -> Result { + self.lock.lock() + defer { self.lock.unlock() } + return try body(&self.state) + } + } + + private static let codexPriorityTurnsMemo = + CodexPriorityLockedState<[String: CodexPriorityTurnsMemoState]>([:]) + private static let codexPriorityTurnsObservationCounter = CodexPriorityLockedState(0) + + private static func nextCodexPriorityTurnsObservationID() -> UInt64 { + self.codexPriorityTurnsObservationCounter.withLock { + $0 &+= 1 + return $0 + } + } + + /// Scans run outside the lock, so overlapping refreshes can write back out of order. + /// A monotonically increasing observation ID makes the later-started scan authoritative; + /// same-observation test snapshots still use coverage/cursor dominance. + static func storeCodexPriorityTurnsMemoIfNewer( + _ updated: CodexPriorityTurnsMemoState, + forPath path: String) + { + self.codexPriorityTurnsMemo.withLock { memo in + if let existing = memo[path], + existing.observationID > updated.observationID + { + return + } + if let existing = memo[path], + existing.observationID == updated.observationID, + existing.fileIdentity == updated.fileIdentity, + existing.coverageSinceEpoch <= updated.coverageSinceEpoch, + existing.lastRowID >= updated.lastRowID + { + return + } + memo[path] = updated + } + } + + static func _test_resetCodexPriorityTurnsMemo() { + self.codexPriorityTurnsMemo.withLock { $0.removeAll() } + self.codexPriorityTurnsObservationCounter.withLock { $0 = 0 } + } + + static func _test_codexPriorityTurnsMemoState(forPath path: String) -> CodexPriorityTurnsMemoState? { + self.codexPriorityTurnsMemo.withLock { $0[path] } + } + + static func _test_accumulateCodexPriorityTurns( + _ db: OpaquePointer?, + into state: inout CodexPriorityTurnsMemoState) -> Bool + { + self.accumulateCodexPriorityTurns(db, into: &state) + } + + static func _test_codexPriorityAccumulationQuery( + _ db: OpaquePointer?, + lastRowID: Int64, + coverageSinceEpoch: Int64) -> String + { + self.codexPriorityAccumulationPlan( + db, + lastRowID: lastRowID, + coverageSinceEpoch: coverageSinceEpoch).query + } + #endif + + /// Resolves priority turn metadata from the codex CLI trace database. The full-table + /// `LIKE` scan over `feedback_log_body` grows with the database (hundreds of megabytes on + /// active machines) and used to run on every refresh past the scan interval. For windows + /// that extend through today — every live refresh — the result is now accumulated per + /// database in process memory and only rows appended since the last call are examined; the + /// database shrinking or being replaced, or the requested window expanding earlier than + /// the accumulated coverage, triggers a full rescan. Windows that end before today keep + /// the original bounded one-shot query so historical lookups never pay an open-ended scan. + static func codexPriorityTurns( + databaseURL: URL? = nil, + sinceDayKey: String? = nil, + untilDayKey: String? = nil) -> [String: CodexPriorityTurnMetadata] + { + let url = databaseURL ?? self.defaultCodexPriorityDatabaseURL() + guard FileManager.default.fileExists(atPath: url.path) else { return [:] } + + #if canImport(SQLite3) + if let untilDayKey, untilDayKey < CostUsageDayRange.dayKey(from: Date()) { + return self.boundedCodexPriorityTurns( + databaseURL: url, + sinceDayKey: sinceDayKey, + untilDayKey: untilDayKey) + } + + guard let opened = self.openCodexPriorityDatabase(at: url) else { return [:] } + let db = opened.db + let fileIdentity = opened.fileIdentity + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let observationID = self.nextCodexPriorityTurnsObservationID() + guard let maxRowID = self.maxCodexLogsRowID(db) else { return [:] } + + let requestedSinceEpoch: Int64 = if sinceDayKey != nil || untilDayKey != nil { + self.epochSeconds(forDayKey: sinceDayKey ?? "0000-01-01") ?? 0 + } else { + 0 + } + + var state = self.codexPriorityTurnsMemo.withLock { $0[url.path] } + if let memo = state, + maxRowID < memo.lastRowID + || requestedSinceEpoch < memo.coverageSinceEpoch + || memo.fileIdentity != fileIdentity + { + state = nil + } + var resolved = state ?? CodexPriorityTurnsMemoState( + observationID: observationID, + coverageSinceEpoch: requestedSinceEpoch, + lastRowID: 0, + fileIdentity: fileIdentity, + turns: [:], + requestSourcesByTurnID: [:], + priorityCompletedModelsByTurnID: [:], + completedModelsByTurnID: [:], + completedTurnIDInsertionOrder: [], + completedTurnIDInsertionOrderStartIndex: 0) + resolved.observationID = observationID + + var prunedDeletedSources = false + if state != nil { + var pruned = resolved + guard let didPrune = self.pruneDeletedCodexPrioritySources(db, from: &pruned) else { + return self.filteredResolvedCodexPriorityTurns( + resolved, + sinceDayKey: sinceDayKey, + untilDayKey: untilDayKey) + } + resolved = pruned + prunedDeletedSources = didPrune + } + + if maxRowID > resolved.lastRowID { + var updated = resolved + guard self.accumulateCodexPriorityTurns(db, into: &updated) else { + return self.filteredResolvedCodexPriorityTurns( + resolved, + sinceDayKey: sinceDayKey, + untilDayKey: untilDayKey) + } + updated.lastRowID = maxRowID + self.storeCodexPriorityTurnsMemoIfNewer(updated, forPath: url.path) + resolved = updated + } else if state == nil || prunedDeletedSources { + self.storeCodexPriorityTurnsMemoIfNewer(resolved, forPath: url.path) + } + + return self.filteredResolvedCodexPriorityTurns( + resolved, + sinceDayKey: sinceDayKey, + untilDayKey: untilDayKey) + #else + return [:] + #endif + } + + #if canImport(SQLite3) + private static func filteredResolvedCodexPriorityTurns( + _ state: CodexPriorityTurnsMemoState, + sinceDayKey: String?, + untilDayKey: String?) -> [String: CodexPriorityTurnMetadata] + { + var turns = state.turns + for (turnID, completedModels) in state.priorityCompletedModelsByTurnID { + turns[turnID]?.model = self.latestCodexCompletedModel(completedModels) + } + guard sinceDayKey != nil || untilDayKey != nil else { return turns } + return turns.filter { _, turn in + self.timestamp(turn.timestamp, isInRangeSince: sinceDayKey, until: untilDayKey) + } + } + + private static func latestCodexCompletedModel(_ modelsByRowID: [Int64: String]) -> String? { + modelsByRowID.max { $0.key < $1.key }?.value + } + + /// The pre-memo one-shot query, kept for windows that end before today: both `ts` bounds + /// stay in SQL, so a narrow historical window never scans the database tail. + private static func boundedCodexPriorityTurns( + databaseURL: URL, + sinceDayKey: String?, + untilDayKey: String?) -> [String: CodexPriorityTurnMetadata] + { + var db: OpaquePointer? + guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(db) + return [:] + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let query = """ + select ts, feedback_log_body + from logs + where ts >= ? and ts < ? + and (feedback_log_body like '%websocket request:%' + or feedback_log_body like '%response.completed%') + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + let start = self.epochSeconds(forDayKey: sinceDayKey ?? "0000-01-01") ?? 0 + let end = self.epochSeconds(forDayKey: self.nextDayKey(after: untilDayKey ?? "9999-12-30")) + ?? Int64.max + sqlite3_bind_int64(stmt, 1, start) + sqlite3_bind_int64(stmt, 2, end) + + var turns: [String: CodexPriorityTurnMetadata] = [:] + var completedModelsByTurnID: [String: String] = [:] + while sqlite3_step(stmt) == SQLITE_ROW { + let timestamp = self.timestamp(stmt: stmt, index: 0) + guard self.timestamp(timestamp, isInRangeSince: sinceDayKey, until: untilDayKey), + let body = self.text(stmt: stmt, index: 1) + else { continue } + if let completed = self.parseCodexCompletedTraceRow(body: body) { + completedModelsByTurnID[completed.turnID] = completed.model + if var existing = turns[completed.turnID] { + existing.model = completed.model + turns[completed.turnID] = existing + } + continue + } + guard var parsed = self.parseCodexPriorityTraceRow(timestamp: timestamp, body: body) + else { continue } + if let completedModel = completedModelsByTurnID[parsed.turnID] { + parsed.model = completedModel + } + turns[parsed.turnID] = parsed + } + return turns + } + + private static func maxCodexLogsRowID(_ db: OpaquePointer?) -> Int64? { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "select max(rowid) from logs", -1, &stmt, nil) == SQLITE_OK + else { return nil } + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + return sqlite3_column_int64(stmt, 0) + } + + static func openCodexPriorityDatabase( + at url: URL, + afterOpen: (() -> Void)? = nil) -> (db: OpaquePointer?, fileIdentity: UInt64)? + { + guard let fileIdentity = self.codexPriorityDatabaseFileIdentity(at: url) else { return nil } + var db: OpaquePointer? + guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(db) + return nil + } + afterOpen?() + guard self.codexPriorityDatabaseFileIdentity(at: url) == fileIdentity else { + sqlite3_close(db) + return nil + } + return (db, fileIdentity) + } + + private static func codexPriorityDatabaseFileIdentity(at url: URL) -> UInt64? { + (try? FileManager.default.attributesOfItem(atPath: url.path))?[.systemFileNumber] + .flatMap { $0 as? UInt64 } + } + + private static func pruneDeletedCodexPrioritySources( + _ db: OpaquePointer?, + from state: inout CodexPriorityTurnsMemoState) -> Bool? + { + let sourceRowIDs = state.requestSourcesByTurnID.values.flatMap(\.keys) + + state.priorityCompletedModelsByTurnID.values.flatMap(\.keys) + + state.completedModelsByTurnID.values.flatMap(\.keys) + guard let retainedRowIDs = self.retainedCodexPrioritySourceRowIDs(db, rowIDs: sourceRowIDs) else { + return nil + } + + var didPrune = false + for (turnID, sources) in state.requestSourcesByTurnID { + let retainedSources = sources.filter { retainedRowIDs.contains($0.key) } + guard retainedSources.count != sources.count else { continue } + didPrune = true + if retainedSources.isEmpty { + state.requestSourcesByTurnID.removeValue(forKey: turnID) + state.turns.removeValue(forKey: turnID) + if let completedModels = state.priorityCompletedModelsByTurnID.removeValue(forKey: turnID) { + self.storePendingCodexCompletedModels(completedModels, turnID: turnID, in: &state) + } + } else { + state.requestSourcesByTurnID[turnID] = retainedSources + state.turns[turnID] = retainedSources.max { $0.key < $1.key }?.value + } + } + + didPrune = self.pruneDeletedCodexCompletedModels( + retainedRowIDs: retainedRowIDs, + from: &state.priorityCompletedModelsByTurnID) || didPrune + didPrune = self.pruneDeletedCodexCompletedModels( + retainedRowIDs: retainedRowIDs, + from: &state.completedModelsByTurnID) || didPrune + self.compactCodexPendingCompletionOrderPrefix(in: &state) + state.completedTurnIDInsertionOrder.removeAll { state.completedModelsByTurnID[$0] == nil } + return didPrune + } + + private static func pruneDeletedCodexCompletedModels( + retainedRowIDs: Set, + from modelsByTurnID: inout [String: [Int64: String]]) -> Bool + { + var didPrune = false + for (turnID, modelsByRowID) in modelsByTurnID { + let retainedModels = modelsByRowID.filter { retainedRowIDs.contains($0.key) } + guard retainedModels.count != modelsByRowID.count else { continue } + didPrune = true + if retainedModels.isEmpty { + modelsByTurnID.removeValue(forKey: turnID) + } else { + modelsByTurnID[turnID] = retainedModels + } + } + return didPrune + } + + private static func retainedCodexPrioritySourceRowIDs( + _ db: OpaquePointer?, + rowIDs: [Int64]) -> Set? + { + guard !rowIDs.isEmpty else { return [] } + + var retained: Set = [] + let chunkSize = 500 + for start in stride(from: 0, to: rowIDs.count, by: chunkSize) { + let end = min(start + chunkSize, rowIDs.count) + let chunk = rowIDs[start.. self.codexPriorityCompletedModelRetentionLimit { + let evicted = state.completedTurnIDInsertionOrder[ + state.completedTurnIDInsertionOrderStartIndex, + ] + state.completedTurnIDInsertionOrderStartIndex += 1 + state.completedModelsByTurnID.removeValue(forKey: evicted) + if state.completedTurnIDInsertionOrderStartIndex + >= self.codexPriorityCompletedModelRetentionLimit + { + self.compactCodexPendingCompletionOrderPrefix(in: &state) + } + } + } + state.completedModelsByTurnID[turnID, default: [:]].merge(completedModels) { _, new in new } + } + + private static func compactCodexPendingCompletionOrderPrefix( + in state: inout CodexPriorityTurnsMemoState) + { + guard state.completedTurnIDInsertionOrderStartIndex > 0 else { return } + state.completedTurnIDInsertionOrder.removeFirst(state.completedTurnIDInsertionOrderStartIndex) + state.completedTurnIDInsertionOrderStartIndex = 0 + } + + private static func accumulateCodexPriorityTurns( + _ db: OpaquePointer?, + into state: inout CodexPriorityTurnsMemoState) -> Bool + { + let plan = self.codexPriorityAccumulationPlan( + db, + lastRowID: state.lastRowID, + coverageSinceEpoch: state.coverageSinceEpoch) + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, plan.query, -1, &stmt, nil) == SQLITE_OK else { return false } + defer { sqlite3_finalize(stmt) } + if plan.usesTimestampIndex { + sqlite3_bind_int64(stmt, 1, state.coverageSinceEpoch) + } else { + sqlite3_bind_int64(stmt, 1, state.lastRowID) + sqlite3_bind_int64(stmt, 2, state.coverageSinceEpoch) + } + + while true { + let stepResult = sqlite3_step(stmt) + guard stepResult == SQLITE_ROW else { return stepResult == SQLITE_DONE } + let rowID = sqlite3_column_int64(stmt, 0) + let timestamp = self.timestamp(stmt: stmt, index: 1) + guard let body = self.text(stmt: stmt, index: 2) else { continue } + if let completed = self.parseCodexCompletedTraceRow(body: body) { + if state.turns[completed.turnID] != nil { + state.priorityCompletedModelsByTurnID[completed.turnID, default: [:]][rowID] = completed.model + } else { + self.storePendingCodexCompletedModels( + [rowID: completed.model], + turnID: completed.turnID, + in: &state) + } + continue + } + guard let parsed = self.parseCodexPriorityTraceRow(timestamp: timestamp, body: body) + else { continue } + state.turns[parsed.turnID] = parsed + state.requestSourcesByTurnID[parsed.turnID, default: [:]][rowID] = parsed + if let completedModels = state.completedModelsByTurnID.removeValue(forKey: parsed.turnID) { + self.compactCodexPendingCompletionOrderPrefix(in: &state) + state.completedTurnIDInsertionOrder.removeAll { $0 == parsed.turnID } + state.priorityCompletedModelsByTurnID[parsed.turnID] = completedModels + } + } + } + + private static func codexPriorityAccumulationPlan( + _ db: OpaquePointer?, + lastRowID: Int64, + coverageSinceEpoch: Int64) -> (query: String, usesTimestampIndex: Bool) + { + if lastRowID == 0, + coverageSinceEpoch > 0, + self.hasCodexLogsTimestampIndex(db) + { + return ( + """ + select rowid, ts, feedback_log_body + from logs indexed by idx_logs_ts + where ts >= ? + and (feedback_log_body like '%websocket request:%' + or feedback_log_body like '%response.completed%') + order by rowid + """, + true) + } + return ( + """ + select rowid, ts, feedback_log_body + from logs + where rowid > ? and ts >= ? + and (feedback_log_body like '%websocket request:%' + or feedback_log_body like '%response.completed%') + order by rowid + """, + false) + } + + private static func hasCodexLogsTimestampIndex(_ db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + let query = """ + select 1 + from sqlite_master + where type = 'index' and tbl_name = 'logs' and name = 'idx_logs_ts' + limit 1 + """ + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return false } + defer { sqlite3_finalize(stmt) } + return sqlite3_step(stmt) == SQLITE_ROW + } + #endif + + static func parseCodexPriorityTraceRow(timestamp: String?, body: String) -> CodexPriorityTurnMetadata? { + guard let markerRange = body.range(of: self.requestMarker) else { return nil } + let prefix = String(body[.. (turnID: String, model: String)? { + let marker = "websocket event:" + guard let markerRange = body.range(of: marker) else { return nil } + let prefix = String(body[.. String? { + guard let range = text.range(of: "\(name)=") else { return nil } + let tail = text[range.upperBound...] + let value = tail.prefix { char in + !char.isWhitespace && char != "," && char != "]" && char != ")" + } + return value.isEmpty ? nil : String(value) + } + + #if canImport(SQLite3) + private static func text(stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { return nil } + return String(cString: cString) + } + + private static func timestamp(stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } + if sqlite3_column_type(stmt, index) == SQLITE_INTEGER { + return String(sqlite3_column_int64(stmt, index)) + } + return self.text(stmt: stmt, index: index) + } + #endif + + private static func timestamp(_ timestamp: String?, isInRangeSince since: String?, until: String?) -> Bool { + guard since != nil || until != nil else { return true } + guard let dayKey = self.dayKey(fromTimestamp: timestamp) else { return false } + if let since, dayKey < since { return false } + if let until, dayKey > until { return false } + return true + } + + private static func dayKey(fromTimestamp timestamp: String?) -> String? { + guard let timestamp else { return nil } + if let seconds = Int64(timestamp) { + return CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(seconds))) + } + let dayKey = timestamp.prefix(10) + return dayKey.count == 10 ? String(dayKey) : nil + } + + private static func nextDayKey(after dayKey: String) -> String { + guard let date = self.localDate(forDayKey: dayKey), + let next = Calendar.current.date(byAdding: .day, value: 1, to: date) + else { return dayKey } + return CostUsageScanner.CostUsageDayRange.dayKey(from: next) + } + + private static func epochSeconds(forDayKey dayKey: String) -> Int64? { + guard let date = self.localDate(forDayKey: dayKey) else { return nil } + return Int64(date.timeIntervalSince1970) + } + + private static func localDate(forDayKey dayKey: String) -> Date? { + let parts = dayKey.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + var components = DateComponents() + components.calendar = Calendar.current + components.year = year + components.month = month + components.day = day + return components.date + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift new file mode 100644 index 0000000000..dadb09039e --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift @@ -0,0 +1,148 @@ +import Foundation + +extension CostUsageScanner { + static func extractCodexTurnContextModel(from bytes: Data) -> String? { + guard let text = truncatedUTF8String(from: bytes) else { return nil } + let object = text[...] + guard Self.extractJSONStringField("type", from: object, atDepth: 1) == "turn_context", + let payloadText = Self.extractJSONObjectField("payload", from: object, atDepth: 1) + else { return nil } + + let payloadModel = Self.extractJSONStringField("model", from: payloadText, atDepth: 1) + ?? Self.extractJSONStringField("model_name", from: payloadText, atDepth: 1) + if let payloadModel { return payloadModel } + + guard let infoText = Self.extractJSONObjectField("info", from: payloadText, atDepth: 1) else { return nil } + return Self.extractJSONStringField("model", from: infoText, atDepth: 1) + ?? Self.extractJSONStringField("model_name", from: infoText, atDepth: 1) + } + + static func truncatedUTF8String(from bytes: Data) -> String? { + for dropCount in 0...min(4, bytes.count) { + let end = bytes.count - dropCount + if let text = String(bytes: bytes.prefix(end), encoding: .utf8) { + return text + } + } + return nil + } + + static func extractJSONStringField( + _ field: String, + from text: Substring, + atDepth targetDepth: Int) -> String? + { + self.extractJSONField(field, from: text, atDepth: targetDepth) { text, index in + guard index < text.endIndex, text[index] == "\"" else { return nil } + let value = Self.parseJSONString(in: text, index: &index) + return value?.isEmpty == true ? nil : value + } + } + + static func extractJSONObjectField( + _ field: String, + from text: Substring, + atDepth targetDepth: Int) -> Substring? + { + self.extractJSONField(field, from: text, atDepth: targetDepth) { text, index in + guard index < text.endIndex, text[index] == "{" else { return nil } + return text[index...] + } + } + + static func extractJSONIntField( + _ field: String, + from text: Substring, + atDepth targetDepth: Int) -> Int? + { + self.extractJSONField(field, from: text, atDepth: targetDepth) { text, index in + Self.parseJSONInt(in: text, index: &index) + } + } + + static func extractJSONField( + _ field: String, + from text: Substring, + atDepth targetDepth: Int, + parseValue: (Substring, inout String.Index) -> T?) -> T? + { + var index = text.startIndex + var depth = 0 + + while index < text.endIndex { + let character = text[index] + if character == "{" { + depth += 1 + text.formIndex(after: &index) + } else if character == "}" { + depth -= 1 + text.formIndex(after: &index) + } else if character == "\"" { + var valueIndex = index + guard let key = Self.parseJSONString(in: text, index: &valueIndex) else { return nil } + defer { index = valueIndex } + guard depth == targetDepth, key == field else { continue } + + Self.skipJSONWhitespace(in: text, index: &valueIndex) + guard valueIndex < text.endIndex, text[valueIndex] == ":" else { continue } + + text.formIndex(after: &valueIndex) + Self.skipJSONWhitespace(in: text, index: &valueIndex) + if let value = parseValue(text, &valueIndex) { + return value + } + } else { + text.formIndex(after: &index) + } + } + + return nil + } + + static func parseJSONString(in text: Substring, index: inout String.Index) -> String? { + guard index < text.endIndex, text[index] == "\"" else { return nil } + text.formIndex(after: &index) + var value = "" + var isEscaped = false + while index < text.endIndex { + let character = text[index] + if isEscaped { + value.append(character) + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + text.formIndex(after: &index) + return value + } else { + value.append(character) + } + text.formIndex(after: &index) + } + + return nil + } + + static func parseJSONInt(in text: Substring, index: inout String.Index) -> Int? { + var sign = 1 + if index < text.endIndex, text[index] == "-" { + sign = -1 + text.formIndex(after: &index) + } + + var value = 0 + var sawDigit = false + while index < text.endIndex, let digit = text[index].wholeNumberValue { + sawDigit = true + value = (value * 10) + digit + text.formIndex(after: &index) + } + return sawDigit ? value * sign : nil + } + + static func skipJSONWhitespace(in text: Substring, index: inout String.Index) { + while index < text.endIndex, text[index].isWhitespace { + text.formIndex(after: &index) + } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift index e7cda6310e..b8bf32153c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift @@ -26,6 +26,10 @@ private enum CostUsageTimestampParser { } extension CostUsageScanner { + static func dateFromTimestamp(_ text: String) -> Date? { + CostUsageTimestampParser.parseISO(text) + } + static func dayKeyFromTimestamp(_ text: String) -> String? { let bytes = Array(text.utf8) guard bytes.count >= 20 else { return nil } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a5ef942b51..aff14a6d9a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -1,16 +1,29 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif import Foundation +// swiftlint:disable type_body_length file_length enum CostUsageScanner { - enum ClaudeLogProviderFilter: Sendable { + typealias CancellationCheck = () throws -> Void + + static let log = CodexBarLog.logger(LogCategories.tokenCost) + static let codexActiveSessionLookbackDays = 30 + static let costScale = 1_000_000_000.0 + + enum ClaudeLogProviderFilter { case all case vertexAIOnly case excludeVertexAI } - struct Options: Sendable { + struct Options { var codexSessionsRoot: URL? var claudeProjectsRoots: [URL]? var cacheRoot: URL? + var codexTraceDatabaseURL: URL? var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. @@ -20,65 +33,433 @@ enum CostUsageScanner { codexSessionsRoot: URL? = nil, claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, + codexTraceDatabaseURL: URL? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot + self.codexTraceDatabaseURL = codexTraceDatabaseURL self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan } } - struct CodexParseResult: Sendable { + struct CodexParseResult { let days: [String: [String: [Int]]] - let parsedBytes: Int64 + var parsedBytes: Int64 let lastModel: String? let lastTotals: CostUsageCodexTotals? + let lastCountedTotals: CostUsageCodexTotals? + let lastRawTotalsBaseline: CostUsageCodexTotals? + let hasDivergentTotals: Bool + let lastCodexTurnID: String? let sessionId: String? + let forkedFromId: String? + let rows: [CodexUsageRow] + } + + struct CodexUsageRow: Codable, Equatable { + let day: String + let model: String + let turnID: String? + let input: Int + let cached: Int + let output: Int } - private struct CodexScanState { + struct CodexScanState { var seenSessionIds: Set = [] var seenFileIds: Set = [] } - struct ClaudeParseResult: Sendable { + private struct CodexTimestampedTotals { + let timestamp: String + let date: Date? + let totals: CostUsageCodexTotals + } + + enum CodexForkBaseline { + case resolved(CostUsageCodexTotals?) + case unresolved + } + + private static func codexTotalsEqual(_ lhs: CostUsageCodexTotals?, _ rhs: CostUsageCodexTotals?) -> Bool { + lhs?.input == rhs?.input && lhs?.cached == rhs?.cached && lhs?.output == rhs?.output + } + + private static func codexTotalsAtLeast(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { + lhs.input >= rhs.input && lhs.cached >= rhs.cached && lhs.output >= rhs.output + } + + private static func codexTotalsAtMost(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { + lhs.input <= rhs.input && lhs.cached <= rhs.cached && lhs.output <= rhs.output + } + + private static func codexShouldPreferTotalDelta( + rawBaseline: CostUsageCodexTotals?, + currentTotal: CostUsageCodexTotals, + totalDelta: CostUsageCodexTotals, + lastDelta: CostUsageCodexTotals, + sawDivergentTotals: Bool) -> Bool + { + guard !sawDivergentTotals, let rawBaseline else { return false } + return Self.codexTotalsAtLeast(currentTotal, rawBaseline) + && Self.codexTotalsAtMost(totalDelta, lastDelta) + } + + private static func codexAddTotals( + _ lhs: CostUsageCodexTotals, + _ rhs: CostUsageCodexTotals) -> CostUsageCodexTotals + { + CostUsageCodexTotals( + input: lhs.input + rhs.input, + cached: lhs.cached + rhs.cached, + output: lhs.output + rhs.output) + } + + private static func codexMinTotals( + _ lhs: CostUsageCodexTotals, + _ rhs: CostUsageCodexTotals) -> CostUsageCodexTotals + { + CostUsageCodexTotals( + input: min(lhs.input, rhs.input), + cached: min(lhs.cached, rhs.cached), + output: min(lhs.output, rhs.output)) + } + + private static func codexTotalDelta( + from baseline: CostUsageCodexTotals?, + to current: CostUsageCodexTotals) -> CostUsageCodexTotals + { + let baseline = baseline ?? .init(input: 0, cached: 0, output: 0) + return CostUsageCodexTotals( + input: max(0, current.input - baseline.input), + cached: max(0, current.cached - baseline.cached), + output: max(0, current.output - baseline.output)) + } + + private static func codexDivergentTotalDelta( + rawBaseline: CostUsageCodexTotals?, + countedBaseline: CostUsageCodexTotals?, + current: CostUsageCodexTotals) -> CostUsageCodexTotals + { + let rawBaseline = rawBaseline ?? .init(input: 0, cached: 0, output: 0) + let countedBaseline = countedBaseline ?? .init(input: 0, cached: 0, output: 0) + + func delta(raw: Int, counted: Int, current: Int) -> Int { + if current >= raw { + return max(0, current - raw) + } + return max(0, current - counted) + } + + return CostUsageCodexTotals( + input: delta(raw: rawBaseline.input, counted: countedBaseline.input, current: current.input), + cached: delta(raw: rawBaseline.cached, counted: countedBaseline.cached, current: current.cached), + output: delta(raw: rawBaseline.output, counted: countedBaseline.output, current: current.output)) + } + + struct CodexScanResources { + let fileIndex: CodexSessionFileIndex + let inheritedResolver: CodexInheritedTotalsResolver + let modelsDevCatalog: ModelsDevCatalog? + let modelsDevCacheRoot: URL? + let priorityTurns: [String: CodexPriorityTurnMetadata] + } + + struct CodexFileScanContext { + let range: CostUsageDayRange + let forceFullScan: Bool + let dropDeferredCodexRows: Bool + let requiresTurnIDCache: Bool + let changedPriorityTurnIDs: Set + let resources: CodexScanResources + let checkCancellation: CancellationCheck? + } + + struct CodexRefreshPlan { + let refreshMs: Int64 + let roots: [URL] + let rootsFingerprint: [String: Int64] + let rootsChanged: Bool + let windowExpanded: Bool + let needsCostCacheMigration: Bool + let modelsDevCatalog: ModelsDevCatalog? + let codexPricingKey: String + let codexPriorityMetadataKey: String + let hasPriorityMetadata: Bool + let priorityTurns: [String: CodexPriorityTurnMetadata] + let priorityTurnKeys: [String: String] + let priorityTurnIDsByDay: [String: [String]] + let pricingChanged: Bool + let priorityMetadataChanged: Bool + let priorityTurnsChanged: Bool + let needsTurnIDCacheMigration: Bool + let changedPriorityTurnIDs: Set + let shouldRefresh: Bool + } + + final class CodexSessionFileIndex { + private let files: [URL] + private let filePaths: Set + private let roots: [URL] + private let checkCancellation: CancellationCheck? + private var nextUnindexedFile = 0 + private var didIndexRoots = false + private var fileURLBySessionId: [String: URL] = [:] + private var missingSessionIds: Set = [] + + init( + files: [URL], + roots: [URL], + cachedSessionFiles: [String: URL] = [:], + checkCancellation: CancellationCheck? = nil) + { + self.files = files + self.filePaths = Set(files.map(\.path)) + self.roots = roots + self.fileURLBySessionId = cachedSessionFiles + self.checkCancellation = checkCancellation + } + + func remember(fileURL: URL, sessionId: String?) { + guard let sessionId, !sessionId.isEmpty else { return } + self.fileURLBySessionId[sessionId] = fileURL + } + + func fileURL(for sessionId: String) throws -> URL? { + if let cached = self.fileURLBySessionId[sessionId] { + return cached + } + if self.missingSessionIds.contains(sessionId) { + return nil + } + + while self.nextUnindexedFile < self.files.count { + try self.checkCancellation?() + let fileURL = self.files[self.nextUnindexedFile] + self.nextUnindexedFile += 1 + guard let indexedSessionId = try CostUsageScanner.parseCodexSessionIdentifier( + fileURL: fileURL, + checkCancellation: self.checkCancellation) + else { + continue + } + self.fileURLBySessionId[indexedSessionId] = fileURL + if indexedSessionId == sessionId { + return fileURL + } + } + + if !self.didIndexRoots { + try self.indexRoots() + if let indexed = self.fileURLBySessionId[sessionId] { + return indexed + } + } + + self.missingSessionIds.insert(sessionId) + return nil + } + + private func indexRoots() throws { + self.didIndexRoots = true + guard !self.roots.isEmpty else { return } + for root in self.roots { + try self.checkCancellation?() + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { continue } + + while let fileURL = enumerator.nextObject() as? URL { + try self.checkCancellation?() + guard fileURL.pathExtension.lowercased() == "jsonl" else { continue } + guard !self.filePaths.contains(fileURL.path) else { continue } + guard let indexedSessionId = try CostUsageScanner.parseCodexSessionIdentifier( + fileURL: fileURL, + checkCancellation: self.checkCancellation) + else { + continue + } + self.fileURLBySessionId[indexedSessionId] = fileURL + } + } + } + } + + final class CodexInheritedTotalsResolver { + private let fileIndex: CodexSessionFileIndex + private let checkCancellation: CancellationCheck? + private var snapshotsBySessionId: [String: [CodexTimestampedTotals]] = [:] + + init(fileIndex: CodexSessionFileIndex, checkCancellation: CancellationCheck?) { + self.fileIndex = fileIndex + self.checkCancellation = checkCancellation + } + + func inheritedTotals(for sessionId: String, atOrBefore cutoffTimestamp: String) throws -> CodexForkBaseline { + guard !cutoffTimestamp.isEmpty else { + CostUsageScanner.log.warning( + "Codex cost usage fork timestamp missing; treating parent baseline as unresolved", + metadata: ["sessionId": sessionId]) + return .unresolved + } + let cutoffDate = CostUsageScanner.dateFromTimestamp(cutoffTimestamp) + if cutoffDate == nil { + CostUsageScanner.log.warning( + "Codex cost usage could not parse fork timestamp; falling back to lexical comparison", + metadata: ["sessionId": sessionId, "timestamp": cutoffTimestamp]) + } + guard let snapshots = try self.snapshots(for: sessionId) else { return .unresolved } + var inherited: CostUsageCodexTotals? + for snapshot in snapshots { + let isAtOrBefore: Bool = if let snapshotDate = snapshot.date, let cutoffDate { + snapshotDate <= cutoffDate + } else { + snapshot.timestamp <= cutoffTimestamp + } + if isAtOrBefore { + inherited = snapshot.totals + } + } + return .resolved(inherited) + } + + private func snapshots(for sessionId: String) throws -> [CodexTimestampedTotals]? { + if let cached = self.snapshotsBySessionId[sessionId] { + return cached + } + try self.checkCancellation?() + guard let fileURL = try self.fileIndex.fileURL(for: sessionId) else { + CostUsageScanner.log.warning( + "Codex cost usage parent session file not found", + metadata: ["sessionId": sessionId]) + return nil + } + let parsed = try CostUsageScanner.parseCodexTokenSnapshots( + fileURL: fileURL, + checkCancellation: self.checkCancellation) + guard let parsedSessionId = parsed.sessionId else { + CostUsageScanner.log.warning( + "Codex cost usage parent session missing session metadata", + metadata: ["sessionId": sessionId, "path": fileURL.path]) + return nil + } + if parsedSessionId != sessionId { + CostUsageScanner.log.warning( + "Codex cost usage parent session resolved to mismatched session id", + metadata: [ + "requestedSessionId": sessionId, + "resolvedSessionId": parsedSessionId, + "path": fileURL.path, + ]) + return nil + } + self.snapshotsBySessionId[sessionId] = parsed.snapshots + return parsed.snapshots + } + } + + struct ClaudeParseResult { let days: [String: [String: [Int]]] + let rows: [ClaudeUsageRow] let parsedBytes: Int64 } + enum ClaudePathRole: String, Codable { + case parent + case subagent + } + + struct ClaudeUsageRow: Codable { + let dayKey: String + let model: String + let sessionId: String? + let messageId: String? + let requestId: String? + let timestampUnixMs: Int64? + let isSidechain: Bool + let pathRole: ClaudePathRole + let input: Int + let cacheRead: Int + let cacheCreate: Int + let cacheCreate1h: Int? + let output: Int + let costNanos: Int + let costPriced: Bool? + } + static func loadDailyReport( provider: UsageProvider, since: Date, until: Date, now: Date = Date(), options: Options = Options()) -> CostUsageDailyReport + { + ( + try? self.loadDailyReportCancellable( + provider: provider, + since: since, + until: until, + now: now, + options: options, + checkCancellation: nil)) ?? CostUsageDailyReport(data: [], summary: nil) + } + + static func loadDailyReportCancellable( + provider: UsageProvider, + since: Date, + until: Date, + now: Date = Date(), + options: Options = Options(), + checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { let range = CostUsageDayRange(since: since, until: until) let emptyReport = CostUsageDailyReport(data: [], summary: nil) + try checkCancellation?() switch provider { case .codex: - return self.loadCodexDaily(range: range, now: now, options: options) + return try self.loadCodexDaily( + range: range, + now: now, + options: options, + checkCancellation: checkCancellation) case .claude: - return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options) + return try self.loadClaudeDaily( + provider: .claude, + range: range, + now: now, + options: options, + checkCancellation: checkCancellation) case .vertexai: var filtered = options if filtered.claudeLogProviderFilter == .all { filtered.claudeLogProviderFilter = .vertexAIOnly } - return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) - case .zai, .gemini, .antigravity, .cursor, .opencode, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp: + return try self.loadClaudeDaily( + provider: .vertexai, + range: range, + now: now, + options: filtered, + checkCancellation: checkCancellation) + case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, + .alibabatokenplan, .factory, + .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, + .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, + .mistral, .deepseek, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .stepfun, .bedrock, .grok, + .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes: return emptyReport } } // MARK: - Day keys - struct CostUsageDayRange: Sendable { + struct CostUsageDayRange { let sinceKey: String let untilKey: String let scanSinceKey: String @@ -135,21 +516,327 @@ enum CostUsageScanner { .appendingPathComponent("archived_sessions", isDirectory: true) } - private static func listCodexSessionFiles(root: URL, scanSinceKey: String, scanUntilKey: String) -> [URL] { + private static func listCodexSessionFiles( + root: URL, + scanSinceKey: String, + scanUntilKey: String, + includeRecursive: Bool) -> [URL] + { let partitioned = self.listCodexSessionFilesByDatePartition( root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey) let flat = self.listCodexSessionFilesFlat(root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey) + let recursive = includeRecursive ? self.listCodexLegacySessionFilesRecursive(root: root) : [] var seen: Set = [] var out: [URL] = [] - for item in partitioned + flat where !seen.contains(item.path) { + for item in partitioned + flat + recursive where !seen.contains(item.path) { seen.insert(item.path) out.append(item) } return out } + private static func cachedCodexSessionFiles( + cache: CostUsageCache, + range: CostUsageDayRange, + roots: [URL], + excludingPaths: Set) -> [URL] + { + cache.files.compactMap { path, usage in + guard !excludingPaths.contains(path) else { return nil } + let hasRelevantDay = usage.days.keys.contains { + CostUsageDayRange.isInRange(dayKey: $0, since: range.scanSinceKey, until: range.scanUntilKey) + } + guard hasRelevantDay else { return nil } + guard FileManager.default.fileExists(atPath: path) else { return nil } + let fileURL = URL(fileURLWithPath: path) + guard Self.isWithinCodexRoots(fileURL: fileURL, roots: roots) else { return nil } + return fileURL + } + } + + private static func cachedCodexSessionIndex( + cache: CostUsageCache, + roots: [URL], + knownExistingPaths: Set) -> [String: URL] + { + var out: [String: URL] = [:] + for (path, usage) in cache.files { + guard let sessionId = usage.sessionId, !sessionId.isEmpty else { continue } + if knownExistingPaths.contains(path) { + out[sessionId] = URL(fileURLWithPath: path) + continue + } + guard FileManager.default.fileExists(atPath: path) else { continue } + let fileURL = URL(fileURLWithPath: path) + guard Self.isWithinCodexRoots(fileURL: fileURL, roots: roots) else { continue } + out[sessionId] = fileURL + } + return out + } + + private static func codexRootsFingerprint(_ roots: [URL]) -> [String: Int64] { + var out: [String: Int64] = [:] + for root in roots { + out[root.standardizedFileURL.path] = 0 + } + return out + } + + static func codexRootsFingerprint(options: Options) -> [String: Int64] { + self.codexRootsFingerprint(self.codexSessionsRoots(options: options)) + } + + private static func codexPricingKey(modelsDevArtifact: ModelsDevCacheArtifact?) -> String { + guard let modelsDevArtifact else { + let fingerprint = CostUsagePricing.codexBuiltInPricingFingerprint() + return "builtin-\(Self.sha256Hex(Data(fingerprint.utf8)))" + } + let fingerprint = self.modelsDevPricingFingerprint(modelsDevArtifact.catalog) + return "models-dev-v\(modelsDevArtifact.version)-\(Self.sha256Hex(Data(fingerprint.utf8)))" + } + + private static func modelsDevPricingFingerprint(_ catalog: ModelsDevCatalog) -> String { + var parts: [String] = [] + for providerID in catalog.providers.keys.sorted() { + guard let provider = catalog.providers[providerID] else { continue } + parts.append("provider=\(providerID)|\(provider.id ?? "")") + for modelKey in provider.models.keys.sorted() { + guard let model = provider.models[modelKey] else { continue } + let cost = model.cost + let contextOver200K = cost?.contextOver200K + parts.append([ + "model=\(modelKey)", + model.id, + Self.optionalDoubleFingerprint(cost?.input), + Self.optionalDoubleFingerprint(cost?.output), + Self.optionalDoubleFingerprint(cost?.cacheRead), + Self.optionalDoubleFingerprint(cost?.cacheWrite), + Self.optionalDoubleFingerprint(contextOver200K?.input), + Self.optionalDoubleFingerprint(contextOver200K?.output), + Self.optionalDoubleFingerprint(contextOver200K?.cacheRead), + Self.optionalDoubleFingerprint(contextOver200K?.cacheWrite), + model.limit?.context.map(String.init) ?? "nil", + ].joined(separator: "|")) + } + } + return parts.joined(separator: "\n") + } + + private static func optionalDoubleFingerprint(_ value: Double?) -> String { + guard let value else { return "nil" } + return String(format: "%.17g", value) + } + + private static func codexPriorityMetadataKey(databaseURL: URL?) -> String { + let url = databaseURL ?? self.defaultCodexPriorityDatabaseURL() + let path = url.standardizedFileURL.path + return FileManager.default.fileExists(atPath: path) ? "sqlite:\(path)" : "missing:\(path)" + } + + private static func codexPriorityMetadataChanged(old: String?, new: String) -> Bool { + guard let old, old != new else { return false } + return new.hasPrefix("sqlite:") + } + + private static func codexPriorityTurnKeys( + _ priorityTurns: [String: CodexPriorityTurnMetadata]) -> [String: String] + { + var partsByDay: [String: [String]] = [:] + for (turnID, turn) in priorityTurns { + guard let dayKey = self.codexPriorityDayKey(turn) else { continue } + partsByDay[dayKey, default: []].append([ + turnID, + turn.model ?? "", + turn.timestamp ?? "", + turn.threadID ?? "", + ].joined(separator: "|")) + } + var out: [String: String] = [:] + for (dayKey, parts) in partsByDay { + out[dayKey] = self.sha256Hex(Data(parts.sorted().joined(separator: "\n").utf8)) + } + return out + } + + private static func codexPriorityTurnIDsByDay( + _ priorityTurns: [String: CodexPriorityTurnMetadata]) -> [String: [String]] + { + var out: [String: Set] = [:] + for (turnID, turn) in priorityTurns { + guard let dayKey = self.codexPriorityDayKey(turn) else { continue } + out[dayKey, default: []].insert(turnID) + } + return out.mapValues { $0.sorted() } + } + + private static func codexPriorityDayKey(_ turn: CodexPriorityTurnMetadata) -> String? { + guard let timestamp = turn.timestamp else { return nil } + let dayKeyFromEpoch = Int64(timestamp).map { + CostUsageDayRange.dayKey(from: Date(timeIntervalSince1970: TimeInterval($0))) + } + return dayKeyFromEpoch ?? self.dayKeyFromTimestamp(timestamp) ?? self.dayKeyFromParsedISO(timestamp) + } + + private static func codexPriorityTurnKeysChanged( + old: [String: String]?, + new: [String: String], + range: CostUsageDayRange) -> Bool + { + for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + where old?[dayKey] != new[dayKey] + { + return true + } + return false + } + + private static func changedPriorityTurnIDs( + old: [String: [String]]?, + new: [String: [String]], + oldKeys: [String: String]?, + newKeys: [String: String], + range: CostUsageDayRange) -> Set + { + var out = Set() + for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + let oldIDs = Set(old?[dayKey] ?? []) + let newIDs = Set(new[dayKey] ?? []) + if oldIDs != newIDs || oldKeys?[dayKey] != newKeys[dayKey] { + out.formUnion(oldIDs) + out.formUnion(newIDs) + } + } + return out + } + + private static func mergePriorityTurnKeys( + existing: [String: String]?, + new: [String: String], + range: CostUsageDayRange, + retainedSinceKey: String, + retainedUntilKey: String) -> [String: String]? + { + var out = existing ?? [:] + for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + out[dayKey] = new[dayKey] + } + out = out.filter { key, _ in + CostUsageDayRange.isInRange(dayKey: key, since: retainedSinceKey, until: retainedUntilKey) + } + return out.isEmpty ? nil : out + } + + private static func mergePriorityTurnIDsByDay( + existing: [String: [String]]?, + new: [String: [String]], + range: CostUsageDayRange, + retainedSinceKey: String, + retainedUntilKey: String) -> [String: [String]]? + { + var out = existing ?? [:] + for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + out[dayKey] = new[dayKey] ?? [] + } + out = out.filter { key, _ in + CostUsageDayRange.isInRange(dayKey: key, since: retainedSinceKey, until: retainedUntilKey) + } + return out.isEmpty ? nil : out + } + + private static func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func listCodexRecentlyModifiedFiles( + root: URL, + scanSinceKey: String, + scanUntilKey: String, + modifiedSince: Date) -> [URL] + { + let lookbackSinceKey = self.dayKey(scanSinceKey, addingDays: -self.codexActiveSessionLookbackDays) + ?? scanSinceKey + let partitioned = self.listCodexSessionFilesByDatePartition( + root: root, + scanSinceKey: lookbackSinceKey, + scanUntilKey: scanUntilKey) + let partitionedModified = self.filterRecentlyModified(files: partitioned, modifiedSince: modifiedSince) + + let legacyRecursive = self.listCodexRecentlyModifiedFilesRecursive(root: root, modifiedSince: modifiedSince) + var seen = Set(partitionedModified.map(\.path)) + var out = partitionedModified + for fileURL in legacyRecursive where !seen.contains(fileURL.path) { + seen.insert(fileURL.path) + out.append(fileURL) + } + return out + } + + private static func filterRecentlyModified(files: [URL], modifiedSince: Date) -> [URL] { + files.filter { fileURL in + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]) + guard values?.isRegularFile == true else { return false } + guard let modifiedAt = values?.contentModificationDate else { return false } + return modifiedAt >= modifiedSince + } + } + + private static func isDatePartitionComponent(_ value: String, length: Int) -> Bool { + value.count == length && value.allSatisfy(\.isNumber) + } + + private static func dayKey(_ dayKey: String, addingDays days: Int) -> String? { + guard let date = self.parseDayKey(dayKey) else { return nil } + guard let shifted = Calendar.current.date(byAdding: .day, value: days, to: date) else { return nil } + return CostUsageDayRange.dayKey(from: shifted) + } + + private static func dayKeys(sinceKey: String, untilKey: String) -> [String] { + guard let since = self.parseDayKey(sinceKey), + self.parseDayKey(untilKey) != nil + else { return sinceKey <= untilKey ? [sinceKey] : [] } + + var out: [String] = [] + var cursor = since + let calendar = Calendar.current + while CostUsageDayRange.dayKey(from: cursor) <= untilKey { + out.append(CostUsageDayRange.dayKey(from: cursor)) + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + if next <= cursor { break } + cursor = next + } + return out + } + + private static func listCodexRecentlyModifiedFilesRecursive(root: URL, modifiedSince: Date) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { return [] } + + var out: [URL] = [] + while let fileURL = enumerator.nextObject() as? URL { + guard fileURL.pathExtension.lowercased() == "jsonl" else { continue } + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]) + guard values?.isRegularFile == true else { continue } + guard let modifiedAt = values?.contentModificationDate, modifiedAt >= modifiedSince else { continue } + out.append(fileURL) + } + return out + } + + private static func isWithinCodexRoots(fileURL: URL, roots: [URL]) -> Bool { + let filePath = fileURL.standardizedFileURL.path + return roots.contains { root in + let rootPath = root.standardizedFileURL.path + if filePath == rootPath { return true } + let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" + return filePath.hasPrefix(prefix) + } + } + private static func listCodexSessionFilesByDatePartition( root: URL, scanSinceKey: String, @@ -206,6 +893,36 @@ enum CostUsageScanner { return out } + private static func listCodexLegacySessionFilesRecursive(root: URL) -> [URL] { + guard FileManager.default.fileExists(atPath: root.path) else { return [] } + let rootPath = root.standardizedFileURL.path + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { return [] } + + var out: [URL] = [] + while let item = enumerator.nextObject() as? URL { + if Self.isCodexDatePartitionAncestor(item, rootPath: rootPath) { + enumerator.skipDescendants() + continue + } + guard item.pathExtension.lowercased() == "jsonl" else { continue } + out.append(item) + } + return out + } + + private static func isCodexDatePartitionAncestor(_ url: URL, rootPath: String) -> Bool { + let path = url.standardizedFileURL.path + guard path.hasPrefix(rootPath + "/") else { return false } + let relative = String(path.dropFirst(rootPath.count + 1)) + let parts = relative.split(separator: "/") + guard parts.count == 1 else { return false } + return Self.isDatePartitionComponent(String(parts[0]), length: 4) + } + private static let codexFilenameDateRegex = try? NSRegularExpression(pattern: "(\\d{4}-\\d{2}-\\d{2})") private static func dayKeyFromFilename(_ filename: String) -> String? { @@ -216,520 +933,1483 @@ enum CostUsageScanner { return String(filename[matchRange]) } - private static func fileIdentityString(fileURL: URL) -> String? { - guard let values = try? fileURL.resourceValues(forKeys: [.fileResourceIdentifierKey]) else { return nil } - guard let identifier = values.fileResourceIdentifier else { return nil } - if let data = identifier as? Data { - return data.base64EncodedString() - } - return String(describing: identifier) + private struct CodexSessionMetadata { + let sessionId: String? + let forkedFromId: String? + let forkTimestamp: String? } - static func parseCodexFile( - fileURL: URL, - range: CostUsageDayRange, - startOffset: Int64 = 0, - initialModel: String? = nil, - initialTotals: CostUsageCodexTotals? = nil) -> CodexParseResult - { - var currentModel = initialModel - var previousTotals = initialTotals - var sessionId: String? + private struct CodexTokenCountRecord { + let timestamp: String + let model: String? + let turnID: String? + let last: CostUsageCodexTotals? + let total: CostUsageCodexTotals? + } - var days: [String: [String: [Int]]] = [:] + private enum CodexFastLine { + case sessionMeta(CodexSessionMetadata) + case turnContext(model: String?) + case taskStarted(turnID: String?) + case tokenCount(CodexTokenCountRecord) + } - func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { - guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) - else { return } - let normModel = CostUsagePricing.normalizeCodexModel(model) + private static let codexJSONFieldCachedInputTokens = Array("cached_input_tokens".utf8) + private static let codexJSONFieldCacheReadInputTokens = Array("cache_read_input_tokens".utf8) + private static let codexJSONFieldForkedFromId = Array("forked_from_id".utf8) + private static let codexJSONFieldForkedFromIdCamel = Array("forkedFromId".utf8) + private static let codexJSONFieldId = Array("id".utf8) + private static let codexJSONFieldInfo = Array("info".utf8) + private static let codexJSONFieldInputTokens = Array("input_tokens".utf8) + private static let codexJSONFieldLastTokenUsage = Array("last_token_usage".utf8) + private static let codexJSONFieldModel = Array("model".utf8) + private static let codexJSONFieldModelName = Array("model_name".utf8) + private static let codexJSONFieldOutputTokens = Array("output_tokens".utf8) + private static let codexJSONFieldParentSessionId = Array("parent_session_id".utf8) + private static let codexJSONFieldParentSessionIdCamel = Array("parentSessionId".utf8) + private static let codexJSONFieldPayload = Array("payload".utf8) + private static let codexJSONFieldSessionId = Array("session_id".utf8) + private static let codexJSONFieldSessionIdCamel = Array("sessionId".utf8) + private static let codexJSONFieldTimestamp = Array("timestamp".utf8) + private static let codexJSONFieldTotalTokenUsage = Array("total_token_usage".utf8) + private static let codexJSONFieldTurnId = Array("turn_id".utf8) + private static let codexJSONFieldTurnIdCamel = Array("turnId".utf8) + private static let codexJSONFieldType = Array("type".utf8) + + private static func codexForkParentId(from payload: [String: Any]?) -> String? { + guard let payload else { return nil } + for key in ["forked_from_id", "forkedFromId", "parent_session_id", "parentSessionId"] { + guard let value = payload[key] as? String else { continue } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return nil + } - var dayModels = days[dayKey] ?? [:] - var packed = dayModels[normModel] ?? [0, 0, 0] - packed[0] = (packed[safe: 0] ?? 0) + input - packed[1] = (packed[safe: 1] ?? 0) + cached - packed[2] = (packed[safe: 2] ?? 0) + output - dayModels[normModel] = packed - days[dayKey] = dayModels + private static func codexForkParentId( + from bytes: UnsafeBufferPointer, + in payloadRange: Range) -> String? + { + for key in [ + self.codexJSONFieldForkedFromId, + self.codexJSONFieldForkedFromIdCamel, + self.codexJSONFieldParentSessionId, + self.codexJSONFieldParentSessionIdCamel, + ] { + guard let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { continue } + return value } + return nil + } - let maxLineBytes = 256 * 1024 - let prefixBytes = 32 * 1024 - - let parsedBytes = (try? CostUsageJsonl.scan( - fileURL: fileURL, - offset: startOffset, - maxLineBytes: maxLineBytes, - prefixBytes: prefixBytes, - onLine: { line in - guard !line.bytes.isEmpty else { return } - guard !line.wasTruncated else { return } - - guard - line.bytes.containsAscii(#""type":"event_msg""#) - || line.bytes.containsAscii(#""type":"turn_context""#) - || line.bytes.containsAscii(#""type":"session_meta""#) - else { return } - - if line.bytes.containsAscii(#""type":"event_msg""#), !line.bytes.containsAscii(#""token_count""#) { - return + private static func codexTurnID(from bytes: UnsafeBufferPointer, in payloadRange: Range) -> String? { + for key in [self.codexJSONFieldTurnId, self.codexJSONFieldTurnIdCamel, self.codexJSONFieldId] { + if let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1), !value.isEmpty { + return value + } + } + if let infoRange = extractJSONByteObjectField(codexJSONFieldInfo, from: bytes, in: payloadRange, atDepth: 1) { + for key in [self.codexJSONFieldTurnId, self.codexJSONFieldTurnIdCamel, self.codexJSONFieldId] { + if let value = extractJSONByteStringField(key, from: bytes, in: infoRange, atDepth: 1), !value.isEmpty { + return value } - - guard - let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], - let type = obj["type"] as? String - else { return } - - if type == "session_meta" { - if sessionId == nil { - let payload = obj["payload"] as? [String: Any] - sessionId = payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? obj["session_id"] as? String - ?? obj["sessionId"] as? String - ?? obj["id"] as? String - } - return + } + } + return nil + } + + private static func codexSessionId( + from bytes: UnsafeBufferPointer, + in rootRange: Range, + payloadRange: Range?) -> String? + { + if let payloadRange { + for key in [self.codexJSONFieldSessionId, self.codexJSONFieldSessionIdCamel, self.codexJSONFieldId] { + if let value = extractJSONByteStringField(key, from: bytes, in: payloadRange, atDepth: 1), + !value.isEmpty + { + return value } + } + } + for key in [Self.codexJSONFieldSessionId, Self.codexJSONFieldSessionIdCamel, Self.codexJSONFieldId] { + if let value = Self.extractJSONByteStringField(key, from: bytes, in: rootRange, atDepth: 1), + !value.isEmpty + { + return value + } + } + return nil + } - guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return } + private static func codexTotals( + from bytes: UnsafeBufferPointer, + in objectRange: Range?) -> CostUsageCodexTotals? + { + guard let objectRange else { return nil } + let input = max( + 0, + Self.extractJSONByteIntField(Self.codexJSONFieldInputTokens, from: bytes, in: objectRange, atDepth: 1) ?? 0) + let cached = max( + 0, + Self.extractJSONByteIntField(Self.codexJSONFieldCachedInputTokens, from: bytes, in: objectRange, atDepth: 1) + ?? Self.extractJSONByteIntField( + Self.codexJSONFieldCacheReadInputTokens, + from: bytes, + in: objectRange, + atDepth: 1) + ?? 0) + let output = max( + 0, + Self + .extractJSONByteIntField(Self.codexJSONFieldOutputTokens, from: bytes, in: objectRange, atDepth: 1) ?? + 0) + return CostUsageCodexTotals(input: input, cached: cached, output: output) + } - if type == "turn_context" { - if let payload = obj["payload"] as? [String: Any] { - if let model = payload["model"] as? String { - currentModel = model - } else if let info = payload["info"] as? [String: Any], let model = info["model"] as? String { - currentModel = model - } + private static func parseCodexFastLine(_ bytes: Data) -> CodexFastLine? { + bytes.withUnsafeBytes { rawBytes in + let rawBuffer = rawBytes.bindMemory(to: UInt8.self) + guard !rawBuffer.isEmpty else { return nil } + let objectRange = 0.. String? + { + try self.parseCodexSessionMetadata(fileURL: fileURL, checkCancellation: checkCancellation)?.sessionId + } + + private static func parseCodexSessionMetadata( + fileURL: URL, + checkCancellation: CancellationCheck? = nil) throws -> CodexSessionMetadata? + { + let handle: FileHandle + do { + handle = try FileHandle(forReadingFrom: fileURL) + } catch { + self.log.warning( + "Codex cost usage failed to open session file for session id parsing", + metadata: ["path": fileURL.path, "error": error.localizedDescription]) + return nil + } + defer { try? handle.close() } + + var buffer = Data() + let newline = Data([0x0A]) + + func parseSessionMetadata(from lineData: Data) -> CodexSessionMetadata? { + guard !lineData.isEmpty else { return nil } + if case let .sessionMeta(metadata) = Self.parseCodexFastLine(lineData) { + return metadata + } + return autoreleasepool { + guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] + else { return nil } + guard obj["type"] as? String == "session_meta" else { return nil } + let payload = obj["payload"] as? [String: Any] + return CodexSessionMetadata( + sessionId: payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String, + forkedFromId: Self.codexForkParentId(from: payload), + forkTimestamp: payload?["timestamp"] as? String + ?? obj["timestamp"] as? String) + } + } - func toInt(_ v: Any?) -> Int { - if let n = v as? NSNumber { return n.intValue } - return 0 + do { + while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty { + try checkCancellation?() + buffer.append(chunk) + while let newlineRange = buffer.range(of: newline) { + let lineData = buffer.subdata(in: 0.. ( + sessionId: String?, + snapshots: [CodexTimestampedTotals]) + { + var sessionId: String? + var previousTotals: CostUsageCodexTotals? + var rawTotalsBaseline: CostUsageCodexTotals? + var sawDivergentTotals = false + var snapshots: [CodexTimestampedTotals] = [] + var warnedAboutUnparsedTimestamp = false + + func parsedSnapshotDate(timestamp: String) -> Date? { + let date = Self.dateFromTimestamp(timestamp) + if date == nil, !warnedAboutUnparsedTimestamp { + warnedAboutUnparsedTimestamp = true + self.log.warning( + "Codex cost usage could not parse parent token snapshot timestamp; " + + "falling back to lexical comparison", + metadata: ["path": fileURL.path, "timestamp": timestamp]) + } + return date + } + + func appendSnapshot(timestamp: String, last: CostUsageCodexTotals?, total: CostUsageCodexTotals?) { + if let last { + let rawDelta = last + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + var countedDelta = rawDelta if let total { - let input = toInt(total["input_tokens"]) - let cached = toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]) - let output = toInt(total["output_tokens"]) - - let prev = previousTotals - deltaInput = max(0, input - (prev?.input ?? 0)) - deltaCached = max(0, cached - (prev?.cached ?? 0)) - deltaOutput = max(0, output - (prev?.output ?? 0)) - previousTotals = CostUsageCodexTotals(input: input, cached: cached, output: output) - } else if let last { - deltaInput = max(0, toInt(last["input_tokens"])) - deltaCached = max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])) - deltaOutput = max(0, toInt(last["output_tokens"])) + let rawTotals = total + let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: rawTotals) + if Self.codexShouldPreferTotalDelta( + rawBaseline: rawTotalsBaseline, + currentTotal: rawTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + countedDelta = totalDelta + } + let next = Self.codexAddTotals(base, countedDelta) + previousTotals = next + rawTotalsBaseline = rawTotals + if !Self.codexTotalsEqual(rawTotals, next) { + sawDivergentTotals = true + } } else { - return + let next = Self.codexAddTotals(base, countedDelta) + previousTotals = next + rawTotalsBaseline = next + } + + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: previousTotals ?? base)) + } else if let total { + let next = total + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: next) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: next) + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + let countedTotals = Self.codexAddTotals(base, delta) + previousTotals = countedTotals + rawTotalsBaseline = next + if !Self.codexTotalsEqual(next, countedTotals) { + sawDivergentTotals = true } + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: countedTotals)) + } + } - if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } - let cachedClamp = min(deltaCached, deltaInput) - add(dayKey: dayKey, model: model, input: deltaInput, cached: cachedClamp, output: deltaOutput) - })) ?? startOffset + do { + _ = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 512 * 1024, + prefixBytes: 512 * 1024, + checkCancellation: checkCancellation, + onLine: { line in + guard !line.bytes.isEmpty, !line.wasTruncated else { return } + if let fastLine = Self.parseCodexFastLine(line.bytes) { + switch fastLine { + case let .sessionMeta(metadata): + if sessionId == nil { + sessionId = metadata.sessionId + } + case let .tokenCount(record): + appendSnapshot(timestamp: record.timestamp, last: record.last, total: record.total) + case .turnContext, .taskStarted: + break + } + return + } - return CodexParseResult( - days: days, - parsedBytes: parsedBytes, - lastModel: currentModel, - lastTotals: previousTotals, - sessionId: sessionId) + autoreleasepool { + guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] + else { return } + + if obj["type"] as? String == "session_meta" { + let payload = obj["payload"] as? [String: Any] + if sessionId == nil { + sessionId = payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String + } + return + } + + guard obj["type"] as? String == "event_msg" else { return } + guard let payload = obj["payload"] as? [String: Any] else { return } + guard payload["type"] as? String == "token_count" else { return } + guard let info = payload["info"] as? [String: Any] else { return } + guard let timestamp = obj["timestamp"] as? String else { return } + + func toInt(_ value: Any?) -> Int { + if let number = value as? NSNumber { return number.intValue } + return 0 + } + + let total = (info["total_token_usage"] as? [String: Any]).map { + CostUsageCodexTotals( + input: toInt($0["input_tokens"]), + cached: toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"]), + output: toInt($0["output_tokens"])) + } + let last = (info["last_token_usage"] as? [String: Any]).map { + CostUsageCodexTotals( + input: max(0, toInt($0["input_tokens"])), + cached: max(0, toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"])), + output: max(0, toInt($0["output_tokens"]))) + } + appendSnapshot(timestamp: timestamp, last: last, total: total) + } + }) + } catch is CancellationError { + throw CancellationError() + } catch { + self.log.warning( + "Codex cost usage failed while scanning parent token snapshots", + metadata: ["path": fileURL.path, "error": error.localizedDescription]) + } + + return (sessionId, snapshots) } - private static func scanCodexFile( + static func parseCodexFile( fileURL: URL, range: CostUsageDayRange, - cache: inout CostUsageCache, - state: inout CodexScanState) + startOffset: Int64 = 0, + initialModel: String? = nil, + initialTotals: CostUsageCodexTotals? = nil, + initialRawTotalsBaseline: CostUsageCodexTotals? = nil, + initialHasDivergentTotals: Bool = false, + initialCodexTurnID: String? = nil, + inheritedTotalsResolver: ((String, String) -> CodexForkBaseline)? = nil) -> CodexParseResult { - let path = fileURL.path - let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] - let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 - let mtimeMs = Int64(mtime * 1000) - let fileId = Self.fileIdentityString(fileURL: fileURL) - - func dropCachedFile(_ cached: CostUsageFileUsage?) { - if let cached { - Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) + let throwingResolver: ((String, String) throws -> CodexForkBaseline)? = inheritedTotalsResolver + .map { resolver in + { sessionId, timestamp in resolver(sessionId, timestamp) } } - cache.files.removeValue(forKey: path) - } + return ( + try? Self.parseCodexFileCancellable( + fileURL: fileURL, + range: range, + startOffset: startOffset, + initialModel: initialModel, + initialTotals: initialTotals, + initialRawTotalsBaseline: initialRawTotalsBaseline, + initialHasDivergentTotals: initialHasDivergentTotals, + initialCodexTurnID: initialCodexTurnID, + inheritedTotalsResolver: throwingResolver, + checkCancellation: nil)) ?? CodexParseResult( + days: [:], + parsedBytes: startOffset, + lastModel: initialModel, + lastTotals: initialTotals, + lastCountedTotals: initialTotals, + lastRawTotalsBaseline: initialRawTotalsBaseline, + hasDivergentTotals: initialHasDivergentTotals, + lastCodexTurnID: initialCodexTurnID, + sessionId: nil, + forkedFromId: nil, + rows: []) + } - if let fileId, state.seenFileIds.contains(fileId) { - dropCachedFile(cache.files[path]) - return + // swiftlint:disable:next cyclomatic_complexity function_body_length + static func parseCodexFileCancellable( + fileURL: URL, + range: CostUsageDayRange, + startOffset: Int64 = 0, + initialModel: String? = nil, + initialTotals: CostUsageCodexTotals? = nil, + initialRawTotalsBaseline: CostUsageCodexTotals? = nil, + initialHasDivergentTotals: Bool = false, + initialCodexTurnID: String? = nil, + inheritedTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, + checkCancellation: CancellationCheck? = nil) throws -> CodexParseResult + { + var currentModel = initialModel + var previousTotals = initialTotals + var sessionId: String? + var forkedFromId: String? + var inheritedTotals: CostUsageCodexTotals? + var remainingInheritedTotals: CostUsageCodexTotals? + var forkBaselineResolved = false + var hasUnresolvedForkBaseline = false + var unresolvedForkTotalWatermark: CostUsageCodexTotals? + var currentTurnID = initialCodexTurnID + var rawTotalsBaseline = initialRawTotalsBaseline ?? initialTotals + var sawDivergentTotals = initialHasDivergentTotals + var deferredError: Error? + + var days: [String: [String: [Int]]] = [:] + var rows: [CodexUsageRow] = [] + + func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { + guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) + else { return } + let normModel = CostUsagePricing.normalizeCodexModel(model) + + var dayModels = days[dayKey] ?? [:] + var packed = dayModels[normModel] ?? [0, 0, 0] + packed[0] = (packed[safe: 0] ?? 0) + input + packed[1] = (packed[safe: 1] ?? 0) + cached + packed[2] = (packed[safe: 2] ?? 0) + output + dayModels[normModel] = packed + days[dayKey] = dayModels } - let cached = cache.files[path] - if let cachedSessionId = cached?.sessionId, state.seenSessionIds.contains(cachedSessionId) { - dropCachedFile(cached) - return + func resolveForkBaseline(parentSessionId: String, forkedAt: String) throws { + guard !forkBaselineResolved else { return } + guard let inheritedTotalsResolver else { return } + forkBaselineResolved = true + switch try inheritedTotalsResolver(parentSessionId, forkedAt) { + case let .resolved(totals): + inheritedTotals = totals + remainingInheritedTotals = totals + hasUnresolvedForkBaseline = false + case .unresolved: + hasUnresolvedForkBaseline = true + } } - let needsSessionId = cached != nil && cached?.sessionId == nil - if let cached, - cached.mtimeUnixMs == mtimeMs, - cached.size == size, - !needsSessionId - { - if let cachedSessionId = cached.sessionId { - state.seenSessionIds.insert(cachedSessionId) + func handleSessionMetadata(_ metadata: CodexSessionMetadata) throws { + if sessionId == nil { + sessionId = metadata.sessionId } - if let fileId { - state.seenFileIds.insert(fileId) + if forkedFromId == nil { + forkedFromId = metadata.forkedFromId + } + if let forkedFromId { + try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: metadata.forkTimestamp ?? "") } - return } - if let cached, cached.sessionId != nil { - let startOffset = cached.parsedBytes ?? cached.size - let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size - && cached.lastTotals != nil - if canIncremental { - let delta = Self.parseCodexFile( - fileURL: fileURL, - range: range, - startOffset: startOffset, - initialModel: cached.lastModel, - initialTotals: cached.lastTotals) - let sessionId = delta.sessionId ?? cached.sessionId - if let sessionId, state.seenSessionIds.contains(sessionId) { - dropCachedFile(cached) - return + // swiftlint:disable:next function_body_length + func handleTokenCount(_ record: CodexTokenCountRecord) throws { + guard let dayKey = Self.dayKeyFromTimestamp(record.timestamp) ?? Self.dayKeyFromParsedISO(record.timestamp) + else { return } + + let model = currentModel ?? record.model ?? "gpt-5" + let total = record.total + let last = record.last + + var deltaInput = 0 + var deltaCached = 0 + var deltaOutput = 0 + + func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { + guard var remaining = remainingInheritedTotals else { return rawDelta } + + let adjusted = CostUsageCodexTotals( + input: max(0, rawDelta.input - remaining.input), + cached: max(0, rawDelta.cached - remaining.cached), + output: max(0, rawDelta.output - remaining.output)) + + remaining.input = max(0, remaining.input - rawDelta.input) + remaining.cached = max(0, remaining.cached - rawDelta.cached) + remaining.output = max(0, remaining.output - rawDelta.output) + remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, + remaining.output == 0 + { + nil + } else { + remaining } - if !delta.days.isEmpty { - Self.applyFileDays(cache: &cache, fileDays: delta.days, sign: 1) + return adjusted + } + + let handledUnresolvedForkTotal = hasUnresolvedForkBaseline && total != nil + if hasUnresolvedForkBaseline, let total { + let currentRawTotals = total + defer { + unresolvedForkTotalWatermark = currentRawTotals } + guard let last, + let watermark = unresolvedForkTotalWatermark + else { + return + } + + let rawLastDelta = last + let rawTotalDelta = Self.codexTotalDelta(from: watermark, to: currentRawTotals) + let adjustedDelta = Self.codexMinTotals(rawLastDelta, rawTotalDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, adjustedDelta) + rawTotalsBaseline = previousTotals + } - var mergedDays = cached.days - Self.mergeFileDays(existing: &mergedDays, delta: delta.days) - cache.files[path] = Self.makeFileUsage( - mtimeUnixMs: mtimeMs, - size: size, - days: mergedDays, - parsedBytes: delta.parsedBytes, - lastModel: delta.lastModel, - lastTotals: delta.lastTotals, - sessionId: sessionId) - if let sessionId { - state.seenSessionIds.insert(sessionId) + if !handledUnresolvedForkTotal, + let total, + forkedFromId != nil, + !hasUnresolvedForkBaseline + { + let rawTotals = total + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true } - if let fileId { - state.seenFileIds.insert(fileId) + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal, let last { + let rawDelta = last + let hadRemainingInheritedTotals = remainingInheritedTotals != nil + var adjustedDelta = adjustedLastDelta(rawDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + + if let total, !hasUnresolvedForkBaseline { + let rawTotals = total + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + if !hadRemainingInheritedTotals, + Self.codexShouldPreferTotalDelta( + rawBaseline: rawTotalsBaseline, + currentTotal: currentTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + adjustedDelta = totalDelta + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + remainingInheritedTotals = nil + } + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(currentTotals, countedTotals) { + sawDivergentTotals = true + } + } else { + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + rawTotalsBaseline = countedTotals + } + } else if !handledUnresolvedForkTotal, let total { + let rawTotals = total + + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals } + + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal { return } - } - - if let cached { - Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) - } - let parsed = Self.parseCodexFile(fileURL: fileURL, range: range) - let sessionId = parsed.sessionId ?? cached?.sessionId - if let sessionId, state.seenSessionIds.contains(sessionId) { - cache.files.removeValue(forKey: path) - return + if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } + let cachedClamp = min(deltaCached, deltaInput) + let normModel = CostUsagePricing.normalizeCodexModel(model) + add( + dayKey: dayKey, + model: normModel, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput) + if CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + { + rows.append(CodexUsageRow( + day: dayKey, + model: normModel, + turnID: record.turnID ?? currentTurnID, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput)) + } } - let usage = Self.makeFileUsage( - mtimeUnixMs: mtimeMs, - size: size, - days: parsed.days, - parsedBytes: parsed.parsedBytes, - lastModel: parsed.lastModel, - lastTotals: parsed.lastTotals, - sessionId: sessionId) - cache.files[path] = usage - Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: 1) - if let sessionId { - state.seenSessionIds.insert(sessionId) - } - if let fileId { - state.seenFileIds.insert(fileId) + func handleFastLine(_ fastLine: CodexFastLine) throws { + switch fastLine { + case let .sessionMeta(metadata): + try handleSessionMetadata(metadata) + case let .turnContext(model): + if let model { + currentModel = model + } + case let .taskStarted(turnID): + currentTurnID = turnID + case let .tokenCount(record): + try handleTokenCount(record) + } } - } - private static func loadCodexDaily(range: CostUsageDayRange, now: Date, options: Options) -> CostUsageDailyReport { - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) - let nowMs = Int64(now.timeIntervalSince1970 * 1000) - - let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) - let shouldRefresh = refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs + let maxLineBytes = 256 * 1024 + let prefixBytes = maxLineBytes - let roots = self.codexSessionsRoots(options: options) - var seenPaths: Set = [] - var files: [URL] = [] - for root in roots { - let rootFiles = Self.listCodexSessionFiles( - root: root, - scanSinceKey: range.scanSinceKey, - scanUntilKey: range.scanUntilKey) - for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { - seenPaths.insert(fileURL.path) - files.append(fileURL) + if startOffset == 0, + let metadata = try Self.parseCodexSessionMetadata( + fileURL: fileURL, + checkCancellation: checkCancellation) + { + sessionId = metadata.sessionId + forkedFromId = metadata.forkedFromId + if let forkedFromId = metadata.forkedFromId, + inheritedTotals == nil + { + let forkedAt = metadata.forkTimestamp ?? "" + try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: forkedAt) } } - let filePathsInScan = Set(files.map(\.path)) - - if shouldRefresh { - if options.forceRescan { - cache = CostUsageCache() - } - var scanState = CodexScanState() - for fileURL in files { - Self.scanCodexFile( - fileURL: fileURL, - range: range, - cache: &cache, - state: &scanState) - } - for key in cache.files.keys where !filePathsInScan.contains(key) { - if let old = cache.files[key] { - Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) - } - cache.files.removeValue(forKey: key) - } + var parsedBytes: Int64 + do { + parsedBytes = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: startOffset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + checkCancellation: checkCancellation, + onLine: { line in + if deferredError != nil { return } + guard !line.bytes.isEmpty else { return } + if line.wasTruncated { + // `turn_context` can carry very large prompts, but its model usually appears near the start. + if let model = Self.extractCodexTurnContextModel(from: line.bytes) { + currentModel = model + } + return + } - Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) - cache.lastScanUnixMs = nowMs - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) - } + guard + line.bytes.containsAscii(#""type":"event_msg""#) + || line.bytes.containsAscii(#""type":"turn_context""#) + || line.bytes.containsAscii(#""type":"session_meta""#) + else { return } + + if line.bytes.containsAscii(#""type":"event_msg""#), + !line.bytes.containsAscii(#""token_count""#), + !line.bytes.containsAscii(#""task_started""#) + { + return + } - return Self.buildCodexReportFromCache(cache: cache, range: range) - } + if let fastLine = Self.parseCodexFastLine(line.bytes) { + do { + try handleFastLine(fastLine) + } catch { + deferredError = error + } + return + } - private static func buildCodexReportFromCache( - cache: CostUsageCache, - range: CostUsageDayRange) -> CostUsageDailyReport - { - var entries: [CostUsageDailyReport.Entry] = [] - var totalInput = 0 - var totalOutput = 0 - var totalTokens = 0 - var totalCost: Double = 0 - var costSeen = false + autoreleasepool { + guard + let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], + let type = obj["type"] as? String + else { return } + + if type == "session_meta" { + let payload = obj["payload"] as? [String: Any] + if sessionId == nil { + sessionId = payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String + } + if forkedFromId == nil { + forkedFromId = Self.codexForkParentId(from: payload) + } + if let forkedFromId { + let forkedAt = payload?["timestamp"] as? String + ?? obj["timestamp"] as? String + ?? "" + do { + try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: forkedAt) + } catch { + deferredError = error + return + } + } + return + } - let dayKeys = cache.days.keys.sorted().filter { - CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) - } + guard let tsText = obj["timestamp"] as? String else { return } + guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + else { return } + + if type == "turn_context" { + if let payload = obj["payload"] as? [String: Any] { + if let model = payload["model"] as? String { + currentModel = model + } else if let info = payload["info"] as? [String: Any], + let model = info["model"] as? String + { + currentModel = model + } + } + return + } - for day in dayKeys { - guard let models = cache.days[day] else { continue } - let modelNames = models.keys.sorted() + guard type == "event_msg" else { return } + guard let payload = obj["payload"] as? [String: Any] else { return } + if (payload["type"] as? String) == "task_started" { + currentTurnID = Self.codexTurnID(from: payload) + return + } + guard (payload["type"] as? String) == "token_count" else { return } + + let info = payload["info"] as? [String: Any] + let modelFromInfo = info?["model"] as? String + ?? info?["model_name"] as? String + ?? payload["model"] as? String + ?? obj["model"] as? String + let model = currentModel ?? modelFromInfo ?? "gpt-5" + + func toInt(_ v: Any?) -> Int { + if let n = v as? NSNumber { return n.intValue } + return 0 + } - var dayInput = 0 - var dayOutput = 0 + func tokenTotals(_ usage: [String: Any]) -> CostUsageCodexTotals { + CostUsageCodexTotals( + input: max(0, toInt(usage["input_tokens"])), + cached: max(0, toInt(usage["cached_input_tokens"] ?? usage["cache_read_input_tokens"])), + output: max(0, toInt(usage["output_tokens"]))) + } - var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] - var dayCost: Double = 0 - var dayCostSeen = false + let total = (info?["total_token_usage"] as? [String: Any]) + let last = (info?["last_token_usage"] as? [String: Any]) + + var deltaInput = 0 + var deltaCached = 0 + var deltaOutput = 0 + + func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { + guard var remaining = remainingInheritedTotals else { return rawDelta } + + let adjusted = CostUsageCodexTotals( + input: max(0, rawDelta.input - remaining.input), + cached: max(0, rawDelta.cached - remaining.cached), + output: max(0, rawDelta.output - remaining.output)) + + remaining.input = max(0, remaining.input - rawDelta.input) + remaining.cached = max(0, remaining.cached - rawDelta.cached) + remaining.output = max(0, remaining.output - rawDelta.output) + remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, + remaining.output == 0 + { + nil + } else { + remaining + } + + return adjusted + } - for model in modelNames { - let packed = models[model] ?? [0, 0, 0] - let input = packed[safe: 0] ?? 0 - let cached = packed[safe: 1] ?? 0 - let output = packed[safe: 2] ?? 0 + let handledUnresolvedForkTotal = hasUnresolvedForkBaseline && total != nil + if hasUnresolvedForkBaseline, let total { + let currentRawTotals = tokenTotals(total) + defer { + unresolvedForkTotalWatermark = currentRawTotals + } + guard let last, + let watermark = unresolvedForkTotalWatermark + else { + return + } + + let rawLastDelta = tokenTotals(last) + let rawTotalDelta = Self.codexTotalDelta(from: watermark, to: currentRawTotals) + let adjustedDelta = Self.codexMinTotals(rawLastDelta, rawTotalDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, adjustedDelta) + rawTotalsBaseline = previousTotals + } - dayInput += input - dayOutput += output + if !handledUnresolvedForkTotal, + let total, + forkedFromId != nil, + !hasUnresolvedForkBaseline + { + let rawTotals = tokenTotals(total) + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal, let last { + let rawDelta = CostUsageCodexTotals( + input: max(0, toInt(last["input_tokens"])), + cached: max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])), + output: max(0, toInt(last["output_tokens"]))) + let hadRemainingInheritedTotals = remainingInheritedTotals != nil + var adjustedDelta = adjustedLastDelta(rawDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + + if let total, !hasUnresolvedForkBaseline { + let rawTotals = tokenTotals(total) + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + if !hadRemainingInheritedTotals, + Self.codexShouldPreferTotalDelta( + rawBaseline: rawTotalsBaseline, + currentTotal: currentTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + adjustedDelta = totalDelta + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + remainingInheritedTotals = nil + } + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(currentTotals, countedTotals) { + sawDivergentTotals = true + } + } else { + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + rawTotalsBaseline = countedTotals + } + } else if !handledUnresolvedForkTotal, let total { + let rawTotals = tokenTotals(total) + + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal { + return + } - let cost = CostUsagePricing.codexCostUSD( - model: model, - inputTokens: input, - cachedInputTokens: cached, - outputTokens: output) - breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost)) - if let cost { - dayCost += cost - dayCostSeen = true - } + if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } + let cachedClamp = min(deltaCached, deltaInput) + let normModel = CostUsagePricing.normalizeCodexModel(model) + add( + dayKey: dayKey, + model: normModel, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput) + if CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + { + rows.append(CodexUsageRow( + day: dayKey, + model: normModel, + turnID: Self.codexTurnID(from: payload) ?? currentTurnID, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput)) + } + } + }) + if let deferredError { + throw deferredError } + } catch is CancellationError { + throw CancellationError() + } catch { + self.log.warning( + "Codex cost usage failed while scanning session file", + metadata: ["path": fileURL.path, "error": error.localizedDescription]) + parsedBytes = startOffset + } - breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) } - let top = Array(breakdown.prefix(3)) + return CodexParseResult( + days: days, + parsedBytes: parsedBytes, + lastModel: currentModel, + lastTotals: sawDivergentTotals && !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) + ? nil + : previousTotals, + lastCountedTotals: previousTotals, + lastRawTotalsBaseline: rawTotalsBaseline, + hasDivergentTotals: sawDivergentTotals && !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals), + lastCodexTurnID: currentTurnID, + sessionId: sessionId, + forkedFromId: forkedFromId, + rows: rows) + } - let dayTotal = dayInput + dayOutput - let entryCost = dayCostSeen ? dayCost : nil - entries.append(CostUsageDailyReport.Entry( - date: day, - inputTokens: dayInput, - outputTokens: dayOutput, - totalTokens: dayTotal, - costUSD: entryCost, - modelsUsed: modelNames, - modelBreakdowns: top)) + private static func codexTurnID(from payload: [String: Any]) -> String? { + if let turnID = payload["turn_id"] as? String ?? payload["turnId"] as? String ?? payload["id"] as? String { + return turnID + } + if let info = payload["info"] as? [String: Any] { + return info["turn_id"] as? String ?? info["turnId"] as? String ?? info["id"] as? String + } + return nil + } - totalInput += dayInput - totalOutput += dayOutput - totalTokens += dayTotal - if let entryCost { - totalCost += entryCost - costSeen = true - } + private static func scanCodexFile( + fileURL: URL, + context: CodexFileScanContext, + cache: inout CostUsageCache, + state: inout CodexScanState) throws + { + try context.checkCancellation?() + let metadata = Self.codexFileMetadata(fileURL: fileURL) + if let fileId = metadata.fileId, state.seenFileIds.contains(fileId) { + Self.dropCachedCodexFile(path: metadata.path, cached: cache.files[metadata.path], cache: &cache) + return } - let summary: CostUsageDailyReport.Summary? = entries.isEmpty - ? nil - : CostUsageDailyReport.Summary( - totalInputTokens: totalInput, - totalOutputTokens: totalOutput, - totalTokens: totalTokens, - totalCostUSD: costSeen ? totalCost : nil) + let cached = cache.files[metadata.path] + if let cachedSessionId = cached?.sessionId, state.seenSessionIds.contains(cachedSessionId) { + Self.dropCachedCodexFile(path: metadata.path, cached: cached, cache: &cache) + return + } - return CostUsageDailyReport(data: entries, summary: summary) + let input = CodexFileScanInput(fileURL: fileURL, metadata: metadata, cached: cached) + if Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { + return + } + if try Self.appendCodexFileIncrementIfPossible(input: input, context: context, cache: &cache, state: &state) { + return + } + try Self.rescanCodexFile(input: input, context: context, cache: &cache, state: &state) } - // MARK: - Shared cache mutations - - static func makeFileUsage( - mtimeUnixMs: Int64, - size: Int64, - days: [String: [String: [Int]]], - parsedBytes: Int64?, - lastModel: String? = nil, - lastTotals: CostUsageCodexTotals? = nil, - sessionId: String? = nil) -> CostUsageFileUsage + private static func makeCodexRefreshPlan( + cache: CostUsageCache, + range: CostUsageDayRange, + now: Date, + nowMs: Int64, + options: Options) -> CodexRefreshPlan { - CostUsageFileUsage( - mtimeUnixMs: mtimeUnixMs, - size: size, - days: days, - parsedBytes: parsedBytes, - lastModel: lastModel, - lastTotals: lastTotals, - sessionId: sessionId) + let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) + let roots = self.codexSessionsRoots(options: options) + let rootsFingerprint = Self.codexRootsFingerprint(roots) + let rootsChanged = cache.roots != rootsFingerprint + let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache) + let needsCostCacheMigration = cache.files.values.contains { Self.needsCodexCostCache($0, range: range) } + let modelsDevLoad = ModelsDevCache.load(now: now, cacheRoot: options.cacheRoot) + let modelsDevCatalog = modelsDevLoad.artifact?.catalog + let codexPricingKey = Self.codexPricingKey(modelsDevArtifact: modelsDevLoad.artifact) + let codexPriorityMetadataKey = Self.codexPriorityMetadataKey(databaseURL: options.codexTraceDatabaseURL) + let hasPriorityMetadata = codexPriorityMetadataKey.hasPrefix("sqlite:") + let pricingChanged = cache.codexPricingKey != nil && cache.codexPricingKey != codexPricingKey + let priorityMetadataChanged = Self.codexPriorityMetadataChanged( + old: cache.codexPriorityMetadataKey, + new: codexPriorityMetadataKey) + let needsTurnIDCacheMigration = hasPriorityMetadata && cache.files.values.contains { + $0.codexTurnIDs == nil && $0.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey) + } + let shouldInspectPriorityTurns = options.forceRescan + || windowExpanded + || rootsChanged + || needsCostCacheMigration + || needsTurnIDCacheMigration + || pricingChanged + || priorityMetadataChanged + || refreshMs == 0 + || cache.lastScanUnixMs == 0 + || nowMs - cache.lastScanUnixMs > refreshMs + let priorityTurns = shouldInspectPriorityTurns ? Self.codexPriorityTurns( + databaseURL: options.codexTraceDatabaseURL, + sinceDayKey: range.scanSinceKey, + untilDayKey: range.scanUntilKey) : [:] + let priorityTurnKeys = Self.codexPriorityTurnKeys(priorityTurns) + let priorityTurnIDsByDay = Self.codexPriorityTurnIDsByDay(priorityTurns) + let priorityTurnsChanged = shouldInspectPriorityTurns + && hasPriorityMetadata + && Self.codexPriorityTurnKeysChanged( + old: cache.codexPriorityTurnKeys, + new: priorityTurnKeys, + range: range) + let changedPriorityTurnIDs = shouldInspectPriorityTurns && hasPriorityMetadata + ? Self.changedPriorityTurnIDs( + old: cache.codexPriorityTurnIDsByDay, + new: priorityTurnIDsByDay, + oldKeys: cache.codexPriorityTurnKeys, + newKeys: priorityTurnKeys, + range: range) + : [] + let shouldRefresh = options.forceRescan + || windowExpanded + || rootsChanged + || needsCostCacheMigration + || needsTurnIDCacheMigration + || pricingChanged + || priorityMetadataChanged + || priorityTurnsChanged + || refreshMs == 0 + || cache.lastScanUnixMs == 0 + || nowMs - cache.lastScanUnixMs > refreshMs + + return CodexRefreshPlan( + refreshMs: refreshMs, + roots: roots, + rootsFingerprint: rootsFingerprint, + rootsChanged: rootsChanged, + windowExpanded: windowExpanded, + needsCostCacheMigration: needsCostCacheMigration, + modelsDevCatalog: modelsDevCatalog, + codexPricingKey: codexPricingKey, + codexPriorityMetadataKey: codexPriorityMetadataKey, + hasPriorityMetadata: hasPriorityMetadata, + priorityTurns: priorityTurns, + priorityTurnKeys: priorityTurnKeys, + priorityTurnIDsByDay: priorityTurnIDsByDay, + pricingChanged: pricingChanged, + priorityMetadataChanged: priorityMetadataChanged, + priorityTurnsChanged: priorityTurnsChanged, + needsTurnIDCacheMigration: needsTurnIDCacheMigration, + changedPriorityTurnIDs: changedPriorityTurnIDs, + shouldRefresh: shouldRefresh) } - static func mergeFileDays( - existing: inout [String: [String: [Int]]], - delta: [String: [String: [Int]]]) + private static func loadCodexDaily( + range: CostUsageDayRange, + now: Date, + options: Options, + checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - for (day, models) in delta { - var dayModels = existing[day] ?? [:] - for (model, packed) in models { - let existingPacked = dayModels[model] ?? [] - let merged = Self.addPacked(a: existingPacked, b: packed, sign: 1) - if merged.allSatisfy({ $0 == 0 }) { - dayModels.removeValue(forKey: model) - } else { - dayModels[model] = merged - } - } + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) + let nowMs = Int64(now.timeIntervalSince1970 * 1000) + let plan = Self.makeCodexRefreshPlan(cache: cache, range: range, now: now, nowMs: nowMs, options: options) - if dayModels.isEmpty { - existing.removeValue(forKey: day) - } else { - existing[day] = dayModels + if plan.shouldRefresh { + try checkCancellation?() + if options.forceRescan { + cache = CostUsageCache() } - } - } - static func applyFileDays(cache: inout CostUsageCache, fileDays: [String: [String: [Int]]], sign: Int) { - for (day, models) in fileDays { - var dayModels = cache.days[day] ?? [:] - for (model, packed) in models { - let existing = dayModels[model] ?? [] - let merged = Self.addPacked(a: existing, b: packed, sign: sign) - if merged.allSatisfy({ $0 == 0 }) { - dayModels.removeValue(forKey: model) - } else { - dayModels[model] = merged + let cachedSinceKey = cache.scanSinceKey + let cachedUntilKey = cache.scanUntilKey + let shouldRunColdCacheLookback = cache.files.isEmpty || plan.rootsChanged + let coldCacheLookbackStart = Self.parseDayKey(range.scanSinceKey) + .map { Calendar.current.startOfDay(for: $0) } + var seenPaths: Set = [] + var files: [URL] = [] + for root in plan.roots { + let rootFiles = Self.listCodexSessionFiles( + root: root, + scanSinceKey: range.scanSinceKey, + scanUntilKey: range.scanUntilKey, + includeRecursive: options.forceRescan) + for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { + seenPaths.insert(fileURL.path) + files.append(fileURL) } - } - if dayModels.isEmpty { - cache.days.removeValue(forKey: day) - } else { - cache.days[day] = dayModels + if shouldRunColdCacheLookback, let coldCacheLookbackStart { + let recentlyModifiedFiles = Self.listCodexRecentlyModifiedFiles( + root: root, + scanSinceKey: range.scanSinceKey, + scanUntilKey: range.scanUntilKey, + modifiedSince: coldCacheLookbackStart) + for fileURL in recentlyModifiedFiles.sorted(by: { $0.path < $1.path }) + where !seenPaths.contains(fileURL.path) + { + seenPaths.insert(fileURL.path) + files.append(fileURL) + } + } } - } - } - static func pruneDays(cache: inout CostUsageCache, sinceKey: String, untilKey: String) { - for key in cache.days.keys where !CostUsageDayRange.isInRange(dayKey: key, since: sinceKey, until: untilKey) { - cache.days.removeValue(forKey: key) - } - } + for fileURL in Self.cachedCodexSessionFiles( + cache: cache, + range: range, + roots: plan.roots, + excludingPaths: seenPaths) + .sorted(by: { $0.path < $1.path }) + { + seenPaths.insert(fileURL.path) + files.append(fileURL) + } - static func addPacked(a: [Int], b: [Int], sign: Int) -> [Int] { - let len = max(a.count, b.count) - var out: [Int] = Array(repeating: 0, count: len) - for idx in 0.. Date? { - let parts = key.split(separator: "-") - guard parts.count == 3 else { return nil } - guard - let y = Int(parts[0]), - let m = Int(parts[1]), - let d = Int(parts[2]) - else { return nil } + let shouldDropAllUnscannedFiles = options.forceRescan || plan.rootsChanged || cache.files.isEmpty + for key in cache.files.keys where !filePathsInScan.contains(key) { + guard let old = cache.files[key] else { continue } + let shouldDrop = shouldDropAllUnscannedFiles || + old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + guard shouldDrop else { continue } + Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) + cache.files.removeValue(forKey: key) + } - var comps = DateComponents() - comps.calendar = Calendar.current - comps.timeZone = TimeZone.current - comps.year = y - comps.month = m - comps.day = d - comps.hour = 12 - return comps.date - } -} + if !shouldDropAllUnscannedFiles { + for key in cache.files.keys { + guard let old = cache.files[key] else { continue } + guard old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + else { continue } + guard FileManager.default.fileExists(atPath: key) else { + Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) + cache.files.removeValue(forKey: key) + continue + } + } + } -extension Data { - func containsAscii(_ needle: String) -> Bool { - guard let n = needle.data(using: .utf8) else { return false } - return self.range(of: n) != nil - } -} + let shouldRetainWiderWindow = !options.forceRescan && !plan.pricingChanged && !plan + .priorityMetadataChanged && !plan.needsTurnIDCacheMigration + let retainedSinceKey = shouldRetainWiderWindow + ? [cachedSinceKey, range.scanSinceKey].compactMap(\.self).min() ?? range.scanSinceKey + : range.scanSinceKey + let retainedUntilKey = shouldRetainWiderWindow + ? [cachedUntilKey, range.scanUntilKey].compactMap(\.self).max() ?? range.scanUntilKey + : range.scanUntilKey + Self.pruneDays(cache: &cache, sinceKey: retainedSinceKey, untilKey: retainedUntilKey) + cache.roots = plan.rootsFingerprint + cache.scanSinceKey = retainedSinceKey + cache.scanUntilKey = retainedUntilKey + cache.codexPricingKey = plan.codexPricingKey + cache.codexPriorityMetadataKey = plan.codexPriorityMetadataKey + if plan.hasPriorityMetadata { + cache.codexPriorityTurnKeys = Self.mergePriorityTurnKeys( + existing: shouldRetainWiderWindow ? cache.codexPriorityTurnKeys : nil, + new: plan.priorityTurnKeys, + range: range, + retainedSinceKey: retainedSinceKey, + retainedUntilKey: retainedUntilKey) + cache.codexPriorityTurnIDsByDay = Self.mergePriorityTurnIDsByDay( + existing: shouldRetainWiderWindow ? cache.codexPriorityTurnIDsByDay : nil, + new: plan.priorityTurnIDsByDay, + range: range, + retainedSinceKey: retainedSinceKey, + retainedUntilKey: retainedUntilKey) + } + cache.lastScanUnixMs = nowMs + try checkCancellation?() + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) + } -extension [Int] { - subscript(safe index: Int) -> Int? { - if index < 0 { return nil } - if index >= self.count { return nil } - return self[index] + return Self.buildCodexReportFromCache( + cache: cache, + range: range, + modelsDevCatalog: plan.modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot, + priorityTurns: plan.priorityTurns) } } -extension [UInt8] { - subscript(safe index: Int) -> UInt8? { - if index < 0 { return nil } - if index >= self.count { return nil } - return self[index] - } -} +// swiftlint:enable type_body_length diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift new file mode 100644 index 0000000000..282c6e55c3 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -0,0 +1,611 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct ModelsDevPricingInfo: Codable, Equatable { + var providerID: String + var providerName: String? + var modelID: String + var modelName: String? + var inputCostPerToken: Double + var outputCostPerToken: Double + var cacheReadInputCostPerToken: Double? + var cacheCreationInputCostPerToken: Double? + var contextWindow: Int? + var thresholdTokens: Int? + var inputCostPerTokenAboveThreshold: Double? + var outputCostPerTokenAboveThreshold: Double? + var cacheReadInputCostPerTokenAboveThreshold: Double? + var cacheCreationInputCostPerTokenAboveThreshold: Double? +} + +struct ModelsDevPricingLookup: Equatable { + var pricing: ModelsDevPricingInfo + var normalizedModelID: String +} + +struct ModelsDevCatalog: Codable, Equatable { + var providers: [String: ModelsDevProvider] + + init(providers: [String: ModelsDevProvider]) { + self.providers = providers + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ModelsDevAnyCodingKey.self) + if let providersKey = ModelsDevAnyCodingKey(stringValue: "providers"), + let decoded = try? container.decode([String: ModelsDevProvider].self, forKey: providersKey) + { + self.providers = decoded.reduce(into: [:]) { result, item in + var provider = item.value + provider.mapKey = provider.mapKey ?? item.key + let providerID = ModelsDevProvider.normalizeProviderID(provider.id ?? item.key) + result[providerID] = provider + } + return + } + + var providers: [String: ModelsDevProvider] = [:] + + for key in container.allKeys { + guard var provider = try? container.decode(ModelsDevProvider.self, forKey: key) else { continue } + provider.mapKey = key.stringValue + let providerID = ModelsDevProvider.normalizeProviderID(provider.id ?? key.stringValue) + providers[providerID] = provider + } + + self.providers = providers + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ModelsDevAnyCodingKey.self) + try container.encode(self.providers, forKey: ModelsDevAnyCodingKey(stringValue: "providers")!) + } + + func pricing(providerID rawProviderID: String, modelID rawModelID: String) -> ModelsDevPricingLookup? { + let providerID = ModelsDevProvider.normalizeProviderID(rawProviderID) + return self.providers[providerID]?.pricing(modelID: rawModelID) + } + + func isPlausibleRefresh() -> Bool { + // These are the direct pricing sources CodexBar relies on. Requiring both + // rejects empty/partial responses without comparing against a fallback- + // enriched cache that intentionally grows as models.dev churns. + ["anthropic", "openai"].allSatisfy { providerID in + self.providers[providerID]?.models.values.contains(where: \.isPriceable) == true + } + } + + func mergingFallbackPricing(from cachedCatalog: ModelsDevCatalog) -> ModelsDevCatalog { + var merged = self + for (providerID, cachedProvider) in cachedCatalog.providers { + let normalizedProviderID = ModelsDevProvider.normalizeProviderID(providerID) + guard var provider = merged.providers[normalizedProviderID] else { + merged.providers[normalizedProviderID] = cachedProvider + continue + } + + for (modelKey, cachedModel) in cachedProvider.models + where cachedModel.isPriceable && !provider.containsPricedModel( + withStableIdentity: cachedModel.stableIdentity) + { + let fallbackKey = provider.models[modelKey] == nil + ? modelKey + : "codexbar-fallback:\(modelKey):\(cachedModel.normalizedID)" + provider.models[fallbackKey] = cachedModel + } + merged.providers[normalizedProviderID] = provider + } + return merged + } +} + +private struct ModelsDevAnyCodingKey: CodingKey { + var intValue: Int? + var stringValue: String + + init?(intValue: Int) { + self.intValue = intValue + self.stringValue = String(intValue) + } + + init?(stringValue: String) { + self.intValue = nil + self.stringValue = stringValue + } +} + +struct ModelsDevProvider: Codable, Equatable { + var id: String? + var name: String? + var models: [String: ModelsDevModel] + var mapKey: String? + + private enum CodingKeys: String, CodingKey { + case id + case name + case models + } + + init(id: String?, name: String?, models: [String: ModelsDevModel], mapKey: String? = nil) { + self.id = id + self.name = name + self.models = models + self.mapKey = mapKey + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: .id) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + + let modelContainer = try container.nestedContainer(keyedBy: ModelsDevAnyCodingKey.self, forKey: .models) + var models: [String: ModelsDevModel] = [:] + for key in modelContainer.allKeys { + guard let model = try? modelContainer.decode(ModelsDevModel.self, forKey: key) else { continue } + models[key.stringValue] = model + } + self.models = models + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.id, forKey: .id) + try container.encodeIfPresent(self.name, forKey: .name) + try container.encode(self.models, forKey: .models) + } + + static func normalizeProviderID(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + func pricing(modelID rawModelID: String) -> ModelsDevPricingLookup? { + let candidates = ModelsDevModelIDNormalizer.candidates(rawModelID) + for candidate in candidates { + if let model = self.models[candidate], + let pricing = model.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) + { + return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: candidate) + } + + for match in self.models.values where match.normalizedID == candidate { + if let pricing = match.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) { + return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: match.normalizedID) + } + } + } + + return nil + } + + func containsPricedModel(withStableIdentity modelID: String) -> Bool { + self.models.values.contains { model in + model.isPriceable && model.stableIdentity == modelID + } + } +} + +struct ModelsDevModel: Codable, Equatable { + var id: String + var name: String? + var cost: ModelsDevCost? + var limit: ModelsDevLimit? + + var normalizedID: String { + ModelsDevModelIDNormalizer.normalize(self.id) + } + + var stableIdentity: String { + ModelsDevModelIDNormalizer.stableIdentity(self.id) + } + + var isPriceable: Bool { + self.cost?.input != nil && self.cost?.output != nil + } + + func pricing(providerID: String, providerName: String?) -> ModelsDevPricingInfo? { + guard let input = self.cost?.input, let output = self.cost?.output else { return nil } + + // models.dev publishes USD per 1M tokens. CodexBar cost math uses USD per token. + let unit = 1_000_000.0 + let contextOver200K = self.cost?.contextOver200K + return ModelsDevPricingInfo( + providerID: ModelsDevProvider.normalizeProviderID(providerID), + providerName: providerName, + modelID: self.id, + modelName: self.name, + inputCostPerToken: input / unit, + outputCostPerToken: output / unit, + cacheReadInputCostPerToken: self.cost?.cacheRead.map { $0 / unit }, + cacheCreationInputCostPerToken: self.cost?.cacheWrite.map { $0 / unit }, + contextWindow: self.limit?.context, + thresholdTokens: contextOver200K == nil ? nil : 200_000, + inputCostPerTokenAboveThreshold: contextOver200K?.input.map { $0 / unit }, + outputCostPerTokenAboveThreshold: contextOver200K?.output.map { $0 / unit }, + cacheReadInputCostPerTokenAboveThreshold: contextOver200K?.cacheRead.map { $0 / unit }, + cacheCreationInputCostPerTokenAboveThreshold: contextOver200K?.cacheWrite.map { $0 / unit }) + } +} + +struct ModelsDevCost: Codable, Equatable { + var input: Double? + var output: Double? + var cacheRead: Double? + var cacheWrite: Double? + var contextOver200K: ModelsDevContextOver200KCost? + + private enum CodingKeys: String, CodingKey { + case input + case output + case cacheRead = "cache_read" + case cacheWrite = "cache_write" + case contextOver200K = "context_over_200k" + } +} + +struct ModelsDevContextOver200KCost: Codable, Equatable { + var input: Double? + var output: Double? + var cacheRead: Double? + var cacheWrite: Double? + + private enum CodingKeys: String, CodingKey { + case input + case output + case cacheRead = "cache_read" + case cacheWrite = "cache_write" + } +} + +struct ModelsDevLimit: Codable, Equatable { + var context: Int? +} + +enum ModelsDevModelIDNormalizer { + static func normalize(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func stableIdentity(_ raw: String) -> String { + let normalized = self.normalize(raw) + if let atSign = normalized.firstIndex(of: "@") { + let base = String(normalized[.. String { + self.candidates(raw, preserveDatedSnapshots: true).reversed().lazy + .map { candidate in + guard candidate.hasSuffix("@default") else { return candidate } + return String(candidate.dropLast("@default".count)) + } + .first { !$0.isEmpty } ?? self.normalize(raw) + } + + static func candidates(_ raw: String, preserveDatedSnapshots: Bool = false) -> [String] { + var candidates: [String] = [] + + func append(_ value: String) { + let normalized = self.normalize(value) + guard !normalized.isEmpty, !candidates.contains(normalized) else { return } + candidates.append(normalized) + } + + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + append(trimmed) + + if trimmed.hasPrefix("openai/") { + append(String(trimmed.dropFirst("openai/".count))) + } + + if trimmed.hasPrefix("anthropic.") { + append(String(trimmed.dropFirst("anthropic.".count))) + } + + if let lastDot = trimmed.lastIndex(of: "."), + trimmed.contains("claude-") + { + let tail = String(trimmed[trimmed.index(after: lastDot)...]) + if tail.hasPrefix("claude-") { + append(tail) + } + } + + var index = 0 + while index < candidates.count { + let candidate = candidates[index] + if let atSign = candidate.firstIndex(of: "@") { + let base = String(candidate[.. Outcome? { + self.lock.lock() + defer { self.lock.unlock() } + guard let entry = self.entries[path], + entry.modificationDate == modificationDate, + entry.size == size + else { + return nil + } + return entry.outcome + } + + func store(path: String, modificationDate: Date?, size: Int?, outcome: Outcome) { + self.lock.lock() + defer { self.lock.unlock() } + self.entries[path] = Entry(modificationDate: modificationDate, size: size, outcome: outcome) + } + + func invalidate(path: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.entries.removeValue(forKey: path) + } +} + +enum ModelsDevCache { + enum Error: Swift.Error, Equatable { + case unreadable + case invalidVersion + case invalidJSON + } + + static let artifactVersion = 1 + static let ttlSeconds: TimeInterval = 24 * 60 * 60 + + private static let memo = ModelsDevCacheMemo() + + private static func fileMetadata(at url: URL) -> (modificationDate: Date?, size: Int?) { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { + return (nil, nil) + } + let modificationDate = attributes[.modificationDate] as? Date + let size = (attributes[.size] as? NSNumber)?.intValue + return (modificationDate, size) + } + + private static func defaultCacheRoot() -> URL { + let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + return root.appendingPathComponent("CodexBar", isDirectory: true) + } + + static func cacheFileURL(cacheRoot: URL? = nil) -> URL { + let root = cacheRoot ?? self.defaultCacheRoot() + return root + .appendingPathComponent("model-pricing", isDirectory: true) + .appendingPathComponent("models-dev-v\(Self.artifactVersion).json", isDirectory: false) + } + + static func load(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCacheLoadResult { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + let metadata = Self.fileMetadata(at: url) + + // Staleness depends on `now`, so the result is always rebuilt; only the read+decode outcome is memoized. + if let outcome = Self.memo.outcome( + path: url.path, + modificationDate: metadata.modificationDate, + size: metadata.size) + { + return Self.result(for: outcome, now: now) + } + + let outcome = Self.readOutcome(at: url) + Self.memo.store( + path: url.path, + modificationDate: metadata.modificationDate, + size: metadata.size, + outcome: outcome) + return Self.result(for: outcome, now: now) + } + + private static func readOutcome(at url: URL) -> ModelsDevCacheMemo.Outcome { + guard let data = try? Data(contentsOf: url) else { + return .failure(.unreadable) + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let decoded = try? decoder.decode(ModelsDevCacheArtifact.self, from: data) else { + return .failure(.invalidJSON) + } + guard decoded.version == Self.artifactVersion else { + return .failure(.invalidVersion) + } + return .decoded(decoded) + } + + private static func result(for outcome: ModelsDevCacheMemo.Outcome, now: Date) -> ModelsDevCacheLoadResult { + switch outcome { + case let .decoded(artifact): + ModelsDevCacheLoadResult( + artifact: artifact, + isStale: now.timeIntervalSince(artifact.fetchedAt) > Self.ttlSeconds, + error: nil) + case let .failure(error): + ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: error) + } + } + + static func save(catalog: ModelsDevCatalog, fetchedAt: Date = Date(), cacheRoot: URL? = nil) { + let artifact = ModelsDevCacheArtifact( + version: Self.artifactVersion, + fetchedAt: fetchedAt, + catalog: catalog) + self.save(artifact: artifact, cacheRoot: cacheRoot) + } + + static func save(artifact: ModelsDevCacheArtifact, cacheRoot: URL? = nil) { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(artifact) else { return } + + let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) + do { + try data.write(to: tmp, options: [.atomic]) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } else { + try FileManager.default.moveItem(at: tmp, to: url) + } + // The on-disk catalog changed; drop the memo so the next load decodes the fresh file. + Self.memo.invalidate(path: url.path) + } catch { + try? FileManager.default.removeItem(at: tmp) + } + } +} + +protocol ModelsDevHTTPTransport: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +struct URLSessionModelsDevTransport: ModelsDevHTTPTransport { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await URLSession.shared.data(for: request) + } +} + +struct ModelsDevClient { + enum Error: Swift.Error, Equatable { + case invalidResponse + case httpStatus(Int) + case invalidJSON + } + + var url: URL + var transport: any ModelsDevHTTPTransport + + init( + url: URL = URL(string: "https://models.dev/api.json")!, + transport: any ModelsDevHTTPTransport = URLSessionModelsDevTransport()) + { + self.url = url + self.transport = transport + } + + func fetchCatalog() async throws -> ModelsDevCatalog { + var request = URLRequest(url: self.url) + request.httpMethod = "GET" + request.timeoutInterval = 20 + + let (data, response) = try await self.transport.data(for: request) + guard let http = response as? HTTPURLResponse else { throw Error.invalidResponse } + guard (200..<300).contains(http.statusCode) else { throw Error.httpStatus(http.statusCode) } + + do { + return try JSONDecoder().decode(ModelsDevCatalog.self, from: data) + } catch { + throw Error.invalidJSON + } + } +} + +enum ModelsDevPricingPipeline { + static func lookup( + providerID: String, + modelID: String, + now: Date = Date(), + cacheRoot: URL? = nil) -> ModelsDevPricingLookup? + { + ModelsDevCache.load(now: now, cacheRoot: cacheRoot) + .artifact? + .catalog + .pricing(providerID: providerID, modelID: modelID) + } + + static func refreshIfNeeded( + now: Date = Date(), + cacheRoot: URL? = nil, + client: ModelsDevClient = ModelsDevClient()) async + { + let load = ModelsDevCache.load(now: now, cacheRoot: cacheRoot) + guard load.isStale else { return } + + do { + let catalog = try await client.fetchCatalog() + let oldCatalog = load.artifact?.catalog + guard catalog.isPlausibleRefresh() else { return } + let refreshedCatalog = oldCatalog.map { catalog.mergingFallbackPricing(from: $0) } ?? catalog + ModelsDevCache.save(catalog: refreshedCatalog, fetchedAt: now, cacheRoot: cacheRoot) + } catch { + // Best-effort refresh only. Future scanner integration should keep using the last valid cache. + } + } +} diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 25a2f85b9c..17affa8b1c 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -1,12 +1,25 @@ import Foundation public struct WidgetSnapshot: Codable, Sendable { + public struct WidgetUsageRowSnapshot: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let percentLeft: Double? + + public init(id: String, title: String, percentLeft: Double?) { + self.id = id + self.title = title + self.percentLeft = percentLeft + } + } + public struct ProviderEntry: Codable, Sendable { public let provider: UsageProvider public let updatedAt: Date public let primary: RateWindow? public let secondary: RateWindow? public let tertiary: RateWindow? + public let usageRows: [WidgetUsageRowSnapshot]? public let creditsRemaining: Double? public let codeReviewRemainingPercent: Double? public let tokenUsage: TokenUsageSummary? @@ -18,6 +31,7 @@ public struct WidgetSnapshot: Codable, Sendable { primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow?, + usageRows: [WidgetUsageRowSnapshot]? = nil, creditsRemaining: Double?, codeReviewRemainingPercent: Double?, tokenUsage: TokenUsageSummary?, @@ -28,6 +42,7 @@ public struct WidgetSnapshot: Codable, Sendable { self.primary = primary self.secondary = secondary self.tertiary = tertiary + self.usageRows = usageRows self.creditsRemaining = creditsRemaining self.codeReviewRemainingPercent = codeReviewRemainingPercent self.tokenUsage = tokenUsage @@ -40,17 +55,54 @@ public struct WidgetSnapshot: Codable, Sendable { public let sessionTokens: Int? public let last30DaysCostUSD: Double? public let last30DaysTokens: Int? + public let currencyCode: String + public let sessionLabel: String + public let last30DaysLabel: String public init( sessionCostUSD: Double?, sessionTokens: Int?, last30DaysCostUSD: Double?, - last30DaysTokens: Int?) + last30DaysTokens: Int?, + currencyCode: String = "USD", + sessionLabel: String = "Today", + last30DaysLabel: String = "30d") { self.sessionCostUSD = sessionCostUSD self.sessionTokens = sessionTokens self.last30DaysCostUSD = last30DaysCostUSD self.last30DaysTokens = last30DaysTokens + self.currencyCode = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "USD" + : currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + self.sessionLabel = sessionLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "Today" + : sessionLabel + self.last30DaysLabel = last30DaysLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "30d" + : last30DaysLabel + } + + private enum CodingKeys: String, CodingKey { + case sessionCostUSD + case sessionTokens + case last30DaysCostUSD + case last30DaysTokens + case currencyCode + case sessionLabel + case last30DaysLabel + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + sessionCostUSD: container.decodeIfPresent(Double.self, forKey: .sessionCostUSD), + sessionTokens: container.decodeIfPresent(Int.self, forKey: .sessionTokens), + last30DaysCostUSD: container.decodeIfPresent(Double.self, forKey: .last30DaysCostUSD), + last30DaysTokens: container.decodeIfPresent(Int.self, forKey: .last30DaysTokens), + currencyCode: container.decodeIfPresent(String.self, forKey: .currencyCode) ?? "USD", + sessionLabel: container.decodeIfPresent(String.self, forKey: .sessionLabel) ?? "Today", + last30DaysLabel: container.decodeIfPresent(String.self, forKey: .last30DaysLabel) ?? "30d") } } @@ -99,17 +151,16 @@ public struct WidgetSnapshot: Codable, Sendable { } public enum WidgetSnapshotStore { - public static let appGroupID = "group.com.steipete.codexbar" - private static let filename = "widget-snapshot.json" + private static let filename = AppGroupSupport.widgetSnapshotFilename public static func load(bundleID: String? = Bundle.main.bundleIdentifier) -> WidgetSnapshot? { - guard let url = self.snapshotURL(bundleID: bundleID) else { return nil } + let url = self.snapshotURL(bundleID: bundleID) guard let data = try? Data(contentsOf: url) else { return nil } return try? self.decoder.decode(WidgetSnapshot.self, from: data) } public static func save(_ snapshot: WidgetSnapshot, bundleID: String? = Bundle.main.bundleIdentifier) { - guard let url = self.snapshotURL(bundleID: bundleID) else { return } + let url = self.snapshotURL(bundleID: bundleID) do { let data = try self.encoder.encode(snapshot) try data.write(to: url, options: [.atomic]) @@ -118,32 +169,12 @@ public enum WidgetSnapshotStore { } } - private static func snapshotURL(bundleID: String?) -> URL? { - let fm = FileManager.default - let groupID = self.groupID(for: bundleID) - #if os(macOS) - if let groupID, let container = fm.containerURL(forSecurityApplicationGroupIdentifier: groupID) { - return container.appendingPathComponent(self.filename, isDirectory: false) - } - #endif - - let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? fm.temporaryDirectory - let dir = base.appendingPathComponent("CodexBar", isDirectory: true) - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent(self.filename, isDirectory: false) + private static func snapshotURL(bundleID: String?) -> URL { + AppGroupSupport.snapshotURL(bundleID: bundleID) } public static func appGroupID(for bundleID: String?) -> String? { - self.groupID(for: bundleID) - } - - private static func groupID(for bundleID: String?) -> String? { - guard let bundleID, !bundleID.isEmpty else { return self.appGroupID } - if bundleID.contains(".debug") { - return "group.com.steipete.codexbar.debug" - } - return self.appGroupID + AppGroupSupport.currentGroupID(for: bundleID) } private static var encoder: JSONEncoder { @@ -163,7 +194,7 @@ public enum WidgetSelectionStore { private static let selectedProviderKey = "widgetSelectedProvider" public static func loadSelectedProvider(bundleID: String? = Bundle.main.bundleIdentifier) -> UsageProvider? { - guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return nil } + let defaults = self.sharedDefaults(bundleID: bundleID) guard let raw = defaults.string(forKey: self.selectedProviderKey) else { return nil } return UsageProvider(rawValue: raw) } @@ -172,12 +203,11 @@ public enum WidgetSelectionStore { _ provider: UsageProvider, bundleID: String? = Bundle.main.bundleIdentifier) { - guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return } + let defaults = self.sharedDefaults(bundleID: bundleID) defaults.set(provider.rawValue, forKey: self.selectedProviderKey) } - private static func sharedDefaults(bundleID: String?) -> UserDefaults? { - guard let groupID = WidgetSnapshotStore.appGroupID(for: bundleID) else { return nil } - return UserDefaults(suiteName: groupID) + private static func sharedDefaults(bundleID: String?) -> UserDefaults { + AppGroupSupport.sharedDefaults(bundleID: bundleID) ?? .standard } } diff --git a/Sources/CodexBarMacroSupport/ProviderRegistrationMacros.swift b/Sources/CodexBarMacroSupport/ProviderRegistrationMacros.swift deleted file mode 100644 index 4adf0218b4..0000000000 --- a/Sources/CodexBarMacroSupport/ProviderRegistrationMacros.swift +++ /dev/null @@ -1,14 +0,0 @@ -@attached(peer, names: prefixed(_CodexBarDescriptorRegistration_)) -public macro ProviderDescriptorRegistration() = #externalMacro( - module: "CodexBarMacros", - type: "ProviderDescriptorRegistrationMacro") - -@attached(member, names: named(descriptor)) -public macro ProviderDescriptorDefinition() = #externalMacro( - module: "CodexBarMacros", - type: "ProviderDescriptorDefinitionMacro") - -@attached(peer, names: prefixed(_CodexBarImplementationRegistration_)) -public macro ProviderImplementationRegistration() = #externalMacro( - module: "CodexBarMacros", - type: "ProviderImplementationRegistrationMacro") diff --git a/Sources/CodexBarMacros/ProviderRegistrationMacros.swift b/Sources/CodexBarMacros/ProviderRegistrationMacros.swift deleted file mode 100644 index 1072e73fec..0000000000 --- a/Sources/CodexBarMacros/ProviderRegistrationMacros.swift +++ /dev/null @@ -1,216 +0,0 @@ -import SwiftCompilerPlugin -import SwiftDiagnostics -import SwiftSyntax -import SwiftSyntaxBuilder -import SwiftSyntaxMacros - -private enum ProviderMacroError { - struct Message: DiagnosticMessage { - let message: String - let diagnosticID: MessageID - let severity: DiagnosticSeverity - } - - static func unsupportedTarget(_ context: some MacroExpansionContext, node: SyntaxProtocol, macro: String) { - context.diagnose(Diagnostic( - node: node, - message: Message( - message: "@\(macro) must be attached to a struct, class, or enum.", - diagnosticID: MessageID(domain: "CodexBarMacros", id: "unsupported_target"), - severity: .error))) - } - - static func missingDescriptor(_ context: some MacroExpansionContext, node: SyntaxProtocol, typeName: String) { - context.diagnose(Diagnostic( - node: node, - message: Message( - message: "\(typeName) must declare static let descriptor or static func makeDescriptor() " + - "to use @ProviderDescriptorRegistration.", - diagnosticID: MessageID(domain: "CodexBarMacros", id: "missing_descriptor"), - severity: .error))) - } - - static func missingMakeDescriptor(_ context: some MacroExpansionContext, node: SyntaxProtocol, typeName: String) { - context.diagnose(Diagnostic( - node: node, - message: Message( - message: "\(typeName) must declare static func makeDescriptor() to use @ProviderDescriptorDefinition.", - diagnosticID: MessageID(domain: "CodexBarMacros", id: "missing_make_descriptor"), - severity: .error))) - } - - static func duplicateDescriptor(_ context: some MacroExpansionContext, node: SyntaxProtocol, typeName: String) { - context.diagnose(Diagnostic( - node: node, - message: Message( - message: "\(typeName) already declares descriptor; remove @ProviderDescriptorDefinition.", - diagnosticID: MessageID(domain: "CodexBarMacros", id: "duplicate_descriptor"), - severity: .error))) - } - - static func missingInit(_ context: some MacroExpansionContext, node: SyntaxProtocol, typeName: String) { - context.diagnose(Diagnostic( - node: node, - message: Message( - message: "\(typeName) must provide an init() to use @ProviderImplementationRegistration.", - diagnosticID: MessageID(domain: "CodexBarMacros", id: "missing_init"), - severity: .error))) - } -} - -private enum ProviderMacroIntrospection { - static func typeDecl(from declaration: some DeclSyntaxProtocol) -> (decl: DeclGroupSyntax, name: String)? { - if let decl = declaration.as(StructDeclSyntax.self) { return (decl, decl.name.text) } - if let decl = declaration.as(ClassDeclSyntax.self) { return (decl, decl.name.text) } - if let decl = declaration.as(EnumDeclSyntax.self) { return (decl, decl.name.text) } - return nil - } - - static func hasStaticDescriptor(in decl: DeclGroupSyntax) -> Bool { - for member in decl.memberBlock.members { - guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue } - guard self.isStatic(varDecl.modifiers) else { continue } - for binding in varDecl.bindings { - guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else { continue } - if pattern.identifier.text == "descriptor" { return true } - } - } - return false - } - - static func hasMakeDescriptor(in decl: DeclGroupSyntax) -> Bool { - for member in decl.memberBlock.members { - guard let funcDecl = member.decl.as(FunctionDeclSyntax.self) else { continue } - guard self.isStatic(funcDecl.modifiers) else { continue } - if funcDecl.name.text == "makeDescriptor" { return true } - } - return false - } - - static func hasAccessibleInit(in decl: DeclGroupSyntax) -> Bool { - if self.hasZeroArgInit(in: decl) { return true } - if decl.is(EnumDeclSyntax.self) { return false } - return self.canSynthesizeDefaultInit(in: decl) - } - - private static func hasZeroArgInit(in decl: DeclGroupSyntax) -> Bool { - for member in decl.memberBlock.members { - guard let initDecl = member.decl.as(InitializerDeclSyntax.self) else { continue } - let params = initDecl.signature.parameterClause.parameters - if params.isEmpty { return true } - let allDefaulted = params.allSatisfy { $0.defaultValue != nil } - if allDefaulted { return true } - } - return false - } - - private static func canSynthesizeDefaultInit(in decl: DeclGroupSyntax) -> Bool { - for member in decl.memberBlock.members { - guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue } - guard !self.isStatic(varDecl.modifiers) else { continue } - for binding in varDecl.bindings { - if binding.accessorBlock != nil { continue } - if binding.initializer == nil { return false } - } - } - return true - } - - private static func isStatic(_ modifiers: DeclModifierListSyntax?) -> Bool { - guard let modifiers else { return false } - return modifiers.contains { $0.name.tokenKind == .keyword(.static) } - } -} - -public struct ProviderDescriptorRegistrationMacro: PeerMacro { - public static func expansion( - of _: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext) throws -> [DeclSyntax] - { - guard let (decl, typeName) = ProviderMacroIntrospection.typeDecl(from: declaration) else { - ProviderMacroError.unsupportedTarget( - context, - node: Syntax(declaration), - macro: "ProviderDescriptorRegistration") - return [] - } - - let hasDescriptor = ProviderMacroIntrospection.hasStaticDescriptor(in: decl) - let hasMakeDescriptor = ProviderMacroIntrospection.hasMakeDescriptor(in: decl) - guard hasDescriptor || hasMakeDescriptor else { - ProviderMacroError.missingDescriptor(context, node: Syntax(declaration), typeName: typeName) - return [] - } - - let registerName = "_CodexBarDescriptorRegistration_\(typeName)" - return [ - DeclSyntax( - "private let \(raw: registerName) = ProviderDescriptorRegistry.register(\(raw: typeName).descriptor)"), - ] - } -} - -public struct ProviderDescriptorDefinitionMacro: MemberMacro { - public static func expansion( - of _: AttributeSyntax, - providingMembersOf declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext) throws -> [DeclSyntax] - { - guard let (decl, typeName) = ProviderMacroIntrospection.typeDecl(from: declaration) else { - ProviderMacroError.unsupportedTarget( - context, - node: Syntax(declaration), - macro: "ProviderDescriptorDefinition") - return [] - } - - if ProviderMacroIntrospection.hasStaticDescriptor(in: decl) { - ProviderMacroError.duplicateDescriptor(context, node: Syntax(declaration), typeName: typeName) - return [] - } - - guard ProviderMacroIntrospection.hasMakeDescriptor(in: decl) else { - ProviderMacroError.missingMakeDescriptor(context, node: Syntax(declaration), typeName: typeName) - return [] - } - - return [DeclSyntax("public static let descriptor: ProviderDescriptor = Self.makeDescriptor()")] - } -} - -public struct ProviderImplementationRegistrationMacro: PeerMacro { - public static func expansion( - of _: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext) throws -> [DeclSyntax] - { - guard let (decl, typeName) = ProviderMacroIntrospection.typeDecl(from: declaration) else { - ProviderMacroError.unsupportedTarget( - context, - node: Syntax(declaration), - macro: "ProviderImplementationRegistration") - return [] - } - - guard ProviderMacroIntrospection.hasAccessibleInit(in: decl) else { - ProviderMacroError.missingInit(context, node: Syntax(declaration), typeName: typeName) - return [] - } - - let registerName = "_CodexBarImplementationRegistration_\(typeName)" - return [ - DeclSyntax( - "private let \(raw: registerName) = ProviderImplementationRegistry.register(\(raw: typeName)())"), - ] - } -} - -@main -struct CodexBarMacroPlugin: CompilerPlugin { - let providingMacros: [Macro.Type] = [ - ProviderDescriptorRegistrationMacro.self, - ProviderDescriptorDefinitionMacro.self, - ProviderImplementationRegistrationMacro.self, - ] -} diff --git a/Sources/CodexBarWidget/BurnDownWidgetProvider.swift b/Sources/CodexBarWidget/BurnDownWidgetProvider.swift new file mode 100644 index 0000000000..34f70535e9 --- /dev/null +++ b/Sources/CodexBarWidget/BurnDownWidgetProvider.swift @@ -0,0 +1,228 @@ +import AppIntents +import CodexBarCore +import WidgetKit + +enum BurnProviderChoice: String, AppEnum { + case codex + case claude + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") + + static let caseDisplayRepresentations: [BurnProviderChoice: DisplayRepresentation] = [ + .codex: DisplayRepresentation(title: "Codex"), + .claude: DisplayRepresentation(title: "Claude"), + ] + + var provider: UsageProvider { + switch self { + case .codex: .codex + case .claude: .claude + } + } +} + +enum BurnWindowChoice: String, AppEnum { + case session + case weekly + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Usage window") + + static let caseDisplayRepresentations: [BurnWindowChoice: DisplayRepresentation] = [ + .session: DisplayRepresentation(title: "Session (5-hour)"), + .weekly: DisplayRepresentation(title: "Weekly (7-day)"), + ] +} + +struct BurnDownSelectionIntent: AppIntent, WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Burn Down" + static let description = IntentDescription("Select the provider and usage window to display.") + + @Parameter(title: "Provider", default: .codex) + var provider: BurnProviderChoice + + @Parameter(title: "Usage window", default: .session) + var window: BurnWindowChoice + + init() { + self.provider = .codex + self.window = .session + } +} + +struct BurnProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Burn Down Provider" + static let description = IntentDescription("Select the provider to display.") + + @Parameter(title: "Provider", default: .codex) + var provider: BurnProviderChoice + + init() { + self.provider = .codex + } +} + +struct BurnDownEntry: TimelineEntry { + let date: Date + let provider: UsageProvider + let window: BurnWindowChoice + let snapshot: WidgetSnapshot +} + +struct CombinedBurnDownEntry: TimelineEntry { + let date: Date + let provider: UsageProvider + let snapshot: WidgetSnapshot +} + +struct BurnDownState { + private static let sessionWindowMinutes = 5 * 60 + private static let weeklyWindowMinutes = 7 * 24 * 60 + + let entry: WidgetSnapshot.ProviderEntry + let selection: BurnWindowChoice + let now: Date + + init?( + snapshot: WidgetSnapshot, + provider: UsageProvider, + selection: BurnWindowChoice, + now: Date = Date()) + { + guard let entry = snapshot.entries.first(where: { $0.provider == provider }) else { return nil } + self.entry = entry + self.selection = selection + self.now = now + } + + var secondaryGloballyCapsPrimary: Bool { + switch self.entry.provider { + case .codex, .claude: true + default: false + } + } + + var secondaryExhausted: Bool { + guard self.secondaryGloballyCapsPrimary, let secondary = self.secondaryWindow else { return false } + guard secondary.remainingPercent <= 0 else { return false } + return secondary.resetsAt.map { $0 > self.now } ?? true + } + + var primaryWindow: RateWindow? { + guard let primary = self.window(minutes: Self.sessionWindowMinutes) else { return nil } + guard self.secondaryExhausted, primary.remainingPercent > 0 else { return primary } + return RateWindow( + usedPercent: 100, + windowMinutes: primary.windowMinutes, + resetsAt: primary.resetsAt, + resetDescription: primary.resetDescription, + nextRegenPercent: primary.nextRegenPercent) + } + + var secondaryWindow: RateWindow? { + self.window(minutes: Self.weeklyWindowMinutes) + } + + var selectedWindow: RateWindow? { + switch self.selection { + case .session: self.primaryWindow + case .weekly: self.secondaryWindow + } + } + + var blankPrimaryChart: Bool { + self.selection == .session + && self.secondaryExhausted + && self.window(minutes: Self.sessionWindowMinutes) != nil + } + + var selectedResetOverride: Date? { + self.blankPrimaryChart ? self.secondaryWindow?.resetsAt : nil + } + + private func window(minutes: Int) -> RateWindow? { + [self.entry.primary, self.entry.secondary] + .compactMap(\.self) + .first { $0.windowMinutes == minutes } + } +} + +enum BurnDownRefreshSchedule { + private static let maximumInterval: TimeInterval = 30 * 60 + + static func nextRefresh( + snapshot: WidgetSnapshot, + provider: UsageProvider, + now: Date = Date()) -> Date + { + let fallback = now.addingTimeInterval(self.maximumInterval) + guard let entry = snapshot.entries.first(where: { $0.provider == provider }) else { return fallback } + let nextReset = [entry.primary?.resetsAt, entry.secondary?.resetsAt] + .compactMap(\.self) + .filter { $0 > now } + .min()? + .addingTimeInterval(1) + return min(fallback, nextReset ?? fallback) + } +} + +struct BurnDownTimelineProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> BurnDownEntry { + BurnDownEntry( + date: Date(), + provider: .codex, + window: .session, + snapshot: WidgetPreviewData.snapshot()) + } + + func snapshot(for configuration: BurnDownSelectionIntent, in context: Context) async -> BurnDownEntry { + BurnDownEntry( + date: Date(), + provider: configuration.provider.provider, + window: configuration.window, + snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()) + } + + func timeline( + for configuration: BurnDownSelectionIntent, + in context: Context) async -> Timeline + { + let entry = BurnDownEntry( + date: Date(), + provider: configuration.provider.provider, + window: configuration.window, + snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()) + let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: entry.snapshot, provider: entry.provider) + return Timeline(entries: [entry], policy: .after(refresh)) + } +} + +struct CombinedBurnDownTimelineProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> CombinedBurnDownEntry { + CombinedBurnDownEntry( + date: Date(), + provider: .codex, + snapshot: WidgetPreviewData.snapshot()) + } + + func snapshot( + for configuration: BurnProviderSelectionIntent, + in context: Context) async -> CombinedBurnDownEntry + { + CombinedBurnDownEntry( + date: Date(), + provider: configuration.provider.provider, + snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()) + } + + func timeline( + for configuration: BurnProviderSelectionIntent, + in context: Context) async -> Timeline + { + let entry = CombinedBurnDownEntry( + date: Date(), + provider: configuration.provider.provider, + snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot()) + let refresh = BurnDownRefreshSchedule.nextRefresh(snapshot: entry.snapshot, provider: entry.provider) + return Timeline(entries: [entry], policy: .after(refresh)) + } +} diff --git a/Sources/CodexBarWidget/BurnDownWidgetViews.swift b/Sources/CodexBarWidget/BurnDownWidgetViews.swift new file mode 100644 index 0000000000..4ba3037065 --- /dev/null +++ b/Sources/CodexBarWidget/BurnDownWidgetViews.swift @@ -0,0 +1,682 @@ +import AppKit +import CodexBarCore +import SwiftUI +import WidgetKit + +// MARK: - Entry View + +struct BurnDownWidgetView: View { + let entry: BurnDownEntry + + var body: some View { + let state = BurnDownState( + snapshot: self.entry.snapshot, + provider: self.entry.provider, + selection: self.entry.window) + + Group { + if let state, let window = state.selectedWindow { + BurnDownLayout( + window: window, + provider: self.entry.provider, + blankChart: state.blankPrimaryChart, + resetsAtOverride: state.selectedResetOverride) + } else { + self.emptyState + } + } + .containerBackground(for: .widget) { + BurnWidgetBackground() + } + } + + private var emptyState: some View { + VStack(spacing: 6) { + Text("Open CodexBar") + .font(.body) + .fontWeight(.semibold) + Text("Usage data will appear once the app refreshes.") + .font(.caption) + .multilineTextAlignment(.center) + .opacity(0.55) + } + .padding(12) + } +} + +// MARK: - Main Layout + +private struct BurnDownLayout: View { + @Environment(\.widgetRenderingMode) private var renderingMode + @Environment(\.colorScheme) private var colorScheme + + let window: RateWindow + let provider: UsageProvider + /// True when the session window is blocked because the weekly budget is exhausted: + /// suppress the chart and retarget "Resets in" to the weekly reset. + var blankChart = false + /// When set, "Resets in" counts down to this date (the weekly reset) instead of the + /// session window's own reset. + var resetsAtOverride: Date? + + var body: some View { + let dark = self.colorScheme == .dark + let isMonochrome = self.renderingMode != .fullColor + let geom = BurnGeom(window: self.window) + let theme = BurnTheme(provider: self.provider, geom: geom, dark: dark, isMonochrome: isMonochrome) + let windowMins = self.window.windowMinutes ?? 300 + let isDailyWindow = windowMins >= 1440 + let now = Date() + let estimatedResetMinutes = self.blankChart || geom.tNow >= 1 + ? nil + : (1 - geom.tNow) * Double(windowMins) + let explicitReset = self.blankChart + ? self.resetsAtOverride + : self.resetsAtOverride ?? self.window.resetsAt + let effectiveResetAt = burnEffectiveResetDate( + explicitResetAt: explicitReset, + estimatedResetMinutes: estimatedResetMinutes, + now: now) + let resetsIn = effectiveResetAt.map { max(0, $0.timeIntervalSince(now) / 60) } ?? 0 + let outInMins = geom.slope < -0.01 ? (geom.vNow / -geom.slope) * Double(windowMins) : Double.infinity + // Very early in the window a single sample can't forecast a credible run-out: a + // tiny burst right after reset extrapolates to "runs dry in minutes" even at ~99% + // remaining. Match the design's fresh-window behaviour ("Runs out: after reset") + // and only surface the estimate once enough of the window has elapsed to trust the + // average burn rate. + let windowEstablished = geom.tNow >= 0.08 + let runsDryBefore = geom.runsOut && outInMins < resetsIn && windowEstablished + + let sign = geom.margin >= 0 ? "+" : "−" + let badgeNum = "\(sign)\(abs(Int(geom.margin.rounded())))%" + // Per the design's edge states, "fresh" and "spent" show only the glyph + word + // (◆ full / ■ spent) with no pace number — the margin is meaningless once the + // budget is full or gone. + let showBadgeNumber = !geom.depleted && !geom.fresh + let statusWord: String = geom.depleted ? "spent" : geom.fresh ? "full" + : geom.status == .ahead ? "conserving" : geom.status == .behind ? "over pace" : "on pace" + let arrow: String = geom.depleted ? "■" : geom.fresh ? "◆" + : geom.status == .ahead ? "▲" : geom.status == .behind ? "▼" : "●" + + let axisDates = burnAxisDateRange( + effectiveResetAt: effectiveResetAt, + windowMinutes: windowMins, + now: now) + let startLabel = burnAxisLabel(axisDates.start, isDailyWindow: isDailyWindow) + let resetLabel = burnAxisLabel(axisDates.reset, isDailyWindow: isDailyWindow) + + VStack(spacing: 0) { + // Header: brand + pace badge + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Circle() + .fill(theme.brandDot) + .frame(width: 7, height: 7) + .shadow(color: theme.brandDot.opacity(0.7), radius: 3.5) + Text(burnProviderName(self.provider)) + .font(.system(size: 14.5, weight: .semibold)) + .foregroundStyle(theme.text) + .lineLimit(1) + } + Text(burnWindowLabel(self.window.windowMinutes)) + .font(.system(size: 11)) + .foregroundStyle(theme.sub) + .kerning(0.2) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 1) { + if showBadgeNumber { + Text(badgeNum) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(theme.statusColor) + .monospacedDigit() + } + HStack(spacing: 3) { + Text(arrow) + .font(.system(size: 8)) + .foregroundStyle(theme.statusColor) + Text(statusWord) + .font(.system(size: 10.5)) + .foregroundStyle(theme.sub) + } + } + } + + // Body: stats + hero / chart + HStack(alignment: .bottom, spacing: 13) { + // Left: stats + hero % + VStack(alignment: .leading, spacing: 0) { + VStack(spacing: 5) { + BurnResetStatRow(resetAt: effectiveResetAt, theme: theme) + BurnStatRow( + label: geom.depleted ? "Ran out" : runsDryBefore ? "Runs out in" : "Runs out", + value: geom + .depleted ? "budget spent" : runsDryBefore ? "~\(burnFmtDuration(outInMins))" : + "after reset", + theme: theme, + danger: geom.depleted || runsDryBefore) + } + .padding(.top, 8) + + Spacer() + + HStack(alignment: .lastTextBaseline, spacing: 5) { + Text("\(Int(geom.vNow.rounded()))") + .font(.system(size: 41, weight: .semibold)) + .foregroundStyle(geom.depleted ? theme.danger : theme.text) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.75) + Text("%") + .font(.system(size: 19, weight: .medium)) + .foregroundStyle(theme.sub) + Text("left") + .font(.system(size: 11)) + .foregroundStyle(theme.sub) + } + } + .frame(width: 143, alignment: .leading) + + // Right: chart + axis. Blanked when the session window is blocked by the + // weekly cap — there's no session burn to chart until the weekly resets. + VStack(spacing: 2) { + if self.blankChart { + Color.clear.frame(height: 84) + Color.clear.frame(height: 13) + } else { + BurnChartCanvas( + geom: geom, + theme: theme) + .frame(height: 84) + + BurnAxisRow( + startLabel: startLabel, + resetLabel: resetLabel, + tNow: geom.tNow, + theme: theme) + .frame(height: 13) + } + } + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 15) + .padding(.top, 13) + .padding(.bottom, 12) + } +} + +// MARK: - Stat Row + +private struct BurnStatRow: View { + let label: String + let value: String + let theme: BurnTheme + let danger: Bool + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(self.label) + .font(.system(size: 11.5)) + .foregroundStyle(self.theme.sub) + Spacer() + Text(self.value) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(self.danger ? self.theme.danger : self.theme.text) + .monospacedDigit() + .lineLimit(1) + } + } +} + +private struct BurnResetStatRow: View { + let resetAt: Date? + let theme: BurnTheme + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text("Resets in") + .font(.system(size: 11.5)) + .foregroundStyle(self.theme.sub) + Spacer() + if let resetAt { + Text(resetAt, style: .relative) + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(self.theme.text) + .monospacedDigit() + .lineLimit(1) + } else { + Text("—") + .font(.system(size: 12.5, weight: .semibold)) + .foregroundStyle(self.theme.text) + } + } + } +} + +// MARK: - Axis Row + +private struct BurnAxisRow: View { + let startLabel: String + let resetLabel: String + let tNow: Double + let theme: BurnTheme + + var body: some View { + GeometryReader { geo in + // Hide "now" when it would collide with the start/reset labels. The edge labels + // are anchored to the ends, so estimate their widths (≈9.5pt monospaced digits) + // and only show "now" when it clears both with a small gap — otherwise the + // now-dot on the chart already conveys position. Matches the design's rule that + // "now" hides near an end label. + let w = geo.size.width + let approxChar: CGFloat = 5.8 + let nowX = self.tNow * w + let nowHalf: CGFloat = 13 + let gap: CGFloat = 6 + let clearsStart = nowX - nowHalf > CGFloat(self.startLabel.count) * approxChar + gap + let clearsReset = nowX + nowHalf < w - CGFloat(self.resetLabel.count) * approxChar - gap + let showNow = self.tNow > 0.05 && self.tNow < 0.95 && clearsStart && clearsReset + + ZStack(alignment: .leading) { + Text(self.startLabel) + .font(.system(size: 9.5)) + .foregroundStyle(self.theme.sub) + .monospacedDigit() + .frame(maxWidth: .infinity, alignment: .leading) + + if showNow { + Text("now") + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(self.theme.text) + .position(x: nowX, y: geo.size.height / 2) + } + + Text(self.resetLabel) + .font(.system(size: 9.5)) + .foregroundStyle(self.theme.sub) + .monospacedDigit() + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } +} + +// MARK: - Chart Canvas + +private struct BurnChartCanvas: View { + let geom: BurnGeom + let theme: BurnTheme + + var body: some View { + Canvas { context, size in + let w = size.width + let h = size.height + let padT: CGFloat = 8 + let padB: CGFloat = 2 + let padL: CGFloat = 1 + let padR: CGFloat = 1 + + func X(_ t: Double) -> CGFloat { + padL + CGFloat(t) * (w - padL - padR) + } + func Y(_ v: Double) -> CGFloat { + padT + CGFloat(1 - v / 100) * (h - padT - padB) + } + + let tNow = self.geom.tNow + let vNow = self.geom.vNow + + // --- Now vertical hairline --- + do { + var p = Path() + p.move(to: CGPoint(x: X(tNow), y: Y(100))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(0))) + context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1) + } + + // --- Baseline --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(0))) + p.addLine(to: CGPoint(x: X(1), y: Y(0))) + context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1) + } + + // --- Area fill (gradient from actual line down to baseline) --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(100))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(0))) + p.addLine(to: CGPoint(x: X(0), y: Y(0))) + p.closeSubpath() + + let gradient = Gradient(stops: [ + .init(color: self.theme.chartFillTop.opacity(self.theme.chartFillTopOpacity), location: 0), + .init(color: self.theme.chartFillTop.opacity(0), location: 0.92), + ]) + context.fill( + p, + with: .linearGradient( + gradient, + startPoint: CGPoint(x: 0, y: padT), + endPoint: CGPoint(x: 0, y: h))) + } + + // --- Ideal line (dashed, knocked back) --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(100))) + p.addLine(to: CGPoint(x: X(1), y: Y(0))) + context.stroke( + p, + with: .color(self.theme.chartIdeal), + style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [2.5, 3])) + } + + // --- Projection (fine dotted) --- + if self.geom.slope < -0.01 { + var p = Path() + p.move(to: CGPoint(x: X(tNow), y: Y(vNow))) + p.addLine(to: CGPoint(x: X(self.geom.projT), y: Y(self.geom.projV))) + context.stroke( + p, + with: .color(self.theme.chartProj.opacity(0.95)), + style: StrokeStyle(lineWidth: 1.6, lineCap: .round, dash: [0.5, 3.5])) + } + + // --- Actual line (solid, hero) --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(100))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow))) + context.stroke( + p, + with: .color(self.theme.chartLine), + style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)) + } + + // --- Now dot (filled, with ring punched in bg color) --- + let dotCenter = CGPoint(x: X(tNow), y: Y(vNow)) + let ringPath = Path(ellipseIn: CGRect( + x: dotCenter.x - 5.4, y: dotCenter.y - 5.4, width: 10.8, height: 10.8)) + context.fill(ringPath, with: .color(self.theme.chartNowRing)) + let dotPath = Path(ellipseIn: CGRect( + x: dotCenter.x - 3.4, y: dotCenter.y - 3.4, width: 6.8, height: 6.8)) + context.fill(dotPath, with: .color(self.theme.chartNowDot)) + } + } +} + +// MARK: - Background + +struct BurnWidgetBackground: View { + @Environment(\.widgetRenderingMode) private var renderingMode + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + if self.renderingMode == .fullColor { + let dark = self.colorScheme == .dark + LinearGradient( + colors: dark + ? [BurnPalette.darkBgTop, BurnPalette.darkBgBottom] + : [BurnPalette.lightBgTop, BurnPalette.lightBgBottom], + startPoint: .init(x: 0.15, y: 0), + endPoint: .init(x: 0.85, y: 1)) + .overlay(alignment: .top) { + LinearGradient( + colors: [ + Color.white.opacity(dark ? 0.04 : 0.60), + Color.white.opacity(0), + ], + startPoint: .top, + endPoint: .center) + } + } else { + Color.clear + } + } +} + +// MARK: - Theme + +struct BurnTheme { + let text: Color + let sub: Color + let hair: Color + let accent: Color + let statusColor: Color + let danger: Color + let brandDot: Color + let chartLine: Color + let chartFillTop: Color + let chartFillTopOpacity: Double + let chartIdeal: Color + let chartProj: Color + let chartGrid: Color + let chartNowDot: Color + let chartNowRing: Color + + init(provider: UsageProvider, geom: BurnGeom, dark: Bool, isMonochrome: Bool) { + if isMonochrome { + let fg = dark ? Color.white : Color.black + self.text = fg.opacity(dark ? 0.95 : 0.90) + self.sub = fg.opacity(dark ? 0.50 : 0.46) + self.hair = fg.opacity(dark ? 0.13 : 0.11) + self.accent = fg.opacity(dark ? 0.95 : 0.85) + self.statusColor = fg.opacity(dark ? 0.90 : 0.80) + self.danger = fg.opacity(dark ? 0.95 : 0.85) + self.brandDot = fg.opacity(dark ? 0.85 : 0.72) + self.chartLine = fg.opacity(dark ? 0.95 : 0.85) + self.chartFillTop = fg.opacity(dark ? 0.95 : 0.85) + self.chartFillTopOpacity = dark ? 0.20 : 0.16 + self.chartIdeal = fg.opacity(dark ? 0.36 : 0.30) + self.chartProj = fg.opacity(dark ? 0.60 : 0.48) + self.chartGrid = fg.opacity(dark ? 0.12 : 0.10) + self.chartNowDot = fg.opacity(dark ? 1.0 : 0.92) + self.chartNowRing = dark + ? Color(red: 0.10, green: 0.10, blue: 0.12).opacity(0.92) + : Color(red: 0.96, green: 0.96, blue: 0.97).opacity(0.95) + } else { + let accentColor = Self.accentColor(geom.status, dark: dark) + self.accent = accentColor + self.statusColor = accentColor + self.text = dark ? Color.white.opacity(0.98) : Color(white: 0.20) + self.sub = dark ? Color(white: 0.60) : Color(white: 0.45) + self.hair = dark ? Color.white.opacity(0.10) : Color.black.opacity(0.09) + self.danger = BurnPalette.behindDark + self.brandDot = Self.brandDotColor(provider) + self.chartLine = accentColor + self.chartFillTop = accentColor + self.chartFillTopOpacity = dark ? 0.30 : 0.22 + self.chartIdeal = dark ? Color.white.opacity(0.45) : Color.black.opacity(0.50) + // Projection goes red when behind (the one allowed color cue in full-color mode) + self.chartProj = geom.status == .behind ? BurnPalette.behindDark : accentColor + self.chartGrid = dark ? Color.white.opacity(0.10) : Color.black.opacity(0.09) + self.chartNowDot = accentColor + self.chartNowRing = dark ? BurnPalette.darkBgBottom : BurnPalette.lightBgBottom + } + } + + private static func accentColor(_ status: BurnGeom.Status, dark: Bool) -> Color { + switch status { + case .ahead: dark ? BurnPalette.aheadDark : BurnPalette.aheadLight + case .onpace: dark ? BurnPalette.onpaceDark : BurnPalette.onpaceLight + case .behind: dark ? BurnPalette.behindDark : BurnPalette.behindLight + } + } + + private static func brandDotColor(_ provider: UsageProvider) -> Color { + switch provider { + case .claude: BurnPalette.claudeDot + case .codex: BurnPalette.codexDot + case .gemini: BurnPalette.geminiDot + default: BurnPalette.genericDot + } + } +} + +// MARK: - Palette + +enum BurnPalette { + // Status accents — approximated from OKLCH (L=0.80 dark, L=0.62 light) + // oklch(0.80 0.15 152) / oklch(0.62 0.15 152) — green + static let aheadDark = Color(red: 0.306, green: 0.800, blue: 0.506) + static let aheadLight = Color(red: 0.192, green: 0.620, blue: 0.376) + // oklch(0.80 0.11 236) / oklch(0.62 0.11 236) — blue + static let onpaceDark = Color(red: 0.408, green: 0.668, blue: 0.910) + static let onpaceLight = Color(red: 0.264, green: 0.474, blue: 0.712) + // oklch(0.72 0.19 26) / oklch(0.60 0.19 26) — red-orange + static let behindDark = Color(red: 0.922, green: 0.420, blue: 0.227) + static let behindLight = Color(red: 0.762, green: 0.294, blue: 0.137) + + // Brand identity dots — always the LLM's hue + static let claudeDot = Color(red: 0.880, green: 0.580, blue: 0.180) // clay/amber, hue 48 + static let codexDot = Color(red: 0.120, green: 0.780, blue: 0.598) // teal, hue 168 + static let geminiDot = Color(red: 0.420, green: 0.440, blue: 0.900) // indigo, hue 268 + static let genericDot = Color(white: 0.60) + + // Backgrounds + static let darkBgTop = Color(red: 0.108, green: 0.108, blue: 0.132) + static let darkBgBottom = Color(red: 0.132, green: 0.132, blue: 0.156) + static let lightBgTop = Color(white: 0.990) + static let lightBgBottom = Color(red: 0.940, green: 0.940, blue: 0.960) +} + +// MARK: - Geometry + +struct BurnGeom { + enum Status { case ahead, onpace, behind } + + let vNow: Double // % remaining (0..100) + let tNow: Double // position in window (0..1) + let idealNow: Double // what you should have left = 100 * (1 - tNow) + let margin: Double // vNow - idealNow; + = conserving, − = over pace + let slope: Double // %/unit-t (negative = burning) + let projT: Double // t where projection ends + let projV: Double // v where projection ends + let runsOut: Bool // projection hits 0 inside the window + + var status: Status { + self.margin > 4 ? .ahead : self.margin < -4 ? .behind : .onpace + } + + var depleted: Bool { + self.vNow <= 0.5 + } + + var fresh: Bool { + self.vNow >= 99.5 + } + + init(window: RateWindow) { + let remaining = max(0, min(100, window.remainingPercent)) + self.vNow = remaining + + let t: Double + if let resetsAt = window.resetsAt, let windowMins = window.windowMinutes, windowMins > 0 { + let minutesUntilReset = max(0, resetsAt.timeIntervalSinceNow / 60) + let minutesElapsed = Double(windowMins) - minutesUntilReset + t = max(0.001, min(0.999, minutesElapsed / Double(windowMins))) + } else { + t = max(0.001, min(0.999, window.usedPercent / 100.0)) + } + self.tNow = t + self.idealNow = 100.0 * (1.0 - t) + self.margin = remaining - self.idealNow + + let slope = t > 0.001 ? (remaining - 100.0) / t : -remaining + self.slope = slope + + if slope < -0.01 { + let tOut = t + remaining / -slope + if tOut <= 1.0 { + self.projT = tOut + self.projV = 0 + self.runsOut = true + } else { + self.projT = 1.0 + self.projV = max(0, remaining + slope * (1.0 - t)) + self.runsOut = false + } + } else { + self.projT = 1.0 + self.projV = remaining + self.runsOut = false + } + } +} + +// MARK: - Helpers + +func burnWindowLabel(_ windowMinutes: Int?) -> String { + guard let mins = windowMinutes else { return "Usage limit" } + if mins < 60 { return "\(mins)-minute limit" } + let hours = mins / 60 + if hours < 24 { return "\(hours)-hour limit" } + return "\(hours / 24)-day limit" +} + +func burnEffectiveResetDate( + explicitResetAt: Date?, + estimatedResetMinutes: Double?, + now: Date) -> Date? +{ + if let explicitResetAt { + return explicitResetAt > now ? explicitResetAt : nil + } + guard let estimatedResetMinutes, estimatedResetMinutes > 0 else { return nil } + return now.addingTimeInterval(estimatedResetMinutes * 60) +} + +func burnAxisDateRange( + effectiveResetAt: Date?, + windowMinutes: Int, + now: Date) -> (start: Date, reset: Date) +{ + let reset = effectiveResetAt ?? now + return (reset.addingTimeInterval(-Double(windowMinutes) * 60), reset) +} + +func burnCompactWindowLabel(_ windowMinutes: Int?, fallback: String) -> String { + guard let minutes = windowMinutes else { return fallback } + if minutes < 60 { return "\(minutes)M" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)H" } + return "\(hours / 24)D" +} + +func burnFmtDuration(_ minutes: Double) -> String { + guard minutes.isFinite, minutes > 0 else { return "—" } + if minutes >= 1440 { + let d = Int(minutes / 1440) + let h = Int(minutes / 60) % 24 + return "\(d)d \(h)h" + } + let h = Int(minutes / 60) + let m = Int(minutes) % 60 + if h <= 0 { return "\(max(1, m))m" } + return "\(h)h \(String(format: "%02d", m))m" +} + +func burnAxisLabel(_ date: Date, isDailyWindow: Bool) -> String { + let f = DateFormatter() + if isDailyWindow { + // A rolling multi-day window's start and reset fall on the same weekday, so "EEE" + // would print the same label at both ends ("Sat … Sat"). Use a numeric date so the + // two ends are distinguishable. + f.setLocalizedDateFormatFromTemplate("Md") + } else { + f.dateStyle = .none + f.timeStyle = .short + } + return f.string(from: date) +} + +func burnProviderName(_ provider: UsageProvider) -> String { + ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue.capitalized +} diff --git a/Sources/CodexBarWidget/CodexBarWidgetBundle.swift b/Sources/CodexBarWidget/CodexBarWidgetBundle.swift index 4b65052170..a2d2cd23ca 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetBundle.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetBundle.swift @@ -8,6 +8,8 @@ struct CodexBarWidgetBundle: WidgetBundle { CodexBarUsageWidget() CodexBarHistoryWidget() CodexBarCompactWidget() + CodexBarBurnDownWidget() + CodexBarCombinedBurnDownWidget() } } @@ -77,3 +79,37 @@ struct CodexBarCompactWidget: Widget { .supportedFamilies([.systemSmall]) } } + +struct CodexBarBurnDownWidget: Widget { + private let kind = "CodexBarBurnDownWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: self.kind, + intent: BurnDownSelectionIntent.self, + provider: BurnDownTimelineProvider()) + { entry in + BurnDownWidgetView(entry: entry) + } + .configurationDisplayName("CodexBar Burn Down") + .description("Remaining budget compared with an ideal steady burn rate.") + .supportedFamilies([.systemMedium]) + } +} + +struct CodexBarCombinedBurnDownWidget: Widget { + private let kind = "CodexBarCombinedBurnDownWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: self.kind, + intent: BurnProviderSelectionIntent.self, + provider: CombinedBurnDownTimelineProvider()) + { entry in + CombinedBurnDownWidgetView(entry: entry) + } + .configurationDisplayName("CodexBar Burn Down (Combined)") + .description("Session and weekly burn-down charts in one tile.") + .supportedFamilies([.systemMedium]) + } +} diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index eb0d005741..923690bb13 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -7,12 +7,15 @@ enum ProviderChoice: String, AppEnum { case codex case claude case gemini + case alibaba + case alibabatokenplan case antigravity case zai case copilot case minimax case kilo case opencode + case opencodego static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") @@ -20,12 +23,15 @@ enum ProviderChoice: String, AppEnum { .codex: DisplayRepresentation(title: "Codex"), .claude: DisplayRepresentation(title: "Claude"), .gemini: DisplayRepresentation(title: "Gemini"), + .alibaba: DisplayRepresentation(title: "Alibaba"), + .alibabatokenplan: DisplayRepresentation(title: "Alibaba Token Plan"), .antigravity: DisplayRepresentation(title: "Antigravity"), .zai: DisplayRepresentation(title: "z.ai"), .copilot: DisplayRepresentation(title: "Copilot"), .minimax: DisplayRepresentation(title: "MiniMax"), .kilo: DisplayRepresentation(title: "Kilo"), .opencode: DisplayRepresentation(title: "OpenCode"), + .opencodego: DisplayRepresentation(title: "OpenCode Go"), ] var provider: UsageProvider { @@ -33,12 +39,15 @@ enum ProviderChoice: String, AppEnum { case .codex: .codex case .claude: .claude case .gemini: .gemini + case .alibaba: .alibaba + case .alibabatokenplan: .alibabatokenplan case .antigravity: .antigravity case .zai: .zai case .copilot: .copilot case .minimax: .minimax case .kilo: .kilo case .opencode: .opencode + case .opencodego: .opencodego } } @@ -46,15 +55,22 @@ enum ProviderChoice: String, AppEnum { init?(provider: UsageProvider) { switch provider { case .codex: self = .codex + case .openai: return nil // OpenAI not yet supported in widgets + case .azureopenai: return nil // Azure OpenAI not yet supported in widgets case .claude: self = .claude case .gemini: self = .gemini + case .alibaba: self = .alibaba + case .alibabatokenplan: self = .alibabatokenplan case .antigravity: self = .antigravity case .cursor: return nil // Cursor not yet supported in widgets case .opencode: self = .opencode + case .opencodego: self = .opencodego case .zai: self = .zai case .factory: return nil // Factory not yet supported in widgets case .copilot: self = .copilot + case .devin: return nil // Devin not yet supported in widgets case .minimax: self = .minimax + case .manus: return nil // Manus not yet supported in widgets case .vertexai: return nil // Vertex AI not yet supported in widgets case .kilo: self = .kilo case .kiro: return nil // Kiro not yet supported in widgets @@ -62,11 +78,35 @@ enum ProviderChoice: String, AppEnum { case .jetbrains: return nil // JetBrains not yet supported in widgets case .kimi: return nil // Kimi not yet supported in widgets case .kimik2: return nil // Kimi K2 not yet supported in widgets + case .moonshot: return nil // Moonshot not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets + case .t3chat: return nil // T3 Chat not yet supported in widgets case .ollama: return nil // Ollama not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets + case .elevenlabs: return nil // ElevenLabs not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets + case .windsurf: return nil // Windsurf not yet supported in widgets + case .perplexity: return nil // Perplexity not yet supported in widgets + case .mimo: return nil // Xiaomi MiMo not yet supported in widgets + case .doubao: return nil // Doubao not yet supported in widgets + case .abacus: return nil // Abacus AI not yet supported in widgets + case .mistral: return nil // Mistral not yet supported in widgets + case .deepseek: return nil // DeepSeek not yet supported in widgets + case .codebuff: return nil // Codebuff not yet supported in widgets + case .crof: return nil // Crof not yet supported in widgets + case .venice: return nil // Venice not yet supported in widgets + case .commandcode: return nil // CommandCode not yet supported in widgets + case .stepfun: return nil // StepFun not yet supported in widgets + case .bedrock: return nil // Bedrock not yet supported in widgets + case .grok: return nil // Grok not yet supported in widgets + case .groq: return nil // Groq not yet supported in widgets + case .llmproxy: return nil // LLM Proxy not yet supported in widgets + case .litellm: return nil // LiteLLM not yet supported in widgets + case .deepgram: return nil // Deepgram not yet supported in widgets + case .poe: return nil // Poe not yet supported in widgets + case .chutes: return nil // Chutes not yet supported in widgets + case .zed: return nil // Zed not yet supported in widgets } } } @@ -89,7 +129,7 @@ struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { static let title: LocalizedStringResource = "Provider" static let description = IntentDescription("Select the provider to display in the widget.") - @Parameter(title: "Provider") + @Parameter(title: "Provider", default: .codex) var provider: ProviderChoice init() { @@ -121,10 +161,10 @@ struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent { static let title: LocalizedStringResource = "Provider + Metric" static let description = IntentDescription("Select the provider and metric to display.") - @Parameter(title: "Provider") + @Parameter(title: "Provider", default: .codex) var provider: ProviderChoice - @Parameter(title: "Metric") + @Parameter(title: "Metric", default: .credits) var metric: CompactMetric init() { @@ -174,7 +214,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { in context: Context) async -> Timeline { let provider = configuration.provider.provider - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) return Timeline(entries: [entry], policy: .after(refresh)) @@ -203,7 +243,7 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { } private func makeEntry() -> CodexBarSwitcherEntry { - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let providers = self.availableProviders(from: snapshot) let stored = WidgetSelectionStore.loadSelectedProvider() let selected = providers.first { $0 == stored } ?? providers.first ?? .codex @@ -218,6 +258,10 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { } private func availableProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] { + Self.supportedProviders(from: snapshot) + } + + static func supportedProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] { let enabled = snapshot.enabledProviders let providers = enabled.isEmpty ? snapshot.entries.map(\.provider) : enabled let supported = providers.filter { ProviderChoice(provider: $0) != nil } @@ -236,10 +280,11 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async -> CodexBarCompactEntry { let provider = configuration.provider.provider + let metric = configuration.metric return CodexBarCompactEntry( date: Date(), provider: provider, - metric: configuration.metric, + metric: metric, snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()) } @@ -248,11 +293,12 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { in context: Context) async -> Timeline { let provider = configuration.provider.provider - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let metric = configuration.metric + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarCompactEntry( date: Date(), provider: provider, - metric: configuration.metric, + metric: metric, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) return Timeline(entries: [entry], policy: .after(refresh)) @@ -260,9 +306,17 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { } enum WidgetPreviewData { + static func emptySnapshot() -> WidgetSnapshot { + WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date()) + } + static func snapshot() -> WidgetSnapshot { - let primary = RateWindow(usedPercent: 35, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 4h") - let secondary = RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 3d") + let primary = RateWindow(usedPercent: 35, windowMinutes: 300, resetsAt: nil, resetDescription: "Resets in 4h") + let secondary = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "Resets in 3d") let entry = WidgetSnapshot.ProviderEntry( provider: .codex, updatedAt: Date(), diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index fbb8c5d9c0..10fe0183fe 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -184,13 +184,19 @@ private struct CompactMetricView: View { let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" return (value, "Credits left", nil) case .todayCost: - let value = self.entry.tokenUsage?.sessionCostUSD.map(WidgetFormat.usd) ?? "—" + let value = self.entry.tokenUsage.map { token in + token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" + } ?? "—" let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) - return (value, "Today cost", detail) + let label = self.entry.tokenUsage.map { "\($0.sessionLabel) cost" } ?? "Today cost" + return (value, label, detail) case .last30DaysCost: - let value = self.entry.tokenUsage?.last30DaysCostUSD.map(WidgetFormat.usd) ?? "—" + let value = self.entry.tokenUsage.map { token in + token.last30DaysCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" + } ?? "—" let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) - return (value, "30d cost", detail) + let label = self.entry.tokenUsage.map { "\($0.last30DaysLabel) cost" } ?? "30d cost" + return (value, label, detail) } } } @@ -258,15 +264,22 @@ private struct ProviderSwitchChip: View { private var shortLabel: String { switch self.provider { case .codex: "Codex" + case .openai: "OpenAI" + case .azureopenai: "Azure OpenAI" case .claude: "Claude" case .gemini: "Gemini" case .antigravity: "Anti" case .cursor: "Cursor" case .opencode: "OpenCode" + case .opencodego: "OpenCode Go" + case .alibaba: "Alibaba" + case .alibabatokenplan: "Token Plan" case .zai: "z.ai" case .factory: "Droid" case .copilot: "Copilot" + case .devin: "Devin" case .minimax: "MiniMax" + case .manus: "Manus" case .vertexai: "Vertex" case .kilo: "Kilo" case .kiro: "Kiro" @@ -274,11 +287,35 @@ private struct ProviderSwitchChip: View { case .jetbrains: "JetBrains" case .kimi: "Kimi" case .kimik2: "Kimi K2" + case .moonshot: "Moonshot" case .amp: "Amp" + case .t3chat: "T3 Chat" case .ollama: "Ollama" case .synthetic: "Synthetic" case .openrouter: "OpenRouter" + case .elevenlabs: "ElevenLabs" case .warp: "Warp" + case .windsurf: "Windsurf" + case .perplexity: "Pplx" + case .mimo: "MiMo" + case .doubao: "Doubao" + case .abacus: "Abacus" + case .mistral: "Mistral" + case .deepseek: "DeepSeek" + case .codebuff: "Codebuff" + case .crof: "Crof" + case .venice: "Venice" + case .commandcode: "Command Code" + case .stepfun: "StepFun" + case .bedrock: "Bedrock" + case .grok: "Grok" + case .groq: "Groq" + case .llmproxy: "LLM Proxy" + case .litellm: "LiteLLM" + case .deepgram: "Deepgram" + case .poe: "Poe" + case .chutes: "Chutes" + case .zed: "Zed" } } } @@ -288,14 +325,15 @@ private struct SwitcherSmallUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows( + for: self.entry, + limit: WidgetUsageRow.smallWidgetRowLimit(for: self.entry))) + { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", @@ -311,21 +349,25 @@ private struct SwitcherMediumUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows( + for: self.entry, + limit: WidgetUsageRow.mediumWidgetRowLimit(for: self.entry))) + { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let credits = entry.creditsRemaining { ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) } } } @@ -336,14 +378,12 @@ private struct SwitcherLargeUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", @@ -356,13 +396,17 @@ private struct SwitcherLargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", + title: token.last30DaysLabel, value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens)) + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) @@ -377,14 +421,15 @@ private struct SmallUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows( + for: self.entry, + limit: WidgetUsageRow.smallWidgetRowLimit(for: self.entry))) + { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", @@ -402,21 +447,25 @@ private struct MediumUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows( + for: self.entry, + limit: WidgetUsageRow.mediumWidgetRowLimit(for: self.entry))) + { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let credits = entry.creditsRemaining { ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) } } .padding(12) @@ -429,14 +478,12 @@ private struct LargeUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", @@ -449,13 +496,17 @@ private struct LargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", + title: token.last30DaysLabel, value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens)) + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) @@ -465,6 +516,130 @@ private struct LargeUsageView: View { } } +struct WidgetUsageRow: Identifiable, Equatable { + let id: String + let title: String + let percentLeft: Double? + + private enum AntigravityQuotaFamily { + case gemini + case claudeGPT + } + + static func smallWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? { + self.antigravityQuotaSummaryRowLimit(for: entry, limit: 2) + } + + static func mediumWidgetRowLimit(for entry: WidgetSnapshot.ProviderEntry) -> Int? { + self.antigravityQuotaSummaryRowLimit(for: entry, limit: 3) + } + + private static func antigravityQuotaSummaryRowLimit( + for entry: WidgetSnapshot.ProviderEntry, + limit: Int) -> Int? + { + guard entry.provider == .antigravity, + entry.usageRows?.contains(where: { + $0.id.hasPrefix("antigravity-quota-summary-") + }) == true + else { + return nil + } + return limit + } + + static func rows(for entry: WidgetSnapshot.ProviderEntry, limit: Int? = nil) -> [WidgetUsageRow] { + let rows: [WidgetUsageRow] + if let usageRows = entry.usageRows { + rows = usageRows.map { row in + WidgetUsageRow(id: row.id, title: row.title, percentLeft: row.percentLeft) + } + } else { + let metadata = ProviderDefaults.metadata[entry.provider] + var defaultRows = [ + WidgetUsageRow( + id: "primary", + title: metadata?.sessionLabel ?? "Session", + percentLeft: entry.primary?.remainingPercent), + WidgetUsageRow( + id: "secondary", + title: metadata?.weeklyLabel ?? "Weekly", + percentLeft: entry.secondary?.remainingPercent), + ] + if metadata?.supportsOpus == true { + defaultRows.append(WidgetUsageRow( + id: "tertiary", + title: metadata?.opusLabel ?? "Opus", + percentLeft: entry.tertiary?.remainingPercent)) + } + rows = defaultRows.filter { $0.percentLeft != nil } + } + guard let limit else { return rows } + if entry.provider == .antigravity, + limit >= 2, + rows.contains(where: { $0.id.hasPrefix("antigravity-quota-summary-") }) + { + var selected = [AntigravityQuotaFamily.gemini, .claudeGPT].compactMap { family in + rows + .filter { self.antigravityQuotaFamily(for: $0) == family } + .min(by: self.isMoreConstrained) + } + let selectedIDs = Set(selected.map(\.id)) + let fallbackRows = rows.enumerated() + .filter { !selectedIDs.contains($0.element.id) } + .sorted { lhs, rhs in + switch (lhs.element.percentLeft, rhs.element.percentLeft) { + case let (.some(left), .some(right)): + left == right ? lhs.offset < rhs.offset : left < right + case (.some, .none): + true + case (.none, .some): + false + case (.none, .none): + lhs.offset < rhs.offset + } + } + .map(\.element) + selected.append(contentsOf: fallbackRows.prefix(max(0, limit - selected.count))) + return selected + } + return Array(rows.prefix(max(0, limit))) + } + + private static func antigravityQuotaFamily(for row: WidgetUsageRow) -> AntigravityQuotaFamily? { + guard row.id.hasPrefix("antigravity-quota-summary-") else { return nil } + let id = row.id.lowercased() + if id.contains("gemini") { + return .gemini + } + if id.contains("3p") || id.contains("third-party") { + return .claudeGPT + } + + let title = row.title.lowercased() + if title.contains("gemini") { + return .gemini + } + if title.contains("claude") || title.contains("gpt") { + return .claudeGPT + } + return nil + } + + private static func isMoreConstrained(_ lhs: WidgetUsageRow, than rhs: WidgetUsageRow) -> Bool { + switch (lhs.percentLeft, rhs.percentLeft) { + case let (.some(left), .some(right)): + left < right + case (.some, .none): + true + case (.none, .some): + false + case (.none, .none): + false + } + } +} + private struct HistoryView: View { let entry: WidgetSnapshot.ProviderEntry let isLarge: Bool @@ -476,11 +651,17 @@ private struct HistoryView: View { .frame(height: self.isLarge ? 90 : 60) if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", - value: WidgetFormat.costAndTokens(cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) + title: token.last30DaysLabel, + value: WidgetFormat.costAndTokens( + cost: token.last30DaysCostUSD, + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } .padding(12) @@ -576,6 +757,10 @@ enum WidgetColors { switch provider { case .codex: Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) + case .openai: + Color(red: 15 / 255, green: 130 / 255, blue: 110 / 255) + case .azureopenai: + Color(red: 0, green: 120 / 255, blue: 212 / 255) case .claude: Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255) case .gemini: @@ -586,14 +771,22 @@ enum WidgetColors { Color(red: 0 / 255, green: 191 / 255, blue: 165 / 255) // #00BFA5 - Cursor teal case .opencode: Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) + case .opencodego: + Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) + case .alibaba, .alibabatokenplan: + Color(red: 1.0, green: 106 / 255, blue: 0) case .zai: Color(red: 232 / 255, green: 90 / 255, blue: 106 / 255) case .factory: Color(red: 255 / 255, green: 107 / 255, blue: 53 / 255) // Factory orange case .copilot: Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple + case .devin: + Color(red: 70 / 255, green: 180 / 255, blue: 130 / 255) case .minimax: Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) + case .manus: + Color(red: 24 / 255, green: 24 / 255, blue: 24 / 255) case .vertexai: Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue case .kilo: @@ -608,16 +801,64 @@ enum WidgetColors { Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) // Kimi orange case .kimik2: Color(red: 76 / 255, green: 0 / 255, blue: 255 / 255) // Kimi K2 purple + case .moonshot: + Color(red: 32 / 255, green: 93 / 255, blue: 235 / 255) case .amp: Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red + case .t3chat: + Color(red: 245 / 255, green: 102 / 255, blue: 71 / 255) case .ollama: Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal case .synthetic: Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal case .openrouter: Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple + case .elevenlabs: + Color(red: 235 / 255, green: 235 / 255, blue: 230 / 255) case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) + case .windsurf: + Color(red: 52 / 255, green: 232 / 255, blue: 187 / 255) // Windsurf #34e8bb + case .perplexity: + Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal + case .mimo: + Color(red: 1.0, green: 105 / 255, blue: 0) + case .doubao: + Color(red: 45 / 255, green: 136 / 255, blue: 255 / 255) // Doubao blue + case .abacus: + Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) + case .mistral: + Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange + case .deepseek: + Color(red: 82 / 255, green: 125 / 255, blue: 240 / 255) + case .codebuff: + Color(red: 68 / 255, green: 255 / 255, blue: 0 / 255) // Codebuff lime + case .crof: + Color(red: 46 / 255, green: 171 / 255, blue: 148 / 255) + case .venice: + Color(red: 51 / 255, green: 153 / 255, blue: 1.0) + case .commandcode: + Color(red: 0, green: 0, blue: 0) + case .stepfun: + Color(red: 255 / 255, green: 140 / 255, blue: 0 / 255) // StepFun orange + case .bedrock: + Color(red: 255 / 255, green: 153 / 255, blue: 0 / 255) // AWS orange + case .grok: + Color(red: 16 / 255, green: 163 / 255, blue: 127 / 255) // Grok teal + case .groq: + Color(red: 245 / 255, green: 104 / 255, blue: 68 / 255) + case .llmproxy: + Color(red: 36 / 255, green: 180 / 255, blue: 126 / 255) + case .litellm: + Color(red: 76 / 255, green: 137 / 255, blue: 240 / 255) + case .deepgram: + Color(red: 10 / 255, green: 18 / 255, blue: 27 / 255) + case .poe: + Color(red: 0.15, green: 0.68, blue: 0.38) + case .chutes: + Color(red: 24 / 255, green: 160 / 255, blue: 88 / 255) + case .zed: + Color(red: 64 / 255, green: 156 / 255, blue: 255 / 255) } } } @@ -636,21 +877,21 @@ enum WidgetFormat { return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) } - static func costAndTokens(cost: Double?, tokens: Int?) -> String { - let costText = cost.map(self.usd) ?? "—" + static func costAndTokens(cost: Double?, tokens: Int?, currencyCode: String = "USD") -> String { + let costText = cost.map { self.currency($0, code: currencyCode) } ?? "—" if let tokens { return "\(costText) · \(self.tokenCount(tokens))" } return costText } - static func usd(_ value: Double) -> String { + static func currency(_ value: Double, code: String) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency - formatter.currencyCode = "USD" + formatter.currencyCode = code formatter.maximumFractionDigits = 2 formatter.minimumFractionDigits = 2 - return formatter.string(from: NSNumber(value: value)) ?? String(format: "$%.2f", value) + return formatter.string(from: NSNumber(value: value)) ?? "\(code) \(String(format: "%.2f", value))" } static func tokenCount(_ value: Int) -> String { @@ -663,6 +904,7 @@ enum WidgetFormat { static func relativeDate(_ date: Date) -> String { let formatter = RelativeDateTimeFormatter() + formatter.locale = Locale(identifier: "en_US") formatter.unitsStyle = .short return formatter.localizedString(for: date, relativeTo: Date()) } diff --git a/Sources/CodexBarWidget/CombinedBurnDownWidgetViews.swift b/Sources/CodexBarWidget/CombinedBurnDownWidgetViews.swift new file mode 100644 index 0000000000..258812b5ad --- /dev/null +++ b/Sources/CodexBarWidget/CombinedBurnDownWidgetViews.swift @@ -0,0 +1,480 @@ +import AppKit +import CodexBarCore +import SwiftUI +import WidgetKit + +// MARK: - Entry View + +struct CombinedBurnDownWidgetView: View { + let entry: CombinedBurnDownEntry + + var body: some View { + let state = BurnDownState( + snapshot: self.entry.snapshot, + provider: self.entry.provider, + selection: .session) + + Group { + if let state { + CombinedBurnDownLayout(state: state, provider: self.entry.provider) + } else { + self.emptyState + } + } + .containerBackground(for: .widget) { + BurnWidgetBackground() + } + } + + private var emptyState: some View { + VStack(spacing: 6) { + Text("Open CodexBar") + .font(.body) + .fontWeight(.semibold) + Text("Usage data will appear once the app refreshes.") + .font(.caption) + .multilineTextAlignment(.center) + .opacity(0.55) + } + .padding(12) + } +} + +// MARK: - Layout + +private struct CombinedBurnDownLayout: View { + @Environment(\.widgetRenderingMode) private var renderingMode + @Environment(\.colorScheme) private var colorScheme + + let state: BurnDownState + let provider: UsageProvider + + var body: some View { + let dark = self.colorScheme == .dark + let isMonochrome = self.renderingMode != .fullColor + + let sessionWindow = self.state.primaryWindow + let weeklyWindow = self.state.secondaryWindow + + let sessionGeom = sessionWindow.map { BurnGeom(window: $0) } + let weeklyGeom = weeklyWindow.map { BurnGeom(window: $0) } + + // Use a neutral baseline theme for the header/hairline colors + let baseGeom = sessionGeom ?? weeklyGeom ?? BurnGeom( + window: RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(2.5 * 3600), + resetDescription: nil)) + let baseTheme = BurnTheme( + provider: self.provider, + geom: baseGeom, + dark: dark, + isMonochrome: isMonochrome) + + VStack(spacing: 0) { + // Header + HStack(alignment: .center) { + HStack(spacing: 6) { + Circle() + .fill(baseTheme.brandDot) + .frame(width: 7, height: 7) + .shadow(color: baseTheme.brandDot.opacity(0.7), radius: 3.5) + Text(burnProviderName(self.provider)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(baseTheme.text) + .lineLimit(1) + } + Spacer() + Text("Session & weekly limits") + .font(.system(size: 10)) + .foregroundStyle(baseTheme.sub) + .kerning(0.3) + } + + // Two rows + VStack(spacing: 0) { + // 5H row — shows % remaining by default + if let win = sessionWindow, let geom = sessionGeom { + CombinedBurnRow( + window: win, + geom: geom, + theme: BurnTheme( + provider: self.provider, + geom: geom, + dark: dark, + isMonochrome: isMonochrome), + tag: burnCompactWindowLabel(win.windowMinutes, fallback: "S"), + periods: 5, + metric: .remaining, + dark: dark, + blankChart: self.state.blankPrimaryChart, + resetsAtOverride: self.state.selectedResetOverride) + } else { + CombinedEmptyRow(tag: "S", theme: baseTheme) + } + + Rectangle() + .fill(baseTheme.hair) + .frame(height: 1) + + // 7D row — shows % off pace by default + if let win = weeklyWindow, let geom = weeklyGeom { + CombinedBurnRow( + window: win, + geom: geom, + theme: BurnTheme( + provider: self.provider, + geom: geom, + dark: dark, + isMonochrome: isMonochrome), + tag: burnCompactWindowLabel(win.windowMinutes, fallback: "W"), + periods: 7, + metric: .pace, + dark: dark) + } else { + CombinedEmptyRow(tag: "W", theme: baseTheme) + } + } + .frame(maxHeight: .infinity) + .padding(.top, 6) + } + .padding(.horizontal, 15) + .padding(.top, 12) + .padding(.bottom, 11) + } +} + +// MARK: - Metric + +private enum CombinedMetric { + case remaining // % left (default for 5H) + case pace // % off ideal pace (default for 7D) + case used // % consumed +} + +// MARK: - Row + +private struct CombinedBurnRow: View { + let window: RateWindow + let geom: BurnGeom + let theme: BurnTheme + let tag: String + let periods: Int + let metric: CombinedMetric + let dark: Bool + var blankChart = false + var resetsAtOverride: Date? + + var body: some View { + let windowMins = self.window.windowMinutes ?? 300 + let isDailyWindow = windowMins >= 1440 + + let heroNum = self.metric == .pace ? abs(Int(self.geom.margin.rounded())) + : self.metric == .used ? Int((100 - self.geom.vNow).rounded()) + : Int(self.geom.vNow.rounded()) + let suffix = self.metric == .remaining ? "left" : self.metric == .used ? "used" : "" + let prefixArrow = self.metric == .pace + + let paceWord: String = self.geom.depleted ? "spent" : self.geom.fresh ? "full" + : self.geom.status == .ahead ? "under pace" + : self.geom.status == .behind ? "over pace" : "on pace" + let arrow: String = self.geom.depleted ? "■" : self.geom.fresh ? "◆" + : self.geom.status == .ahead ? "▲" : self.geom.status == .behind ? "▼" : "●" + + let explicitReset = self.blankChart + ? self.resetsAtOverride + : self.resetsAtOverride ?? self.window.resetsAt + let now = Date() + let estimatedResetMinutes = self.blankChart || self.geom.tNow >= 1 + ? nil + : (1 - self.geom.tNow) * Double(windowMins) + let effectiveResetDate = burnEffectiveResetDate( + explicitResetAt: explicitReset, + estimatedResetMinutes: estimatedResetMinutes, + now: now) + let heroColor = self.geom.depleted ? self.theme.danger : self.theme.statusColor + + HStack(alignment: .center, spacing: 12) { + // Label column + VStack(alignment: .leading, spacing: 0) { + // Line 1: tag + (arrow for non-pace metrics) + pace word + // For remaining/used: "5H ▼ over pace". For pace: "7D on pace" (arrow is on hero line). + HStack(alignment: .firstTextBaseline, spacing: 5) { + Text(self.tag) + .font(.system(size: 9.5, weight: .heavy)) + .foregroundStyle(self.theme.sub) + .kerning(1) + HStack(alignment: .firstTextBaseline, spacing: 2) { + if !prefixArrow { + Text(arrow) + .font(.system(size: 8)) + .foregroundStyle(self.theme.statusColor) + } + Text(paceWord) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(self.theme.statusColor) + } + .lineLimit(1) + } + + // Line 2: hero number. Pace metric prefixes an arrow glyph. + HStack(alignment: .lastTextBaseline, spacing: 2) { + if prefixArrow { + Text(arrow) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(heroColor) + } + Text("\(heroNum)") + .font(.system(size: 27, weight: .semibold)) + .foregroundStyle(heroColor) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.8) + Text("%") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(self.theme.sub) + if !suffix.isEmpty { + Text(suffix) + .font(.system(size: 10)) + .foregroundStyle(self.theme.sub) + } + } + .padding(.top, 1) + + // Line 3: reset line — refresh glyph + countdown + compact time + HStack(spacing: 4) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 9)) + .foregroundStyle(self.theme.sub.opacity(0.85)) + if let effectiveResetDate { + Text(effectiveResetDate, style: .relative) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(self.theme.text) + .monospacedDigit() + Text("· \(combinedCompactResetTime(effectiveResetDate, isDailyWindow: isDailyWindow))") + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(self.theme.text) + } else { + Text("—") + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(self.theme.text) + } + } + .padding(.top, 2) + .lineLimit(1) + } + .frame(width: 112, alignment: .leading) + + // Chart column — blanked when the session window is blocked by an exhausted + // weekly cap; there is no session burn to chart until the weekly resets. + if self.blankChart { + Color.clear + .frame(maxWidth: .infinity) + .frame(height: 50) + } else { + CombinedBurnChartCanvas(geom: self.geom, theme: self.theme, periods: self.periods, dark: self.dark) + .frame(maxWidth: .infinity) + .frame(height: 50) + } + } + .frame(maxHeight: .infinity) + } +} + +// MARK: - Empty Row + +private struct CombinedEmptyRow: View { + let tag: String + let theme: BurnTheme + + var body: some View { + HStack { + Text(self.tag) + .font(.system(size: 9.5, weight: .heavy)) + .foregroundStyle(self.theme.sub) + .kerning(1) + Text("No data") + .font(.system(size: 10)) + .foregroundStyle(self.theme.sub) + Spacer() + } + .frame(maxHeight: .infinity) + } +} + +// MARK: - Mini Chart Canvas + +private struct CombinedBurnChartCanvas: View { + let geom: BurnGeom + let theme: BurnTheme + let periods: Int + let dark: Bool + + var body: some View { + Canvas { context, size in + let w = size.width + let h = size.height + let padT: CGFloat = 5 + let padB: CGFloat = 2 + let padL: CGFloat = 1 + let padR: CGFloat = 1 + + func X(_ t: Double) -> CGFloat { + padL + CGFloat(t) * (w - padL - padR) + } + func Y(_ v: Double) -> CGFloat { + padT + CGFloat(1 - v / 100) * (h - padT - padB) + } + + let tNow = self.geom.tNow + let vNow = self.geom.vNow + let barColor = self.dark ? Color.white : Color.black + + // --- Usage bars (background texture) --- + // Drawn first so the actual line renders on top. + // Heights are relative-to-ideal: idealPerPeriod maps to ~46% of plot height. + let plotH = h - padT - padB + let refH = 0.46 * plotH // reference height = ideal-pace bar height + let idealPerPeriod = 100.0 / Double(self.periods) + let burnRate = tNow > 0.001 ? (100.0 - vNow) / tNow : 0.0 // %/unit-t + let slotW = (w - padL - padR) / CGFloat(self.periods) + + for i in 0.. 0 { + let rect = CGRect( + x: slotX, + y: h - padB - baseH, + width: slotW - 1, + height: baseH) + context.fill(Path(rect), with: .color(barColor.opacity(0.17))) + } + // Overage segment — above ideal reference line + if totalBarH > refH { + let overH = totalBarH - refH + let rect = CGRect( + x: slotX, + y: h - padB - totalBarH, + width: slotW - 1, + height: overH) + context.fill(Path(rect), with: .color(barColor.opacity(0.34))) + } + } else if slotStart < tNow { + // Current (partial) period — narrower bar ending at tNow + let partialFrac = (tNow - slotStart) / (slotEnd - slotStart) + let consumed = burnRate * (tNow - slotStart) + let ratio = consumed / idealPerPeriod + let totalBarH = CGFloat(ratio) * refH + let baseH = min(totalBarH, refH) + let barW = CGFloat(partialFrac) * (slotW - 1) + + if baseH > 0 { + let rect = CGRect( + x: slotX, + y: h - padB - baseH, + width: barW, + height: baseH) + context.fill(Path(rect), with: .color(barColor.opacity(0.13))) + } + if totalBarH > refH { + let overH = totalBarH - refH + let rect = CGRect( + x: slotX, + y: h - padB - totalBarH, + width: barW, + height: overH) + context.fill(Path(rect), with: .color(barColor.opacity(0.26))) + } + } else { + // Future period — faint full-height placeholder + let rect = CGRect( + x: slotX, + y: h - padB - refH, + width: slotW - 1, + height: refH) + context.fill(Path(rect), with: .color(barColor.opacity(0.045))) + } + } + + // --- Now vertical hairline --- + do { + var p = Path() + p.move(to: CGPoint(x: X(tNow), y: Y(100))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(0))) + context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1) + } + + // --- Baseline --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(0))) + p.addLine(to: CGPoint(x: X(1), y: Y(0))) + context.stroke(p, with: .color(self.theme.chartGrid), lineWidth: 1) + } + + // --- Ideal line (dashed, recedes) --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(100))) + p.addLine(to: CGPoint(x: X(1), y: Y(0))) + context.stroke( + p, + with: .color(self.theme.chartIdeal), + style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [2.5, 3])) + } + + // --- Projection (fine dotted) --- + if self.geom.slope < -0.01 { + var p = Path() + p.move(to: CGPoint(x: X(tNow), y: Y(vNow))) + p.addLine(to: CGPoint(x: X(self.geom.projT), y: Y(self.geom.projV))) + context.stroke( + p, + with: .color(self.theme.chartProj.opacity(0.95)), + style: StrokeStyle(lineWidth: 1.6, lineCap: .round, dash: [0.5, 3.5])) + } + + // --- Actual line (solid, dominant) --- + do { + var p = Path() + p.move(to: CGPoint(x: X(0), y: Y(100))) + p.addLine(to: CGPoint(x: X(tNow), y: Y(vNow))) + context.stroke( + p, + with: .color(self.theme.chartLine), + style: StrokeStyle(lineWidth: 2.4, lineCap: .round, lineJoin: .round)) + } + + // --- Now dot (filled, ringed) --- + let dotCenter = CGPoint(x: X(tNow), y: Y(vNow)) + let ringPath = Path(ellipseIn: CGRect( + x: dotCenter.x - 5.4, y: dotCenter.y - 5.4, width: 10.8, height: 10.8)) + context.fill(ringPath, with: .color(self.theme.chartNowRing)) + let dotPath = Path(ellipseIn: CGRect( + x: dotCenter.x - 3.4, y: dotCenter.y - 3.4, width: 6.8, height: 6.8)) + context.fill(dotPath, with: .color(self.theme.chartNowDot)) + } + } +} + +// MARK: - Compact reset time helper + +/// Formats a reset date compactly: "4:30p", "5p", "Sun 9a". +/// Weekday prefix is added for the 7-day window or when the reset is ≥20h away. +private func combinedCompactResetTime(_ date: Date, isDailyWindow: Bool) -> String { + let includeDay = isDailyWindow || date.timeIntervalSinceNow >= 20 * 3600 + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate(includeDay ? "EEEjm" : "jm") + return formatter.string(from: date) +} diff --git a/Tests/CodexBarTests/APITokenFetchStrategyTests.swift b/Tests/CodexBarTests/APITokenFetchStrategyTests.swift new file mode 100644 index 0000000000..f1eea0b207 --- /dev/null +++ b/Tests/CodexBarTests/APITokenFetchStrategyTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private enum APITokenStrategyTestError: Error { + case missingCredentials +} + +private struct APITokenStrategyStubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw APITokenStrategyTestError.missingCredentials + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } +} + +struct APITokenFetchStrategyTests { + @Test + func `missing token is unavailable and preserves provider error`() async { + let strategy = Self.makeStrategy() + let context = Self.makeContext(environment: [:]) + + #expect(await strategy.isAvailable(context) == false) + await #expect(throws: APITokenStrategyTestError.missingCredentials) { + try await strategy.fetch(context) + } + } + + @Test + func `resolved token loads usage and stamps result metadata`() async throws { + let strategy = Self.makeStrategy() + let context = Self.makeContext(environment: ["TEST_API_KEY": "test-token"]) + + #expect(await strategy.isAvailable(context)) + let result = try await strategy.fetch(context) + + #expect(result.strategyID == "test.api") + #expect(result.strategyKind == .apiToken) + #expect(result.sourceLabel == "test-source") + #expect(result.usage.updatedAt == Date(timeIntervalSince1970: 42)) + #expect(strategy.shouldFallback(on: APITokenStrategyTestError.missingCredentials, context: context) == false) + } + + private static func makeStrategy() -> APITokenFetchStrategy { + APITokenFetchStrategy( + id: "test.api", + sourceLabel: "test-source", + resolveToken: { $0["TEST_API_KEY"] }, + missingCredentialsError: { APITokenStrategyTestError.missingCredentials }, + loadUsage: { token, context in + UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: token == context.env["TEST_API_KEY"] + ? Date(timeIntervalSince1970: 42) + : Date.distantFuture) + }) + } + + private static func makeContext(environment: [String: String]) -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: nil, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: APITokenStrategyStubClaudeFetcher(), + browserDetection: browserDetection) + } +} diff --git a/Tests/CodexBarTests/AbacusProviderTests.swift b/Tests/CodexBarTests/AbacusProviderTests.swift new file mode 100644 index 0000000000..8aca07d2b9 --- /dev/null +++ b/Tests/CodexBarTests/AbacusProviderTests.swift @@ -0,0 +1,289 @@ +import Foundation +import Testing +@testable import CodexBarCore + +// MARK: - Descriptor Tests + +struct AbacusDescriptorTests { + @Test + func `descriptor has correct identity`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.id == .abacus) + #expect(descriptor.metadata.displayName == "Abacus AI") + #expect(descriptor.metadata.cliName == "abacusai") + } + + @Test + func `descriptor does not expose a separate credits panel`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.supportsCredits == false) + #expect(meta.supportsOpus == false) + } + + @Test + func `descriptor is not primary provider`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.isPrimaryProvider == false) + #expect(meta.defaultEnabled == false) + } + + @Test + func `descriptor supports auto and web source modes`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.fetchPlan.sourceModes.contains(.auto)) + #expect(descriptor.fetchPlan.sourceModes.contains(.web)) + } + + @Test + func `descriptor has no version detector`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.cli.versionDetector == nil) + } + + @Test + func `descriptor does not support token cost`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.tokenCost.supportsTokenCost == false) + } + + @Test + func `cli aliases include abacus-ai`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.cli.aliases.contains("abacus-ai")) + } + + @Test + func `dashboard url points to compute points page`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.dashboardURL?.contains("compute-points") == true) + } +} + +// MARK: - Usage Snapshot Conversion Tests + +struct AbacusUsageSnapshotTests { + @Test + func `converts full snapshot to usage snapshot`() throws { + let resetDate = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = AbacusUsageSnapshot( + creditsUsed: 250, + creditsTotal: 1000, + resetsAt: resetDate, + planName: "Pro") + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary != nil) + #expect(abs((usage.primary?.usedPercent ?? 0) - 25.0) < 0.01) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits") + #expect(usage.primary?.resetsAt == resetDate) + // Window derived from actual billing cycle (1 calendar month before resetDate) + let cycleStart = try #require(Calendar.current.date(byAdding: .month, value: -1, to: resetDate)) + let expectedMinutes = Int(resetDate.timeIntervalSince(cycleStart) / 60) + #expect(usage.primary?.windowMinutes == expectedMinutes) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.identity?.providerID == .abacus) + #expect(usage.identity?.loginMethod == "Pro") + } + + @Test + func `handles zero usage`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 500, + resetsAt: nil, + planName: "Basic") + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + #expect(usage.primary?.resetDescription == "0 / 500 credits") + } + + @Test + func `handles full usage`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 1000, + creditsTotal: 1000, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? 0) - 100.0) < 0.01) + #expect(usage.primary?.resetDescription == "1,000 / 1,000 credits") + } + + @Test + func `handles nil credits gracefully`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: nil, + creditsTotal: nil, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + #expect(usage.primary?.resetDescription == nil) + } + + @Test + func `handles nil total with non-nil used`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 100, + creditsTotal: nil, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + } + + @Test + func `handles zero total credits`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 0, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + } + + @Test + func `formats large credit values with comma grouping`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 12345, + creditsTotal: 50000, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "12,345 / 50,000 credits") + } + + @Test + func `formats fractional credit values`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 42.5, + creditsTotal: 100, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "42.5 / 100 credits") + } + + @Test + func `window minutes represents monthly cycle`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 100, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + // 30 days * 24 hours * 60 minutes = 43200 + #expect(usage.primary?.windowMinutes == 43200) + } + + @Test + func `identity has no email or organization`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 100, + resetsAt: nil, + planName: "Pro") + + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.accountEmail == nil) + #expect(usage.identity?.accountOrganization == nil) + } +} + +// MARK: - Error Description Tests + +struct AbacusErrorTests { + @Test + func `noSessionCookie error mentions login`() { + let error = AbacusUsageError.noSessionCookie + #expect(error.errorDescription?.contains("log in") == true) + } + + @Test + func `sessionExpired error mentions expired`() { + let error = AbacusUsageError.sessionExpired + #expect(error.errorDescription?.contains("expired") == true) + } + + @Test + func `networkError includes message`() { + let error = AbacusUsageError.networkError("HTTP 500") + #expect(error.errorDescription?.contains("HTTP 500") == true) + } + + @Test + func `parseFailed includes message`() { + let error = AbacusUsageError.parseFailed("Invalid JSON") + #expect(error.errorDescription?.contains("Invalid JSON") == true) + } + + @Test + func `unauthorized error mentions login`() { + let error = AbacusUsageError.unauthorized + #expect(error.errorDescription?.contains("log in") == true) + } +} + +// MARK: - Error Classification Tests + +struct AbacusErrorClassificationTests { + @Test + func `unauthorized is recoverable and auth related`() { + let error = AbacusUsageError.unauthorized + #expect(error.isRecoverable == true) + #expect(error.isAuthRelated == true) + } + + @Test + func `sessionExpired is recoverable and auth related`() { + let error = AbacusUsageError.sessionExpired + #expect(error.isRecoverable == true) + #expect(error.isAuthRelated == true) + } + + @Test + func `parseFailed is not recoverable`() { + let error = AbacusUsageError.parseFailed("bad json") + #expect(error.isRecoverable == false) + #expect(error.isAuthRelated == false) + #expect(error.shouldTryNextImportedSession == true) + #expect(error.shouldClearCachedCookie == true) + } + + @Test + func `networkError is not recoverable`() { + let error = AbacusUsageError.networkError("timeout") + #expect(error.isRecoverable == false) + #expect(error.isAuthRelated == false) + #expect(error.shouldTryNextImportedSession == true) + #expect(error.shouldClearCachedCookie == false) + } + + @Test + func `noSessionCookie is not recoverable`() { + let error = AbacusUsageError.noSessionCookie + #expect(error.isRecoverable == false) + #expect(error.isAuthRelated == false) + #expect(error.shouldTryNextImportedSession == false) + #expect(error.shouldClearCachedCookie == false) + } + + @Test + func `auth failures continue imported session scanning`() { + #expect(AbacusUsageError.unauthorized.shouldTryNextImportedSession == true) + #expect(AbacusUsageError.sessionExpired.shouldTryNextImportedSession == true) + #expect(AbacusUsageError.unauthorized.shouldClearCachedCookie == true) + #expect(AbacusUsageError.sessionExpired.shouldClearCachedCookie == true) + } +} diff --git a/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift new file mode 100644 index 0000000000..eaa30a0e83 --- /dev/null +++ b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift @@ -0,0 +1,142 @@ +import Foundation +import os.lock +import Testing +@testable import CodexBarCore + +#if os(macOS) +import SweetCookieKit + +@Suite(.serialized) +struct AlibabaCodingPlanCookieImporterTests { + @Test(.disabled( + if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1", + "Default-home cookie access is explicitly enabled for this test run.")) + func `default home import is suppressed before profile and keychain access`() throws { + let profileProbeCount = OSAllocatedUnfairLock(initialState: 0) + let keychainProbeCount = OSAllocatedUnfairLock(initialState: 0) + let defaultHome = try #require(BrowserCookieClient.defaultHomeDirectories().first) + let detection = BrowserDetection( + homeDirectory: defaultHome.path, + cacheTTL: 0, + fileExists: { _ in + profileProbeCount.withLock { $0 += 1 } + return true + }, + directoryContents: { _ in + profileProbeCount.withLock { $0 += 1 } + return ["Default"] + }) + + _ = KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + keychainProbeCount.withLock { $0 += 1 } + return .allowed + } operation: { + #expect(throws: AlibabaCodingPlanSettingsError.self) { + _ = try AlibabaCodingPlanCookieImporter.importSession(browserDetection: detection) + } + } + } + + #expect(profileProbeCount.withLock { $0 } == 0) + #expect(keychainProbeCount.withLock { $0 } == 0) + } + + @Test(.disabled( + if: ProcessInfo.processInfo.environment[BrowserCookieAccessGate.allowTestCookieAccessEnvironmentKey] == "1", + "Default-home cookie access is explicitly enabled for this test run.")) + func `chromium fallback rejects default client before keychain access`() { + let keychainProbeCount = OSAllocatedUnfairLock(initialState: 0) + _ = KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + keychainProbeCount.withLock { $0 += 1 } + return .allowed + } operation: { + #expect(throws: BrowserCookieStoreAccessSuppressedError.self) { + _ = try AlibabaChromiumCookieFallbackImporter.importSession( + browser: .chrome, + domains: ["example.com"]) + } + } + } + #expect(keychainProbeCount.withLock { $0 } == 0) + } + + @Test + func `domain matching requires exact or label bounded suffix`() { + #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("console.aliyun.com")) + #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain(".modelstudio.console.alibabacloud.com")) + #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("foo.aliyun.com")) + #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("evilaliyun.com") == false) + #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("notalibabacloud.com") == false) + } + + @Test + func `cookie import candidates honor provided browser order`() throws { + BrowserCookieAccessGate.resetForTesting() + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let firefoxProfile = temp + .appendingPathComponent("Library") + .appendingPathComponent("Application Support") + .appendingPathComponent("Firefox") + .appendingPathComponent("Profiles") + .appendingPathComponent("abc.default-release") + try FileManager.default.createDirectory(at: firefoxProfile, withIntermediateDirectories: true) + FileManager.default.createFile( + atPath: firefoxProfile.appendingPathComponent("cookies.sqlite").path, + contents: Data()) + + let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let importOrder: BrowserCookieImportOrder = [.firefox, .safari, .chrome] + + let candidates = AlibabaCodingPlanCookieImporter.cookieImportCandidates( + browserDetection: detection, + importOrder: importOrder) + + let expected: [Browser] = [.firefox, .safari] + #expect(candidates == expected) + } + + @Test + func `default cookie import candidates skip keychain browsers during tests`() throws { + BrowserCookieAccessGate.resetForTesting() + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let chromeProfile = temp + .appendingPathComponent("Library") + .appendingPathComponent("Application Support") + .appendingPathComponent("Google") + .appendingPathComponent("Chrome") + .appendingPathComponent("Default") + try FileManager.default.createDirectory(at: chromeProfile, withIntermediateDirectories: true) + let cookiesDir = chromeProfile.appendingPathComponent("Network") + try FileManager.default.createDirectory(at: cookiesDir, withIntermediateDirectories: true) + FileManager.default.createFile( + atPath: cookiesDir.appendingPathComponent("Cookies").path, + contents: Data()) + + let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let candidates = AlibabaCodingPlanCookieImporter.cookieImportCandidates(browserDetection: detection) + + #expect(candidates.first == .safari) + #expect(candidates.contains(.chrome) == false) + } +} + +#else + +struct AlibabaCodingPlanCookieImporterTests { + @Test + func `non mac OS placeholder`() { + #expect(true) + } +} + +#endif diff --git a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift new file mode 100644 index 0000000000..5e7534e31f --- /dev/null +++ b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift @@ -0,0 +1,1143 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AlibabaCodingPlanSettingsReaderTests { + @Test + func `api token reads from environment`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: ["ALIBABA_CODING_PLAN_API_KEY": "abc123"]) + #expect(token == "abc123") + } + + @Test + func `api token reads qwen alias from environment`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: ["ALIBABA_QWEN_API_KEY": "qwen123"]) + #expect(token == "qwen123") + } + + @Test + func `api token reads dashscope alias from environment`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: ["DASHSCOPE_API_KEY": "dashscope123"]) + #expect(token == "dashscope123") + } + + @Test + func `api token prefers coding plan key over aliases`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: [ + "ALIBABA_CODING_PLAN_API_KEY": "coding-plan", + "ALIBABA_QWEN_API_KEY": "qwen", + "DASHSCOPE_API_KEY": "dashscope", + ]) + #expect(token == "coding-plan") + } + + @Test + func `api token strips quotes`() { + let token = AlibabaCodingPlanSettingsReader + .apiToken(environment: ["ALIBABA_CODING_PLAN_API_KEY": "\"token-xyz\""]) + #expect(token == "token-xyz") + } + + @Test + func `quota URL infers scheme`() { + let url = AlibabaCodingPlanSettingsReader + .quotaURL(environment: [AlibabaCodingPlanSettingsReader + .quotaURLKey: "modelstudio.console.alibabacloud.com/data/api.json"]) + #expect(url?.absoluteString == "https://modelstudio.console.alibabacloud.com/data/api.json") + } + + @Test + func `endpoint overrides allow custom https hosts by default`() { + let env = [ + AlibabaCodingPlanSettingsReader.hostKey: "https://attacker.example", + AlibabaCodingPlanSettingsReader.quotaURLKey: "https://attacker.example/data/api.json", + ] + + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == "attacker.example") + #expect(AlibabaCodingPlanSettingsReader.quotaURL(environment: env)?.host == "attacker.example") + #expect(AlibabaCodingPlanSettingsReader.rejectedEndpointOverrideKey(environment: env) == nil) + } + + @Test + func `host endpoint overrides preserve explicit port`() { + let env = [AlibabaCodingPlanSettingsReader.hostKey: "proxy.example.test:8443"] + + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == "proxy.example.test:8443") + #expect( + AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env).absoluteString == + "https://proxy.example.test:8443/data/api.json?action=zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2&product=broadscope-bailian&api=queryCodingPlanInstanceInfoV2¤tRegionId=ap-southeast-1") + #expect( + AlibabaCodingPlanUsageFetcher.resolveConsoleDashboardURL(region: .international, environment: env) + .absoluteString + .hasPrefix("https://proxy.example.test:8443/") == true) + } + + @Test + func `endpoint overrides reject encoded host delimiters before suffix matching`() { + let encodedSlash = "https://attacker.example%2f.modelstudio.console.alibabacloud.com" + let doubleEncodedSlash = "https://attacker.example%252f.modelstudio.console.alibabacloud.com" + let env = [ + AlibabaCodingPlanSettingsReader.hostKey: encodedSlash, + AlibabaCodingPlanSettingsReader.quotaURLKey: "\(encodedSlash)/data/api.json", + ] + + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == nil) + #expect(AlibabaCodingPlanSettingsReader.quotaURL(environment: env) == nil) + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: [ + AlibabaCodingPlanSettingsReader.hostKey: doubleEncodedSlash, + ]) == nil) + } + + @Test + func `endpoint overrides reject whitespace and control characters in hosts`() { + for host in ["https://bad host", "https://bad%20host", "https://bad%09host"] { + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: [ + AlibabaCodingPlanSettingsReader.hostKey: host, + ]) == nil) + #expect(AlibabaCodingPlanSettingsReader.quotaURL(environment: [ + AlibabaCodingPlanSettingsReader.quotaURLKey: "\(host)/data/api.json", + ]) == nil) + } + } + + @Test + func `endpoint overrides require https and no userinfo`() { + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: [ + AlibabaCodingPlanSettingsReader.hostKey: "http://modelstudio.console.alibabacloud.com", + ]) == nil) + #expect(AlibabaCodingPlanSettingsReader.quotaURL(environment: [ + AlibabaCodingPlanSettingsReader.quotaURLKey: + "https://user:pass@modelstudio.console.alibabacloud.com/data/api.json", + ]) == nil) + } + + @Test + func `strict provider endpoint mode rejects custom hosts`() { + let env = [ + AlibabaCodingPlanSettingsReader.requireProviderEndpointOverridesKey: "true", + AlibabaCodingPlanSettingsReader.hostKey: "proxy.example.test", + AlibabaCodingPlanSettingsReader.quotaURLKey: "https://proxy.example.test/data/api.json", + ] + + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == nil) + #expect(AlibabaCodingPlanSettingsReader.quotaURL(environment: env) == nil) + #expect(AlibabaCodingPlanSettingsReader + .rejectedEndpointOverrideKey(environment: env) == AlibabaCodingPlanSettingsReader.hostKey) + } + + @Test + func `strict provider endpoint mode rejects customer controlled Alibaba Cloud hosts`() { + let env = [ + AlibabaCodingPlanSettingsReader.requireProviderEndpointOverridesKey: "true", + AlibabaCodingPlanSettingsReader.hostKey: "tenant.cn-beijing.fc.aliyuncs.com", + ] + + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == nil) + #expect(AlibabaCodingPlanSettingsReader + .rejectedEndpointOverrideKey(environment: env) == AlibabaCodingPlanSettingsReader.hostKey) + } + + @Test + func `strict provider endpoint mode accepts known Coding Plan hosts`() { + let env = [ + AlibabaCodingPlanSettingsReader.requireProviderEndpointOverridesKey: "true", + AlibabaCodingPlanSettingsReader.hostKey: "bailian-beijing-cs.aliyuncs.com", + ] + + #expect( + AlibabaCodingPlanSettingsReader.hostOverride(environment: env) == + "bailian-beijing-cs.aliyuncs.com") + #expect(AlibabaCodingPlanSettingsReader.rejectedEndpointOverrideKey(environment: env) == nil) + } + + @Test + func `custom https compatibility mode still rejects http and userinfo`() { + #expect(AlibabaCodingPlanSettingsReader.hostOverride(environment: [ + AlibabaCodingPlanSettingsReader.hostKey: "http://proxy.example.test", + ]) == nil) + #expect(AlibabaCodingPlanSettingsReader.rejectedEndpointOverrideKey(environment: [ + AlibabaCodingPlanSettingsReader.quotaURLKey: "https://user:pass@proxy.example.test/data/api.json", + ]) == AlibabaCodingPlanSettingsReader.quotaURLKey) + } + + @Test + func `missing cookie error includes access hint when present`() { + let error = AlibabaCodingPlanSettingsError + .missingCookie(details: "Safari cookie file exists but is not readable.") + #expect(error.errorDescription?.contains("Safari cookie file exists but is not readable.") == true) + } +} + +struct AlibabaCodingPlanUsageSnapshotTests { + @Test + func `maps usage snapshot windows`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset5h = Date(timeIntervalSince1970: 1_700_000_300) + let resetWeek = Date(timeIntervalSince1970: 1_700_010_000) + let resetMonth = Date(timeIntervalSince1970: 1_700_100_000) + let snapshot = AlibabaCodingPlanUsageSnapshot( + planName: "Pro", + fiveHourUsedQuota: 20, + fiveHourTotalQuota: 100, + fiveHourNextRefreshTime: reset5h, + weeklyUsedQuota: 120, + weeklyTotalQuota: 400, + weeklyNextRefreshTime: resetWeek, + monthlyUsedQuota: 500, + monthlyTotalQuota: 2000, + monthlyNextRefreshTime: resetMonth, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 20) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 30) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 25) + #expect(usage.tertiary?.windowMinutes == 43200) + #expect(usage.loginMethod(for: .alibaba) == "Pro") + } + + @Test + func `shifts primary reset forward when backend reset is not future`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let stalePrimaryReset = Date(timeIntervalSince1970: 1_699_999_900) + let snapshot = AlibabaCodingPlanUsageSnapshot( + planName: "Lite", + fiveHourUsedQuota: 70, + fiveHourTotalQuota: 1200, + fiveHourNextRefreshTime: stalePrimaryReset, + weeklyUsedQuota: 80, + weeklyTotalQuota: 9000, + weeklyNextRefreshTime: Date(timeIntervalSince1970: 1_700_010_000), + monthlyUsedQuota: 80, + monthlyTotalQuota: 18000, + monthlyNextRefreshTime: Date(timeIntervalSince1970: 1_700_100_000), + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetsAt == stalePrimaryReset.addingTimeInterval(TimeInterval(5 * 60 * 60))) + } +} + +struct AlibabaCodingPlanUsageParsingTests { + @Test + func `parses quota payload`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { "planName": "Alibaba Coding Plan Pro" } + ], + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 52, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000, + "perWeekUsedQuota": 800, + "perWeekTotalQuota": 5000, + "perWeekQuotaNextRefreshTime": 1700100000000, + "perBillMonthUsedQuota": 1200, + "perBillMonthTotalQuota": 20000, + "perBillMonthQuotaNextRefreshTime": 1701000000000 + } + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Alibaba Coding Plan Pro") + #expect(snapshot.fiveHourUsedQuota == 52) + #expect(snapshot.fiveHourTotalQuota == 1000) + #expect(snapshot.weeklyTotalQuota == 5000) + #expect(snapshot.monthlyTotalQuota == 20000) + #expect(snapshot.fiveHourNextRefreshTime == Date(timeIntervalSince1970: 1_700_000_300)) + } + + @Test + func `multi instance quota payload uses selected active instance plan name`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Expired Starter", + "status": "EXPIRED", + "endTime": "2025-04-01 17:00", + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 7, + "per5HourTotalQuota": 100, + "per5HourQuotaNextRefreshTime": 1700000100000 + } + }, + { + "planName": "Active Pro", + "status": "VALID", + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 52, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000 + } + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Active Pro") + #expect(snapshot.fiveHourUsedQuota == 52) + #expect(snapshot.fiveHourTotalQuota == 1000) + #expect(snapshot.fiveHourNextRefreshTime == Date(timeIntervalSince1970: 1_700_000_300)) + } + + @Test + func `missing quota data without positive active signal fails`() { + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { "planName": "Alibaba Coding Plan Pro" } + ] + }, + "status_code": 0 + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.self) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `plan usage without positive active proof fails`() { + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Alibaba Coding Plan Pro", + "planUsage": "18%" + } + ] + }, + "status_code": 0 + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.self) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `parses wrapped JSON string payload`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let inner = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Coding Plan Lite", + "status": "VALID", + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 0, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000 + } + } + ] + }, + "statusCode": 200 + } + """ + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "\"", with: "\\\"") + + let wrapped = """ + { + "successResponse": { + "body": "\(inner)" + } + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(wrapped.utf8), now: now) + + #expect(snapshot.planName == "Coding Plan Lite") + #expect(snapshot.fiveHourTotalQuota == 1000) + #expect(snapshot.fiveHourUsedQuota == 0) + } + + @Test + func `plan usage fallback stays visible but non quantitative`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Coding Plan Lite", + "status": "VALID", + "planUsage": "0%", + "endTime": "2026-04-01 17:00" + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Coding Plan Lite") + #expect(snapshot.fiveHourUsedQuota == nil) + #expect(snapshot.fiveHourTotalQuota == nil) + #expect(snapshot.fiveHourNextRefreshTime == nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibaba) == "Coding Plan Lite") + } + + @Test + func `falls back to active plan when quota and usage missing`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Coding Plan Lite", + "status": "VALID", + "endTime": "2026-04-01 17:00" + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Coding Plan Lite") + #expect(snapshot.fiveHourUsedQuota == nil) + #expect(snapshot.fiveHourTotalQuota == nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibaba) == "Coding Plan Lite") + } + + @Test + func `future end time counts as positive active signal`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Coding Plan Lite", + "endTime": "2030-04-01 17:00" + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Coding Plan Lite") + #expect(snapshot.fiveHourUsedQuota == nil) + #expect(snapshot.weeklyTotalQuota == nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibaba) == "Coding Plan Lite") + } + + @Test + func `multi instance fallback uses selected active instance plan name`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Expired Starter", + "status": "EXPIRED", + "endTime": "2025-04-01 17:00" + }, + { + "planName": "Active Pro", + "status": "VALID", + "planUsage": "42%" + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Active Pro") + #expect(snapshot.fiveHourUsedQuota == nil) + #expect(snapshot.fiveHourTotalQuota == nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibaba) == "Active Pro") + } + + @Test + func `active instance without quota does not borrow quota from another instance`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Expired Starter", + "status": "EXPIRED", + "endTime": "2025-04-01 17:00", + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 7, + "per5HourTotalQuota": 100, + "per5HourQuotaNextRefreshTime": 1700000100000 + } + }, + { + "planName": "Active Pro", + "status": "VALID" + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Active Pro") + #expect(snapshot.fiveHourUsedQuota == nil) + #expect(snapshot.fiveHourTotalQuota == nil) + #expect(snapshot.fiveHourNextRefreshTime == nil) + } + + @Test + func `payload level active proof does not label first instance when no instance is active`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "status": "VALID", + "codingPlanInstanceInfos": [ + { + "planName": "Expired Starter", + "status": "EXPIRED", + "endTime": "2025-04-01 17:00" + }, + { + "planName": "No Proof Pro" + } + ] + }, + "status_code": 0 + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.self) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + } + } + + @Test + func `does not fallback for inactive plan without quota`() { + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Coding Plan Lite", + "status": "EXPIRED" + } + ] + }, + "status_code": 0 + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.self) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `console need login payload maps to login required`() { + let json = """ + { + "code": "ConsoleNeedLogin", + "message": "You need to log in.", + "requestId": "abc", + "successResponse": false + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.loginRequired) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `console need login payload maps to unavailable API key mode`() { + let json = """ + { + "code": "ConsoleNeedLogin", + "message": "You need to log in.", + "requestId": "abc", + "successResponse": false + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.apiKeyUnavailableInRegion) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot( + from: Data(json.utf8), + authMode: .apiKey) + } + } +} + +@Suite(.serialized) +struct AlibabaCodingPlanFallbackTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext( + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot? = nil, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: browserDetection) + } + + @Test + func `falls back on TLS failure in auto mode`() { + let strategy = AlibabaCodingPlanWebFetchStrategy() + let context = self.makeContext(sourceMode: .auto) + #expect(strategy.shouldFallback(on: URLError(.secureConnectionFailed), context: context)) + } + + @Test + func `does not fallback on TLS failure when source forced to web`() { + let strategy = AlibabaCodingPlanWebFetchStrategy() + let context = self.makeContext(sourceMode: .web) + #expect(strategy.shouldFallback(on: URLError(.secureConnectionFailed), context: context) == false) + } + + @Test + func `auto mode does not borrow manual cookie authority when browser import fails`() { + let strategy = AlibabaCodingPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: "session=manual-cookie", + apiRegion: .international)) + let context = self.makeContext(sourceMode: .auto, settings: settings) + + CookieHeaderCache.clear(provider: .alibaba) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie() + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + } + + do { + _ = try AlibabaCodingPlanWebFetchStrategy.resolveCookieHeader(context: context, allowCached: false) + Issue.record("Expected auto mode to fail instead of borrowing the manual cookie header") + } catch let error as AlibabaCodingPlanSettingsError { + guard case .missingCookie = error else { + Issue.record("Expected missingCookie, got \(error)") + return + } + #expect(strategy.shouldFallback(on: error, context: context)) + } catch { + Issue.record("Expected AlibabaCodingPlanSettingsError, got \(error)") + } + } + + @Test + func `auto mode skips web when no alibaba session is available`() async { + let strategy = AlibabaCodingPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil, + apiRegion: .international)) + let context = self.makeContext( + sourceMode: .auto, + settings: settings, + env: [AlibabaCodingPlanSettingsReader.apiTokenKey: "token-abc"]) + + CookieHeaderCache.clear(provider: .alibaba) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie() + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + } + + #expect(await strategy.isAvailable(context) == false) + } +} + +struct AlibabaCodingPlanRegionTests { + @Test + func `defaults to international endpoint`() { + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: [:]) + #expect(url.host == "modelstudio.console.alibabacloud.com") + #expect(url.path == "/data/api.json") + } + + @Test + func `uses china mainland host`() { + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .chinaMainland, environment: [:]) + #expect(url.host == "bailian.console.aliyun.com") + } + + @Test + func `host override wins for quota URL`() { + let env = [AlibabaCodingPlanSettingsReader.hostKey: "custom.aliyun.com"] + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env) + #expect(url.host == "custom.aliyun.com") + #expect(url.path == "/data/api.json") + } + + @Test + func `host override uses selected region for quota URL`() { + let env = [AlibabaCodingPlanSettingsReader.hostKey: "custom.aliyun.com"] + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .chinaMainland, environment: env) + #expect(url.host == "custom.aliyun.com") + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let currentRegion = components?.queryItems?.first(where: { $0.name == "currentRegionId" })?.value + #expect(currentRegion == AlibabaCodingPlanAPIRegion.chinaMainland.currentRegionID) + } + + @Test + func `bare host override builds console dashboard URL`() { + let env = [AlibabaCodingPlanSettingsReader.hostKey: "custom.aliyun.com"] + let url = AlibabaCodingPlanUsageFetcher.resolveConsoleDashboardURL(region: .international, environment: env) + #expect(url.scheme == "https") + #expect(url.host == "custom.aliyun.com") + #expect(url.path == AlibabaCodingPlanAPIRegion.international.dashboardURL.path) + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let tab = components?.queryItems?.first(where: { $0.name == "tab" })?.value + #expect(tab == "coding-plan") + } + + @Test + func `quota url override beats host`() { + let env = [ + AlibabaCodingPlanSettingsReader.quotaURLKey: + "https://modelstudio.console.alibabacloud.com/custom/quota", + ] + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env) + #expect(url.absoluteString == "https://modelstudio.console.alibabacloud.com/custom/quota") + } + + @Test + func `custom quota url override is preserved by default`() { + let env = [AlibabaCodingPlanSettingsReader.quotaURLKey: "https://attacker.example/custom/quota"] + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env) + #expect(url.host == "attacker.example") + } + + @Test + func `strict provider endpoint mode falls back to provider endpoint`() { + let env = [ + AlibabaCodingPlanSettingsReader.requireProviderEndpointOverridesKey: "true", + AlibabaCodingPlanSettingsReader.quotaURLKey: "https://attacker.example/custom/quota", + ] + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env) + #expect(url.host == AlibabaCodingPlanAPIRegion.international.quotaURL.host) + } + + @Test + func `explicit endpoint override rejects invalid api scheme before network`() async { + await #expect(throws: ProviderEndpointOverrideError.alibabaCodingPlan( + AlibabaCodingPlanSettingsReader.quotaURLKey)) + { + _ = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + apiKey: "cpk-test", + environment: [AlibabaCodingPlanSettingsReader + .quotaURLKey: "http://modelstudio.console.alibabacloud.com/custom/quota"]) + } + } + + @Test + func `explicit endpoint override rejects invalid cookie scheme before network`() async { + await #expect(throws: ProviderEndpointOverrideError.alibabaCodingPlan( + AlibabaCodingPlanSettingsReader.quotaURLKey)) + { + _ = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + cookieHeader: "login_aliyunid_ticket=ticket; login_aliyunid_pk=user", + environment: [AlibabaCodingPlanSettingsReader + .quotaURLKey: "http://modelstudio.console.alibabacloud.com/custom/quota"]) + } + } +} + +@Suite(.serialized) +struct AlibabaCodingPlanUsageFetcherRequestTests { + @Test + func `api401 maps to invalid credentials`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + let (response, data) = Self.makeResponse( + url: url, + body: #"{"message":"unauthorized"}"#, + statusCode: 401) + return (data, response) + } + + await #expect(throws: AlibabaCodingPlanUsageError.invalidCredentials) { + _ = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + apiKey: "cpk-test", + region: .chinaMainland, + environment: [ + AlibabaCodingPlanSettingsReader.quotaURLKey: "https://bailian.console.aliyun.com/data/api.json", + ], + transport: transport) + } + } + + @Test + func `cookie SEC token fallback survives user info request failure`() async throws { + let registered = URLProtocol.registerClass(AlibabaConsoleSECTokenStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(AlibabaConsoleSECTokenStubURLProtocol.self) + } + AlibabaConsoleSECTokenStubURLProtocol.handler = nil + } + + AlibabaConsoleSECTokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "modelstudio.console.alibabacloud.com", request.httpMethod == "GET" { + return Self.makeResponse(url: url, body: "", statusCode: 200) + } + + if url.host == "modelstudio.console.alibabacloud.com", url.path == "/tool/user/info.json" { + throw URLError(.timedOut) + } + + if url.host == "bailian-singapore-cs.alibabacloud.com", request.httpMethod == "POST" { + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=cookie-sec-token")) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { "planName": "Alibaba Coding Plan Pro", "status": "VALID" } + ], + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 52, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000 + } + }, + "status_code": 0 + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + + let snapshot = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + cookieHeader: "sec_token=cookie-sec-token; login_aliyunid_ticket=ticket; login_aliyunid_pk=user", + region: .international, + environment: [:], + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.planName == "Alibaba Coding Plan Pro") + #expect(snapshot.fiveHourUsedQuota == 52) + #expect(snapshot.fiveHourTotalQuota == 1000) + } + + @Test + func `host override applies to user info SEC token fallback`() async throws { + let registered = URLProtocol.registerClass(AlibabaConsoleSECTokenStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(AlibabaConsoleSECTokenStubURLProtocol.self) + } + AlibabaConsoleSECTokenStubURLProtocol.handler = nil + } + + AlibabaConsoleSECTokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.host == "modelstudio.console.alibabacloud.com") + + if request.httpMethod == "GET", url.path == AlibabaCodingPlanAPIRegion.international.dashboardURL.path { + return Self.makeResponse(url: url, body: "", statusCode: 200) + } + + if request.httpMethod == "GET", url.path == "/tool/user/info.json" { + return Self.makeResponse( + url: url, + body: #"{"data":{"secToken":"override-sec-token"}}"#, + statusCode: 200) + } + + if request.httpMethod == "POST", url.path == "/data/api.json" { + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=override-sec-token")) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { "planName": "Alibaba Coding Plan Pro", "status": "VALID" } + ], + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 21, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000 + } + }, + "status_code": 0 + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + + let snapshot = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + cookieHeader: "sec_token=cookie-sec-token; login_aliyunid_ticket=ticket; login_aliyunid_pk=user", + region: .international, + environment: [AlibabaCodingPlanSettingsReader.hostKey: "https://modelstudio.console.alibabacloud.com"], + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.planName == "Alibaba Coding Plan Pro") + #expect(snapshot.fiveHourUsedQuota == 21) + #expect(snapshot.fiveHourTotalQuota == 1000) + } + + @Test + func `console request body uses region specific metadata`() async throws { + let registered = URLProtocol.registerClass(AlibabaConsoleSECTokenStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(AlibabaConsoleSECTokenStubURLProtocol.self) + } + AlibabaConsoleSECTokenStubURLProtocol.handler = nil + } + + AlibabaConsoleSECTokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if request.httpMethod == "GET", url.path == AlibabaCodingPlanAPIRegion.chinaMainland.dashboardURL.path { + return Self.makeResponse(url: url, body: "", statusCode: 200) + } + + if request.httpMethod == "GET", url.path == "/tool/user/info.json" { + return Self.makeResponse(url: url, body: #"{"data":{"secToken":"cn-sec-token"}}"#, statusCode: 200) + } + + if request.httpMethod == "POST", url.path == "/data/api.json" { + let body = Self.requestBodyString(from: request) + let params = try #require(Self.requestParamsDictionary(from: body)) + let data = try #require(params["Data"] as? [String: Any]) + let cornerstone = try #require(data["cornerstoneParam"] as? [String: Any]) + #expect(cornerstone["domain"] as? String == AlibabaCodingPlanAPIRegion.chinaMainland.consoleDomain) + #expect(cornerstone["consoleSite"] as? String == AlibabaCodingPlanAPIRegion.chinaMainland.consoleSite) + #expect( + cornerstone["feURL"] as? String + == AlibabaCodingPlanAPIRegion.chinaMainland.dashboardURL.absoluteString) + + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { "planName": "Alibaba Coding Plan Pro", "status": "VALID" } + ], + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 21, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000 + } + }, + "status_code": 0 + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + + let snapshot = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + cookieHeader: "sec_token=cookie-sec-token; login_aliyunid_ticket=ticket; login_aliyunid_pk=user", + region: .chinaMainland, + environment: [:], + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.planName == "Alibaba Coding Plan Pro") + #expect(snapshot.fiveHourUsedQuota == 21) + #expect(snapshot.fiveHourTotalQuota == 1000) + } + + private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func requestBodyString(from request: URLRequest) -> String { + if let data = request.httpBody { + return String(data: data, encoding: .utf8) ?? "" + } + + guard let stream = request.httpBodyStream else { + return "" + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let count = stream.read(buffer, maxLength: bufferSize) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + + return String(data: data, encoding: .utf8) ?? "" + } + + private static func requestParamsDictionary(from body: String) -> [String: Any]? { + guard let components = URLComponents(string: "https://example.invalid/?\(body)"), + let params = components.queryItems?.first(where: { $0.name == "params" })?.value, + let data = params.data(using: .utf8) + else { + return nil + } + + let object = try? JSONSerialization.jsonObject(with: data, options: []) + return object as? [String: Any] + } +} + +final class AlibabaUsageFetcherStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "bailian.console.aliyun.com" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +final class AlibabaConsoleSECTokenStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return [ + "modelstudio.console.alibabacloud.com", + "bailian-singapore-cs.alibabacloud.com", + "bailian.console.aliyun.com", + "bailian-cs.console.aliyun.com", + "bailian-beijing-cs.aliyuncs.com", + ].contains(host) + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift new file mode 100644 index 0000000000..fb17b4d0fa --- /dev/null +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -0,0 +1,774 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AlibabaTokenPlanSettingsReaderTests { + @Test + func `cookie reads from environment`() { + let cookie = AlibabaTokenPlanSettingsReader.cookieHeader(environment: [ + AlibabaTokenPlanSettingsReader.cookieHeaderKey: "\"login_aliyunid_ticket=ticket\"", + ]) + #expect(cookie == "login_aliyunid_ticket=ticket") + } + + @Test + func `quota URL infers HTTPS scheme`() { + let url = AlibabaTokenPlanSettingsReader.quotaURL(environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "quota.token-plan.test/data/api.json", + ]) + + #expect(url?.scheme == "https") + #expect(url?.host == "quota.token-plan.test") + } + + @Test + func `quota URL rejects non HTTPS schemes`() { + let httpURL = AlibabaTokenPlanSettingsReader.quotaURL(environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "http://quota.token-plan.test/data/api.json", + ]) + let ftpURL = AlibabaTokenPlanSettingsReader.quotaURL(environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "ftp://quota.token-plan.test/data/api.json", + ]) + + #expect(httpURL == nil) + #expect(ftpURL == nil) + } + + @Test + func `host override rejects non HTTPS schemes`() { + let httpHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [ + AlibabaTokenPlanSettingsReader.hostKey: "http://dashboard.token-plan.test", + ]) + let httpsHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [ + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ]) + let bareHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [ + AlibabaTokenPlanSettingsReader.hostKey: "dashboard.token-plan.test", + ]) + + #expect(httpHost == nil) + #expect(httpsHost == "https://dashboard.token-plan.test") + #expect(bareHost == "dashboard.token-plan.test") + } + + @Test + func `default quota URL targets subscription summary API`() { + let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL + #expect(url.host == "bailian.console.aliyun.com") + #expect(url.absoluteString.contains("GetSubscriptionSummary")) + #expect(url.absoluteString.contains("BssOpenAPI-V3")) + } +} + +struct AlibabaTokenPlanCookieHeaderTests { + @Test + func `builds URL scoped headers for API and dashboard`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), + self.cookie(name: "sec_token", value: "shared", domain: ".console.aliyun.com"), + self.cookie(name: "sec_token", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.alibabacloud.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies)) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("login_current_pk=account")) + #expect(headers.apiCookieHeader.contains("sec_token=dashboard")) + #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) + #expect(headers.dashboardCookieHeader.contains("sec_token=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) + } + + @Test + func `cached token plan headers preserve URL scoping`() throws { + let headers = AlibabaTokenPlanCookieHeaders( + apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api", + dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard") + + let cached = try #require(AlibabaTokenPlanCookieHeaders(cachedHeader: headers.cacheCookieHeader)) + + #expect(cached.apiCookieHeader.contains("api_only=api")) + #expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(cached.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!cached.dashboardCookieHeader.contains("api_only=api")) + } + + @Test + func `builds headers from environment scoped URLs`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), + self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"), + self.cookie(name: "prod_dashboard_only", value: "prod-dashboard", domain: "bailian.console.aliyun.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test/data/api.json", + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ])) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("api_only=api")) + #expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard")) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } +} + +struct AlibabaTokenPlanUsageSnapshotTests { + @Test + func `maps used and total quota to primary window`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset = Date(timeIntervalSince1970: 1_700_100_000) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 250, + totalQuota: 1000, + remainingQuota: nil, + resetsAt: reset, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetsAt == reset) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits used") + #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") + } + + @Test + func `does not create primary window from balance only`() { + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: nil, + totalQuota: nil, + remainingQuota: 700, + resetsAt: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") + } +} + +@Suite(.serialized) +struct AlibabaTokenPlanUsageParsingTests { + @Test + func `parses subscription summary payload`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 875, + "NearestExpireDate": 1701000000000 + }, + "Code": "200" + } + """ + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "TOKEN PLAN") + #expect(snapshot.usedQuota == 125) + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.remainingQuota == 875) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_701_000_000)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5) + } + + @Test + func `parses nested subscription summary body`() throws { + let body = """ + { + "success": true, + "data": { + "totalCount": 1, + "totalSurplusValue": 750, + "totalValue": 1000 + } + } + """ + let payload = ["successResponse": ["body": body]] + let data = try JSONSerialization.data(withJSONObject: payload) + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: data) + + #expect(snapshot.planName == "TOKEN PLAN") + #expect(snapshot.usedQuota == 250) + #expect(snapshot.remainingQuota == 750) + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 25) + } + + @Test + func `empty subscription summary stays visible without quota window`() throws { + let json = """ + { + "Success": true, + "Data": { + "TotalCount": 0 + } + } + """ + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + #expect(snapshot.planName == nil) + #expect(snapshot.totalQuota == nil) + #expect(snapshot.toUsageSnapshot().primary == nil) + } + + @Test + func `login payload maps to login required`() { + let json = """ + { + "code": "ConsoleNeedLogin", + "message": "You need to log in.", + "successResponse": false + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `post only token payload maps to login required`() { + let json = """ + { + "code": "PostonlyOrTokenError", + "message": "Your request has expired. Please refresh the page.", + "successResponse": false + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `nested unsuccessful subscription summary maps to API error`() throws { + let body = """ + { + "success": false, + "message": "Subscription lookup failed" + } + """ + let payload = ["successResponse": ["body": body]] + let data = try JSONSerialization.data(withJSONObject: payload) + + #expect(throws: AlibabaTokenPlanUsageError.apiError("Subscription lookup failed")) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: data) + } + } + + @Test + func `forbidden payload maps to invalid credentials`() { + let json = """ + { + "statusCode": 403, + "message": "Forbidden" + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.invalidCredentials) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `failed forbidden payload maps to invalid credentials`() { + let json = """ + { + "successResponse": false, + "statusCode": 403, + "message": "Forbidden" + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.invalidCredentials) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `html login payload maps to login required`() { + let html = """ + + Please login to Alibaba Cloud + + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(html.utf8)) + } + } + + @Test + func `non json payload maps to parse failed`() { + #expect(throws: AlibabaTokenPlanUsageError.parseFailed("Invalid JSON response")) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data("not-json".utf8)) + } + } + + @Test + func `SEC token preflight falls back to user info`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "alibaba-token-plan.test", + url.path == "/cn-beijing", + request.httpMethod == "GET" + { + #expect(url.port == 9443) + return Self.makeResponse(url: url, body: "", statusCode: 200) + } + + if url.host == "alibaba-token-plan.test", + url.path == "/tool/user/info.json", + request.httpMethod == "GET" + { + #expect(url.port == 9443) + #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json, text/plain, */*") + let json = """ + { + "code": "200", + "data": { + "secToken": "user-info-token" + }, + "successResponse": true + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + if url.host == "alibaba-token-plan.test", request.httpMethod == "POST" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") + #expect(request.value(forHTTPHeaderField: "Referer") == AlibabaTokenPlanUsageFetcher.dashboardURL + .absoluteString) + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=user-info-token")) + #expect(body.contains("GetSubscriptionSummary")) + #expect(body.contains("BssOpenAPI-V3")) + #expect(body.contains("ProductCode")) + #expect(body.contains("sfm_tokenplanteams_dp_cn")) + let json = """ + { + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 900 + } + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", + dashboardCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://alibaba-token-plan.test:9443"], + session: session) + + #expect(snapshot.planName == "TOKEN PLAN") + } + + @Test + func `SEC token preflight uses injected session`() async throws { + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "session-token.test", request.httpMethod == "GET" { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + + if url.host == "session-token.test", request.httpMethod == "POST" { + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=session-html-token")) + let json = """ + { + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 900 + } + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://session-token.test"], + session: session) + + #expect(snapshot.planName == "TOKEN PLAN") + } + + @Test + func `redirect preserves cookie only for same host HTTPS requests`() throws { + let sourceURL = try #require(URL(string: "https://bailian.console.aliyun.com/data/api.json")) + let sameHostURL = try #require(URL(string: "https://bailian.console.aliyun.com/redirected")) + let crossHostURL = try #require(URL(string: "https://signin.aliyun.com/login")) + let insecureURL = try #require(URL(string: "http://bailian.console.aliyun.com/redirected")) + let response = try #require(HTTPURLResponse( + url: sourceURL, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: nil)) + + var sameHostRequest = URLRequest(url: sameHostURL) + sameHostRequest.setValue("old=value", forHTTPHeaderField: "Cookie") + let sameHostRedirect = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: sameHostRequest, + cookieHeader: "login_aliyunid_ticket=ticket")) + #expect(sameHostRedirect.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket") + + var crossHostRequest = URLRequest(url: crossHostURL) + crossHostRequest.setValue("old=value", forHTTPHeaderField: "Cookie") + let crossHostRedirect = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: crossHostRequest, + cookieHeader: "login_aliyunid_ticket=ticket")) + #expect(crossHostRedirect.value(forHTTPHeaderField: "Cookie") == nil) + + let insecureRedirect = AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: URLRequest(url: insecureURL), + cookieHeader: "login_aliyunid_ticket=ticket") + #expect(insecureRedirect == nil) + } + + @Test + func `dashboard redirect preserves dashboard cookie header`() throws { + let sourceURL = try #require(URL(string: "https://bailian.console.aliyun.com/cn-beijing")) + let targetURL = try #require(URL(string: "https://bailian.console.aliyun.com/redirected")) + let response = try #require(HTTPURLResponse( + url: sourceURL, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: nil)) + var request = URLRequest(url: targetURL) + request.setValue("api_only=wrong", forHTTPHeaderField: "Cookie") + + let redirected = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: request, + cookieHeader: "dashboard_only=keep")) + + #expect(redirected.value(forHTTPHeaderField: "Cookie") == "dashboard_only=keep") + } + + private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func requestBodyString(from request: URLRequest) -> String { + if let data = request.httpBody { + return String(data: data, encoding: .utf8) ?? "" + } + if let stream = request.httpBodyStream { + stream.open() + defer { + stream.close() + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + return String(data: data, encoding: .utf8) ?? "" + } + return "" + } +} + +@Suite(.serialized) +struct AlibabaTokenPlanWebStrategyTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `auto web strategy surfaces cookie import errors`() async throws { + let strategy = AlibabaTokenPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + CookieHeaderCache.clear(provider: .alibabatokenplan) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie( + details: "macOS Keychain denied access to Chrome Safe Storage.") + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + } + + #expect(await strategy.isAvailable(context)) + + do { + _ = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeader(context: context, allowCached: false) + Issue.record("Expected cookie import failure to be surfaced") + } catch let error as AlibabaTokenPlanSettingsError { + guard case let .missingCookie(details) = error else { + Issue.record("Expected missingCookie, got \(error)") + return + } + #expect(details == "macOS Keychain denied access to Chrome Safe Storage.") + #expect(error.localizedDescription.contains("Alibaba Token Plan")) + #expect(!error.localizedDescription.contains("Alibaba Coding Plan")) + } + } + + @Test + func `auto web strategy imports subscription scoped token plan cookies`() throws { + let strategy = AlibabaTokenPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + CookieHeaderCache.clear(provider: .alibabatokenplan) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie( + name: "modelstudio_only", + value: "modelstudio", + domain: "modelstudio.console.alibabacloud.com"), + self.cookie(name: "alibabacloud_only", value: "cloud", domain: ".alibabacloud.com"), + ], + sourceLabel: "Chrome Default") + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + CookieHeaderCache.clear(provider: .alibabatokenplan) + } + + let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) + + #expect(headers.apiCookieHeader == headers.dashboardCookieHeader) + #expect(headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) + #expect(!headers.apiCookieHeader.contains("alibabacloud_only=cloud")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) + #expect(!headers.dashboardCookieHeader.contains("alibabacloud_only=cloud")) + + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import") + } + let cachedHeaders = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true) + #expect(cachedHeaders.apiCookieHeader == headers.apiCookieHeader) + #expect(cachedHeaders.dashboardCookieHeader == headers.dashboardCookieHeader) + #expect(strategy.id == "alibaba-token-plan.web") + } + + @Test + func `auto web strategy scopes imported cookies to environment overrides`() throws { + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let environment = [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test/data/api.json", + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ] + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: settings, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + CookieHeaderCache.clear(provider: .alibabatokenplan) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), + self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"), + self.cookie( + name: "prod_dashboard_only", + value: "prod-dashboard", + domain: "bailian.console.aliyun.com"), + ], + sourceLabel: "Chrome Default") + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + CookieHeaderCache.clear(provider: .alibabatokenplan) + } + + let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) + + #expect(headers.apiCookieHeader.contains("api_only=api")) + #expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard")) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } +} + +final class AlibabaTokenPlanStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return host == "bailian.console.aliyun.com" || + host == "alibaba-token-plan.test" || + host == "session-token.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift index 8a4e8506cb..150042c73c 100644 --- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift @@ -2,27 +2,133 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct AmpUsageFetcherTests { + private func makeContext( + sourceMode: ProviderSourceMode, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `uses amp internal usage endpoint`() { + #expect( + AmpUsageFetcher.usageURL.absoluteString == + "https://ampcode.com/api/internal?userDisplayBalanceInfo") + } + + @Test + func `web fallback requires browser import or a manual session cookie`() { + let disabled = ProviderSettingsSnapshot.AmpProviderSettings(cookieSource: .off, manualCookieHeader: nil) + let invalidManual = ProviderSettingsSnapshot.AmpProviderSettings( + cookieSource: .manual, + manualCookieHeader: "other=value") + let validManual = ProviderSettingsSnapshot.AmpProviderSettings( + cookieSource: .manual, + manualCookieHeader: "session=test") + + #expect(AmpStatusFetchStrategy.canUseWebFallback( + settings: nil, + canImportBrowserCookies: false) == false) + #expect(AmpStatusFetchStrategy.canUseWebFallback( + settings: nil, + canImportBrowserCookies: true)) + #expect(AmpStatusFetchStrategy.canUseWebFallback( + settings: disabled, + canImportBrowserCookies: true) == false) + #expect(AmpStatusFetchStrategy.canUseWebFallback( + settings: invalidManual, + canImportBrowserCookies: false) == false) + #expect(AmpStatusFetchStrategy.canUseWebFallback( + settings: validManual, + canImportBrowserCookies: false)) + } + + @Test + func `cli cancellation does not fall back to web`() { + let strategy = AmpCLIFetchStrategy() + let context = self.makeContext(sourceMode: .auto) + + #expect(!strategy.shouldFallback(on: CancellationError(), context: context)) + #expect(!strategy.shouldFallback(on: URLError(.cancelled), context: context)) + #expect(strategy.shouldFallback(on: AmpUsageError.parseFailed("missing"), context: context)) + #expect(!strategy.shouldFallback( + on: AmpUsageError.parseFailed("missing"), + context: self.makeContext(sourceMode: .cli))) + } + + @Test + func `api request uses bearer token without cookies`() throws { + let request = try AmpUsageFetcher.makeUsageAPIRequest(apiToken: "sgamp_test") + + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sgamp_test") + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + } + + @Test + func `api strategy falls back only from auto mode and preserves cancellation`() { + let strategy = AmpAPIFetchStrategy() + let auto = self.makeContext(sourceMode: .auto) + + #expect(strategy.shouldFallback(on: AmpUsageError.missingAPIToken, context: auto)) + #expect(strategy.shouldFallback(on: AmpUsageError.invalidAPIToken, context: auto)) + #expect(strategy.shouldFallback(on: URLError(.timedOut), context: auto)) + #expect(!strategy.shouldFallback(on: CancellationError(), context: auto)) + #expect(!strategy.shouldFallback(on: URLError(.cancelled), context: auto)) + #expect(!strategy.shouldFallback( + on: AmpUsageError.invalidAPIToken, + context: self.makeContext(sourceMode: .api))) + } + @Test - func attachesCookieForAmpHosts() { + func `amp config token resolves through environment`() { + let env = [AmpSettingsReader.apiTokenKey: " 'sgamp_test' "] + + #expect(ProviderTokenResolver.ampToken(environment: env) == "sgamp_test") + } + + @Test + func `attaches cookie for amp hosts`() { #expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com/settings"))) #expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://www.ampcode.com"))) #expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://app.ampcode.com/path"))) } @Test - func rejectsNonAmpHosts() { + func `rejects non amp hosts`() { #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://example.com"))) #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com.evil.com"))) #expect(!AmpUsageFetcher.shouldAttachCookie(to: nil)) } @Test - func detectsLoginRedirects() throws { + func `rejects non https amp urls`() { + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://ampcode.com/settings"))) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://www.ampcode.com"))) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ampcode.com/path"))) + } + + @Test + func `detects login redirects`() throws { let signIn = try #require(URL(string: "https://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) #expect(AmpUsageFetcher.isLoginRedirect(signIn)) + let downgradedSignIn = try #require(URL(string: "http://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) + #expect(AmpUsageFetcher.isLoginRedirect(downgradedSignIn)) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: downgradedSignIn)) + let sso = try #require(URL(string: "https://ampcode.com/auth/sso?returnTo=%2Fsettings")) #expect(AmpUsageFetcher.isLoginRedirect(sso)) @@ -31,10 +137,14 @@ struct AmpUsageFetcherTests { let signin = try #require(URL(string: "https://www.ampcode.com/signin")) #expect(AmpUsageFetcher.isLoginRedirect(signin)) + + let hostedAuth = try #require(URL( + string: "https://auth.ampcode.com/?client_id=test&redirect_uri=https%3A%2F%2Fampcode.com%2Fauth%2Fcallback")) + #expect(AmpUsageFetcher.isLoginRedirect(hostedAuth)) } @Test - func ignoresNonLoginURLs() throws { + func `ignores non login UR ls`() throws { let settings = try #require(URL(string: "https://ampcode.com/settings")) #expect(!AmpUsageFetcher.isLoginRedirect(settings)) diff --git a/Tests/CodexBarTests/AmpUsageParserTests.swift b/Tests/CodexBarTests/AmpUsageParserTests.swift index 89ab59bdc3..66a1cfc0b3 100644 --- a/Tests/CodexBarTests/AmpUsageParserTests.swift +++ b/Tests/CodexBarTests/AmpUsageParserTests.swift @@ -2,10 +2,156 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct AmpUsageParserTests { @Test - func parsesFreeTierUsageFromSettingsHTML() throws { + func `amp cli probe runs usage and parses balances`() async throws { + let script = """ + [ "$1" = "usage" ] || exit 2 + cat <<'EOF' + Signed in as cli@example.com (team) + Amp Free: $6/$10 remaining (replenishes +$0.5/hour) + Individual credits: $12.50 remaining + Workspace Test Team: $7.25 remaining + EOF + """ + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = try await AmpCLIProbe(arguments: ["-c", script, "amp", "usage"]).fetch( + environment: ["AMP_CLI_PATH": "/bin/sh"], + now: now) + + #expect(snapshot.freeUsed == 4) + #expect(snapshot.individualCredits == 12.5) + #expect(snapshot.workspaceBalances == [AmpWorkspaceBalance(name: "Test Team", remaining: 7.25)]) + #expect(snapshot.accountEmail == "cli@example.com") + #expect(snapshot.updatedAt == now) + } + + @Test + func `parses current amp usage display text`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let output = """ + \u{1B}[2mSigned in as ampcode@3kh0.net (echo)\u{1B}[0m + Amp Free: $4.71/$10 remaining (replenishes +$0.42/hour) - https://ampcode.com/settings#amp-free + Individual credits: $25.64 remaining (set up automatic top-up to avoid running out) - https://ampcode.com/settings + Workspace meow: $10.22 remaining (set up automatic top-up to avoid running out) - https://ampcode.com/workspaces/meow + """ + + let snapshot = try AmpUsageParser.parse(displayText: output, now: now) + + #expect(snapshot.freeQuota == 10) + #expect(try abs(#require(snapshot.freeUsed) - 5.29) < 0.001) + #expect(snapshot.hourlyReplenishment == 0.42) + #expect(snapshot.windowHours == 24) + #expect(snapshot.individualCredits == 25.64) + #expect(snapshot.workspaceBalances == [AmpWorkspaceBalance(name: "meow", remaining: 10.22)]) + #expect(snapshot.accountEmail == "ampcode@3kh0.net") + #expect(snapshot.accountOrganization == "echo") + #expect(snapshot.toUsageSnapshot(now: now).ampUsage == AmpUsageDetails( + individualCredits: 25.64, + workspaceBalances: [AmpWorkspaceBalance(name: "meow", remaining: 10.22)])) + + let encoded = try JSONEncoder().encode(snapshot.toUsageSnapshot(now: now)) + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: encoded) + #expect(decoded.ampUsage == AmpUsageDetails( + individualCredits: 25.64, + workspaceBalances: [AmpWorkspaceBalance(name: "meow", remaining: 10.22)])) + } + + @Test + func `parses individual credits without free tier usage`() throws { + let output = """ + Signed in as paid@example.com + Individual credits: $25.64 remaining + """ + + let snapshot = try AmpUsageParser.parse(displayText: output) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.freeQuota == nil) + #expect(snapshot.freeUsed == nil) + #expect(snapshot.individualCredits == 25.64) + #expect(usage.primary == nil) + #expect(usage.ampUsage == AmpUsageDetails(individualCredits: 25.64, workspaceBalances: [])) + #expect(usage.identity?.loginMethod == "Amp") + } + + @Test + func `parses workspace credits without free tier usage`() throws { + let output = """ + Signed in as workspace@example.com (team) + Workspace Alpha Team: $1,234.56 remaining + Workspace Beta: $7 remaining + """ + + let snapshot = try AmpUsageParser.parse(displayText: output) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.freeQuota == nil) + #expect(snapshot.workspaceBalances == [ + AmpWorkspaceBalance(name: "Alpha Team", remaining: 1234.56), + AmpWorkspaceBalance(name: "Beta", remaining: 7), + ]) + #expect(usage.primary == nil) + #expect(usage.ampUsage == AmpUsageDetails( + individualCredits: nil, + workspaceBalances: snapshot.workspaceBalances)) + } + + @Test + func `signed in identity can contain login`() throws { + let output = """ + Signed in as login@example.com (login-team) + Amp Free: $6/$10 remaining (replenishes +$0.5/hour) + """ + + let snapshot = try AmpUsageParser.parse(displayText: output) + + #expect(snapshot.accountEmail == "login@example.com") + #expect(snapshot.accountOrganization == "login-team") + } + + @Test + func `parses current usage api response`() throws { + let now = Date(timeIntervalSince1970: 1_700_005_000) + let displayText = """ + Signed in as user@example.com (team) + Amp Free: $8/$10 remaining (replenishes +$0.5/hour) + Individual credits: $12.50 remaining + Workspace Alpha Team: $1,234.56 remaining + Workspace Beta: $7 remaining + """ + let data = try JSONSerialization.data(withJSONObject: [ + "ok": true, + "result": ["displayText": displayText], + ]) + + let snapshot = try AmpUsageFetcher.parseUsageAPIResponse(data, now: now) + + #expect(snapshot.freeUsed == 2) + #expect(snapshot.individualCredits == 12.5) + #expect(snapshot.workspaceBalances == [ + AmpWorkspaceBalance(name: "Alpha Team", remaining: 1234.56), + AmpWorkspaceBalance(name: "Beta", remaining: 7), + ]) + #expect(snapshot.accountEmail == "user@example.com") + #expect(snapshot.accountOrganization == "team") + } + + @Test + func `usage api auth error is invalid API token`() { + let data = Data(#"{"ok":false,"error":{"code":"auth-required","message":"Sign in"}}"#.utf8) + + #expect { + try AmpUsageFetcher.parseUsageAPIResponse(data) + } throws: { error in + guard case AmpUsageError.invalidAPIToken = error else { return false } + return true + } + } + + @Test + func `parses free tier usage from settings HTML`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """ +
+Usage limits +5h limit +72% remaining +Resets today at 2:15 PM +Weekly limit +41% remaining +Resets Fri at 9:00 AM +
+ + diff --git a/Tests/CodexBarTests/Fixtures/codex-historical-usage-real-legacy.jsonl b/Tests/CodexBarTests/Fixtures/codex-historical-usage-real-legacy.jsonl new file mode 100644 index 0000000000..24c52e180c --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/codex-historical-usage-real-legacy.jsonl @@ -0,0 +1,6 @@ +{"accountKey":"d12264663b6c957df161c4eaa04268ee1eba54730fc206d6d1530a333fbc1bf5","provider":"codex","resetsAt":"2026-02-17T05:37:00Z","sampledAt":"2026-02-10T05:37:00Z","source":"backfill","usedPercent":0,"v":1,"windowKind":"secondary","windowMinutes":10080} +{"accountKey":"d12264663b6c957df161c4eaa04268ee1eba54730fc206d6d1530a333fbc1bf5","provider":"codex","resetsAt":"2026-02-17T05:37:00Z","sampledAt":"2026-02-10T17:37:00Z","source":"backfill","usedPercent":15.888694315201283,"v":1,"windowKind":"secondary","windowMinutes":10080} +{"accountKey":"d12264663b6c957df161c4eaa04268ee1eba54730fc206d6d1530a333fbc1bf5","provider":"codex","resetsAt":"2026-02-17T05:37:00Z","sampledAt":"2026-02-11T05:37:00Z","source":"backfill","usedPercent":36.46543073970202,"v":1,"windowKind":"secondary","windowMinutes":10080} +{"accountKey":"d12264663b6c957df161c4eaa04268ee1eba54730fc206d6d1530a333fbc1bf5","provider":"codex","resetsAt":"2026-02-17T05:37:00Z","sampledAt":"2026-02-16T05:37:00Z","source":"backfill","usedPercent":100,"v":1,"windowKind":"secondary","windowMinutes":10080} +{"accountKey":"d12264663b6c957df161c4eaa04268ee1eba54730fc206d6d1530a333fbc1bf5","provider":"codex","resetsAt":"2026-02-17T05:37:00Z","sampledAt":"2026-02-16T17:37:00Z","source":"backfill","usedPercent":100,"v":1,"windowKind":"secondary","windowMinutes":10080} +{"accountKey":"d12264663b6c957df161c4eaa04268ee1eba54730fc206d6d1530a333fbc1bf5","provider":"codex","resetsAt":"2026-02-17T05:37:00Z","sampledAt":"2026-02-17T05:37:00Z","source":"backfill","usedPercent":100,"v":1,"windowKind":"secondary","windowMinutes":10080} diff --git a/Tests/CodexBarTests/Fixtures/codex-plan-utilization-real-migration.json b/Tests/CodexBarTests/Fixtures/codex-plan-utilization-real-migration.json new file mode 100644 index 0000000000..0b52361658 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/codex-plan-utilization-real-migration.json @@ -0,0 +1,211 @@ +{ + "version": 1, + "preferredAccountKey": "codex:v1:provider-account:0c2a5eef-a612-45bb-9796-9aa83ce1bed7", + "unscoped": [], + "accounts": { + "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897": [ + { + "name": "session", + "windowMinutes": 300, + "entries": [ + { + "capturedAt": "2026-03-23T09:55:44Z", + "resetsAt": "2026-03-23T10:28:16Z", + "usedPercent": 9 + }, + { + "capturedAt": "2026-03-23T10:27:49Z", + "resetsAt": "2026-03-23T10:28:16Z", + "usedPercent": 13 + }, + { + "capturedAt": "2026-04-01T07:07:28Z", + "resetsAt": "2026-04-01T12:07:28Z", + "usedPercent": 0 + }, + { + "capturedAt": "2026-04-01T07:59:25Z", + "resetsAt": "2026-04-01T12:57:12Z", + "usedPercent": 0 + }, + { + "capturedAt": "2026-04-01T08:30:22Z", + "resetsAt": "2026-04-01T12:57:12Z", + "usedPercent": 6 + } + ] + }, + { + "name": "weekly", + "windowMinutes": 10080, + "entries": [ + { + "capturedAt": "2026-03-23T09:55:44Z", + "resetsAt": "2026-03-25T17:41:02Z", + "usedPercent": 51 + }, + { + "capturedAt": "2026-03-23T10:54:47Z", + "resetsAt": "2026-03-25T17:41:02Z", + "usedPercent": 53 + }, + { + "capturedAt": "2026-03-23T11:59:59Z", + "resetsAt": "2026-03-25T17:41:02Z", + "usedPercent": 53 + }, + { + "capturedAt": "2026-04-01T07:43:34Z", + "resetsAt": "2026-04-03T05:42:22Z", + "usedPercent": 62 + }, + { + "capturedAt": "2026-04-01T07:59:25Z", + "resetsAt": "2026-04-08T07:57:12Z", + "usedPercent": 0 + }, + { + "capturedAt": "2026-04-01T08:30:22Z", + "resetsAt": "2026-04-08T07:57:12Z", + "usedPercent": 2 + } + ] + } + ], + "ae933d834c92219543c3f3ee7d5117b129f89174b6c47b2ab793df968f3616f4": [ + { + "name": "weekly", + "windowMinutes": 10080, + "entries": [ + { + "capturedAt": "2026-03-28T06:50:16Z", + "resetsAt": "2026-04-02T20:34:15Z", + "usedPercent": 1 + }, + { + "capturedAt": "2026-03-29T13:32:29Z", + "resetsAt": "2026-04-02T20:34:15Z", + "usedPercent": 1 + }, + { + "capturedAt": "2026-03-29T14:45:59Z", + "resetsAt": "2026-04-02T20:34:15Z", + "usedPercent": 1 + }, + { + "capturedAt": "2026-03-31T13:49:22Z", + "resetsAt": "2026-04-02T20:34:16Z", + "usedPercent": 1 + }, + { + "capturedAt": "2026-03-31T16:58:41Z", + "resetsAt": "2026-04-02T20:34:15Z", + "usedPercent": 1 + }, + { + "capturedAt": "2026-03-31T17:07:31Z", + "resetsAt": "2026-04-02T20:34:15Z", + "usedPercent": 1 + } + ] + } + ], + "codex:v1:email-hash:e0905a26346930c615f5517913dad1a23312d882159719fbc429819216ff42b2": [ + { + "name": "session", + "windowMinutes": 300, + "entries": [ + { + "capturedAt": "2026-03-29T16:53:28Z", + "usedPercent": 18 + }, + { + "capturedAt": "2026-03-29T17:24:28Z", + "usedPercent": 18 + }, + { + "capturedAt": "2026-04-01T15:03:43Z", + "usedPercent": 10 + }, + { + "capturedAt": "2026-04-01T16:37:42Z", + "usedPercent": 10 + }, + { + "capturedAt": "2026-04-01T19:49:10Z", + "usedPercent": 10 + } + ] + } + ], + "codex:v1:provider-account:0c2a5eef-a612-45bb-9796-9aa83ce1bed7": [ + { + "name": "session", + "windowMinutes": 300, + "entries": [ + { + "capturedAt": "2026-04-01T08:58:21Z", + "resetsAt": "2026-04-01T12:57:13Z", + "usedPercent": 6 + }, + { + "capturedAt": "2026-04-01T09:54:18Z", + "resetsAt": "2026-04-01T12:57:13Z", + "usedPercent": 8 + }, + { + "capturedAt": "2026-04-01T18:58:52Z", + "resetsAt": "2026-04-01T22:57:27Z", + "usedPercent": 4 + }, + { + "capturedAt": "2026-04-01T19:44:58Z", + "resetsAt": "2026-04-01T22:57:27Z", + "usedPercent": 8 + }, + { + "capturedAt": "2026-04-01T20:33:41Z", + "resetsAt": "2026-04-01T22:57:27Z", + "usedPercent": 10 + } + ] + }, + { + "name": "weekly", + "windowMinutes": 10080, + "entries": [ + { + "capturedAt": "2026-04-01T08:58:21Z", + "resetsAt": "2026-04-08T07:57:13Z", + "usedPercent": 2 + }, + { + "capturedAt": "2026-04-01T09:54:18Z", + "resetsAt": "2026-04-08T07:57:13Z", + "usedPercent": 2 + }, + { + "capturedAt": "2026-04-01T10:54:30Z", + "resetsAt": "2026-04-08T07:57:12Z", + "usedPercent": 3 + }, + { + "capturedAt": "2026-04-01T18:58:52Z", + "resetsAt": "2026-04-08T07:57:12Z", + "usedPercent": 10 + }, + { + "capturedAt": "2026-04-01T19:44:58Z", + "resetsAt": "2026-04-08T07:57:13Z", + "usedPercent": 12 + }, + { + "capturedAt": "2026-04-01T20:33:41Z", + "resetsAt": "2026-04-08T07:57:12Z", + "usedPercent": 12 + } + ] + } + ] + } +} diff --git a/Tests/CodexBarTests/Fixtures/models-dev-subset.json b/Tests/CodexBarTests/Fixtures/models-dev-subset.json new file mode 100644 index 0000000000..f2a0c0857e --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/models-dev-subset.json @@ -0,0 +1,89 @@ +{ + "openai": { + "id": "openai", + "name": "OpenAI", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "name": "GPT-4o mini", + "cost": { + "input": 0.15, + "output": 0.6, + "cache_read": 0.08 + }, + "limit": { + "context": 128000, + "output": 16384 + } + }, + "shared-model": { + "id": "shared-model", + "name": "Shared model via OpenAI", + "cost": { + "input": 1, + "output": 2 + }, + "limit": { + "context": 128000 + } + } + } + }, + "anthropic": { + "id": "anthropic", + "name": "Anthropic", + "models": { + "shared-model": { + "id": "shared-model", + "name": "Shared model via Anthropic", + "cost": { + "input": 3, + "output": 4 + }, + "limit": { + "context": 200000 + } + }, + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "cost": { + "input": 3, + "output": 15, + "cache_read": 0.3, + "cache_write": 3.75, + "context_over_200k": { + "input": 6, + "output": 22.5, + "cache_read": 0.6, + "cache_write": 7.5 + } + }, + "limit": { + "context": 1000000, + "output": 64000 + } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "name": "Vertex (Anthropic)", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "cost": { + "input": 3.1, + "output": 15.1, + "cache_read": 0.31, + "cache_write": 3.76 + }, + "limit": { + "context": 1000000, + "output": 64000 + } + } + } + } +} diff --git a/Tests/CodexBarTests/GeminiAPITestHelpers.swift b/Tests/CodexBarTests/GeminiAPITestHelpers.swift index 0e20efc340..b243f22ab7 100644 --- a/Tests/CodexBarTests/GeminiAPITestHelpers.swift +++ b/Tests/CodexBarTests/GeminiAPITestHelpers.swift @@ -37,6 +37,11 @@ enum GeminiAPITestHelpers { "remainingFraction": 0.9, "resetTime": "2025-01-01T00:00:00Z", ], + [ + "modelId": "gemini-2.5-flash-lite", + "remainingFraction": 0.8, + "resetTime": "2025-01-01T00:00:00Z", + ], ], ]) } diff --git a/Tests/CodexBarTests/GeminiLoginAlertTests.swift b/Tests/CodexBarTests/GeminiLoginAlertTests.swift index ce919a897c..5905904b64 100644 --- a/Tests/CodexBarTests/GeminiLoginAlertTests.swift +++ b/Tests/CodexBarTests/GeminiLoginAlertTests.swift @@ -1,10 +1,9 @@ import Testing @testable import CodexBar -@Suite struct GeminiLoginAlertTests { @Test - func returnsAlertForMissingBinary() { + func `returns alert for missing binary`() { let result = GeminiLoginRunner.Result(outcome: .missingBinary) let info = StatusItemController.geminiLoginAlertInfo(for: result) #expect(info?.title == "Gemini CLI not found") @@ -12,7 +11,7 @@ struct GeminiLoginAlertTests { } @Test - func returnsAlertForLaunchFailure() { + func `returns alert for launch failure`() { let result = GeminiLoginRunner.Result(outcome: .launchFailed("Boom")) let info = StatusItemController.geminiLoginAlertInfo(for: result) #expect(info?.title == "Could not open Terminal for Gemini") @@ -20,7 +19,7 @@ struct GeminiLoginAlertTests { } @Test - func returnsNilOnSuccess() { + func `returns nil on success`() { let result = GeminiLoginRunner.Result(outcome: .success) let info = StatusItemController.geminiLoginAlertInfo(for: result) #expect(info == nil) diff --git a/Tests/CodexBarTests/GeminiMenuCardTests.swift b/Tests/CodexBarTests/GeminiMenuCardTests.swift new file mode 100644 index 0000000000..bf7e89afff --- /dev/null +++ b/Tests/CodexBarTests/GeminiMenuCardTests.swift @@ -0,0 +1,57 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct GeminiMenuCardTests { + @Test + func `gemini model uses flash lite title for tertiary metric`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .gemini, + accountEmail: "gemini@example.com", + accountOrganization: nil, + loginMethod: "Paid") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1h"), + secondary: RateWindow( + usedPercent: 25, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(7200), + resetDescription: "Resets in 2h"), + tertiary: RateWindow( + usedPercent: 40, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(10800), + resetDescription: "Resets in 3h"), + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.gemini]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .gemini, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "gemini@example.com", plan: "Paid"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Pro", "Flash", "Flash Lite"]) + } +} diff --git a/Tests/CodexBarTests/GeminiSourceLabelTests.swift b/Tests/CodexBarTests/GeminiSourceLabelTests.swift new file mode 100644 index 0000000000..8ba8ee11c5 --- /dev/null +++ b/Tests/CodexBarTests/GeminiSourceLabelTests.swift @@ -0,0 +1,9 @@ +import Testing +@testable import CodexBarCore + +struct GeminiSourceLabelTests { + @Test + func `Gemini source label reflects OAuth backed API requests`() { + #expect(GeminiStatusFetchStrategy.sourceLabel == "oauth-api") + } +} diff --git a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift index 8cbff0e447..57c8b003eb 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeAPITests.swift @@ -1,11 +1,16 @@ import CodexBarCore import Foundation import Testing +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif -@Suite("Gemini API", .serialized) +@Suite(.serialized) struct GeminiStatusProbeAPITests { @Test - func missingCredentialsThrowsNotLoggedIn() async throws { + func `missing credentials throws not logged in`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } @@ -15,11 +20,11 @@ struct GeminiStatusProbeAPITests { } } - @Test - func rejectsApiKeyAuthType() async throws { + @Test(arguments: ["gemini-api-key", "api-key"]) + func `rejects api key auth types`(authType: String) async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } - try env.writeSettings(authType: "api-key") + try env.writeSettings(authType: authType) let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path) await Self.expectError(.unsupportedAuthType("API key")) { @@ -28,7 +33,7 @@ struct GeminiStatusProbeAPITests { } @Test - func rejectsVertexAuthType() async throws { + func `rejects vertex auth type`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeSettings(authType: "vertex-ai") @@ -40,7 +45,7 @@ struct GeminiStatusProbeAPITests { } @Test - func refreshesExpiredTokenAndUpdatesStoredCredentials() async throws { + func `refreshes expired token and updates stored credentials`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -67,6 +72,13 @@ struct GeminiStatusProbeAPITests { switch host { case "oauth2.googleapis.com": + // Fail the refresh if the client_id did not come from the test stub. + // This guards against the probe accidentally extracting OAuth creds + // from an unrelated Gemini install on the developer's machine. + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } let json = GeminiAPITestHelpers.jsonData([ "access_token": "new-token", "expires_in": 3600, @@ -112,7 +124,78 @@ struct GeminiStatusProbeAPITests { } @Test - func refreshesExpiredTokenWithNixShareLayout() async throws { + func `refreshes when stored Gemini credentials only have refresh token`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: nil, + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let binURL = try env.writeFakeGeminiCLI() + let previousValue = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + defer { + if let previousValue { + setenv("GEMINI_CLI_PATH", previousValue, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + let auth = request.value(forHTTPHeaderField: "Authorization") + guard auth == "Bearer new-token" else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountEmail == "user@example.com") + + let updated = try env.readCredentials() + #expect(updated["access_token"] as? String == "new-token") + } + + @Test + func `refreshes expired token with nix share layout`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -139,6 +222,139 @@ struct GeminiStatusProbeAPITests { switch host { case "oauth2.googleapis.com": + // Fail the refresh if the client_id did not come from the test stub. + // This guards against the probe accidentally extracting OAuth creds + // from an unrelated Gemini install on the developer's machine. + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + let json = GeminiAPITestHelpers.jsonData(["projects": []]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + let auth = request.value(forHTTPHeaderField: "Authorization") + if auth != "Bearer new-token" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + let auth = request.value(forHTTPHeaderField: "Authorization") + if auth != "Bearer new-token" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + #expect(snapshot.accountPlan == "Paid") + } + + @Test + func `refreshes expired token with fnm bundle layout when fnm keeps stdout open`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let childPIDFile = env.homeURL.appendingPathComponent("fnm-child.pid") + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com")) + + let binURL = try env.writeFakeGeminiCLI(layout: .fnmBundle) + // Match the real fnm layout: package root is inside the same multishell + // dir as the bin symlink target, under lib/node_modules/@google/gemini-cli. + let multishellRoot = binURL.deletingLastPathComponent().deletingLastPathComponent() + let packageJSONPath = multishellRoot + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + .appendingPathComponent("package.json") + let npmRoot = packageJSONPath + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + _ = try env.writeFakeFnm( + npmRoot: npmRoot, + geminiPackageJSONPath: packageJSONPath.path, + holdNpmRootStdoutOpen: true) + + let previousPath = ProcessInfo.processInfo.environment["PATH"] + let previousPIDFile = ProcessInfo.processInfo.environment["CODEXBAR_TEST_CHILD_PID_FILE"] + let fakeBinDir = env.homeURL.appendingPathComponent("bin").path + let pathValue = if let previousPath, !previousPath.isEmpty { + "\(fakeBinDir):\(binURL.deletingLastPathComponent().path):\(previousPath)" + } else { + "\(fakeBinDir):\(binURL.deletingLastPathComponent().path)" + } + setenv("PATH", pathValue, 1) + setenv("CODEXBAR_TEST_CHILD_PID_FILE", childPIDFile.path, 1) + + let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + defer { + if let previousPath { + setenv("PATH", previousPath, 1) + } else { + unsetenv("PATH") + } + + if let previousPIDFile { + setenv("CODEXBAR_TEST_CHILD_PID_FILE", previousPIDFile, 1) + } else { + unsetenv("CODEXBAR_TEST_CHILD_PID_FILE") + } + + if let previousGeminiPath { + setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + // Fail the refresh if the client_id did not come from the test stub. + // This guards against the probe accidentally extracting OAuth creds + // from an unrelated Gemini install on the developer's machine. + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } let json = GeminiAPITestHelpers.jsonData([ "access_token": "new-token", "expires_in": 3600, @@ -175,13 +391,144 @@ struct GeminiStatusProbeAPITests { } } + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let start = Date() + let snapshot = try await probe.fetch() + let elapsed = Date().timeIntervalSince(start) + #expect(snapshot.accountPlan == "Paid") + #expect(elapsed < 3, "fnm package discovery should not wait for inherited stdout EOF, took \(elapsed)s") + + let updated = try env.readCredentials() + #expect(updated["access_token"] as? String == "new-token") + } + + @Test + func `fnm helper timeout hard stops a process that ignores SIGTERM`() throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let pidFile = env.homeURL.appendingPathComponent("fnm-timeout.pid") + let helper = env.homeURL.appendingPathComponent("fnm-timeout") + try """ + #!/bin/sh + printf '%s\\n' "$$" > "$1" + trap '' TERM + while true; do sleep 1; done + """.write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + let start = Date() + let result = GeminiStatusProbe.runProcess( + executable: helper.path, + arguments: [pidFile.path], + environment: [:], + timeout: 0.5) + let elapsed = Date().timeIntervalSince(start) + let text = try String(contentsOf: pidFile, encoding: .utf8) + let processID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))) + defer { _ = kill(processID, SIGKILL) } + + #expect(result == nil) + #expect(kill(processID, 0) == -1) + #expect(elapsed < 2, "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)s") + } + + @Test + func `fnm helper completed no-output failure returns promptly`() throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + let helper = env.homeURL.appendingPathComponent("fnm-failure") + try """ + #!/bin/sh + exit 23 + """.write(to: helper, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: helper.path) + + let start = Date() + let result = GeminiStatusProbe.runProcess( + executable: helper.path, + arguments: [], + environment: [:], + timeout: 2) + + #expect(result == nil) + #expect(Date().timeIntervalSince(start) < 1) + } + + @Test + func `refreshes expired token with homebrew bundle layout`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "old-token", + refreshToken: "refresh-token", + expiry: Date().addingTimeInterval(-3600), + idToken: GeminiAPITestHelpers.makeIDToken(email: "user@example.com")) + + let binURL = try env.writeFakeGeminiCLI(layout: .homebrewBundle) + let previousGeminiPath = ProcessInfo.processInfo.environment["GEMINI_CLI_PATH"] + setenv("GEMINI_CLI_PATH", binURL.path, 1) + defer { + if let previousGeminiPath { + setenv("GEMINI_CLI_PATH", previousGeminiPath, 1) + } else { + unsetenv("GEMINI_CLI_PATH") + } + } + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + + switch host { + case "oauth2.googleapis.com": + let body = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } ?? "" + guard body.contains("client_id=test-client-id") else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 400, body: Data()) + } + let json = GeminiAPITestHelpers.jsonData([ + "access_token": "new-token", + "expires_in": 3600, + "id_token": GeminiAPITestHelpers.makeIDToken(email: "user@example.com"), + ]) + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 200, body: json) + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + guard request.value(forHTTPHeaderField: "Authorization") == "Bearer new-token" else { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 401, body: Data()) + } + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path == "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + } + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + let probe = GeminiStatusProbe(timeout: 2, homeDirectory: env.homeURL.path, dataLoader: dataLoader) let snapshot = try await probe.fetch() #expect(snapshot.accountPlan == "Paid") + + let updated = try env.readCredentials() + #expect(updated["access_token"] as? String == "new-token") } @Test - func usesCodeAssistProjectForQuota() async throws { + func `uses code assist project for quota`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -250,7 +597,66 @@ struct GeminiStatusProbeAPITests { } @Test - func failsRefreshWhenOAuthConfigMissing() async throws { + func `falls back to curl loader when URL session times out`() async throws { + let calls = LoaderCalls() + let url = try #require(URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) + let request = URLRequest(url: url) + let body = Data("{\"ok\":true}".utf8) + let loader = GeminiStatusProbe.dataLoaderWithCurlFallback( + primary: { _ in + calls.incrementPrimary() + throw URLError(.timedOut) + }, + fallback: { request in + calls.incrementFallback() + let (response, data) = GeminiAPITestHelpers.response( + url: request.url!.absoluteString, + status: 200, + body: body) + return (data, response) + }) + + let (loadedBody, loadedResponse) = try await loader(request) + let counts = calls.counts() + #expect(loadedBody == body) + #expect((loadedResponse as? HTTPURLResponse)?.statusCode == 200) + #expect(counts.primary == 1) + #expect(counts.fallback == 1) + } + + @Test + func `does not fall back to curl loader for non-timeout errors`() async throws { + let calls = LoaderCalls() + let url = try #require(URL(string: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) + let request = URLRequest(url: url) + let loader = GeminiStatusProbe.dataLoaderWithCurlFallback( + primary: { _ in + calls.incrementPrimary() + throw URLError(.cannotFindHost) + }, + fallback: { request in + calls.incrementFallback() + let (response, data) = GeminiAPITestHelpers.response( + url: request.url!.absoluteString, + status: 200, + body: Data()) + return (data, response) + }) + + do { + _ = try await loader(request) + Issue.record("Expected non-timeout URLSession error") + } catch let error as URLError { + #expect(error.code == .cannotFindHost) + } + + let counts = calls.counts() + #expect(counts.primary == 1) + #expect(counts.fallback == 0) + } + + @Test + func `fails refresh when O auth config missing`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -277,7 +683,7 @@ struct GeminiStatusProbeAPITests { } @Test - func reportsApiErrors() async throws { + func `reports api errors`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -313,7 +719,7 @@ struct GeminiStatusProbeAPITests { } @Test - func reportsNotLoggedInWhenAccessTokenMissing() async throws { + func `reports not logged in when access token missing`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -329,7 +735,7 @@ struct GeminiStatusProbeAPITests { } @Test - func reportsNotLoggedInOn401() async throws { + func `reports not logged in on401`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -365,7 +771,7 @@ struct GeminiStatusProbeAPITests { } @Test - func reportsParseErrorsForInvalidPayload() async throws { + func `reports parse errors for invalid payload`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -418,4 +824,28 @@ struct GeminiStatusProbeAPITests { #expect(error as? GeminiStatusProbeError == expected) } } + + private final class LoaderCalls: @unchecked Sendable { + private let lock = NSLock() + private var primaryCount = 0 + private var fallbackCount = 0 + + func incrementPrimary() { + self.lock.lock() + self.primaryCount += 1 + self.lock.unlock() + } + + func incrementFallback() { + self.lock.lock() + self.fallbackCount += 1 + self.lock.unlock() + } + + func counts() -> (primary: Int, fallback: Int) { + self.lock.lock() + defer { self.lock.unlock() } + return (self.primaryCount, self.fallbackCount) + } + } } diff --git a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift index a8b2656dd7..9e38fb8534 100644 --- a/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbePlanTests.swift @@ -2,10 +2,10 @@ import CodexBarCore import Foundation import Testing -@Suite("Gemini Plan", .serialized) +@Suite(.serialized) struct GeminiStatusProbePlanTests { @Test - func selectsProjectIdForQuotaRequests() async throws { + func `selects project id for quota requests`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -53,10 +53,13 @@ struct GeminiStatusProbePlanTests { let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) let snapshot = try await probe.fetch() #expect(snapshot.modelQuotas.contains { $0.percentLeft == 40 }) + let usage = snapshot.toUsageSnapshot() + #expect(usage.secondary?.remainingPercent == 40) + #expect(usage.tertiary == nil) } @Test - func prefersLoadCodeAssistProjectForQuotaRequests() async throws { + func `prefers load code assist project for quota requests`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -107,10 +110,61 @@ struct GeminiStatusProbePlanTests { let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) let snapshot = try await probe.fetch() #expect(snapshot.modelQuotas.contains { $0.percentLeft == 40 }) + let usage = snapshot.toUsageSnapshot() + #expect(usage.secondary?.remainingPercent == 40) + #expect(usage.tertiary == nil) } @Test - func detectsPaidFromStandardTier() async throws { + func `separates flash and flash lite quota buckets from api response`() async throws { + let env = try GeminiTestEnvironment() + defer { env.cleanup() } + try env.writeCredentials( + accessToken: "token", + refreshToken: nil, + expiry: Date().addingTimeInterval(3600), + idToken: nil) + + let dataLoader = GeminiAPITestHelpers.dataLoader { request in + guard let url = request.url, let host = url.host else { + throw URLError(.badURL) + } + switch host { + case "cloudresourcemanager.googleapis.com": + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.jsonData(["projects": []])) + case "cloudcode-pa.googleapis.com": + if url.path == "/v1internal:loadCodeAssist" { + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.loadCodeAssistStandardTierResponse()) + } + if url.path != "/v1internal:retrieveUserQuota" { + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + return GeminiAPITestHelpers.response( + url: url.absoluteString, + status: 200, + body: GeminiAPITestHelpers.sampleQuotaResponse()) + default: + return GeminiAPITestHelpers.response(url: url.absoluteString, status: 404, body: Data()) + } + } + + let probe = GeminiStatusProbe(timeout: 1, homeDirectory: env.homeURL.path, dataLoader: dataLoader) + let snapshot = try await probe.fetch() + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.remainingPercent == 60.0) + #expect(usage.secondary?.remainingPercent == 90.0) + #expect(usage.tertiary?.remainingPercent == 80.0) + } + + @Test + func `detects paid from standard tier`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -154,7 +208,7 @@ struct GeminiStatusProbePlanTests { } @Test - func detectsWorkspaceFromFreeTierWithHostedDomain() async throws { + func `detects workspace from free tier with hosted domain`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } let idToken = GeminiAPITestHelpers.makeIDToken(email: "user@company.com", hostedDomain: "company.com") @@ -199,7 +253,7 @@ struct GeminiStatusProbePlanTests { } @Test - func detectsFreeFromFreeTierWithoutHostedDomain() async throws { + func `detects free from free tier without hosted domain`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } let idToken = GeminiAPITestHelpers.makeIDToken(email: "user@gmail.com") @@ -244,7 +298,7 @@ struct GeminiStatusProbePlanTests { } @Test - func detectsLegacyFromLegacyTier() async throws { + func `detects legacy from legacy tier`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( @@ -288,7 +342,7 @@ struct GeminiStatusProbePlanTests { } @Test - func leavesBlankWhenLoadCodeAssistFails() async throws { + func `leaves blank when load code assist fails`() async throws { let env = try GeminiTestEnvironment() defer { env.cleanup() } try env.writeCredentials( diff --git a/Tests/CodexBarTests/GeminiStatusProbeTests.swift b/Tests/CodexBarTests/GeminiStatusProbeTests.swift index 7ede0ffc33..a3994c64f1 100644 --- a/Tests/CodexBarTests/GeminiStatusProbeTests.swift +++ b/Tests/CodexBarTests/GeminiStatusProbeTests.swift @@ -2,7 +2,6 @@ import CodexBarCore import Foundation import Testing -@Suite struct GeminiStatusProbeTests { /// Sample /stats output from Gemini CLI (actual format with box-drawing chars) static let sampleStatsOutput = """ @@ -17,14 +16,14 @@ struct GeminiStatusProbeTests { // MARK: - Legacy CLI parsing tests (kept for fallback support) @Test - func parsesMinimumPercentFromMultipleModels() throws { + func `parses minimum percent from multiple models`() throws { let snap = try GeminiStatusProbe.parse(text: Self.sampleStatsOutput) #expect(snap.dailyPercentLeft == 99.8) #expect(snap.resetDescription == "Resets in 20h 37m") } @Test - func parsesLowerPercentCorrectly() throws { + func `parses lower percent correctly`() throws { let output = """ │ Model Usage Reqs Usage left │ │ gemini-2.5-flash 10 85.5% (Resets in 12h) │ @@ -36,7 +35,7 @@ struct GeminiStatusProbeTests { } @Test - func handlesZeroPercentUsage() throws { + func `handles zero percent usage`() throws { let output = """ │ gemini-2.5-flash 50 0.0% (Resets in 6h) │ │ gemini-2.5-pro 20 15.0% (Resets in 6h) │ @@ -47,7 +46,7 @@ struct GeminiStatusProbeTests { } @Test - func handles100PercentRemaining() throws { + func `handles100 percent remaining`() throws { let output = """ │ gemini-2.5-flash - 100.0% (Resets in 24h) │ """ @@ -56,14 +55,14 @@ struct GeminiStatusProbeTests { } @Test - func throwsOnEmptyOutput() { + func `throws on empty output`() { #expect(throws: GeminiStatusProbeError.self) { try GeminiStatusProbe.parse(text: "") } } @Test - func throwsOnNoUsageData() { + func `throws on no usage data`() { let output = """ Welcome to Gemini CLI! Type /help for available commands. @@ -74,7 +73,7 @@ struct GeminiStatusProbeTests { } @Test - func stripsANSICodesBeforeParsing() throws { + func `strips ANSI codes before parsing`() throws { let output = "\u{1B}[32m│\u{1B}[0m gemini-2.5-flash - 75.5% " + "(Resets in 18h) │" @@ -83,7 +82,7 @@ struct GeminiStatusProbeTests { } @Test - func preservesRawText() throws { + func `preserves raw text`() throws { let snap = try GeminiStatusProbe.parse(text: Self.sampleStatsOutput) #expect(snap.rawText == Self.sampleStatsOutput) #expect(snap.accountEmail == nil) // Legacy parse doesn't extract email @@ -91,7 +90,7 @@ struct GeminiStatusProbeTests { } @Test - func parsesVariousResetDescriptions() throws { + func `parses various reset descriptions`() throws { let cases: [(String, String)] = [ ("Resets in 24h", "Resets in 24h"), ("Resets in 1h 30m", "Resets in 1h 30m"), @@ -107,7 +106,7 @@ struct GeminiStatusProbeTests { } @Test - func throwsNotLoggedInOnAuthPrompt() { + func `throws not logged in on auth prompt`() { let authOutputs = [ "Waiting for auth... (Press ESC or CTRL+C to cancel)", "Login with Google\nUse Gemini API key", @@ -123,7 +122,7 @@ struct GeminiStatusProbeTests { // MARK: - Model quota grouping tests @Test - func parsesModelsIntoQuotaArray() throws { + func `parses models into quota array`() throws { let snap = try GeminiStatusProbe.parse(text: Self.sampleStatsOutput) // Should parse multiple models (exact count may change as Google adds/removes models) #expect(snap.modelQuotas.count >= 2) @@ -135,27 +134,29 @@ struct GeminiStatusProbeTests { } @Test - func lowestPercentLeftReturnsMinimum() throws { + func `lowest percent left returns minimum`() throws { let snap = try GeminiStatusProbe.parse(text: Self.sampleStatsOutput) // Flash models are 99.8%, Pro models are 100%, so min should be 99.8 #expect(snap.lowestPercentLeft == 99.8) } @Test - func tierGroupingByKeyword() throws { + func `tier grouping by keyword`() throws { // Test that flash/pro keyword filtering works (model names may change) let snap = try GeminiStatusProbe.parse(text: Self.sampleStatsOutput) - let flashQuotas = snap.modelQuotas.filter { $0.modelId.contains("flash") } + let flashQuotas = snap.modelQuotas.filter { $0.modelId.contains("flash") && !$0.modelId.contains("flash-lite") } + let flashLiteQuotas = snap.modelQuotas.filter { $0.modelId.contains("flash-lite") } let proQuotas = snap.modelQuotas.filter { $0.modelId.contains("pro") } // Should have at least one of each tier in sample #expect(!flashQuotas.isEmpty) + #expect(!flashLiteQuotas.isEmpty) #expect(!proQuotas.isEmpty) } @Test - func tierMinimumCalculation() throws { + func `tier minimum calculation`() throws { // Use controlled test data to verify min-per-tier logic // Model names must start with "gemini-" to match the parser regex let output = """ @@ -177,7 +178,7 @@ struct GeminiStatusProbeTests { } @Test - func quotasHaveResetDescriptions() throws { + func `quotas have reset descriptions`() throws { let snap = try GeminiStatusProbe.parse(text: Self.sampleStatsOutput) // At least some quotas should have reset descriptions @@ -185,10 +186,74 @@ struct GeminiStatusProbeTests { #expect(hasResets) } + @Test + func `to usage snapshot creates separate flash and flash lite meters`() throws { + let snap = try GeminiStatusProbe.parse(text: Self.sampleStatsOutput) + let usage = snap.toUsageSnapshot() + + #expect(usage.primary?.remainingPercent == 100.0) + #expect(usage.secondary?.remainingPercent == 99.8) + #expect(usage.tertiary?.remainingPercent == 99.8) + #expect(usage.primary?.windowMinutes == 1440) + #expect(usage.secondary?.windowMinutes == 1440) + #expect(usage.tertiary?.windowMinutes == 1440) + #expect(usage.secondary?.resetDescription == "Resets in 20h 37m") + #expect(usage.tertiary?.resetDescription == "Resets in 20h 37m") + } + + @Test + func `to usage snapshot does not let flash lite contaminate flash bucket`() throws { + let output = """ + │ gemini-2.5-flash 10 91.0% (Resets in 12h) │ + │ gemini-2.5-flash-lite 5 33.0% (Resets in 6h) │ + │ gemini-2.5-pro 2 80.0% (Resets in 24h) │ + """ + let snap = try GeminiStatusProbe.parse(text: output) + let usage = snap.toUsageSnapshot() + + #expect(usage.secondary?.remainingPercent == 91.0) + #expect(usage.tertiary?.remainingPercent == 33.0) + #expect(usage.secondary?.resetDescription == "Resets in 12h") + #expect(usage.tertiary?.resetDescription == "Resets in 6h") + } + + @Test + func `to usage snapshot omits tertiary when no flash lite exists`() throws { + let output = """ + │ gemini-2.5-flash 10 85.0% (Resets in 12h) │ + │ gemini-2.5-pro 2 95.0% (Resets in 24h) │ + """ + let snap = try GeminiStatusProbe.parse(text: output) + let usage = snap.toUsageSnapshot() + + #expect(usage.secondary?.remainingPercent == 85.0) + #expect(usage.tertiary == nil) + } + + @Test + func `to usage snapshot uses lowest remaining quota per tier`() throws { + let output = """ + │ gemini-a-flash 10 91.0% (Resets in 12h) │ + │ gemini-b-flash 5 74.0% (Resets in 10h) │ + │ gemini-c-flash-lite 8 66.0% (Resets in 9h) │ + │ gemini-d-flash-lite 1 42.0% (Resets in 7h) │ + │ gemini-e-pro 2 88.0% (Resets in 24h) │ + │ gemini-f-pro 1 97.0% (Resets in 25h) │ + """ + let snap = try GeminiStatusProbe.parse(text: output) + let usage = snap.toUsageSnapshot() + + #expect(usage.primary?.remainingPercent == 88.0) + #expect(usage.secondary?.remainingPercent == 74.0) + #expect(usage.tertiary?.remainingPercent == 42.0) + #expect(usage.secondary?.resetDescription == "Resets in 10h") + #expect(usage.tertiary?.resetDescription == "Resets in 7h") + } + // MARK: - Live API test @Test - func liveGeminiFetch() async throws { + func `live gemini fetch`() async throws { guard ProcessInfo.processInfo.environment["LIVE_GEMINI_FETCH"] == "1" else { return } diff --git a/Tests/CodexBarTests/GeminiTestEnvironment.swift b/Tests/CodexBarTests/GeminiTestEnvironment.swift index 3d6b0b4bb6..1d2a427644 100644 --- a/Tests/CodexBarTests/GeminiTestEnvironment.swift +++ b/Tests/CodexBarTests/GeminiTestEnvironment.swift @@ -4,18 +4,26 @@ struct GeminiTestEnvironment { enum GeminiCLILayout { case npmNested case nixShare + case fnmBundle + case homebrewBundle } let homeURL: URL private let geminiDir: URL + private let antigravityDir: URL init() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) let geminiDir = root.appendingPathComponent(".gemini") try FileManager.default.createDirectory(at: geminiDir, withIntermediateDirectories: true) + let antigravityDir = root + .appendingPathComponent(".codexbar") + .appendingPathComponent("antigravity") + try FileManager.default.createDirectory(at: antigravityDir, withIntermediateDirectories: true) self.homeURL = root self.geminiDir = geminiDir + self.antigravityDir = antigravityDir } func cleanup() { @@ -34,11 +42,16 @@ struct GeminiTestEnvironment { try data.write(to: self.geminiDir.appendingPathComponent("settings.json"), options: .atomic) } - func writeCredentials(accessToken: String, refreshToken: String?, expiry: Date, idToken: String?) throws { + func writeCredentials( + accessToken: String?, + refreshToken: String?, + expiry: Date, + idToken: String?) throws + { var payload: [String: Any] = [ - "access_token": accessToken, "expiry_date": expiry.timeIntervalSince1970 * 1000, ] + if let accessToken { payload["access_token"] = accessToken } if let refreshToken { payload["refresh_token"] = refreshToken } if let idToken { payload["id_token"] = idToken } let data = try JSONSerialization.data(withJSONObject: payload) @@ -52,14 +65,45 @@ struct GeminiTestEnvironment { return object as? [String: Any] ?? [:] } + func writeAntigravityCredentials( + accessToken: String, + refreshToken: String?, + expiry: Date, + idToken: String? = nil, + email: String? = nil, + projectID: String? = nil, + clientID: String? = nil, + clientSecret: String? = nil) throws + { + var payload: [String: Any] = [ + "access_token": accessToken, + "expiry_date": expiry.timeIntervalSince1970 * 1000, + ] + if let refreshToken { payload["refresh_token"] = refreshToken } + if let idToken { payload["id_token"] = idToken } + if let email { payload["email"] = email } + if let projectID { payload["project_id"] = projectID } + if let clientID { payload["client_id"] = clientID } + if let clientSecret { payload["client_secret"] = clientSecret } + let data = try JSONSerialization.data(withJSONObject: payload) + try data.write(to: self.antigravityDir.appendingPathComponent("oauth_creds.json"), options: .atomic) + } + + func readAntigravityCredentials() throws -> [String: Any] { + let url = self.antigravityDir.appendingPathComponent("oauth_creds.json") + let data = try Data(contentsOf: url) + let object = try JSONSerialization.jsonObject(with: data) + return object as? [String: Any] ?? [:] + } + func writeFakeGeminiCLI(includeOAuth: Bool = true, layout: GeminiCLILayout = .npmNested) throws -> URL { let base = self.homeURL.appendingPathComponent("gemini-cli") let binDir = base.appendingPathComponent("bin") try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) - let oauthPath: URL = switch layout { + switch layout { case .npmNested: - base + let oauthPath = base .appendingPathComponent("lib") .appendingPathComponent("node_modules") .appendingPathComponent("@google") @@ -71,8 +115,28 @@ struct GeminiTestEnvironment { .appendingPathComponent("src") .appendingPathComponent("code_assist") .appendingPathComponent("oauth2.js") + + if includeOAuth { + try FileManager.default.createDirectory( + at: oauthPath.deletingLastPathComponent(), + withIntermediateDirectories: true) + + let oauthContent = """ + const OAUTH_CLIENT_ID = 'test-client-id'; + const OAUTH_CLIENT_SECRET = 'test-client-secret'; + """ + try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8) + } + + let geminiBinary = binDir.appendingPathComponent("gemini") + try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: geminiBinary.path) + return geminiBinary + case .nixShare: - base + let oauthPath = base .appendingPathComponent("share") .appendingPathComponent("gemini-cli") .appendingPathComponent("node_modules") @@ -82,25 +146,226 @@ struct GeminiTestEnvironment { .appendingPathComponent("src") .appendingPathComponent("code_assist") .appendingPathComponent("oauth2.js") + + if includeOAuth { + try FileManager.default.createDirectory( + at: oauthPath.deletingLastPathComponent(), + withIntermediateDirectories: true) + + let oauthContent = """ + const OAUTH_CLIENT_ID = 'test-client-id'; + const OAUTH_CLIENT_SECRET = 'test-client-secret'; + """ + try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8) + } + + let geminiBinary = binDir.appendingPathComponent("gemini") + try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: geminiBinary.path) + return geminiBinary + + case .fnmBundle: + // Mirror a real fnm multishell layout: bin/gemini is a single relative + // symlink into the same multishell's lib/node_modules/@google/gemini-cli, + // which is a plain directory with the real package.json + bundle/*.js. + let multishellRoot = self.homeURL + .appendingPathComponent("Library") + .appendingPathComponent("Caches") + .appendingPathComponent("fnm_multishells") + .appendingPathComponent("12345_67890") + let binDir = multishellRoot.appendingPathComponent("bin") + let packageRoot = multishellRoot + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + let bundleDir = packageRoot.appendingPathComponent("bundle") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true) + + let packageJSON = """ + { + "name": "@google/gemini-cli" + } + """ + try packageJSON.write( + to: packageRoot.appendingPathComponent("package.json"), + atomically: true, + encoding: .utf8) + + let chunkName = "chunk-TEST123.js" + let geminiEntry = bundleDir.appendingPathComponent("gemini.js") + let geminiContent = """ + #!/usr/bin/env node + import { start } from "./\(chunkName)"; + start(); + """ + try geminiContent.write(to: geminiEntry, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: geminiEntry.path) + + let chunkContent = if includeOAuth { + """ + export const start = () => {}; + const OAUTH_CLIENT_ID = 'test-client-id'; + const OAUTH_CLIENT_SECRET = 'test-client-secret'; + """ + } else { + "export const start = () => {};\n" + } + try chunkContent.write( + to: bundleDir.appendingPathComponent(chunkName), + atomically: true, + encoding: .utf8) + + // Relative symlink matching what fnm actually creates: + // fnm_multishells/XXX/bin/gemini -> ../lib/node_modules/@google/gemini-cli/bundle/gemini.js + // Use the path-based API so the target is stored as a literal relative + // string; the URL-based API resolves URL(fileURLWithPath: "../...") against + // the process CWD, which produces a bogus absolute target. + let geminiBinary = binDir.appendingPathComponent("gemini") + try FileManager.default.createSymbolicLink( + atPath: geminiBinary.path, + withDestinationPath: "../lib/node_modules/@google/gemini-cli/bundle/gemini.js") + + return geminiBinary + + case .homebrewBundle: + return try self.writeFakeHomebrewGeminiCLI(base: base, includeOAuth: includeOAuth) } + } - if includeOAuth { - try FileManager.default.createDirectory( - at: oauthPath.deletingLastPathComponent(), - withIntermediateDirectories: true) + private func writeFakeHomebrewGeminiCLI(base: URL, includeOAuth: Bool) throws -> URL { + let cellarRoot = base + .appendingPathComponent("Cellar") + .appendingPathComponent("gemini-cli") + .appendingPathComponent("0.41.2") + let binDir = cellarRoot.appendingPathComponent("bin") + let packageRoot = cellarRoot + .appendingPathComponent("libexec") + .appendingPathComponent("lib") + .appendingPathComponent("node_modules") + .appendingPathComponent("@google") + .appendingPathComponent("gemini-cli") + let bundleDir = packageRoot.appendingPathComponent("bundle") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: bundleDir, withIntermediateDirectories: true) - let oauthContent = """ - const OAUTH_CLIENT_ID = 'test-client-id'; - const OAUTH_CLIENT_SECRET = 'test-client-secret'; + let packageJSON = """ + { + "name": "@google/gemini-cli" + } + """ + try packageJSON.write( + to: packageRoot.appendingPathComponent("package.json"), + atomically: true, + encoding: .utf8) + + let entry = bundleDir.appendingPathComponent("gemini.js") + try "#!/usr/bin/env node\nawait import('./gemini-HASH.js');\n".write( + to: entry, + atomically: true, + encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: entry.path) + try "export const run = () => {};\n".write( + to: bundleDir.appendingPathComponent("gemini-HASH.js"), + atomically: true, + encoding: .utf8) + + let chunkContent = if includeOAuth { """ - try oauthContent.write(to: oauthPath, atomically: true, encoding: .utf8) + var OAUTH_CLIENT_ID = "test-client-id"; + var OAUTH_CLIENT_SECRET = "test-client-secret"; + """ + } else { + "export const unrelated = true;\n" } + try chunkContent.write( + to: bundleDir.appendingPathComponent("chunk-OAUTH.js"), + atomically: true, + encoding: .utf8) let geminiBinary = binDir.appendingPathComponent("gemini") - try "#!/bin/bash\nexit 0\n".write(to: geminiBinary, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink( + atPath: geminiBinary.path, + withDestinationPath: "../libexec/lib/node_modules/@google/gemini-cli/bundle/gemini.js") + return geminiBinary + } + + func writeFakeFnm( + currentVersion: String = "v24.6.0", + npmRoot: String? = nil, + geminiPackageJSONPath: String, + holdNpmRootStdoutOpen: Bool = false) throws -> URL + { + let binDir = self.homeURL.appendingPathComponent("bin") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + + let fnmPath = binDir.appendingPathComponent("fnm") + let stdoutHolder = holdNpmRootStdoutOpen + ? #""" + python3 - <<'PY' + import os + import subprocess + import sys + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(5)"], + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle: + handle.write(str(child.pid)) + PY + """# + : ":" + let script = if let npmRoot { + """ + #!/bin/bash + if [ "$1" = "current" ]; then + printf '%s\n' "\(currentVersion)" + exit 0 + fi + + if [ "$1" = "exec" ] && [ "$4" = "npm" ] && [ "$5" = "root" ] && [ "$6" = "-g" ]; then + \(stdoutHolder) + printf '%s\n' "\(npmRoot)" + exit 0 + fi + + if [ "$1" = "exec" ] && [ "$4" = "node" ]; then + printf '%s\n' "\(geminiPackageJSONPath)" + exit 0 + fi + + exit 1 + """ + } else { + """ + #!/bin/bash + if [ "$1" = "current" ]; then + printf '%s\n' "\(currentVersion)" + exit 0 + fi + + if [ "$1" = "exec" ]; then + printf '%s\n' "\(geminiPackageJSONPath)" + exit 0 + fi + + exit 1 + """ + } + try script.write(to: fnmPath, atomically: true, encoding: .utf8) try FileManager.default.setAttributes( [.posixPermissions: 0o755], - ofItemAtPath: geminiBinary.path) - return geminiBinary + ofItemAtPath: fnmPath.path) + return fnmPath } } diff --git a/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift b/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift new file mode 100644 index 0000000000..1d51d3dfa2 --- /dev/null +++ b/Tests/CodexBarTests/GoogleWorkspaceStatusNetworkTests.swift @@ -0,0 +1,70 @@ +import Foundation +import os +import Testing +@testable import CodexBar + +@MainActor +struct GoogleWorkspaceStatusNetworkTests { + @Test + func `fetchWorkspaceStatus uses shared client`() async throws { + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + let body = Data(#""" + [ + { + "begin": "2026-05-10T10:00:00+00:00", + "end": null, + "affected_products": [ + {"title": "Gemini", "id": "npdyhgECDJ6tB66MxXyo"} + ], + "most_recent_update": { + "when": "2026-05-10T10:15:00+00:00", + "status": "SERVICE_OUTAGE", + "text": "**Summary**\nGemini API error.\n" + } + } + ] + """#.utf8) + return (body, response) + } + + let status = try await UsageStore.fetchWorkspaceStatus( + productID: "npdyhgECDJ6tB66MxXyo", + transport: transport) + + #expect(status.indicator == .critical) + #expect(status.description == "Gemini API error.") + let requests = await transport.requests() + #expect(requests.count == 1) + #expect(requests.first?.url?.host == "www.google.com") + } + + @Test + func `fetchWorkspaceStatus decodes off the main thread when called from the main actor`() async throws { + // The incidents feed can run to hundreds of kilobytes; decoding it on the main + // actor stalls the UI for 150-340ms per Google-status provider per refresh (#1399). + let decodedOffMainThread = OSAllocatedUnfairLock(initialState: false) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data("[]".utf8), response) + } + + let status = try await UsageStore.fetchWorkspaceStatus( + productID: "npdyhgECDJ6tB66MxXyo", + transport: transport, + beforeDecoding: { + decodedOffMainThread.withLock { $0 = !Thread.isMainThread } + }) + + #expect(status.indicator == .none) + #expect(decodedOffMainThread.withLock { $0 }) + } +} diff --git a/Tests/CodexBarTests/GoogleWorkspaceStatusTests.swift b/Tests/CodexBarTests/GoogleWorkspaceStatusTests.swift index bcab931a80..1b37b9bb19 100644 --- a/Tests/CodexBarTests/GoogleWorkspaceStatusTests.swift +++ b/Tests/CodexBarTests/GoogleWorkspaceStatusTests.swift @@ -2,13 +2,12 @@ import Foundation import Testing @testable import CodexBar -@Suite @MainActor struct GoogleWorkspaceStatusTests { private let productID = "npdyhgECDJ6tB66MxXyo" @Test - func parseWorkspaceStatusSelectsWorstIncident() throws { + func `parse workspace status selects worst incident`() throws { let data = Data(#""" [ { @@ -47,7 +46,7 @@ struct GoogleWorkspaceStatusTests { } @Test - func parseWorkspaceStatusIgnoresResolvedIncidents() throws { + func `parse workspace status ignores resolved incidents`() throws { let data = Data(#""" [ { diff --git a/Tests/CodexBarTests/GrokAuthTests.swift b/Tests/CodexBarTests/GrokAuthTests.swift new file mode 100644 index 0000000000..a2ed52e554 --- /dev/null +++ b/Tests/CodexBarTests/GrokAuthTests.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokAuthTests { + @Test + func `parses OIDC SuperGrok entry`() throws { + let json = #""" + { + "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828": { + "key": "secret-access-token-123", + "auth_mode": "oidc", + "create_time": "2026-05-15T13:31:33.384327Z", + "user_id": "user-uuid", + "email": "user@example.com", + "first_name": "Ada", + "last_name": "Lovelace", + "team_id": "team-uuid", + "refresh_token": "refresh-secret", + "expires_at": "2026-05-22T19:31:33.384327Z", + "oidc_issuer": "https://auth.x.ai", + "oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828" + } + } + """# + let data = Data(json.utf8) + let creds = try GrokCredentialsStore.parse(data: data) + + #expect(creds.accessToken == "secret-access-token-123") + #expect(creds.refreshToken == "refresh-secret") + #expect(creds.email == "user@example.com") + #expect(creds.teamId == "team-uuid") + #expect(creds.authMode == "oidc") + #expect(creds.displayName == "Ada Lovelace") + #expect(creds.loginMethod == "SuperGrok") + #expect(creds.expiresAt != nil) + } + + @Test + func `falls back to legacy session scope when OIDC absent`() throws { + let json = #""" + { + "https://accounts.x.ai/sign-in": { + "key": "legacy-token", + "auth_mode": "session", + "email": "legacy@example.com" + } + } + """# + let data = Data(json.utf8) + let creds = try GrokCredentialsStore.parse(data: data) + #expect(creds.accessToken == "legacy-token") + #expect(creds.email == "legacy@example.com") + #expect(creds.loginMethod == "session") + } + + @Test + func `throws missingTokens when key absent`() { + let json = #"{"https://auth.x.ai::abc": {"auth_mode": "oidc"}}"# + let data = Data(json.utf8) + #expect(throws: GrokCredentialsError.self) { + _ = try GrokCredentialsStore.parse(data: data) + } + } + + @Test + func `throws decodeFailed when JSON is invalid`() { + let data = Data("not-json".utf8) + #expect(throws: GrokCredentialsError.self) { + _ = try GrokCredentialsStore.parse(data: data) + } + } + + @Test + func `isExpired reflects past expires_at`() throws { + // Past expiry + let pastJson = #""" + { + "https://auth.x.ai::client": { + "key": "stale-token", + "expires_at": "2020-01-01T00:00:00Z" + } + } + """# + let past = try GrokCredentialsStore.parse(data: Data(pastJson.utf8)) + #expect(past.isExpired == true) + + // Future expiry + let futureJson = #""" + { + "https://auth.x.ai::client": { + "key": "fresh-token", + "expires_at": "2099-01-01T00:00:00Z" + } + } + """# + let future = try GrokCredentialsStore.parse(data: Data(futureJson.utf8)) + #expect(future.isExpired == false) + + // Missing expires_at — treated as non-expired so we never spuriously lock + // out clients whose auth.json shape predates this field. + let noExpiryJson = #""" + { + "https://auth.x.ai::client": { + "key": "ageless-token" + } + } + """# + let noExpiry = try GrokCredentialsStore.parse(data: Data(noExpiryJson.utf8)) + #expect(noExpiry.isExpired == false) + } + + @Test + func `expired credentials are preserved when billing succeeds`() throws { + let pastJson = #""" + { + "https://auth.x.ai::client": { + "key": "stale-token", + "email": "grok@example.com", + "team_id": "team_123", + "expires_at": "2020-01-01T00:00:00Z" + } + } + """# + let expired = try GrokCredentialsStore.parse(data: Data(pastJson.utf8)) + let billing = try JSONDecoder().decode(GrokBillingResponse.self, from: Data(#"{}"#.utf8)) + let webBilling = GrokWebBillingSnapshot( + usedPercent: 42, + resetsAt: Date(timeIntervalSince1970: 1_800_000_000)) + + #expect(GrokStatusProbe.credentialsForSnapshot(credentials: expired, billing: nil) == nil) + #expect(GrokStatusProbe.credentialsForSnapshot(credentials: expired, billing: billing)? + .email == "grok@example.com") + #expect(GrokStatusProbe.credentialsForSnapshot(credentials: expired, billing: nil, webBilling: webBilling)? + .email == "grok@example.com") + } + + @Test + func `remote auth failures surface even with fresh local credentials`() { + #expect(GrokStatusProbe.shouldSurfaceRemoteAuthError(GrokWebBillingError.requestFailed(401, "unauthorized"))) + #expect(GrokStatusProbe.shouldSurfaceRemoteAuthError(GrokWebBillingError.requestFailed(403, "forbidden"))) + #expect(GrokStatusProbe.shouldSurfaceRemoteAuthError(GrokWebBillingError.rpcFailed(16, "token expired"))) + #expect(!GrokStatusProbe.shouldSurfaceRemoteAuthError(GrokWebBillingError.parseFailed)) + } + + @Test + func `falls back to legacy when OIDC entry has no key`() throws { + // A stale/partial OIDC record must not shadow a healthy legacy session. + let json = #""" + { + "https://auth.x.ai::stale-client": { + "auth_mode": "oidc", + "email": "stale@example.com" + }, + "https://accounts.x.ai/sign-in": { + "key": "healthy-legacy-token", + "auth_mode": "session", + "email": "healthy@example.com" + } + } + """# + let data = Data(json.utf8) + let creds = try GrokCredentialsStore.parse(data: data) + #expect(creds.accessToken == "healthy-legacy-token") + #expect(creds.email == "healthy@example.com") + } + + @Test + func `prefers OIDC entry over legacy session when both present`() throws { + let json = #""" + { + "https://accounts.x.ai/sign-in": { + "key": "legacy-should-not-win", + "auth_mode": "session" + }, + "https://auth.x.ai::client-id": { + "key": "oidc-wins", + "auth_mode": "oidc", + "email": "preferred@example.com" + } + } + """# + let data = Data(json.utf8) + let creds = try GrokCredentialsStore.parse(data: data) + #expect(creds.accessToken == "oidc-wins") + #expect(creds.email == "preferred@example.com") + } +} diff --git a/Tests/CodexBarTests/GrokBillingResponseTests.swift b/Tests/CodexBarTests/GrokBillingResponseTests.swift new file mode 100644 index 0000000000..f0c5e27af6 --- /dev/null +++ b/Tests/CodexBarTests/GrokBillingResponseTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct GrokBillingResponseTests { + @Test + func `decodes full BillingConfigResponse and computes percent`() throws { + let json = #""" + { + "billingCycle": { + "billingPeriodStart": "2026-05-01T00:00:00Z", + "billingPeriodEnd": "2026-06-01T00:00:00Z" + }, + "monthlyLimit": { "val": 99900 }, + "onDemandCap": { "val": 0 }, + "on_demand_enabled": false, + "disabledByConfig": false, + "usage": { + "includedUsed": { "val": 49950 }, + "onDemandUsed": { "val": 0 }, + "totalUsed": { "val": 49950 } + } + } + """# + let data = Data(json.utf8) + let response = try JSONDecoder().decode(GrokBillingResponse.self, from: data) + + #expect(response.monthlyLimit?.val == 99900) + #expect(response.usage?.totalUsed?.val == 49950) + #expect(response.monthlyUsedPercent == 50.0) + #expect(response.billingPeriodEndDate != nil) + #expect(response.billingPeriodMinutes == 31 * 24 * 60) + } + + @Test + func `monthlyUsedPercent returns nil when limit missing`() throws { + let json = #""" + { + "usage": { "totalUsed": { "val": 100 } } + } + """# + let data = Data(json.utf8) + let response = try JSONDecoder().decode(GrokBillingResponse.self, from: data) + #expect(response.monthlyUsedPercent == nil) + } + + @Test + func `monthlyUsedPercent clamps over-100 usage`() throws { + let json = #""" + { + "monthlyLimit": { "val": 1000 }, + "usage": { "totalUsed": { "val": 5000 } } + } + """# + let data = Data(json.utf8) + let response = try JSONDecoder().decode(GrokBillingResponse.self, from: data) + #expect(response.monthlyUsedPercent == 100.0) + } + + @Test + func `handles missing optional fields gracefully`() throws { + let json = #"{}"# + let data = Data(json.utf8) + let response = try JSONDecoder().decode(GrokBillingResponse.self, from: data) + #expect(response.billingCycle == nil) + #expect(response.monthlyLimit == nil) + #expect(response.monthlyUsedPercent == nil) + } +} diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift new file mode 100644 index 0000000000..408b6a7f87 --- /dev/null +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -0,0 +1,883 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct GrokWebBillingFetcherTests { + private final class AttemptCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + + func increment() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + self.value += 1 + return self.value + } + + func current() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + + @Test + func `provider exposes cli and web source modes`() { + #expect(GrokProviderDescriptor.descriptor.fetchPlan.sourceModes == [.auto, .cli, .web]) + } + + @Test + func `descriptor uses Credits label for primary usage window`() { + let metadata = GrokProviderDescriptor.descriptor.metadata + #expect(metadata.sessionLabel == "Credits") + #expect(metadata.weeklyLabel == "On-demand") + #expect(!metadata.supportsOpus) + } + + @Test + func `primaryLabel derives Weekly or Monthly from resetsAt`() { + let now = Date() + let in6Days = now.addingTimeInterval(6 * 86400) + let in30Days = now.addingTimeInterval(30 * 86400) + let in90Days = now.addingTimeInterval(90 * 86400) + let lateWeeklyWindow = RateWindow( + usedPercent: 25, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(86400), + resetDescription: nil) + + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in6Days, now: now) == "Weekly") + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in30Days, now: now) == "Monthly") + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in90Days, now: now) == nil) + #expect(GrokProviderDescriptor.primaryLabel(window: lateWeeklyWindow, now: now) == "Weekly") + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: nil) == nil) + } + + @Test + func `cli runtime does not import browser cookies unless explicitly enabled`() { + #expect(GrokWebFetchStrategy.canImportBrowserCookies(runtime: .app, env: [:])) + #expect(!GrokWebFetchStrategy.canImportBrowserCookies(runtime: .cli, env: [:])) + #expect(GrokWebFetchStrategy.canImportBrowserCookies( + runtime: .cli, + env: ["CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT": "1"])) + } + + @Test + func `web strategy tries later browser session when first cookie is stale`() async throws { + let stale = try #require(Self.cookie(name: "sso", value: "stale")) + let valid = try #require(Self.cookie(name: "sso", value: "valid")) + let sessions = [ + GrokCookieImporter.SessionInfo(cookies: [stale], sourceLabel: "Chrome Profile 1"), + GrokCookieImporter.SessionInfo(cookies: [valid], sourceLabel: "Chrome Profile 2"), + ] + var attemptedHeaders: [String] = [] + + let result = try await GrokWebFetchStrategy.fetchFirstValidCookieSession(sessions) { cookieHeader, _ in + attemptedHeaders.append(cookieHeader) + guard cookieHeader.contains("valid") else { + throw GrokWebBillingError.requestFailed(401, "stale") + } + return GrokWebBillingSnapshot( + usedPercent: 12, + resetsAt: Date(timeIntervalSince1970: 1_800_000_000)) + } + + #expect(attemptedHeaders == ["sso=stale", "sso=valid"]) + #expect(result.0.usedPercent == 12) + #expect(result.1 == "Chrome Profile 2") + } + + @Test + func `cookie authenticated web billing does not reuse auth file identity`() { + #expect(GrokWebFetchStrategy.credentialsForWebBillingSnapshot( + credentials: Self.credentials, + authenticatedByAuthFile: false) == nil) + #expect(GrokWebFetchStrategy.credentialsForWebBillingSnapshot( + credentials: Self.credentials, + authenticatedByAuthFile: true)? + .email == "grok@example.com") + } + + @Test + func `parses grok grpc web billing frame`() throws { + let reset = UInt64(1_800_000_000) + let payload = Self.protobufPayload(usedPercent: 42.5, resetEpoch: reset) + let data = Self.grpcFrame(payload) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + data, + now: Date(timeIntervalSince1970: 1_799_000_000)) + + #expect(snapshot.usedPercent == 42.5) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) + } + + @Test + func `parses unframed grok billing protobuf payload`() throws { + let hex = + "0a3f0d7f6a9c3f12001a002206088097f3d0062a060880b191d2063a07080215a9389b3f3a07080115d6ea183c" + + "421208011206088097f3d0061a060880b191d206" + let data = try #require(Self.data(hexString: hex)) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + data, + now: Date(timeIntervalSince1970: 1_780_000_000)) + + #expect(snapshot.usedPercent == 1.222000002861023) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_782_864_000)) + } + + @Test + func `parses unframed zero percent payload that resembles an empty grpc frame`() throws { + let reset = UInt64(1_800_000_000) + let payload = Self.protobufPayload(usedPercent: 0, resetEpoch: reset) + + #expect(GrokWebBillingFetcher.grpcWebDataFrames(from: payload).isEmpty) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + payload, + now: Date(timeIntervalSince1970: 1_799_000_000)) + + #expect(snapshot.usedPercent == 0) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) + } + + @Test + func `does not treat grpc frame prefix as raw protobuf`() { + #expect(!GrokWebBillingFetcher.looksLikeProtobufPayload(Data([0, 0, 0, 0, 10]))) + } + + @Test + func `web strategy tries cookie plus bearer before cookie only`() async throws { + let cookie = try #require(Self.cookie(name: "sso", value: "session")) + let sessions = [GrokCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome")] + var attempts: [String] = [] + + let result = try await GrokWebFetchStrategy.fetchFirstValidCookieSession( + sessions, + credentials: Self.credentials) + { _, authCredentials in + let mode = authCredentials == nil ? "cookie-only" : "cookie+bearer" + attempts.append(mode) + guard mode == "cookie+bearer" else { + throw GrokWebBillingError.requestFailed(401, "needs bearer") + } + return GrokWebBillingSnapshot(usedPercent: 9, resetsAt: nil) + } + + #expect(attempts == ["cookie+bearer"]) + #expect(result.0.usedPercent == 9) + } + + @Test + func `web strategy skips expired bearer for browser cookies`() async throws { + let cookie = try #require(Self.cookie(name: "sso", value: "session")) + let sessions = [GrokCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome")] + let expired = GrokCredentials( + accessToken: "expired-token", + refreshToken: nil, + scope: "https://auth.x.ai::client", + authMode: "oidc", + userId: nil, + email: nil, + firstName: nil, + lastName: nil, + teamId: nil, + oidcIssuer: nil, + oidcClientId: nil, + expiresAt: .distantPast, + createTime: nil) + var attempts: [String] = [] + + _ = try await GrokWebFetchStrategy.fetchFirstValidCookieSession( + sessions, + credentials: expired) + { _, authCredentials in + attempts.append(authCredentials == nil ? "cookie-only" : "cookie+bearer") + return GrokWebBillingSnapshot(usedPercent: 9, resetsAt: nil) + } + + #expect(attempts == ["cookie-only"]) + } + + @Test + func `web strategy preserves malformed auth file error`() async throws { + let grokHome = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-GrokWebBilling-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: grokHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: grokHome) } + try Data("not-json".utf8).write(to: grokHome.appendingPathComponent("auth.json")) + + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: ["GROK_HOME": grokHome.path], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + + await #expect { + _ = try await GrokWebFetchStrategy().fetch(context) + } throws: { error in + guard case GrokCredentialsError.decodeFailed = error else { return false } + return true + } + } + + @Test + func `status seven scope failure is not classified as bad credentials`() { + #expect(!GrokWebBillingError.isAuthenticationFailure( + status: 7, + message: "OAuth2 access token lacks the required billing scope")) + } + + @Test + func `ignores grpc web trailer frames`() { + let payload = Self.protobufPayload(usedPercent: 12.25, resetEpoch: 1_800_000_001) + let trailer = Data("grpc-status: 0\r\n".utf8) + let data = Self.grpcFrame(payload) + Self.grpcFrame(trailer, flags: 0x80) + + let frames = GrokWebBillingFetcher.grpcWebDataFrames(from: data) + + #expect(frames == [payload]) + } + + @Test + func `web fetch turns grpc unauthenticated trailer into reauth guidance`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let body = Self.grpcFrame(Data("grpc-status: 16\r\ngrpc-message: token%20expired\r\n".utf8), flags: 0x80) + + #expect(GrokWebBillingFetcher.grpcWebTrailerFields(from: body)["grpc-status"] == "16") + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + return (response, body) + } + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + } throws: { error in + error.localizedDescription.contains("grok login") + } + } + + @Test + func `web fetch turns grpc unauthenticated headers into reauth guidance`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: [ + "Content-Type": "application/grpc-web+proto", + "grpc-status": "16", + "grpc-message": "Invalid%20bearer%20token.", + ])! + return (response, Data()) + } + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + } throws: { error in + error.localizedDescription.contains("grok login") + } + } + + @Test + func `web fetch turns grpc permission denied bad credentials into reauth guidance`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let message = "The OAuth2 access token could not be validated. [WKE=unauthenticated:bad-credentials]" + let encodedMessage = message.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? message + let body = Self.grpcFrame( + Data("grpc-status: 7\r\ngrpc-message: \(encodedMessage)\r\n".utf8), + flags: 0x80) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + return (response, body) + } + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + } throws: { error in + error.localizedDescription.contains("grok.com") && + error.localizedDescription.contains("grok login") && + !error.localizedDescription.contains("status 7") + } + } + + @Test + func `rejects reset only billing because it cannot render usage`() { + var payload = Data() + payload.append(0x10) // field 2, varint reset timestamp + payload.append(contentsOf: Self.varint(1_800_000_001)) + + #expect { + _ = try GrokWebBillingFetcher.parseGRPCWebResponse(Self.grpcFrame(payload)) + } throws: { error in + guard case GrokWebBillingError.parseFailed = error else { return false } + return true + } + } + + @Test + func `parses grok no usage yet billing response as zero percent`() throws { + let data = Data([ + 0x00, 0x00, 0x00, 0x00, 0x37, 0x0A, 0x35, 0x12, + 0x00, 0x1A, 0x00, 0x22, 0x06, 0x08, 0x80, 0xDA, + 0xCF, 0xCF, 0x06, 0x2A, 0x06, 0x08, 0x80, 0x97, + 0xF3, 0xD0, 0x06, 0x32, 0x09, 0x0A, 0x05, 0x08, + 0xEA, 0x0F, 0x10, 0x04, 0x12, 0x00, 0x32, 0x09, + 0x0A, 0x05, 0x08, 0xEA, 0x0F, 0x10, 0x03, 0x12, + 0x00, 0x32, 0x09, 0x0A, 0x05, 0x08, 0xEA, 0x0F, + 0x10, 0x02, 0x12, 0x00, 0x80, 0x00, 0x00, 0x00, + 0x0F, 0x67, 0x72, 0x70, 0x63, 0x2D, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x3A, 0x30, 0x0D, 0x0A, + ]) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + data, + now: Date(timeIntervalSince1970: 1_768_000_000)) + + #expect(snapshot.usedPercent == 0) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_780_272_000)) + } + + @Test + func `parses omitted zero percent with current billing period`() throws { + let data = Data([ + 0x00, 0x00, 0x00, 0x00, 0x2A, 0x0A, 0x28, 0x12, + 0x00, 0x1A, 0x00, 0x22, 0x06, 0x08, 0x80, 0x97, + 0xF3, 0xD0, 0x06, 0x2A, 0x06, 0x08, 0x80, 0xB1, + 0x91, 0xD2, 0x06, 0x42, 0x12, 0x08, 0x01, 0x12, + 0x06, 0x08, 0x80, 0x97, 0xF3, 0xD0, 0x06, 0x1A, + 0x06, 0x08, 0x80, 0xB1, 0x91, 0xD2, 0x06, 0x80, + 0x00, 0x00, 0x00, 0x0F, 0x67, 0x72, 0x70, 0x63, + 0x2D, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x3A, + 0x30, 0x0D, 0x0A, + ]) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + data, + now: Date(timeIntervalSince1970: 1_781_000_000)) + + #expect(snapshot.usedPercent == 0) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_782_864_000)) + } + + @Test + func `uses billing field one instead of earlier unrelated float`() throws { + var payload = Data() + payload.append(0x4D) // field 9, fixed32 unrelated in-range float + var unrelatedBits = Float(7).bitPattern.littleEndian + withUnsafeBytes(of: &unrelatedBits) { payload.append(contentsOf: $0) } + payload.append(0x0D) // field 1, fixed32 billing usage percent + var usageBits = Float(42).bitPattern.littleEndian + withUnsafeBytes(of: &usageBits) { payload.append(contentsOf: $0) } + payload.append(0x10) // field 2, varint reset timestamp + payload.append(contentsOf: Self.varint(1_800_000_001)) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse(Self.grpcFrame(payload)) + + #expect(snapshot.usedPercent == 42) + } + + @Test + func `chooses future billing end instead of recent billing start`() throws { + let recentStart = UInt64(1_800_000_000) + let billingEnd = UInt64(1_802_592_000) + var payload = Data() + payload.append(0x0D) // field 1, fixed32 usage percent + var percentBits = Float(33).bitPattern.littleEndian + withUnsafeBytes(of: &percentBits) { payload.append(contentsOf: $0) } + payload.append(0x10) // field 2, varint billing start + payload.append(contentsOf: Self.varint(recentStart)) + payload.append(0x18) // field 3, varint billing end + payload.append(contentsOf: Self.varint(billingEnd)) + + let snapshot = try GrokWebBillingFetcher.parseGRPCWebResponse( + Self.grpcFrame(payload), + now: Date(timeIntervalSince1970: TimeInterval(recentStart + 1800))) + + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(billingEnd))) + } + + @Test + func `web fetch posts grpc web request with bearer token`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let reset = UInt64(1_800_000_002) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(url == endpoint) + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer token-123") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://grok.com") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://grok.com/?_s=usage") + #expect(request.value(forHTTPHeaderField: "Accept") == "*/*") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/grpc-web+proto") + #expect(request.value(forHTTPHeaderField: "x-grpc-web") == "1") + #expect(request.timeoutInterval == 15) + + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + let body = Self.grpcFrame(Self.protobufPayload(usedPercent: 55.5, resetEpoch: reset)) + return (response, body) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + + #expect(GrokWebBillingStubURLProtocol.requests.count == 1) + #expect(GrokWebBillingStubURLProtocol.requestBodies == [Data([0x00, 0x00, 0x00, 0x00, 0x00])]) + #expect(snapshot.usedPercent == 55.5) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) + } + + @Test + func `web fetch retries transient grpc timeout once`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let reset = UInt64(1_800_000_005) + let attempts = AttemptCounter() + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let attempt = attempts.increment() + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + if attempt == 1 { + let body = Self.grpcFrame( + Data("grpc-status: 1\r\ngrpc-message: Timeout%20expired\r\n".utf8), + flags: 0x80) + return (response, body) + } + return (response, Self.grpcFrame(Self.protobufPayload(usedPercent: 25, resetEpoch: reset))) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + + #expect(attempts.current() == 2) + #expect(GrokWebBillingStubURLProtocol.requests.count == 2) + #expect(snapshot.usedPercent == 25) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: TimeInterval(reset))) + } + + @Test + func `web fetch retries grpc deadline exceeded without message`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let attempts = AttemptCounter() + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let attempt = attempts.increment() + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + if attempt == 1 { + return (response, Self.grpcFrame(Data("grpc-status: 4\r\n".utf8), flags: 0x80)) + } + return (response, Self.grpcFrame(Self.protobufPayload(usedPercent: 25, resetEpoch: 1_800_000_005))) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + + #expect(attempts.current() == 2) + #expect(snapshot.usedPercent == 25) + } + + @Test + func `web fetch retries HTTP gateway timeout once`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + let attempts = AttemptCounter() + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let attempt = attempts.increment() + let url = try #require(request.url) + let statusCode = attempt == 1 ? 504 : 200 + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + if attempt == 1 { + return (response, Data("gateway timeout".utf8)) + } + return (response, Self.grpcFrame(Self.protobufPayload(usedPercent: 25, resetEpoch: 1_800_000_005))) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + + #expect(attempts.current() == 2) + #expect(snapshot.usedPercent == 25) + } + + @Test + func `web fetch can authenticate with browser cookies`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "sso=session; sso-rw=session") + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + #expect(request.value(forHTTPHeaderField: "x-user-agent") == "connect-es/2.1.1") + let response = HTTPURLResponse( + url: endpoint, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + let body = Self.grpcFrame(Self.protobufPayload(usedPercent: 9, resetEpoch: 1_800_000_004)) + return (response, body) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + cookieHeader: "sso=session; sso-rw=session", + session: session, + endpoint: endpoint) + + #expect(snapshot.usedPercent == 9) + } + + @Test + func `web fetch sends browser cookies with bearer credentials`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "sso=session") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer token-123") + let response = HTTPURLResponse( + url: endpoint, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/grpc-web+proto"])! + return (response, Self.protobufPayload(usedPercent: 9, resetEpoch: 1_800_000_004)) + } + + let snapshot = try await GrokWebBillingFetcher.fetch( + cookieHeader: "sso=session", + credentials: Self.credentials, + session: session, + endpoint: endpoint) + + #expect(snapshot.usedPercent == 9) + } + + @Test + func `web fetch turns unauthorized response into reauth guidance`() async throws { + defer { + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [GrokWebBillingStubURLProtocol.self] + let session = URLSession(configuration: config) + let endpoint = try #require(URL(string: "https://grok.test/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig")) + + GrokWebBillingStubURLProtocol.requests = [] + GrokWebBillingStubURLProtocol.requestBodies = [] + GrokWebBillingStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: nil, + headerFields: ["Content-Type": "text/plain"])! + return (response, Data("unauthorized".utf8)) + } + + await #expect { + _ = try await GrokWebBillingFetcher.fetch( + credentials: Self.credentials, + session: session, + endpoint: endpoint) + } throws: { error in + error.localizedDescription.contains("grok login") + } + } + + @Test + func `usage snapshot maps web billing when cli billing is absent`() { + let snapshot = GrokUsageSnapshot( + billing: nil, + webBilling: GrokWebBillingSnapshot( + usedPercent: 67.25, + resetsAt: Date(timeIntervalSince1970: 1_800_000_003)), + credentials: Self.credentials, + localSummary: nil, + cliVersion: nil, + updatedAt: Date(timeIntervalSince1970: 1_799_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 67.25) + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_800_000_003)) + #expect(usage.accountEmail(for: .grok) == "grok@example.com") + #expect(usage.loginMethod(for: .grok) == "SuperGrok") + } + + private static let credentials = GrokCredentials( + accessToken: "token-123", + refreshToken: "refresh-123", + scope: "https://auth.x.ai::client", + authMode: "oidc", + userId: "user-123", + email: "grok@example.com", + firstName: "G", + lastName: "Rok", + teamId: "team-123", + oidcIssuer: "https://auth.x.ai", + oidcClientId: "client", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + createTime: Date(timeIntervalSince1970: 1_799_000_000)) + + private static func protobufPayload(usedPercent: Float, resetEpoch: UInt64) -> Data { + var data = Data() + data.append(0x0D) // field 1, fixed32 + var percentBits = usedPercent.bitPattern.littleEndian + withUnsafeBytes(of: &percentBits) { data.append(contentsOf: $0) } + data.append(0x10) // field 2, varint + data.append(contentsOf: Self.varint(resetEpoch)) + return data + } + + private static func grpcFrame(_ payload: Data, flags: UInt8 = 0x00) -> Data { + var data = Data([flags]) + let length = UInt32(payload.count).bigEndian + withUnsafeBytes(of: length) { data.append(contentsOf: $0) } + data.append(payload) + return data + } + + private static func varint(_ value: UInt64) -> [UInt8] { + var remaining = value + var bytes: [UInt8] = [] + repeat { + var byte = UInt8(remaining & 0x7F) + remaining >>= 7 + if remaining != 0 { byte |= 0x80 } + bytes.append(byte) + } while remaining != 0 + return bytes + } + + private static func cookie(name: String, value: String) -> HTTPCookie? { + HTTPCookie(properties: [ + .domain: "grok.com", + .path: "/", + .name: name, + .value: value, + ]) + } + + private static func data(hexString: String) -> Data? { + var data = Data() + var index = hexString.startIndex + while index < hexString.endIndex { + let next = hexString.index(index, offsetBy: 2, limitedBy: hexString.endIndex) ?? hexString.endIndex + guard let byte = UInt8(hexString[index.. (HTTPURLResponse, Data))? + + override static func canInit(with _: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + Self.requestBodies.append(Self.readBody(from: self.request)) + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + private static func readBody(from request: URLRequest) -> Data? { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count > 0 { + data.append(buffer, count: count) + } else { + break + } + } + return data + } +} diff --git a/Tests/CodexBarTests/GroqUsageFetcherTests.swift b/Tests/CodexBarTests/GroqUsageFetcherTests.swift new file mode 100644 index 0000000000..2924e9501e --- /dev/null +++ b/Tests/CodexBarTests/GroqUsageFetcherTests.swift @@ -0,0 +1,41 @@ +import CodexBarCore +import Foundation +import Testing + +struct GroqUsageFetcherTests { + @Test + func `parses prometheus scalar response`() throws { + let json = """ + { + "status": "success", + "data": { + "result": [ + { "value": [1710000000, "2.5"] }, + { "value": [1710000000, "1.5"] } + ] + } + } + """ + + let value = try GroqUsageFetcher._parseScalarForTesting(Data(json.utf8)) + + #expect(value == 4) + } + + @Test + func `snapshot maps prometheus rates to menu windows`() { + let snapshot = GroqUsageSnapshot( + requestRatePerSecond: 2, + inputTokenRatePerSecond: 100, + outputTokenRatePerSecond: 50, + promptCacheHitRatePerSecond: 3, + updatedAt: Date(timeIntervalSince1970: 1)) + .toUsageSnapshot() + + #expect(snapshot.identity?.providerID == .groq) + #expect(snapshot.identity?.loginMethod == "Prometheus metrics") + #expect(snapshot.primary?.resetDescription == "120 req/min") + #expect(snapshot.secondary?.resetDescription == "9000 tok/min") + #expect(snapshot.tertiary?.resetDescription == "180 cache/min") + } +} diff --git a/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift new file mode 100644 index 0000000000..f49dbc31f1 --- /dev/null +++ b/Tests/CodexBarTests/HistoricalUsagePaceBackfillAuthorityTests.swift @@ -0,0 +1,510 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension HistoricalUsagePaceTests { + @MainActor + @Test + func `backfill skips when dashboard authority is display only`() async throws { + let store = try Self.makeUsageStoreForBackfillTests( + suite: "HistoricalUsagePaceTests-backfill-display-only", + historyFileURL: Self.makeTempURL()) + store._setCodexHistoricalDatasetForTesting(nil) + + let snapshotNow = Date(timeIntervalSince1970: 1_770_000_000) + let dashboard = OpenAIDashboardSnapshot( + signedInEmail: "shared@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: Self.syntheticBreakdown(endingAt: snapshotNow, days: 35, dailyCredits: 10), + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: snapshotNow.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + creditsRemaining: nil, + accountPlan: nil, + updatedAt: snapshotNow) + + store.backfillCodexHistoricalFromDashboardIfNeeded( + dashboard, + authorityDecision: CodexDashboardAuthorityDecision( + disposition: .displayOnly, + reason: .sameEmailAmbiguity(email: "shared@example.com"), + allowedEffects: [], + cleanup: Set(CodexDashboardCleanup.allCases)), + attachedAccountEmail: "shared@example.com") + + try await Task.sleep(for: .milliseconds(250)) + #expect(store.codexHistoricalDataset == nil) + } + + @MainActor + @Test + func `backfill skips when dashboard authority fail closes`() async throws { + let store = try Self.makeUsageStoreForBackfillTests( + suite: "HistoricalUsagePaceTests-backfill-fail-closed", + historyFileURL: Self.makeTempURL()) + store._setCodexHistoricalDatasetForTesting(nil) + + let snapshotNow = Date(timeIntervalSince1970: 1_770_000_000) + let dashboard = OpenAIDashboardSnapshot( + signedInEmail: "wrong@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: Self.syntheticBreakdown(endingAt: snapshotNow, days: 35, dailyCredits: 10), + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: snapshotNow.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + creditsRemaining: nil, + accountPlan: nil, + updatedAt: snapshotNow) + + store.backfillCodexHistoricalFromDashboardIfNeeded( + dashboard, + authorityDecision: CodexDashboardAuthorityDecision( + disposition: .failClosed, + reason: .wrongEmail(expected: "expected@example.com", actual: "wrong@example.com"), + allowedEffects: [], + cleanup: Set(CodexDashboardCleanup.allCases)), + attachedAccountEmail: "expected@example.com") + + try await Task.sleep(for: .milliseconds(250)) + #expect(store.codexHistoricalDataset == nil) + } + + @MainActor + @Test + func `backfill uses dashboard secondary when available`() async throws { + let store = try Self.makeUsageStoreForBackfillTests( + suite: "HistoricalUsagePaceTests-backfill-dashboard-secondary", + historyFileURL: Self.makeTempURL()) + store._setCodexHistoricalDatasetForTesting(nil) + + let snapshotNow = Date(timeIntervalSince1970: 1_770_000_000) + let staleSnapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 5, + windowMinutes: 10080, + resetsAt: snapshotNow.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + providerCost: nil, + updatedAt: snapshotNow.addingTimeInterval(-30 * 60), + identity: nil) + store._setSnapshotForTesting(staleSnapshot, provider: .codex) + + let dashboard = OpenAIDashboardSnapshot( + signedInEmail: nil, + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: Self.syntheticBreakdown(endingAt: snapshotNow, days: 35, dailyCredits: 10), + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: snapshotNow.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + creditsRemaining: nil, + accountPlan: nil, + updatedAt: snapshotNow) + store.backfillCodexHistoricalFromDashboardIfNeeded( + dashboard, + authorityDecision: CodexDashboardAuthorityDecision( + disposition: .attach, + reason: .trustedEmailMatchNoCompetingOwner, + allowedEffects: [.historicalBackfill], + cleanup: []), + attachedAccountEmail: "attached@example.com") + + for _ in 0..<40 { + if (store.codexHistoricalDataset?.weeks.count ?? 0) >= 3 { + break + } + try await Task.sleep(for: .milliseconds(50)) + } + #expect((store.codexHistoricalDataset?.weeks.count ?? 0) >= 3) + } + + @MainActor + @Test + func `backfill uses normalized dashboard weekly when only primary window is weekly`() async throws { + let store = try Self.makeUsageStoreForBackfillTests( + suite: "HistoricalUsagePaceTests-backfill-normalized-dashboard-weekly", + historyFileURL: Self.makeTempURL()) + store._setCodexHistoricalDatasetForTesting(nil) + + let snapshotNow = Date(timeIntervalSince1970: 1_770_000_000) + let seededSnapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: snapshotNow.addingTimeInterval(2 * 60 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: snapshotNow, + identity: nil) + store._setSnapshotForTesting(seededSnapshot, provider: .codex) + + let dashboard = OpenAIDashboardSnapshot( + signedInEmail: nil, + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: Self.syntheticBreakdown(endingAt: snapshotNow, days: 35, dailyCredits: 10), + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: snapshotNow.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: nil, + updatedAt: snapshotNow) + store.backfillCodexHistoricalFromDashboardIfNeeded( + dashboard, + authorityDecision: CodexDashboardAuthorityDecision( + disposition: .attach, + reason: .trustedEmailMatchNoCompetingOwner, + allowedEffects: [.historicalBackfill], + cleanup: []), + attachedAccountEmail: "attached@example.com") + + for _ in 0..<40 { + if (store.codexHistoricalDataset?.weeks.count ?? 0) >= 3 { + break + } + try await Task.sleep(for: .milliseconds(50)) + } + #expect((store.codexHistoricalDataset?.weeks.count ?? 0) >= 3) + } + + @MainActor + @Test + func `backfill uses attached account email from authority instead of dashboard email`() async throws { + let store = try Self.makeUsageStoreForBackfillTests( + suite: "HistoricalUsagePaceTests-backfill-attached-email", + historyFileURL: Self.makeTempURL()) + let isolatedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-historical-attached-email-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: isolatedHome, withIntermediateDirectories: true) + store.settings._test_liveSystemCodexAccount = nil + store.settings._test_codexReconciliationEnvironment = ["CODEX_HOME": isolatedHome.path] + defer { + store.settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: isolatedHome) + } + store._setCodexHistoricalDatasetForTesting(nil) + + let snapshotNow = Date(timeIntervalSince1970: 1_770_000_000) + let dashboard = OpenAIDashboardSnapshot( + signedInEmail: "dashboard@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: Self.syntheticBreakdown(endingAt: snapshotNow, days: 35, dailyCredits: 10), + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: snapshotNow.addingTimeInterval(2 * 24 * 60 * 60), + resetDescription: nil), + creditsRemaining: nil, + accountPlan: nil, + updatedAt: snapshotNow) + + store.backfillCodexHistoricalFromDashboardIfNeeded( + dashboard, + authorityDecision: CodexDashboardAuthorityDecision( + disposition: .attach, + reason: .trustedEmailMatchNoCompetingOwner, + allowedEffects: [.historicalBackfill], + cleanup: []), + attachedAccountEmail: "attached@example.com") + + for _ in 0..<40 { + if store.codexHistoricalDatasetAccountKey != nil { + break + } + try await Task.sleep(for: .milliseconds(50)) + } + + #expect( + store.codexHistoricalDatasetAccountKey == + CodexHistoryOwnership.canonicalEmailHashKey(for: "attached@example.com")) + #expect( + store.codexHistoricalDatasetAccountKey != + CodexHistoryOwnership.canonicalEmailHashKey(for: "dashboard@example.com")) + } + + @Test + func `will last decision uses smoothed probability when risk hidden`() throws { + let now = Date(timeIntervalSince1970: 0) + let windowMinutes = 10080 + let duration = TimeInterval(windowMinutes) * 60 + let currentResetsAt = now.addingTimeInterval(duration / 2) + let window = RateWindow( + usedPercent: 50, + windowMinutes: windowMinutes, + resetsAt: currentResetsAt, + resetDescription: nil) + + let weeks = (0..<4).map { index in + HistoricalWeekProfile( + resetsAt: currentResetsAt.addingTimeInterval(-duration * Double(index + 1)), + windowMinutes: windowMinutes, + curve: Self.linearCurve(end: 100)) + } + let pace = try #require(CodexHistoricalPaceEvaluator.evaluate( + window: window, + now: now, + dataset: CodexHistoricalDataset(weeks: weeks))) + #expect(pace.runOutProbability == nil) + + let totalWeight = weeks.enumerated().reduce(0.0) { partial, element in + let ageWeeks = currentResetsAt.timeIntervalSince(element.element.resetsAt) / duration + return partial + exp(-ageWeeks / 3.0) + } + let smoothedProbability = (totalWeight + 0.5) / (totalWeight + 1.0) + #expect(pace.willLastToReset == (smoothedProbability < 0.5)) + } + + @MainActor + @Test + func `usage store falls back to linear when history disabled or insufficient`() throws { + let suite = "HistoricalUsagePaceTests-usage-store" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.historicalTrackingEnabled = true + settings.weeklyProgressWorkDays = 5 + + let planHistoryStore = testPlanUtilizationHistoryStore( + suiteName: "HistoricalUsagePaceTests-\(UUID().uuidString)") + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + historicalUsageHistoryStore: HistoricalUsageHistoryStore(fileURL: Self.makeTempURL()), + planUtilizationHistoryStore: planHistoryStore) + + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), + resetDescription: nil) + + let twoWeeksDataset = CodexHistoricalDataset(weeks: [ + HistoricalWeekProfile( + resetsAt: now.addingTimeInterval(-7 * 24 * 60 * 60), + windowMinutes: 10080, + curve: Self.linearCurve(end: 100)), + HistoricalWeekProfile( + resetsAt: now.addingTimeInterval(-14 * 24 * 60 * 60), + windowMinutes: 10080, + curve: Self.linearCurve(end: 100)), + ]) + store._setCodexHistoricalDatasetForTesting(twoWeeksDataset) + + let computed = store.weeklyPace(provider: .codex, window: window, now: now) + let workdayAware = UsagePace.weekly( + window: window, + now: now, + defaultWindowMinutes: 10080, + workDays: 5) + #expect(computed != nil) + #expect(abs((computed?.deltaPercent ?? 0) - (workdayAware?.deltaPercent ?? 0)) < 0.001) + } + + @MainActor + @Test + func `usage store preserves historical Codex pace when work days are off`() throws { + let suite = "HistoricalUsagePaceTests-workdays-off-preserve-history-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + store.settings.weeklyProgressWorkDays = nil + + let now = Date(timeIntervalSince1970: 0) + let duration = TimeInterval(10080 * 60) + let resetsAt = now.addingTimeInterval(duration / 2) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + let dataset = CodexHistoricalDataset(weeks: (0..<4).map { index in + HistoricalWeekProfile( + resetsAt: resetsAt.addingTimeInterval(-duration * Double(index + 1)), + windowMinutes: 10080, + curve: Self.linearCurve(end: 80)) + }) + let expected = try #require(CodexHistoricalPaceEvaluator.evaluate( + window: window, + now: now, + dataset: dataset)) + let linear = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + store._setCodexHistoricalDatasetForTesting( + dataset, + accountKey: store.codexOwnershipContext().canonicalKey) + + let computed = try #require(store.weeklyPace(provider: .codex, window: window, now: now)) + + #expect(abs(expected.expectedUsedPercent - linear.expectedUsedPercent) > 0.001) + #expect(abs(computed.expectedUsedPercent - expected.expectedUsedPercent) < 0.001) + #expect(abs(computed.deltaPercent - expected.deltaPercent) < 0.001) + #expect(computed.etaSeconds == expected.etaSeconds) + #expect(computed.willLastToReset == expected.willLastToReset) + #expect(computed.runOutProbability == expected.runOutProbability) + } + + @MainActor + @Test + func `usage store preserves historical Codex pace when work days are configured`() throws { + let suite = "HistoricalUsagePaceTests-workdays-preserve-history-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + store.settings.weeklyProgressWorkDays = 5 + + let now = Date(timeIntervalSince1970: 0) + let duration = TimeInterval(10080 * 60) + let resetsAt = now.addingTimeInterval(duration / 2) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + let weeks = (0..<4).map { index in + HistoricalWeekProfile( + resetsAt: resetsAt.addingTimeInterval(-duration * Double(index + 1)), + windowMinutes: 10080, + curve: Self.linearCurve(end: 80)) + } + let dataset = CodexHistoricalDataset(weeks: weeks) + let expected = try #require(CodexHistoricalPaceEvaluator.evaluate( + window: window, + now: now, + dataset: dataset)) + store._setCodexHistoricalDatasetForTesting( + dataset, + accountKey: store.codexOwnershipContext().canonicalKey) + + let computed = try #require(store.weeklyPace(provider: .codex, window: window, now: now)) + + #expect(abs(computed.expectedUsedPercent - expected.expectedUsedPercent) < 0.001) + #expect(abs(computed.deltaPercent - expected.deltaPercent) < 0.001) + #expect(computed.etaSeconds == expected.etaSeconds) + #expect(computed.willLastToReset == expected.willLastToReset) + #expect(computed.runOutProbability == expected.runOutProbability) + } + + @MainActor + @Test + func `usage store computes linear pace for providers with quota windows`() throws { + let suite = "HistoricalUsagePaceTests-generic-provider-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), + resetDescription: nil) + + let pace = store.weeklyPace(provider: .zai, window: window, now: now) + + #expect(pace != nil) + #expect(abs((pace?.deltaPercent ?? 0) - (40 - (3.0 / 7.0 * 100.0))) < 0.001) + } + + @MainActor + @Test + func `usage store applies configured work days to generic weekly pace`() throws { + let suite = "HistoricalUsagePaceTests-generic-workdays-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + store.settings.weeklyProgressWorkDays = 5 + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 11))) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(store.weeklyPace(provider: .zai, window: window, now: now)) + + #expect(abs(pace.expectedUsedPercent - 60) < 0.001) + #expect(abs(pace.deltaPercent) < 0.001) + } + + @MainActor + @Test + func `usage store returns nil pace when generic window lacks explicit duration`() throws { + let suite = "HistoricalUsagePaceTests-no-window-minutes-\(UUID().uuidString)" + let store = try Self.makeUsageStoreForBackfillTests( + suite: suite, + historyFileURL: Self.makeTempURL()) + + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 40, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), + resetDescription: nil) + + let pace = store.weeklyPace(provider: .factory, window: window, now: now) + + #expect(pace == nil) + } +} diff --git a/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift new file mode 100644 index 0000000000..9cfa9ede16 --- /dev/null +++ b/Tests/CodexBarTests/HistoricalUsagePaceOwnershipTests.swift @@ -0,0 +1,444 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension HistoricalUsagePaceTests { + @Test + func `history store ownership aware load aliases legacy email hash into canonical email hash`() async { + let fileURL = Self.makeTempURL() + let store = HistoricalUsageHistoryStore(fileURL: fileURL) + let resetsAt = Date(timeIntervalSince1970: 1_770_000_000) + let normalizedEmail = "person@example.com" + let legacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: normalizedEmail) + let canonicalKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + + await Self.recordCompleteWeek(into: store, resetsAt: resetsAt, accountKey: legacyEmailHash) + + let dataset = await store.loadCodexDataset( + canonicalAccountKey: canonicalKey, + canonicalEmailHashKey: canonicalKey, + legacyEmailHash: legacyEmailHash, + hasAdjacentMultiAccountVeto: false) + + #expect(dataset?.weeks.count == 1) + } + + @Test + func `history store ownership aware load keeps ambiguous nil key history unscoped`() async { + let fileURL = Self.makeTempURL() + let store = HistoricalUsageHistoryStore(fileURL: fileURL) + let resetsAt = Date(timeIntervalSince1970: 1_770_000_000) + let targetEmail = "person@example.com" + let targetCanonicalKey = CodexHistoryOwnership.canonicalEmailHashKey(for: targetEmail) + let targetLegacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: targetEmail) + let otherCanonicalKey = CodexHistoryOwnership.canonicalKey(for: .providerAccount(id: "acct-other")) + + await Self.recordCompleteWeek(into: store, resetsAt: resetsAt, accountKey: nil) + _ = await store.recordCodexWeekly( + window: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + sampledAt: resetsAt.addingTimeInterval(-(10080 * 60)), + accountKey: targetLegacyEmailHash) + _ = await store.recordCodexWeekly( + window: RateWindow( + usedPercent: 18, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + sampledAt: resetsAt.addingTimeInterval(-(10080 * 60) + 60), + accountKey: otherCanonicalKey) + + let dataset = await store.loadCodexDataset( + canonicalAccountKey: targetCanonicalKey, + canonicalEmailHashKey: targetCanonicalKey, + legacyEmailHash: targetLegacyEmailHash, + hasAdjacentMultiAccountVeto: false) + + #expect(dataset == nil) + } + + @Test + func `history store ownership aware load adopts nil key history only for strict single owner continuity`() async { + let fileURL = Self.makeTempURL() + let store = HistoricalUsageHistoryStore(fileURL: fileURL) + let resetsAt = Date(timeIntervalSince1970: 1_770_000_000) + let normalizedEmail = "person@example.com" + let legacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: normalizedEmail) + let canonicalKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + + await Self.recordCompleteWeek(into: store, resetsAt: resetsAt, accountKey: nil) + _ = await store.recordCodexWeekly( + window: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + sampledAt: resetsAt.addingTimeInterval(-(10080 * 60)), + accountKey: legacyEmailHash) + + let dataset = await store.loadCodexDataset( + canonicalAccountKey: canonicalKey, + canonicalEmailHashKey: canonicalKey, + legacyEmailHash: legacyEmailHash, + hasAdjacentMultiAccountVeto: false) + + #expect(dataset?.weeks.count == 1) + } + + @Test + func `history store ignores later unrelated owners when evaluating nil key continuity`() async throws { + let fileURL = Self.makeTempURL() + let store = HistoricalUsageHistoryStore(fileURL: fileURL) + let resetsAt = Date(timeIntervalSince1970: 1_770_000_000) + let laterResetsAt = resetsAt.addingTimeInterval(21 * 24 * 60 * 60) + let normalizedEmail = "person@example.com" + let legacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: normalizedEmail) + let canonicalKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + let otherCanonicalKey = try #require( + CodexHistoryOwnership.canonicalKey(for: .providerAccount(id: "acct-other"))) + + await Self.recordCompleteWeek(into: store, resetsAt: resetsAt, accountKey: nil) + _ = await store.recordCodexWeekly( + window: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + sampledAt: resetsAt.addingTimeInterval(-(10080 * 60)), + accountKey: legacyEmailHash) + _ = await store.recordCodexWeekly( + window: RateWindow( + usedPercent: 18, + windowMinutes: 10080, + resetsAt: laterResetsAt, + resetDescription: nil), + sampledAt: laterResetsAt.addingTimeInterval(-(10080 * 60)), + accountKey: otherCanonicalKey) + + let dataset = await store.loadCodexDataset( + canonicalAccountKey: canonicalKey, + canonicalEmailHashKey: canonicalKey, + legacyEmailHash: legacyEmailHash, + hasAdjacentMultiAccountVeto: false) + + #expect(dataset?.weeks.count == 1) + #expect(dataset?.weeks.first?.resetsAt == resetsAt) + } + + @MainActor + @Test + func `refresh historical dataset keeps nil key history unscoped when managed and live accounts are distinct`() + async throws + { + let historyStore = HistoricalUsageHistoryStore(fileURL: Self.makeTempURL()) + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let liveAccount = Self.liveAccount( + email: "live@example.com", + identity: .providerAccount(id: "live-acct")) + let resetsAt = Date(timeIntervalSince1970: 1_770_000_000) + let managedLegacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: managedAccount.email) + let managedCanonicalKey = CodexHistoryOwnership.canonicalEmailHashKey(for: managedAccount.email) + + await Self.recordCompleteWeek(into: historyStore, resetsAt: resetsAt, accountKey: nil) + _ = await historyStore.recordCodexWeekly( + window: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + sampledAt: resetsAt.addingTimeInterval(-(10080 * 60)), + accountKey: managedLegacyEmailHash) + + let store = try Self.makeUsageStoreForHistoricalTests( + suite: "HistoricalUsagePaceTests-adjacent-veto", + historicalUsageHistoryStore: historyStore) + store.settings._test_activeManagedCodexAccount = managedAccount + store.settings.codexActiveSource = .managedAccount(id: managedAccount.id) + store.settings._test_liveSystemCodexAccount = liveAccount + defer { + store.settings._test_activeManagedCodexAccount = nil + store.settings._test_liveSystemCodexAccount = nil + store.settings.codexActiveSource = .liveSystem + } + + await store.refreshHistoricalDatasetIfNeeded() + + #expect(store.codexHistoricalDataset == nil) + #expect(store.codexHistoricalDatasetAccountKey == managedCanonicalKey) + } + + @MainActor + @Test + func `refresh historical dataset ignores extra saved managed accounts for adjacent veto`() async throws { + let historyStore = HistoricalUsageHistoryStore(fileURL: Self.makeTempURL()) + let activeManagedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let inactiveManagedAccount = ManagedCodexAccount( + id: UUID(), + email: "other@example.com", + managedHomePath: "/tmp/other-codex-home", + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2) + let resetsAt = Date(timeIntervalSince1970: 1_770_000_000) + let managedLegacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: activeManagedAccount.email) + let managedCanonicalKey = CodexHistoryOwnership.canonicalEmailHashKey(for: activeManagedAccount.email) + + await Self.recordCompleteWeek(into: historyStore, resetsAt: resetsAt, accountKey: nil) + _ = await historyStore.recordCodexWeekly( + window: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + sampledAt: resetsAt.addingTimeInterval(-(10080 * 60)), + accountKey: managedLegacyEmailHash) + + let store = try Self.makeUsageStoreForHistoricalTests( + suite: "HistoricalUsagePaceTests-saved-managed-accounts", + historicalUsageHistoryStore: historyStore) + let managedStoreURL = FileManager.default.temporaryDirectory + .appendingPathComponent("HistoricalUsagePaceTests-\(UUID().uuidString)-managed-accounts.json") + let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL) + try managedStore.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [activeManagedAccount, inactiveManagedAccount])) + store.settings._test_managedCodexAccountStoreURL = managedStoreURL + store.settings._test_activeManagedCodexAccount = activeManagedAccount + store.settings.codexActiveSource = .managedAccount(id: activeManagedAccount.id) + defer { + store.settings._test_managedCodexAccountStoreURL = nil + store.settings._test_activeManagedCodexAccount = nil + store.settings.codexActiveSource = .liveSystem + } + + await store.refreshHistoricalDatasetIfNeeded() + + #expect(store.codexHistoricalDataset?.weeks.count == 1) + #expect(store.codexHistoricalDatasetAccountKey == managedCanonicalKey) + } + + @Test + func `history store ownership aware load merges legacy email hash into provider account continuity`() async throws { + let fileURL = Self.makeTempURL() + let store = HistoricalUsageHistoryStore(fileURL: fileURL) + let resetsAt = Date(timeIntervalSince1970: 1_770_000_000) + let normalizedEmail = "person@example.com" + let legacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: normalizedEmail) + let canonicalEmailHashKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount(id: "acct-1"))) + + await Self.recordCompleteWeek(into: store, resetsAt: resetsAt, accountKey: legacyEmailHash) + + let dataset = await store.loadCodexDataset( + canonicalAccountKey: providerAccountKey, + canonicalEmailHashKey: canonicalEmailHashKey, + legacyEmailHash: legacyEmailHash, + hasAdjacentMultiAccountVeto: false) + + #expect(dataset?.weeks.count == 1) + } + + @Test + func `history store real local fixture aliases bare email hash into canonical continuity`() async throws { + let fileURL = Self.makeTempURL() + try Self.writeHistoricalFixture(named: "codex-historical-usage-real-legacy.jsonl", to: fileURL) + let store = HistoricalUsageHistoryStore(fileURL: fileURL) + let formatter = ISO8601DateFormatter() + let normalizedEmail = "rdsarna@gmail.com" + let legacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: normalizedEmail) + let canonicalEmailHashKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount(id: "acct-123"))) + let fixtureResetAt = try #require(formatter.date(from: "2026-02-17T05:37:00Z")) + let weekSeconds = TimeInterval(7 * 24 * 60 * 60) + let weeksToShift = max(0, Int(ceil(Date().timeIntervalSince(fixtureResetAt) / weekSeconds))) + let dateShift = TimeInterval(weeksToShift) * weekSeconds + let records = try Self.readHistoricalRecords(from: fileURL) + try Self.writeHistoricalRecords( + records.map { record in + HistoricalUsageRecord( + v: record.v, + provider: record.provider, + windowKind: record.windowKind, + source: record.source, + accountKey: record.accountKey, + sampledAt: record.sampledAt.addingTimeInterval(dateShift), + usedPercent: record.usedPercent, + resetsAt: record.resetsAt.addingTimeInterval(dateShift), + windowMinutes: record.windowMinutes) + }, + to: fileURL) + let expectedResetAt = Self.normalizeReset(fixtureResetAt.addingTimeInterval(dateShift)) + + let dataset = await store.loadCodexDataset( + canonicalAccountKey: providerAccountKey, + canonicalEmailHashKey: canonicalEmailHashKey, + legacyEmailHash: legacyEmailHash, + hasAdjacentMultiAccountVeto: false) + + #expect(dataset?.weeks.count == 1) + #expect(dataset?.weeks.first?.resetsAt == expectedResetAt) + } + + @MainActor + @Test + func `usage store records historical pace with canonical provider account key`() async throws { + let historyFileURL = Self.makeTempURL() + let store = try Self.makeUsageStoreForBackfillTests( + suite: "HistoricalUsagePaceTests-provider-account-write", + historyFileURL: historyFileURL) + store.settings._test_liveSystemCodexAccount = Self.liveAccount( + email: "person@example.com", + identity: .providerAccount(id: "acct-123")) + defer { store.settings._test_liveSystemCodexAccount = nil } + + let updatedAt = Date(timeIntervalSince1970: 1_770_000_000) + let snapshot = Self.weeklySnapshot( + email: "person@example.com", + usedPercent: 42, + resetsAt: updatedAt.addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: updatedAt) + + store.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) + let expectedKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount(id: "acct-123"))) + let records = try await Self.waitForHistoricalWrite( + store: store, + at: historyFileURL, + minimumCount: 1, + expectedAccountKey: expectedKey) + + #expect(records.last?.accountKey == expectedKey) + #expect(store.codexHistoricalDatasetAccountKey == expectedKey) + } + + @MainActor + @Test + func `usage store records historical pace with canonical email hash key`() async throws { + let historyFileURL = Self.makeTempURL() + let store = try Self.makeUsageStoreForBackfillTests( + suite: "HistoricalUsagePaceTests-email-hash-write", + historyFileURL: historyFileURL) + store.settings._test_liveSystemCodexAccount = Self.liveAccount(email: "person@example.com") + defer { store.settings._test_liveSystemCodexAccount = nil } + + let updatedAt = Date(timeIntervalSince1970: 1_770_000_000) + let snapshot = Self.weeklySnapshot( + email: "Person@example.com", + usedPercent: 42, + resetsAt: updatedAt.addingTimeInterval(2 * 24 * 60 * 60), + updatedAt: updatedAt) + + store.recordCodexHistoricalSampleIfNeeded(snapshot: snapshot) + let expectedKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "person@example.com") + let records = try await Self.waitForHistoricalWrite( + store: store, + at: historyFileURL, + minimumCount: 1, + expectedAccountKey: expectedKey) + + #expect(records.last?.accountKey == expectedKey) + #expect(store.codexHistoricalDatasetAccountKey == expectedKey) + } + + @MainActor + @Test + func `refresh historical dataset aliases legacy email hash into canonical email hash`() async throws { + let historyFileURL = Self.makeTempURL() + let historyStore = HistoricalUsageHistoryStore(fileURL: historyFileURL) + let normalizedEmail = "person@example.com" + let legacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: normalizedEmail) + let canonicalKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + let resetsAt = Date(timeIntervalSince1970: 1_770_000_000) + await Self.recordCompleteWeek(into: historyStore, resetsAt: resetsAt, accountKey: legacyEmailHash) + + let store = try Self.makeUsageStoreForHistoricalTests( + suite: "HistoricalUsagePaceTests-refresh-legacy-alias", + historicalUsageHistoryStore: historyStore) + store.settings._test_liveSystemCodexAccount = Self.liveAccount(email: normalizedEmail) + defer { store.settings._test_liveSystemCodexAccount = nil } + + await store.refreshHistoricalDatasetIfNeeded() + + #expect(store.codexHistoricalDatasetAccountKey == canonicalKey) + #expect(store.codexHistoricalDataset?.weeks.count == 1) + } + + @MainActor + @Test + func `refresh historical dataset carries matching email continuity into provider account`() async throws { + let historyFileURL = Self.makeTempURL() + let historyStore = HistoricalUsageHistoryStore(fileURL: historyFileURL) + let normalizedEmail = "person@example.com" + let legacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: normalizedEmail) + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount(id: "acct-123"))) + let resetsAt = Date(timeIntervalSince1970: 1_770_000_000) + await Self.recordCompleteWeek(into: historyStore, resetsAt: resetsAt, accountKey: legacyEmailHash) + + let store = try Self.makeUsageStoreForHistoricalTests( + suite: "HistoricalUsagePaceTests-refresh-provider-account-continuity", + historicalUsageHistoryStore: historyStore) + store.settings._test_liveSystemCodexAccount = Self.liveAccount( + email: normalizedEmail, + identity: .providerAccount(id: "acct-123")) + defer { store.settings._test_liveSystemCodexAccount = nil } + + await store.refreshHistoricalDatasetIfNeeded() + + #expect(store.codexHistoricalDatasetAccountKey == providerAccountKey) + #expect(store.codexHistoricalDataset?.weeks.count == 1) + } + + @MainActor + @Test + func `refresh historical dataset ignores stale dashboard signals and uses active account ownership`() async throws { + let historyFileURL = Self.makeTempURL() + let historyStore = HistoricalUsageHistoryStore(fileURL: historyFileURL) + let staleEmail = "old@example.com" + let staleLegacyEmailHash = CodexHistoryOwnership.legacyEmailHash(normalizedEmail: staleEmail) + let staleResetsAt = Date(timeIntervalSince1970: 1_770_000_000) + await Self.recordCompleteWeek(into: historyStore, resetsAt: staleResetsAt, accountKey: staleLegacyEmailHash) + + let store = try Self.makeUsageStoreForHistoricalTests( + suite: "HistoricalUsagePaceTests-refresh-prefers-active-email", + historicalUsageHistoryStore: historyStore) + let activeProviderAccountKey = try #require( + CodexHistoryOwnership.canonicalKey(for: .providerAccount(id: "acct-new"))) + store.settings._test_liveSystemCodexAccount = Self.liveAccount( + email: "new@example.com", + identity: .providerAccount(id: "acct-new")) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: staleEmail, + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: nil, + updatedAt: staleResetsAt) + store.lastOpenAIDashboardTargetEmail = staleEmail + defer { store.settings._test_liveSystemCodexAccount = nil } + + await store.refreshHistoricalDatasetIfNeeded() + + #expect(store.codexHistoricalDatasetAccountKey == activeProviderAccountKey) + #expect(store.codexHistoricalDataset == nil) + } +} diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift index 9a04cb2736..bb6102c7d2 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift @@ -4,15 +4,7 @@ import Testing @testable import CodexBar extension HistoricalUsagePaceTests { - private static let utcTimeZone: TimeZone = { - if let zone = TimeZone(identifier: "UTC") { - return zone - } - if let zone = TimeZone(secondsFromGMT: 0) { - return zone - } - return TimeZone.current - }() + private static let dashboardTimeZone: TimeZone = .current static func linearCurve(end: Double) -> [Double] { let clampedEnd = max(0, min(100, end)) @@ -45,10 +37,10 @@ extension HistoricalUsagePaceTests { overridesByDayOffset: [Int: Double] = [:]) -> [OpenAIDashboardDailyBreakdown] { var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = Self.utcTimeZone + calendar.timeZone = Self.dashboardTimeZone let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = Self.utcTimeZone + formatter.timeZone = Self.dashboardTimeZone formatter.dateFormat = "yyyy-MM-dd" let endDay = calendar.startOfDay(for: endDate) @@ -65,10 +57,10 @@ extension HistoricalUsagePaceTests { static func gregorianDate(year: Int, month: Int, day: Int, hour: Int) -> Date { var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = Self.utcTimeZone + calendar.timeZone = Self.dashboardTimeZone var components = DateComponents() components.calendar = calendar - components.timeZone = Self.utcTimeZone + components.timeZone = Self.dashboardTimeZone components.year = year components.month = month components.day = day @@ -91,10 +83,10 @@ extension HistoricalUsagePaceTests { return nil } var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = Self.utcTimeZone + calendar.timeZone = Self.dashboardTimeZone var dateComponents = DateComponents() dateComponents.calendar = calendar - dateComponents.timeZone = Self.utcTimeZone + dateComponents.timeZone = Self.dashboardTimeZone dateComponents.year = year dateComponents.month = month dateComponents.day = day @@ -102,7 +94,7 @@ extension HistoricalUsagePaceTests { } static func normalizeReset(_ value: Date) -> Date { - let bucket = 60.0 + let bucket = 5 * 60.0 let rounded = (value.timeIntervalSinceReferenceDate / bucket).rounded() * bucket return Date(timeIntervalSinceReferenceDate: rounded) } @@ -119,6 +111,35 @@ extension HistoricalUsagePaceTests { } } + static func writeHistoricalFixture(named name: String, to fileURL: URL) throws { + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures", isDirectory: true) + .appendingPathComponent(name, isDirectory: false) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + let data = try Data(contentsOf: fixtureURL) + try data.write(to: fileURL, options: .atomic) + } + + static func writeHistoricalRecords(_ records: [HistoricalUsageRecord], to fileURL: URL) throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + let lines = try records.map { record -> String in + let data = try encoder.encode(record) + guard let line = String(bytes: data, encoding: .utf8) else { + throw CocoaError(.fileWriteUnknown) + } + return line + } + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try (lines.joined(separator: "\n") + "\n").write(to: fileURL, atomically: true, encoding: .utf8) + } + static func recordDedupKeyCount(_ records: [HistoricalUsageRecord]) -> Int { struct Key: Hashable { let resetsAt: Date @@ -147,8 +168,114 @@ extension HistoricalUsagePaceTests { .joined(separator: "||") } + static func liveAccount( + email: String, + identity: CodexIdentity = .unresolved) -> ObservedSystemCodexAccount + { + ObservedSystemCodexAccount( + email: email, + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: identity) + } + + static func weeklySnapshot( + email: String? = nil, + usedPercent: Double, + resetsAt: Date, + updatedAt: Date) -> UsageSnapshot + { + UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + tertiary: nil, + providerCost: nil, + updatedAt: updatedAt, + identity: email.map { + ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: $0, + accountOrganization: nil, + loginMethod: "Pro") + }) + } + + static func recordCompleteWeek( + into store: HistoricalUsageHistoryStore, + resetsAt: Date, + accountKey: String?) async + { + let windowMinutes = 10080 + let duration = TimeInterval(windowMinutes) * 60 + let windowStart = resetsAt.addingTimeInterval(-duration) + let samples: [(u: Double, used: Double)] = [ + (0.02, 3), + (0.10, 10), + (0.40, 40), + (0.60, 60), + (0.80, 80), + (0.98, 95), + ] + + for sample in samples { + _ = await store.recordCodexWeekly( + window: RateWindow( + usedPercent: sample.used, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: nil), + sampledAt: windowStart.addingTimeInterval(sample.u * duration), + accountKey: accountKey) + } + } + + static func waitForHistoricalRecords( + at fileURL: URL, + minimumCount: Int, + timeoutMilliseconds: UInt64 = 10000) async throws -> [HistoricalUsageRecord] + { + let deadline = ContinuousClock.now + .milliseconds(timeoutMilliseconds) + while ContinuousClock.now < deadline { + if let records = try? Self.readHistoricalRecords(from: fileURL), records.count >= minimumCount { + return records + } + try await Task.sleep(for: .milliseconds(25)) + } + + return try Self.readHistoricalRecords(from: fileURL) + } + @MainActor - static func makeUsageStoreForBackfillTests(suite: String, historyFileURL: URL) throws -> UsageStore { + static func waitForHistoricalWrite( + store: UsageStore, + at fileURL: URL, + minimumCount: Int, + expectedAccountKey: String?, + timeoutMilliseconds: UInt64 = 10000) async throws -> [HistoricalUsageRecord] + { + let deadline = ContinuousClock.now + .milliseconds(timeoutMilliseconds) + while ContinuousClock.now < deadline { + let records = (try? Self.readHistoricalRecords(from: fileURL)) ?? [] + if records.count >= minimumCount, + store.codexHistoricalDatasetAccountKey == expectedAccountKey + { + return records + } + try await Task.sleep(for: .milliseconds(25)) + } + + return try Self.readHistoricalRecords(from: fileURL) + } + + @MainActor + static func makeUsageStoreForHistoricalTests( + suite: String, + historicalUsageHistoryStore: HistoricalUsageHistoryStore) throws -> UsageStore + { let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) let settings = SettingsStore( @@ -170,10 +297,20 @@ extension HistoricalUsagePaceTests { copilotTokenStore: InMemoryCopilotTokenStore(), tokenAccountStore: InMemoryTokenAccountStore()) settings.historicalTrackingEnabled = true + let planHistoryStore = testPlanUtilizationHistoryStore( + suiteName: "HistoricalUsagePaceTests-\(UUID().uuidString)") return UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, + historicalUsageHistoryStore: historicalUsageHistoryStore, + planUtilizationHistoryStore: planHistoryStore) + } + + @MainActor + static func makeUsageStoreForBackfillTests(suite: String, historyFileURL: URL) throws -> UsageStore { + try self.makeUsageStoreForHistoricalTests( + suite: suite, historicalUsageHistoryStore: HistoricalUsageHistoryStore(fileURL: historyFileURL)) } } diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift index 642add9aa7..103a6cd83b 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift @@ -3,10 +3,10 @@ import Foundation import Testing @testable import CodexBar -@Suite +@Suite(.serialized) struct HistoricalUsagePaceTests { @Test - func historyStore_reconstructsDeterministicMonotoneCurve() async throws { + func `history store reconstructs deterministic monotone curve`() async throws { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let windowMinutes = 10080 @@ -47,7 +47,7 @@ struct HistoricalUsagePaceTests { } @Test - func reconstructWeekCurve_anchorsAtZeroAtWindowStart() async throws { + func `reconstruct week curve anchors at zero at window start`() async throws { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let windowMinutes = 10080 @@ -80,7 +80,7 @@ struct HistoricalUsagePaceTests { } @Test - func reconstructWeekCurve_addsEndAnchorWithoutBreakingMonotonicity() async throws { + func `reconstruct week curve adds end anchor without breaking monotonicity`() async throws { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let windowMinutes = 10080 @@ -116,7 +116,7 @@ struct HistoricalUsagePaceTests { } @Test - func historyStore_requiresStartAndEndCoverageForCompleteWeek() async { + func `history store requires start and end coverage for complete week`() async { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let windowMinutes = 10080 @@ -149,7 +149,7 @@ struct HistoricalUsagePaceTests { } @Test - func evaluator_appliesSmoothedProbabilityAndHidesRiskBelowThreshold() throws { + func `evaluator applies smoothed probability and hides risk below threshold`() throws { let now = Date(timeIntervalSince1970: 0) let windowMinutes = 10080 let duration = TimeInterval(windowMinutes) * 60 @@ -197,7 +197,7 @@ struct HistoricalUsagePaceTests { } @Test - func evaluator_neverReturnsNegativeEtaWithOutlierWeek() { + func `evaluator never returns negative eta with outlier week`() { let now = Date(timeIntervalSince1970: 0) let windowMinutes = 10080 let duration = TimeInterval(windowMinutes) * 60 @@ -229,7 +229,7 @@ struct HistoricalUsagePaceTests { } @Test - func historyStore_backfillsFromUsageBreakdownWhenHistoryIsEmpty() async { + func `history store backfills from usage breakdown when history is empty`() async { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let now = Date(timeIntervalSince1970: 1_770_000_000) @@ -252,7 +252,7 @@ struct HistoricalUsagePaceTests { } @Test - func historyStore_backfillIsIdempotentForExistingWeeks() async { + func `history store backfill is idempotent for existing weeks`() async { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let now = Date(timeIntervalSince1970: 1_770_000_000) @@ -286,7 +286,45 @@ struct HistoricalUsagePaceTests { } @Test - func historyStore_backfillFillsIncompleteExistingWeek() async { + func `history store backfill is idempotent across minute scale reset jitter`() async { + let fileURL = Self.makeTempURL() + let store = HistoricalUsageHistoryStore(fileURL: fileURL) + let now = Date(timeIntervalSince1970: 1_770_000_000) + let windowMinutes = 10080 + let canonicalReset = Self.normalizeReset(now.addingTimeInterval(2 * 24 * 60 * 60)) + let firstWindow = RateWindow( + usedPercent: 50, + windowMinutes: windowMinutes, + resetsAt: canonicalReset.addingTimeInterval(-90), + resetDescription: nil) + let secondWindow = RateWindow( + usedPercent: 50, + windowMinutes: windowMinutes, + resetsAt: canonicalReset.addingTimeInterval(90), + resetDescription: nil) + + let breakdown = Self.syntheticBreakdown(endingAt: now, days: 35, dailyCredits: 10) + let first = await store.backfillCodexWeeklyFromUsageBreakdown( + breakdown, + referenceWindow: firstWindow, + now: now, + accountKey: nil) + let recordsAfterFirst = (try? Self.readHistoricalRecords(from: fileURL)) ?? [] + let second = await store.backfillCodexWeeklyFromUsageBreakdown( + breakdown, + referenceWindow: secondWindow, + now: now, + accountKey: nil) + let recordsAfterSecond = (try? Self.readHistoricalRecords(from: fileURL)) ?? [] + + #expect((first?.weeks.count ?? 0) >= 3) + #expect(first?.weeks.count == second?.weeks.count) + #expect(recordsAfterSecond.count == recordsAfterFirst.count) + #expect(Self.datasetCurveSignature(first) == Self.datasetCurveSignature(second)) + } + + @Test + func `history store backfill fills incomplete existing week`() async { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let now = Date(timeIntervalSince1970: 1_770_000_000) @@ -321,7 +359,7 @@ struct HistoricalUsagePaceTests { } @Test - func historyStore_shouldAcceptDoesNotCrossWindowMinutesRegimes() async throws { + func `history store should accept does not cross window minutes regimes`() async throws { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let sampledAt = Date(timeIntervalSince1970: 1_770_000_000) @@ -353,12 +391,12 @@ struct HistoricalUsagePaceTests { } @Test - func weeksWithResetJitter_areGroupedTogether() async { + func `weeks with reset jitter are grouped together`() async { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let windowMinutes = 10080 let duration = TimeInterval(windowMinutes) * 60 - let canonicalReset = Date(timeIntervalSince1970: 1_770_000_000) + let canonicalReset = Self.normalizeReset(Date(timeIntervalSince1970: 1_770_000_000)) let windowStart = canonicalReset.addingTimeInterval(-duration) let samples: [(u: Double, used: Double)] = [ @@ -370,7 +408,7 @@ struct HistoricalUsagePaceTests { (0.98, 95), ] for (index, sample) in samples.enumerated() { - let jitteredReset = canonicalReset.addingTimeInterval(index.isMultiple(of: 2) ? -20 : 20) + let jitteredReset = canonicalReset.addingTimeInterval(index.isMultiple(of: 2) ? -100 : 100) _ = await store.recordCodexWeekly( window: RateWindow( usedPercent: sample.used, @@ -386,7 +424,7 @@ struct HistoricalUsagePaceTests { } @Test - func backfill_matchesIncompleteWeek_whenResetJitterExists() async throws { + func `backfill matches incomplete week when reset jitter exists`() async throws { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let now = Date(timeIntervalSince1970: 1_770_000_000) @@ -400,7 +438,7 @@ struct HistoricalUsagePaceTests { window: RateWindow( usedPercent: 35, windowMinutes: windowMinutes, - resetsAt: targetReset.addingTimeInterval(30), + resetsAt: targetReset.addingTimeInterval(120), resetDescription: nil), sampledAt: targetStart.addingTimeInterval(duration * 0.5), accountKey: nil) @@ -428,7 +466,7 @@ struct HistoricalUsagePaceTests { } @Test - func backfill_doesNotStopAtThreeWeeks_whenMoreBackfillableWeeksExist() async throws { + func `backfill does not stop at three weeks when more backfillable weeks exist`() async throws { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let now = Date(timeIntervalSince1970: 1_770_000_000) @@ -474,7 +512,7 @@ struct HistoricalUsagePaceTests { } @Test - func loadDataset_usesOnlyCurrentAccountKey() async throws { + func `load dataset uses only current account key`() async throws { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let windowMinutes = 10080 @@ -514,7 +552,7 @@ struct HistoricalUsagePaceTests { } @Test - func backfill_filtersByAccountKey() async throws { + func `backfill filters by account key`() async throws { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let now = Date(timeIntervalSince1970: 1_770_000_000) @@ -548,7 +586,7 @@ struct HistoricalUsagePaceTests { } @Test - func coverageTolerance_allowsDayBoundaryShiftWithoutNoOp() async { + func `coverage tolerance allows day boundary shift without no op`() async { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let now = Date(timeIntervalSince1970: 1_770_000_000) @@ -573,7 +611,7 @@ struct HistoricalUsagePaceTests { } @Test - func backfill_treatsOmittedRecentZeroUsageDaysAsCoverage() async { + func `backfill treats omitted recent zero usage days as coverage`() async { let fileURL = Self.makeTempURL() let store = HistoricalUsageHistoryStore(fileURL: fileURL) let now = Self.gregorianDate(year: 2026, month: 2, day: 26, hour: 20) @@ -604,7 +642,39 @@ struct HistoricalUsagePaceTests { } @Test - func partialDayCredits_areNotUndercountedAtAsOfTime() { + func `backfill treats omitted leading zero usage days as coverage`() async { + let fileURL = Self.makeTempURL() + let store = HistoricalUsageHistoryStore(fileURL: fileURL) + let now = Self.gregorianDate(year: 2026, month: 2, day: 26, hour: 20) + let resetsAt = now.addingTimeInterval(2 * 24 * 60 * 60) + let referenceWindow = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let breakdown = Self.syntheticBreakdown( + endingAt: now, + days: 35, + dailyCredits: 10, + overridesByDayOffset: [ + 3: 0, + 4: 0, + 5: 0, + ]) + .filter { $0.totalCreditsUsed > 0 } + + let dataset = await store.backfillCodexWeeklyFromUsageBreakdown( + breakdown, + referenceWindow: referenceWindow, + now: now, + accountKey: nil) + + #expect((dataset?.weeks.count ?? 0) >= 3) + } + + @Test + func `partial day credits are not undercounted at as of time`() { let asOf = Self.gregorianDate(year: 2026, month: 2, day: 26, hour: 12) let start = Self.gregorianDate(year: 2026, month: 2, day: 20, hour: 0) let breakdown = Self.syntheticBreakdown( @@ -626,7 +696,7 @@ struct HistoricalUsagePaceTests { } @Test - func gregorianDayParsing_isStableForYYYYMMDD() { + func `gregorian day parsing is stable for YYYYMMDD`() { let parsed = HistoricalUsageHistoryStore._dayStartForTesting("2026-02-26") let expected = Self.gregorianDate(year: 2026, month: 2, day: 26, hour: 0) #expect(parsed == expected) @@ -634,7 +704,7 @@ struct HistoricalUsagePaceTests { @MainActor @Test - func backfill_skipsWhenTimestampMismatchExceeds5Minutes() async throws { + func `backfill skips when timestamp mismatch exceeds5 minutes`() async throws { let store = try Self.makeUsageStoreForBackfillTests( suite: "HistoricalUsagePaceTests-backfill-mismatch", historyFileURL: Self.makeTempURL()) @@ -667,147 +737,87 @@ struct HistoricalUsagePaceTests { creditsRemaining: nil, accountPlan: nil, updatedAt: snapshotNow.addingTimeInterval(-10 * 60)) - store.backfillCodexHistoricalFromDashboardIfNeeded(dashboard) + store.backfillCodexHistoricalFromDashboardIfNeeded( + dashboard, + authorityDecision: CodexDashboardAuthorityDecision( + disposition: .attach, + reason: .trustedEmailMatchNoCompetingOwner, + allowedEffects: [.historicalBackfill], + cleanup: []), + attachedAccountEmail: "attached@example.com") try await Task.sleep(for: .milliseconds(250)) #expect(store.codexHistoricalDataset == nil) } - @MainActor @Test - func backfill_usesDashboardSecondaryWhenAvailable() async throws { - let store = try Self.makeUsageStoreForBackfillTests( - suite: "HistoricalUsagePaceTests-backfill-dashboard-secondary", - historyFileURL: Self.makeTempURL()) - store._setCodexHistoricalDatasetForTesting(nil) - - let snapshotNow = Date(timeIntervalSince1970: 1_770_000_000) - let staleSnapshot = UsageSnapshot( - primary: nil, - secondary: RateWindow( - usedPercent: 5, - windowMinutes: 10080, - resetsAt: snapshotNow.addingTimeInterval(2 * 24 * 60 * 60), - resetDescription: nil), - tertiary: nil, - providerCost: nil, - updatedAt: snapshotNow.addingTimeInterval(-30 * 60), - identity: nil) - store._setSnapshotForTesting(staleSnapshot, provider: .codex) - - let dashboard = OpenAIDashboardSnapshot( - signedInEmail: nil, - codeReviewRemainingPercent: nil, - creditEvents: [], - dailyBreakdown: [], - usageBreakdown: Self.syntheticBreakdown(endingAt: snapshotNow, days: 35, dailyCredits: 10), - creditsPurchaseURL: nil, - primaryLimit: nil, - secondaryLimit: RateWindow( - usedPercent: 50, - windowMinutes: 10080, - resetsAt: snapshotNow.addingTimeInterval(2 * 24 * 60 * 60), - resetDescription: nil), - creditsRemaining: nil, - accountPlan: nil, - updatedAt: snapshotNow) - store.backfillCodexHistoricalFromDashboardIfNeeded(dashboard) - - for _ in 0..<40 { - if (store.codexHistoricalDataset?.weeks.count ?? 0) >= 3 { - break + func `exhausted historical weeks extend linearly and don't flatline at 100`() throws { + // Build a historical dataset where a week reaches 100% at u = 0.5 + var earlyExhaustedCurve = [Double]() + for i in 0..= 3) - } + let now = Date() + let week = HistoricalWeekProfile( + resetsAt: now.addingTimeInterval(-10080 * 60), + windowMinutes: 10080, + curve: earlyExhaustedCurve) + let dataset = CodexHistoricalDataset(weeks: [week, week, week, week, week]) - @Test - func willLastDecision_usesSmoothedProbabilityWhenRiskHidden() throws { - let now = Date(timeIntervalSince1970: 0) - let windowMinutes = 10080 - let duration = TimeInterval(windowMinutes) * 60 - let currentResetsAt = now.addingTimeInterval(duration / 2) let window = RateWindow( - usedPercent: 50, - windowMinutes: windowMinutes, - resetsAt: currentResetsAt, + usedPercent: 80, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(0.25 * 10080 * 60), resetDescription: nil) - let weeks = (0..<4).map { index in - HistoricalWeekProfile( - resetsAt: currentResetsAt.addingTimeInterval(-duration * Double(index + 1)), - windowMinutes: windowMinutes, - curve: Self.linearCurve(end: 100)) - } let pace = try #require(CodexHistoricalPaceEvaluator.evaluate( window: window, now: now, - dataset: CodexHistoricalDataset(weeks: weeks))) - #expect(pace.runOutProbability == nil) + dataset: dataset)) - let totalWeight = weeks.enumerated().reduce(0.0) { partial, element in - let ageWeeks = currentResetsAt.timeIntervalSince(element.element.resetsAt) / duration - return partial + exp(-ageWeeks / 3.0) - } - let smoothedProbability = (totalWeight + 0.5) / (totalWeight + 1.0) - #expect(pace.willLastToReset == (smoothedProbability < 0.5)) + #expect(pace.willLastToReset == false) + #expect(pace.runOutProbability != nil) + #expect(try #require(pace.runOutProbability) > 0) + #expect(pace.deltaPercent > 0) + + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + #expect(detail.leftLabel == "5% in deficit") + #expect(detail.rightLabel?.contains("Lasts until reset") == false) } - @MainActor @Test - func usageStore_fallsBackToLinearWhenHistoryDisabledOrInsufficient() throws { - let suite = "HistoricalUsagePaceTests-usage-store" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: testConfigStore(suiteName: suite), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore(), - codexCookieStore: InMemoryCookieHeaderStore(), - claudeCookieStore: InMemoryCookieHeaderStore(), - cursorCookieStore: InMemoryCookieHeaderStore(), - opencodeCookieStore: InMemoryCookieHeaderStore(), - factoryCookieStore: InMemoryCookieHeaderStore(), - minimaxCookieStore: InMemoryMiniMaxCookieStore(), - minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), - kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), - augmentCookieStore: InMemoryCookieHeaderStore(), - ampCookieStore: InMemoryCookieHeaderStore(), - copilotTokenStore: InMemoryCopilotTokenStore(), - tokenAccountStore: InMemoryTokenAccountStore()) - settings.historicalTrackingEnabled = true - - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings, - historicalUsageHistoryStore: HistoricalUsageHistoryStore(fileURL: Self.makeTempURL())) - - let now = Date(timeIntervalSince1970: 0) - let window = RateWindow( - usedPercent: 50, + func `exhausted actual returns zero eta`() throws { + let now = Date() + let resetsAt = now.addingTimeInterval(3600) + let week = HistoricalWeekProfile( + resetsAt: now.addingTimeInterval(-10080 * 60), windowMinutes: 10080, - resetsAt: now.addingTimeInterval(4 * 24 * 60 * 60), - resetDescription: nil) + curve: Array(repeating: 100.0, count: 169)) + let dataset = CodexHistoricalDataset(weeks: [week, week, week]) - let twoWeeksDataset = CodexHistoricalDataset(weeks: [ - HistoricalWeekProfile( - resetsAt: now.addingTimeInterval(-7 * 24 * 60 * 60), + for usedPercent in [100.0, 120.0] { + let window = RateWindow( + usedPercent: usedPercent, windowMinutes: 10080, - curve: Self.linearCurve(end: 100)), - HistoricalWeekProfile( - resetsAt: now.addingTimeInterval(-14 * 24 * 60 * 60), - windowMinutes: 10080, - curve: Self.linearCurve(end: 100)), - ]) - store._setCodexHistoricalDatasetForTesting(twoWeeksDataset) - - let computed = store.weeklyPace(provider: .codex, window: window, now: now) - let linear = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) - #expect(computed != nil) - #expect(abs((computed?.deltaPercent ?? 0) - (linear?.deltaPercent ?? 0)) < 0.001) + resetsAt: resetsAt, + resetDescription: nil, + nextRegenPercent: nil) + + let pace = try #require(CodexHistoricalPaceEvaluator.evaluate( + window: window, + now: now, + dataset: dataset)) + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == 0) + #expect(pace.runOutProbability == 1) + + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + #expect(detail.rightLabel == "Runs out now · ≈ 100% run-out risk") + } } } diff --git a/Tests/CodexBarTests/IconRendererHideCrittersTests.swift b/Tests/CodexBarTests/IconRendererHideCrittersTests.swift new file mode 100644 index 0000000000..eb879f1b2c --- /dev/null +++ b/Tests/CodexBarTests/IconRendererHideCrittersTests.swift @@ -0,0 +1,79 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct IconRendererHideCrittersTests { + private func pixels(_ image: NSImage) throws -> Data { + try #require(image.tiffRepresentation) + } + + private func icon(style: IconStyle, weeklyRemaining: Double? = 40, hideCritters: Bool) -> NSImage { + IconRenderer.makeIcon( + primaryRemaining: 60, + weeklyRemaining: weeklyRemaining, + creditsRemaining: nil, + stale: false, + style: style, + hideCritters: hideCritters) + } + + @Test(arguments: [ + IconStyle.codex, + .claude, + .gemini, + .antigravity, + .factory, + .warp, + ]) + func `hiding critters removes every decorated style twist`(style: IconStyle) throws { + let decorated = self.icon(style: style, hideCritters: false) + let plain = self.icon(style: style, hideCritters: true) + + #expect(try self.pixels(decorated) != self.pixels(plain)) + } + + @Test(arguments: [ + IconStyle.codex, + .claude, + .gemini, + .antigravity, + .factory, + .warp, + ]) + func `hidden decorated styles match plain capsule bars`(style: IconStyle) throws { + let hidden = self.icon(style: style, hideCritters: true) + let reference = self.icon(style: .cursor, hideCritters: true) + + #expect(try self.pixels(hidden) == self.pixels(reference)) + } + + @Test + func `hiding critters removes warp eyes without weekly quota`() throws { + let decorated = self.icon(style: .warp, weeklyRemaining: nil, hideCritters: false) + let plain = self.icon(style: .warp, weeklyRemaining: nil, hideCritters: true) + + #expect(try self.pixels(decorated) != self.pixels(plain)) + } + + @Test + func `hiding critters is a no-op for an undecorated style`() throws { + // Cursor has no critter twist, so the flag must not alter its bars. + let withFlag = self.icon(style: .cursor, hideCritters: true) + let withoutFlag = self.icon(style: .cursor, hideCritters: false) + + #expect(try self.pixels(withFlag) == self.pixels(withoutFlag)) + } + + @Test + func `morph icon honors hide critters at full progress`() throws { + // At full progress the morph cross-fades into the bar icon, which carries + // the Codex face. A distinct cache key must keep the two renders separate. + let decorated = IconRenderer.makeMorphIcon(progress: 1, style: .codex, hideCritters: false) + let plain = IconRenderer.makeMorphIcon(progress: 1, style: .codex, hideCritters: true) + + #expect(try self.pixels(decorated) != self.pixels(plain)) + } +} diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift new file mode 100644 index 0000000000..2584e5dfed --- /dev/null +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -0,0 +1,124 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct InlineCostHistoryDashboardLabelTests { + @Test + func `local cost history KPI titles preserve one day and dynamic windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let daily = [ + CostUsageDailyReport.Entry( + date: "2023-11-14", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 0.12, + modelsUsed: ["claude-sonnet-4"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: ["claude-opus-4"], + modelBreakdowns: nil), + ] + + func makeModel(historyDays: Int) -> UsageMenuCardView.Model { + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + historyDays: historyDays, + daily: daily, + updatedAt: now) + return UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + let oneDay = makeModel(historyDays: 1) + #expect(oneDay.inlineUsageDashboard?.kpis[1].title == "Today") + #expect(oneDay.inlineUsageDashboard?.kpis[2].title == "Today tokens") + + let sevenDays = makeModel(historyDays: 7) + #expect(sevenDays.inlineUsageDashboard?.kpis[1].title == "Last 7 days Cost") + #expect(sevenDays.inlineUsageDashboard?.kpis[2].title == "Last 7 days tokens") + + let thirtyDays = makeModel(historyDays: 30) + #expect(thirtyDays.inlineUsageDashboard?.kpis[1].title == "30d cost") + #expect(thirtyDays.inlineUsageDashboard?.kpis[2].title == "30d tokens") + } + + @Test + func `custom cost history KPI title keeps token label distinct`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + historyLabel: "This month", + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis[1].title == "This month") + #expect(model.inlineUsageDashboard?.kpis[2].title == "This month tokens") + } +} diff --git a/Tests/CodexBarTests/InlineUsageDashboardBarColorTests.swift b/Tests/CodexBarTests/InlineUsageDashboardBarColorTests.swift new file mode 100644 index 0000000000..78b34b2c14 --- /dev/null +++ b/Tests/CodexBarTests/InlineUsageDashboardBarColorTests.swift @@ -0,0 +1,88 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct InlineUsageDashboardBarColorTests { + /// The inline usage bars must be tinted with each provider's branding color (the same color + /// used by the switcher tab and the detailed cost-history chart) rather than a fixed palette. + @Test + func `bar color matches branding for every provider`() { + for provider in UsageProvider.allCases { + let branding = ProviderDescriptorRegistry.descriptor(for: provider).branding.color + let expected = Color(red: branding.red, green: branding.green, blue: branding.blue) + #expect( + UsageMenuCardView.Model.inlineDashboardBarColor(for: provider) == expected, + "inline bar color did not match branding for \(provider.rawValue)") + } + } + + /// The resolved dashboard model must actually carry the provider's branding color, and two + /// providers with different branding must end up with different bar colors. + @Test + func `resolved dashboard carries provider branding color`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let daily = [ + CostUsageDailyReport.Entry( + date: "2023-11-14", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 0.12, + modelsUsed: ["gpt-5"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: ["gpt-5"], + modelBreakdowns: nil), + ] + + func makeModel(provider: UsageProvider) throws -> UsageMenuCardView.Model { + let metadata = try #require(ProviderDefaults.metadata[provider]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + historyDays: 30, + daily: daily, + updatedAt: now) + return UsageMenuCardView.Model.make(.init( + provider: provider, + metadata: metadata, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + let codex = try makeModel(provider: .codex) + let claude = try makeModel(provider: .claude) + + #expect(codex.inlineUsageDashboard?.barColor + == UsageMenuCardView.Model.inlineDashboardBarColor(for: .codex)) + #expect(claude.inlineUsageDashboard?.barColor + == UsageMenuCardView.Model.inlineDashboardBarColor(for: .claude)) + #expect(codex.inlineUsageDashboard?.barColor != claude.inlineUsageDashboard?.barColor) + } +} diff --git a/Tests/CodexBarTests/InstallOriginTests.swift b/Tests/CodexBarTests/InstallOriginTests.swift index 6257229a25..7099bb6c85 100644 --- a/Tests/CodexBarTests/InstallOriginTests.swift +++ b/Tests/CodexBarTests/InstallOriginTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBar -@Suite struct InstallOriginTests { @Test - func detectsHomebrewCaskroom() { + func `detects homebrew caskroom`() { #expect( InstallOrigin .isHomebrewCask( diff --git a/Tests/CodexBarTests/JetBrainsIDEDetectorTests.swift b/Tests/CodexBarTests/JetBrainsIDEDetectorTests.swift index 2e7eeeef9b..ecbee841ed 100644 --- a/Tests/CodexBarTests/JetBrainsIDEDetectorTests.swift +++ b/Tests/CodexBarTests/JetBrainsIDEDetectorTests.swift @@ -1,10 +1,9 @@ import CodexBarCore import Testing -@Suite struct JetBrainsIDEDetectorTests { @Test - func parsesIDEDirectoryCaseInsensitive() { + func `parses IDE directory case insensitive`() { let info = JetBrainsIDEDetector._parseIDEDirectoryForTesting( dirname: "Webstorm2024.1", basePath: "/test") diff --git a/Tests/CodexBarTests/JetBrainsStatusProbeTests.swift b/Tests/CodexBarTests/JetBrainsStatusProbeTests.swift index 8e64863a7b..d5fc20945e 100644 --- a/Tests/CodexBarTests/JetBrainsStatusProbeTests.swift +++ b/Tests/CodexBarTests/JetBrainsStatusProbeTests.swift @@ -2,10 +2,9 @@ import CodexBarCore import Foundation import Testing -@Suite struct JetBrainsStatusProbeTests { @Test - func parsesQuotaXMLWithTariffQuota() throws { + func `parses quota XML with tariff quota`() throws { // Real-world format with tariffQuota containing available credits let quotaInfo = [ "{ "type": "Available",", @@ -57,7 +56,7 @@ struct JetBrainsStatusProbeTests { } @Test - func parsesQuotaXMLWithoutTariffQuota() throws { + func `parses quota XML without tariff quota`() throws { // Fallback format without tariffQuota let quotaInfo = [ "{ "type": "paid",", @@ -104,7 +103,7 @@ struct JetBrainsStatusProbeTests { } @Test - func calculatesUsagePercentageFromAvailable() { + func `calculates usage percentage from available`() { // available = 75_000, maximum = 100_000 -> 75% remaining, 25% used let quotaInfo = JetBrainsQuotaInfo( type: "paid", @@ -118,7 +117,7 @@ struct JetBrainsStatusProbeTests { } @Test - func calculatesUsagePercentageAtZero() { + func `calculates usage percentage at zero`() { let quotaInfo = JetBrainsQuotaInfo( type: "paid", used: 0, @@ -131,7 +130,7 @@ struct JetBrainsStatusProbeTests { } @Test - func calculatesUsagePercentageAtMax() { + func `calculates usage percentage at max`() { let quotaInfo = JetBrainsQuotaInfo( type: "paid", used: 100_000, @@ -144,7 +143,7 @@ struct JetBrainsStatusProbeTests { } @Test - func handlesZeroMaximum() { + func `handles zero maximum`() { let quotaInfo = JetBrainsQuotaInfo( type: "free", used: 1000, @@ -157,7 +156,7 @@ struct JetBrainsStatusProbeTests { } @Test - func convertsToUsageSnapshot() throws { + func `converts to usage snapshot`() throws { let quotaInfo = JetBrainsQuotaInfo( type: "Available", used: 7478.3, @@ -196,7 +195,7 @@ struct JetBrainsStatusProbeTests { } @Test - func usageSnapshotUsesRefillDateForReset() throws { + func `usage snapshot uses refill date for reset`() throws { let refillDate = Date().addingTimeInterval(86400 * 6) // 6 days from now let untilDate = Date().addingTimeInterval(86400 * 300) // 300 days from now @@ -225,7 +224,7 @@ struct JetBrainsStatusProbeTests { } @Test - func parsesIDEDirectory() { + func `parses IDE directory`() { let ides = [ ("IntelliJIdea2024.3", "IntelliJ IDEA", "2024.3"), ("PyCharm2024.2", "PyCharm", "2024.2"), @@ -249,7 +248,7 @@ struct JetBrainsStatusProbeTests { } @Test - func expandsTildeInCustomPath() async throws { + func `expands tilde in custom path`() async throws { let fileManager = FileManager.default let home = fileManager.homeDirectoryForCurrentUser let testRoot = home @@ -300,7 +299,7 @@ struct JetBrainsStatusProbeTests { } @Test - func handlesHTMLEntities() throws { + func `handles HTML entities`() throws { let quotaInfo = [ "{"type":"free"", ","current":"0"", @@ -326,7 +325,7 @@ struct JetBrainsStatusProbeTests { } @Test - func throwsOnMissingQuotaInfo() throws { + func `throws on missing quota info`() throws { let xml = """ @@ -342,7 +341,7 @@ struct JetBrainsStatusProbeTests { } @Test - func throwsOnEmptyQuotaInfo() throws { + func `throws on empty quota info`() throws { let xml = """ diff --git a/Tests/CodexBarTests/KeyboardShortcutsBundleTests.swift b/Tests/CodexBarTests/KeyboardShortcutsBundleTests.swift index 61e511225a..2541001d17 100644 --- a/Tests/CodexBarTests/KeyboardShortcutsBundleTests.swift +++ b/Tests/CodexBarTests/KeyboardShortcutsBundleTests.swift @@ -1,9 +1,19 @@ import KeyboardShortcuts import Testing +@testable import CodexBar @MainActor -@Suite struct KeyboardShortcutsBundleTests { - @Test func recorderInitializesWithoutCrashing() { +struct KeyboardShortcutsBundleTests { + @Test func `recorder initializes without crashing`() { _ = KeyboardShortcuts.RecorderCocoa(for: .init("test.keyboardshortcuts.bundle")) } + + @Test func `open menu recorder expands beyond dependency intrinsic width`() { + let recorder = KeyboardShortcuts.RecorderCocoa(for: .init("test.keyboardshortcuts.width")) + let size = OpenMenuShortcutRecorder.fittedSize(intrinsicHeight: recorder.intrinsicContentSize.height) + + #expect(size.width == OpenMenuShortcutRecorder.preferredWidth) + #expect(size.width > recorder.intrinsicContentSize.width) + #expect(size.height == recorder.intrinsicContentSize.height) + } } diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 3bf24ca8fd..186534dd0e 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -10,7 +10,36 @@ struct KeychainCacheStoreTests { } @Test - func storesAndLoadsEntry() { + func `tests suppress real keychain access by default`() { + guard ProcessInfo.processInfo.environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1" else { return } + + #expect(KeychainCacheStore.canUseRealKeychainForTesting == false) + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let entry = TestEntry(value: "implicit", storedAt: Date(timeIntervalSince1970: 0)) + + KeychainCacheStore.store(key: key, entry: entry) + defer { KeychainCacheStore.clear(key: key) } + + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case let .found(loaded): + #expect(loaded == entry) + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected implicit test cache entry") + } + } + + @Test + func `background interaction keeps real keychain cache available for no UI reads writes and deletes`() { + KeychainAccessGate.withTaskOverrideForTesting(false) { + ProviderInteractionContext.$current.withValue(.background) { + #expect(KeychainCacheStore.canUseRealKeychainForTesting == true) + #expect(KeychainCacheStore.canEnumerateOrDeleteRealKeychainForTesting == true) + } + } + } + + @Test + func `stores and loads entry`() { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -24,13 +53,13 @@ struct KeychainCacheStoreTests { switch KeychainCacheStore.load(key: key, as: TestEntry.self) { case let .found(loaded): #expect(loaded == entry) - case .missing, .invalid: + case .missing, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected keychain cache entry") } } @Test - func overwritesExistingEntry() { + func `overwrites existing entry`() { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -45,13 +74,13 @@ struct KeychainCacheStoreTests { switch KeychainCacheStore.load(key: key, as: TestEntry.self) { case let .found(loaded): #expect(loaded == second) - case .missing, .invalid: + case .missing, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected overwritten keychain cache entry") } } @Test - func clearRemovesEntry() { + func `clear removes entry`() { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -64,8 +93,125 @@ struct KeychainCacheStoreTests { switch KeychainCacheStore.load(key: key, as: TestEntry.self) { case .missing: #expect(true) - case .found, .invalid: + case .found, .temporarilyUnavailable, .invalid: #expect(Bool(false), "Expected keychain cache entry to be cleared") } } + + @Test + func `clear reports whether an entry was removed`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let entry = TestEntry(value: "gone", storedAt: Date(timeIntervalSince1970: 0)) + KeychainCacheStore.store(key: key, entry: entry) + + #expect(KeychainCacheStore.clear(key: key) == true) + #expect(KeychainCacheStore.clear(key: key) == false) + } + + @Test + func `keys lists only matching category for current service`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let serviceA = "cache-keys-a-\(UUID().uuidString)" + let serviceB = "cache-keys-b-\(UUID().uuidString)" + let cookieA = KeychainCacheStore.Key(category: "cookie", identifier: "codex") + let scopedCookieA = KeychainCacheStore.Key(category: "cookie", identifier: "codex.managed.account") + let oauthA = KeychainCacheStore.Key(category: "oauth", identifier: "codex") + let cookieB = KeychainCacheStore.Key(category: "cookie", identifier: "claude") + let entry = TestEntry(value: "value", storedAt: Date(timeIntervalSince1970: 0)) + + KeychainCacheStore.withServiceOverrideForTesting(serviceA) { + KeychainCacheStore.store(key: cookieA, entry: entry) + KeychainCacheStore.store(key: scopedCookieA, entry: entry) + KeychainCacheStore.store(key: oauthA, entry: entry) + } + KeychainCacheStore.withServiceOverrideForTesting(serviceB) { + KeychainCacheStore.store(key: cookieB, entry: entry) + } + + let keys = KeychainCacheStore.withServiceOverrideForTesting(serviceA) { + KeychainCacheStore.keys(category: "cookie") + } + + #expect(keys == [cookieA, scopedCookieA]) + } + + #if os(macOS) + @Test + func `interaction not allowed is treated as temporarily unavailable`() { + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let result: KeychainCacheStore.LoadResult = KeychainCacheStore.loadResultForKeychainReadFailure( + status: errSecInteractionNotAllowed, + key: key) + + switch result { + case .temporarilyUnavailable: + #expect(true) + case .found, .missing, .invalid: + #expect(Bool(false), "Expected temporary keychain lock to be retry-later") + } + } + + @Test + func `delete interaction not allowed is non fatal`() { + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + #expect(KeychainCacheStore.clearResultForKeychainDeleteStatus( + errSecInteractionNotAllowed, + key: key) == .failed) + } + + @Test + func `load failure override bypasses test store without affecting store or clear`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + let entry = TestEntry(value: "stored", storedAt: Date(timeIntervalSince1970: 0)) + KeychainCacheStore.store(key: key, entry: entry) + defer { KeychainCacheStore.clear(key: key) } + + KeychainCacheStore.withLoadFailureStatusOverrideForTesting(errSecInteractionNotAllowed) { + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case .temporarilyUnavailable: + #expect(true) + case .found, .missing, .invalid: + #expect(Bool(false), "Expected override to run before test store") + } + } + + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case let .found(loaded): + #expect(loaded == entry) + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected override not to mutate test store") + } + } + + @Test + func `cache ACL trusts bundled app and CLI helper`() { + let root = URL(fileURLWithPath: "/Applications/CodexBar.app") + let executable = root.appendingPathComponent("Contents/MacOS/CodexBar") + let helper = root.appendingPathComponent("Contents/Helpers/CodexBarCLI") + let existing = Set([ + root.path, + executable.path, + helper.path, + ]) + + let paths = KeychainCacheStore.trustedApplicationPathsForCacheAccess( + bundleURL: root, + executableURL: executable, + fileExists: { existing.contains($0) }) + + #expect(paths == [ + root.path, + helper.path, + executable.path, + ]) + } + #endif } diff --git a/Tests/CodexBarTests/KeychainMigrationTests.swift b/Tests/CodexBarTests/KeychainMigrationTests.swift index a6357b11e1..7b3084b61b 100644 --- a/Tests/CodexBarTests/KeychainMigrationTests.swift +++ b/Tests/CodexBarTests/KeychainMigrationTests.swift @@ -1,12 +1,11 @@ import Testing @testable import CodexBar -@Suite struct KeychainMigrationTests { @Test - func migrationListCoversKnownKeychainItems() { + func `migration list covers known keychain items`() { let items = Set(KeychainMigration.itemsToMigrate.map(\.label)) - let expected: Set = [ + let expected: Set = [ "com.steipete.CodexBar:codex-cookie", "com.steipete.CodexBar:claude-cookie", "com.steipete.CodexBar:cursor-cookie", diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift new file mode 100644 index 0000000000..58591543f0 --- /dev/null +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +import Darwin +import LocalAuthentication +import Security + +struct KeychainNoUIQueryTests { + private func resolveSecurityUIFailValue() -> String { + let securityPath = "/System/Library/Frameworks/Security.framework/Security" + guard let handle = dlopen(securityPath, RTLD_NOW) else { + return "u_AuthUIF" + } + defer { dlclose(handle) } + guard let symbol = dlsym(handle, "kSecUseAuthenticationUIFail") else { + return "u_AuthUIF" + } + let valuePointer = symbol.assumingMemoryBound(to: CFString?.self) + return (valuePointer.pointee as String?) ?? "u_AuthUIF" + } + + @Test + func `apply sets non interactive context and UI fail policy`() { + var query: [String: Any] = [:] + + KeychainNoUIQuery.apply(to: &query) + + let context = query[kSecUseAuthenticationContext as String] as? LAContext + #expect(context != nil) + #expect(context?.interactionNotAllowed == true) + + let uiPolicy = query[kSecUseAuthenticationUI as String] as? String + #expect(uiPolicy == self.resolveSecurityUIFailValue()) + #expect(uiPolicy == (KeychainNoUIQuery.uiFailPolicyForTesting() as String)) + #expect(uiPolicy != "kSecUseAuthenticationUIFail") + } + + @Test + func `preflight query is strictly non interactive and does not request secret data`() { + let query = KeychainAccessPreflight.makeGenericPasswordPreflightQuery( + service: "test.service", + account: "test.account") + + #expect(query[kSecReturnData as String] == nil) + #expect(query[kSecReturnAttributes as String] as? Bool == true) + #expect((query[kSecUseAuthenticationContext as String] as? LAContext)?.interactionNotAllowed == true) + #expect((query[kSecUseAuthenticationUI as String] as? String) == self.resolveSecurityUIFailValue()) + } + + @Test + func `preflight query executes without invalid UI policy`() { + let query = KeychainAccessPreflight.makeGenericPasswordPreflightQuery( + service: "codexbar.keychain.noui.\(UUID().uuidString)", + account: nil) + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + #expect(status == errSecItemNotFound || status == errSecInteractionNotAllowed) + } +} +#endif diff --git a/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift b/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift new file mode 100644 index 0000000000..215efc1a58 --- /dev/null +++ b/Tests/CodexBarTests/KeychainPromptCoordinatorTests.swift @@ -0,0 +1,40 @@ +import Testing +@testable import CodexBar + +struct KeychainPromptCoordinatorTests { + @Test + func `detects raw SwiftPM debug executable`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/arm64-apple-macosx/debug/CodexBar")) + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/debug/CodexBar")) + } + + @Test + func `detects raw SwiftPM release executable`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/arm64-apple-macosx/release/CodexBar")) + } + + @Test + func `detects custom SwiftPM scratch path`() { + #expect(KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/tmp/codexbar-build/arm64-apple-macosx/debug/CodexBar")) + } + + @Test + func `keeps packaged app keychain behavior`() { + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Applications/CodexBar.app/Contents/MacOS/CodexBar")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/package/CodexBar.app/Contents/MacOS/CodexBar")) + } + + @Test + func `ignores unrelated executable paths`() { + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable( + "/Users/me/CodexBar/.build/debug/CodexBarCLI")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable("")) + #expect(!KeychainPromptCoordinator.isUnbundledCodexBarExecutable("CodexBar")) + } +} diff --git a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift new file mode 100644 index 0000000000..fb00d1ae64 --- /dev/null +++ b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing + +struct KeychainPromptSafetyAuditTests { + @Test + func `agent instructions forbid keychain prompt validation`() throws { + let agents = try Self.readRepoFile("AGENTS.md") + + #expect(agents.contains("Never run tests/checks or ad-hoc validation that can display macOS Keychain prompts")) + #expect(agents.contains("use parser tests, stubs, test stores, or `KeychainNoUIQuery`")) + } + + @Test + func `live TTY integration tests are opt in`() throws { + let ttyTests = try Self.readRepoFile("Tests/CodexBarTests/TTYIntegrationTests.swift") + + #expect(ttyTests.contains("LIVE_CODEX_TTY")) + #expect(ttyTests.contains("LIVE_CLAUDE_TTY")) + #expect(ttyTests.contains("guard ProcessInfo.processInfo.environment[\"LIVE_CODEX_TTY\"] == \"1\"")) + #expect(ttyTests.contains("guard ProcessInfo.processInfo.environment[\"LIVE_CLAUDE_TTY\"] == \"1\"")) + } + + @Test + func `interactive keychain prompt test paths use test doubles`() throws { + let promptLiteral = "allowKeychainPrompt: true" + let testFiles = try Self.swiftTestFiles(excludingSelf: true) + let promptCallSites = try testFiles.flatMap { file in + try Self.lines(in: file) + .enumerated() + .filter { _, line in line.contains(promptLiteral) } + .map { lineNumber, _ in PromptCallSite(file: file, lineNumber: lineNumber + 1) } + } + + #expect(promptCallSites.isEmpty == false) + for callSite in promptCallSites { + let lines = try Self.lines(in: callSite.file) + let usesScopedKeychainDouble = Self.hasOpenKeychainTestDouble(lines: lines, before: callSite.lineNumber) + let failureMessage = "\(callSite.file.path):\(callSite.lineNumber) has \(promptLiteral) " + + "without an enclosing keychain test double" + #expect(usesScopedKeychainDouble, "\(failureMessage)") + } + } + + @Test + func `tests do not call SecItemCopyMatching except no UI query coverage`() throws { + let offenders = try Self.swiftTestFiles().filter { file in + let text = try Self.readFile(file) + return text.contains("SecItemCopyMatching") + && !file.path.hasSuffix("Tests/CodexBarTests/KeychainNoUIQueryTests.swift") + && !file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift") + } + + #expect(offenders.isEmpty, "Unexpected direct SecItemCopyMatching in tests: \(offenders.map(\.path))") + } + + private static func repoRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private static func readRepoFile(_ relativePath: String) throws -> String { + try self.readFile(self.repoRoot().appendingPathComponent(relativePath)) + } + + private static func readFile(_ url: URL) throws -> String { + try String(contentsOf: url, encoding: .utf8) + } + + private static func lines(in url: URL) throws -> [Substring] { + try self.readFile(url).split(separator: "\n", omittingEmptySubsequences: false) + } + + private static func swiftTestFiles(excludingSelf: Bool = false) throws -> [URL] { + let testsRoot = self.repoRoot().appendingPathComponent("Tests/CodexBarTests", isDirectory: true) + guard let enumerator = FileManager.default.enumerator( + at: testsRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + else { return [] } + + var files: [URL] = [] + for case let file as URL in enumerator where file.pathExtension == "swift" { + let values = try file.resourceValues(forKeys: [.isRegularFileKey]) + if values.isRegularFile == true { + if excludingSelf, file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift") { + continue + } + files.append(file) + } + } + return files + } + + private static func hasOpenKeychainTestDouble(lines: [Substring], before oneBasedLineNumber: Int) -> Bool { + let helperNames = [ + "withClaudeKeychainOverridesForTesting", + "withSecurityCLIReadOverrideForTesting", + "KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting", + ] + let targetIndex = oneBasedLineNumber - 1 + let lineRange = lines.indices.prefix(through: targetIndex) + return lineRange.contains { index in + helperNames.contains { lines[index].contains($0) } + && self.hasOpenBraceScope(lines: lines, from: index, through: targetIndex) + } + } + + private static func hasOpenBraceScope(lines: [Substring], from startIndex: Int, through endIndex: Int) -> Bool { + var balance = 0 + var sawOpeningBrace = false + for line in lines[startIndex...endIndex] { + for character in line { + switch character { + case "{": + balance += 1 + sawOpeningBrace = true + case "}": + balance -= 1 + default: + continue + } + } + } + return sawOpeningBrace && balance > 0 + } + + private struct PromptCallSite { + let file: URL + let lineNumber: Int + } +} diff --git a/Tests/CodexBarTests/KiloBearerTokenResolverTests.swift b/Tests/CodexBarTests/KiloBearerTokenResolverTests.swift new file mode 100644 index 0000000000..371bc3bfb1 --- /dev/null +++ b/Tests/CodexBarTests/KiloBearerTokenResolverTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct KiloBearerTokenResolverTests { + private func writeAuthFile(_ json: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("kilo-resolver-tests-\(UUID().uuidString)", isDirectory: true) + let kiloDir = directory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("kilo", isDirectory: true) + try FileManager.default.createDirectory(at: kiloDir, withIntermediateDirectories: true) + let authURL = kiloDir.appendingPathComponent("auth.json", isDirectory: false) + try json.write(to: authURL, atomically: true, encoding: .utf8) + return directory + } + + @Test + func `api mode uses provided apiKey`() throws { + let resolved = try KiloBearerTokenResolver.resolve( + source: .api, + apiKey: "kilo_abc", + environment: [:]) + #expect(resolved.token == "kilo_abc") + #expect(resolved.sourceLabel == "api") + } + + @Test + func `api mode falls back to KILO_API_KEY env var when apiKey is empty`() throws { + let resolved = try KiloBearerTokenResolver.resolve( + source: .api, + apiKey: nil, + environment: ["KILO_API_KEY": "kilo_from_env"]) + #expect(resolved.token == "kilo_from_env") + #expect(resolved.sourceLabel == "api") + } + + @Test + func `api mode throws missingCredentials when nothing available`() { + #expect(throws: KiloUsageError.missingCredentials) { + try KiloBearerTokenResolver.resolve( + source: .api, + apiKey: nil, + environment: [:]) + } + } + + @Test + func `cli mode reads token from auth.json`() throws { + let home = try self.writeAuthFile(#"{ "kilo": { "access": "cli-token" } }"#) + defer { try? FileManager.default.removeItem(at: home) } + + let resolved = try KiloBearerTokenResolver.resolve( + source: .cli, + apiKey: nil, + environment: ["HOME": home.path]) + #expect(resolved.token == "cli-token") + #expect(resolved.sourceLabel == "cli") + } + + @Test + func `cli mode throws cliSessionMissing when auth.json missing`() { + let nonexistentHome = FileManager.default.temporaryDirectory + .appendingPathComponent("kilo-no-such-home-\(UUID().uuidString)", isDirectory: true) + #expect(throws: (any Error).self) { + try KiloBearerTokenResolver.resolve( + source: .cli, + apiKey: nil, + environment: ["HOME": nonexistentHome.path]) + } + } + + @Test + func `cli mode throws cliSessionInvalid for malformed JSON`() throws { + let home = try self.writeAuthFile(#"{ "kilo": { } }"#) + defer { try? FileManager.default.removeItem(at: home) } + + #expect(throws: (any Error).self) { + try KiloBearerTokenResolver.resolve( + source: .cli, + apiKey: nil, + environment: ["HOME": home.path]) + } + } + + @Test + func `auto mode prefers API key when available`() throws { + let home = try self.writeAuthFile(#"{ "kilo": { "access": "cli-token" } }"#) + defer { try? FileManager.default.removeItem(at: home) } + + let resolved = try KiloBearerTokenResolver.resolve( + source: .auto, + apiKey: "kilo_api", + environment: ["HOME": home.path]) + #expect(resolved.token == "kilo_api") + #expect(resolved.sourceLabel == "api") + } + + @Test + func `auto mode falls back to CLI when API key missing`() throws { + let home = try self.writeAuthFile(#"{ "kilo": { "access": "cli-fallback" } }"#) + defer { try? FileManager.default.removeItem(at: home) } + + let resolved = try KiloBearerTokenResolver.resolve( + source: .auto, + apiKey: nil, + environment: ["HOME": home.path]) + #expect(resolved.token == "cli-fallback") + #expect(resolved.sourceLabel == "cli") + } + + @Test + func `auto mode surfaces CLI error when neither path available`() { + let nonexistentHome = FileManager.default.temporaryDirectory + .appendingPathComponent("kilo-no-such-home-\(UUID().uuidString)", isDirectory: true) + #expect(throws: (any Error).self) { + try KiloBearerTokenResolver.resolve( + source: .auto, + apiKey: nil, + environment: ["HOME": nonexistentHome.path]) + } + } +} diff --git a/Tests/CodexBarTests/KiloOrganizationTests.swift b/Tests/CodexBarTests/KiloOrganizationTests.swift new file mode 100644 index 0000000000..7b12e273e5 --- /dev/null +++ b/Tests/CodexBarTests/KiloOrganizationTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct KiloOrganizationTests { + @Test + func `decodes from canonical Kilo profile payload`() throws { + let json = #""" + { "id": "org_123", "name": "Acme Corp", "role": "owner" } + """# + let data = Data(json.utf8) + let org = try JSONDecoder().decode(KiloOrganization.self, from: data) + #expect(org.id == "org_123") + #expect(org.name == "Acme Corp") + #expect(org.role == "owner") + } + + @Test + func `decodes when role missing`() throws { + let json = #""" + { "id": "org_xyz", "name": "No Role Org" } + """# + let data = Data(json.utf8) + let org = try JSONDecoder().decode(KiloOrganization.self, from: data) + #expect(org.role == nil) + } + + @Test + func `equality covers all stored fields`() { + let a = KiloOrganization(id: "org_1", name: "A", role: "member") + let b = KiloOrganization(id: "org_1", name: "A", role: "member") + let differentRole = KiloOrganization(id: "org_1", name: "A", role: "owner") + #expect(a == b) + #expect(a != differentRole) + } +} + +struct KiloUsageScopeTests { + @Test + func `personal scope identifier is stable`() { + let scope: KiloUsageScope = .personal + #expect(scope.scopeIdentifier == "personal") + } + + @Test + func `organization scope identifier prefixes id`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.scopeIdentifier == "org:org_42") + } + + @Test + func `organizationID is nil for personal`() { + #expect(KiloUsageScope.personal.organizationID == nil) + } + + @Test + func `organizationID returns id for organization`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.organizationID == "org_42") + } + + @Test + func `displayName falls back to Personal for personal`() { + #expect(KiloUsageScope.personal.displayName == "Personal") + } + + @Test + func `displayName uses org name for organization`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.displayName == "Acme") + } +} diff --git a/Tests/CodexBarTests/KiloSettingsReaderTests.swift b/Tests/CodexBarTests/KiloSettingsReaderTests.swift index 239729fe19..40335b4779 100644 --- a/Tests/CodexBarTests/KiloSettingsReaderTests.swift +++ b/Tests/CodexBarTests/KiloSettingsReaderTests.swift @@ -1,10 +1,9 @@ import CodexBarCore import Testing -@Suite struct KiloSettingsReaderTests { @Test - func apiURLDefaultsToAppKiloAITrpc() { + func `api URL defaults to app kilo AI trpc`() { let url = KiloSettingsReader.apiURL(environment: [:]) #expect(url.scheme == "https") @@ -13,7 +12,7 @@ struct KiloSettingsReaderTests { } @Test - func apiURLIgnoresEnvironmentOverride() { + func `api URL ignores environment override`() { let url = KiloSettingsReader.apiURL(environment: ["KILO_API_URL": "https://proxy.example.com/trpc"]) #expect(url.host() == "app.kilo.ai") @@ -21,19 +20,19 @@ struct KiloSettingsReaderTests { } @Test - func descriptorUsesAppKiloAIDashboard() { + func `descriptor uses app kilo AI dashboard`() { let descriptor = ProviderDescriptorRegistry.descriptor(for: .kilo) - #expect(descriptor.metadata.dashboardURL == "https://app.kilo.ai/account/usage") + #expect(descriptor.metadata.dashboardURL == "https://app.kilo.ai/usage") } @Test - func descriptorUsesDedicatedKiloIconResource() { + func `descriptor uses dedicated kilo icon resource`() { let descriptor = ProviderDescriptorRegistry.descriptor(for: .kilo) #expect(descriptor.branding.iconResourceName == "ProviderIcon-kilo") } @Test - func descriptorSupportsAutoAPIAndCLISourceModes() { + func `descriptor supports auto API and CLI source modes`() { let descriptor = ProviderDescriptorRegistry.descriptor(for: .kilo) let expected: Set = [.auto, .api, .cli] #expect(descriptor.fetchPlan.sourceModes == expected) diff --git a/Tests/CodexBarTests/KiloSettingsStoreTests.swift b/Tests/CodexBarTests/KiloSettingsStoreTests.swift new file mode 100644 index 0000000000..c5156a854c --- /dev/null +++ b/Tests/CodexBarTests/KiloSettingsStoreTests.swift @@ -0,0 +1,57 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct KiloSettingsStoreTests { + private func makeSettings() throws -> SettingsStore { + let suite = "KiloSettingsStoreTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + @Test + func `defaults to empty known organizations and empty enabled ids`() throws { + let settings = try self.makeSettings() + #expect(settings.kiloKnownOrganizations.isEmpty) + #expect(settings.kiloEnabledOrganizationIDs.isEmpty) + } + + @Test + func `setting known organizations persists them`() throws { + let settings = try self.makeSettings() + let orgs = [ + KiloOrganization(id: "org_1", name: "Alpha", role: "owner"), + KiloOrganization(id: "org_2", name: "Beta", role: "member"), + ] + settings.kiloKnownOrganizations = orgs + #expect(settings.kiloKnownOrganizations == orgs) + } + + @Test + func `setting enabled org ids persists them`() throws { + let settings = try self.makeSettings() + settings.kiloEnabledOrganizationIDs = ["org_1", "org_2"] + #expect(settings.kiloEnabledOrganizationIDs == ["org_1", "org_2"]) + } + + @Test + func `setKiloKnownOrganizations prunes stale enabled ids`() throws { + let settings = try self.makeSettings() + settings.kiloKnownOrganizations = [ + KiloOrganization(id: "org_1", name: "Alpha", role: nil), + KiloOrganization(id: "org_2", name: "Beta", role: nil), + ] + settings.kiloEnabledOrganizationIDs = ["org_1", "org_2"] + settings.setKiloKnownOrganizationsPruningEnabled( + [KiloOrganization(id: "org_2", name: "Beta", role: nil)]) + #expect(settings.kiloKnownOrganizations.map(\.id) == ["org_2"]) + #expect(settings.kiloEnabledOrganizationIDs == ["org_2"]) + } +} diff --git a/Tests/CodexBarTests/KiloUsageFetcherTests.swift b/Tests/CodexBarTests/KiloUsageFetcherTests.swift index b61f5e0a1a..bd2828c503 100644 --- a/Tests/CodexBarTests/KiloUsageFetcherTests.swift +++ b/Tests/CodexBarTests/KiloUsageFetcherTests.swift @@ -2,7 +2,6 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct KiloUsageFetcherTests { private struct StubClaudeFetcher: ClaudeUsageFetching { func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { @@ -38,7 +37,7 @@ struct KiloUsageFetcherTests { } @Test - func batchURLUsesAuthenticatedTRPCBatchFormat() throws { + func `batch URL uses authenticated TRPC batch format`() throws { let baseURL = try #require(URL(string: "https://kilo.example/trpc")) let url = try KiloUsageFetcher._buildBatchURLForTesting(baseURL: baseURL) @@ -65,7 +64,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotMapsBusinessFieldsAndIdentity() throws { + func `parse snapshot maps business fields and identity`() throws { let json = """ [ { @@ -117,7 +116,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotMapsKiloPassWindowFromSubscriptionState() throws { + func `parse snapshot maps kilo pass window from subscription state`() throws { let json = """ [ { @@ -174,7 +173,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotMapsKnownTierNamesAndDefaultsToKiloPass() throws { + func `parse snapshot maps known tier names and defaults to kilo pass`() throws { let proTierJSON = """ [ { "result": { "data": { "creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": false } } }, @@ -200,7 +199,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotUsesAutoTopUpAmountWhenEnabledWithoutPaymentMethod() throws { + func `parse snapshot uses auto top up amount when enabled without payment method`() throws { let json = """ [ { "result": { "data": { "creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": true } } }, @@ -214,7 +213,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotFallbackPassFieldsUseMicroDollarScale() throws { + func `parse snapshot fallback pass fields use micro dollar scale`() throws { let json = """ [ { @@ -264,7 +263,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotTreatsEmptyAndNullBusinessFieldsAsNoDataSuccess() throws { + func `parse snapshot treats empty and null business fields as no data success`() throws { let json = """ [ { @@ -309,7 +308,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotKeepsSparseIndexedObjectRoutingByProcedureIndex() throws { + func `parse snapshot keeps sparse indexed object routing by procedure index`() throws { let json = """ { "0": { @@ -344,7 +343,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotUsesTopLevelCreditsUsedFallback() throws { + func `parse snapshot uses top level credits used fallback`() throws { let json = """ [ { @@ -368,7 +367,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotKeepsZeroTotalVisibleWhenActivityExists() throws { + func `parse snapshot keeps zero total visible when activity exists`() throws { let json = """ [ { @@ -413,7 +412,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotTreatsZeroBalanceWithoutCreditBlocksAsVisibleZeroTotal() throws { + func `parse snapshot treats zero balance without credit blocks as visible zero total`() throws { let json = """ [ { @@ -455,7 +454,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotDegradesOptionalAutoTopUpTRPCError() throws { + func `parse snapshot degrades optional auto top up TRPC error`() throws { let json = """ [ { @@ -498,7 +497,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotKeepsRequiredProcedureTRPCErrorFatal() { + func `parse snapshot keeps required procedure TRPC error fatal`() { let json = """ [ { @@ -534,7 +533,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotMapsUnauthorizedTRPCError() { + func `parse snapshot maps unauthorized TRPC error`() { let json = """ [ { @@ -560,7 +559,7 @@ struct KiloUsageFetcherTests { } @Test - func parseSnapshotMapsInvalidJSONToParseError() { + func `parse snapshot maps invalid JSON to parse error`() { #expect { _ = try KiloUsageFetcher._parseSnapshotForTesting(Data("not-json".utf8)) } throws: { error in @@ -571,7 +570,7 @@ struct KiloUsageFetcherTests { } @Test - func statusErrorMappingCoversAuthAndServerFailures() { + func `status error mapping covers auth and server failures`() { #expect(KiloUsageFetcher._statusErrorForTesting(401) == .unauthorized) #expect(KiloUsageFetcher._statusErrorForTesting(403) == .unauthorized) #expect(KiloUsageFetcher._statusErrorForTesting(404) == .endpointNotFound) @@ -588,14 +587,14 @@ struct KiloUsageFetcherTests { } @Test - func fetchUsageWithoutCredentialsFailsFast() async { + func `fetch usage without credentials fails fast`() async { await #expect(throws: KiloUsageError.missingCredentials) { _ = try await KiloUsageFetcher.fetchUsage(apiKey: " ", environment: [:]) } } @Test - func descriptorFetchOutcomeWithoutCredentialsReturnsActionableError() async { + func `descriptor fetch outcome without credentials returns actionable error`() async { let descriptor = ProviderDescriptorRegistry.descriptor(for: .kilo) let outcome = await descriptor.fetchOutcome(context: self.makeContext()) @@ -612,7 +611,7 @@ struct KiloUsageFetcherTests { } @Test - func descriptorAPIModeIgnoresCLISessionFallback() async throws { + func `descriptor API mode ignores CLI session fallback`() async throws { let homeDirectory = try self.makeTemporaryHomeDirectory() defer { try? FileManager.default.removeItem(at: homeDirectory) } try self.writeKiloAuthFile( @@ -636,7 +635,7 @@ struct KiloUsageFetcherTests { } @Test - func descriptorCLIModeMissingSessionReturnsActionableError() async throws { + func `descriptor CLI mode missing session returns actionable error`() async throws { let homeDirectory = try self.makeTemporaryHomeDirectory() defer { try? FileManager.default.removeItem(at: homeDirectory) } let expectedPath = KiloSettingsReader.defaultAuthFileURL(homeDirectory: homeDirectory).path @@ -658,7 +657,7 @@ struct KiloUsageFetcherTests { } @Test - func descriptorAutoModeFallsBackFromAPIToCLI() async throws { + func `descriptor auto mode falls back from API to CLI`() async throws { let homeDirectory = try self.makeTemporaryHomeDirectory() defer { try? FileManager.default.removeItem(at: homeDirectory) } let expectedPath = KiloSettingsReader.defaultAuthFileURL(homeDirectory: homeDirectory).path @@ -680,7 +679,7 @@ struct KiloUsageFetcherTests { } @Test - func apiStrategyFallsBackOnUnauthorizedOnlyInAutoMode() { + func `api strategy falls back on unauthorized only in auto mode`() { let strategy = KiloAPIFetchStrategy() #expect(strategy.shouldFallback( on: KiloUsageError.unauthorized, @@ -691,7 +690,7 @@ struct KiloUsageFetcherTests { } @Test - func apiStrategyFallsBackOnMissingCredentialsOnlyInAutoMode() { + func `api strategy falls back on missing credentials only in auto mode`() { let strategy = KiloAPIFetchStrategy() #expect(strategy.shouldFallback( on: KiloUsageError.missingCredentials, @@ -701,6 +700,78 @@ struct KiloUsageFetcherTests { context: self.makeContext(sourceMode: .api))) } + @Test + func `request builder adds org header for organization scope`() throws { + let baseURL = try #require(URL(string: "https://kilo.example/trpc")) + let request = try KiloUsageFetcher._buildRequestForTesting( + baseURL: baseURL, + apiKey: "test-token", + scope: .organization(id: "org_42", name: "Acme")) + #expect(request.value(forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") == "org_42") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + } + + @Test + func `request builder omits org header for personal scope`() throws { + let baseURL = try #require(URL(string: "https://kilo.example/trpc")) + let request = try KiloUsageFetcher._buildRequestForTesting( + baseURL: baseURL, + apiKey: "test-token", + scope: .personal) + #expect(request.value(forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") == nil) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + } + + @Test + func `parseOrganizations decodes tRPC array shape`() throws { + let json = #""" + [ + { + "result": { + "data": { + "json": [ + { "id": "org_1", "name": "Alpha", "role": "owner" }, + { "id": "org_2", "name": "Beta", "role": "member" } + ] + } + } + } + ] + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.count == 2) + #expect(orgs[0].id == "org_1") + #expect(orgs[0].name == "Alpha") + #expect(orgs[0].role == "owner") + #expect(orgs[1].id == "org_2") + #expect(orgs[1].role == "member") + } + + @Test + func `parseOrganizations decodes profile REST shape`() throws { + let json = #""" + { + "user": { "email": "test@example.com" }, + "organizations": [ + { "id": "org_42", "name": "Gamma" } + ] + } + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.count == 1) + #expect(orgs[0].id == "org_42") + #expect(orgs[0].role == nil) + } + + @Test + func `parseOrganizations returns empty for no orgs`() throws { + let json = #""" + { "user": { "email": "x@y" }, "organizations": [] } + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.isEmpty) + } + private func makeTemporaryHomeDirectory() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/Tests/CodexBarTests/KimiK2SettingsReaderTests.swift b/Tests/CodexBarTests/KimiK2SettingsReaderTests.swift index d291a921f7..32d311704a 100644 --- a/Tests/CodexBarTests/KimiK2SettingsReaderTests.swift +++ b/Tests/CodexBarTests/KimiK2SettingsReaderTests.swift @@ -1,25 +1,23 @@ import CodexBarCore import Testing -@Suite struct KimiK2SettingsReaderTests { @Test - func apiKeyIsTrimmed() { + func `api key is trimmed`() { let env = ["KIMI_API_KEY": " key-123 "] #expect(KimiK2SettingsReader.apiKey(environment: env) == "key-123") } @Test - func apiKeyStripsQuotes() { + func `api key strips quotes`() { let env = ["KIMI_KEY": "\"quoted-456\""] #expect(KimiK2SettingsReader.apiKey(environment: env) == "quoted-456") } } -@Suite struct KimiK2ProviderTokenResolverTests { @Test - func resolvesFromEnvironment() { + func `resolves from environment`() { let env = ["KIMI_API_KEY": "env-token"] let resolution = ProviderTokenResolver.kimiK2Resolution(environment: env) #expect(resolution?.token == "env-token") diff --git a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift index 0fdb74f42c..9b549bfbf7 100644 --- a/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift +++ b/Tests/CodexBarTests/KimiK2UsageFetcherTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct KimiK2UsageFetcherTests { @Test - func parsesUsageFromNestedUsage() throws { + func `parses usage from nested usage`() throws { let json = """ { "data": { @@ -29,7 +28,7 @@ struct KimiK2UsageFetcherTests { } @Test - func usesHeaderFallbackForRemainingCredits() throws { + func `uses header fallback for remaining credits`() throws { let json = """ { "total_credits_consumed": 50 } """ @@ -42,7 +41,7 @@ struct KimiK2UsageFetcherTests { } @Test - func parsesNumericTimestampSeconds() throws { + func `parses numeric timestamp seconds`() throws { let json = """ { "timestamp": 1700000000, @@ -58,7 +57,7 @@ struct KimiK2UsageFetcherTests { } @Test - func parsesNumericTimestampMilliseconds() throws { + func `parses numeric timestamp milliseconds`() throws { let json = """ { "timestamp": 1700000000000, @@ -74,7 +73,7 @@ struct KimiK2UsageFetcherTests { } @Test - func invalidRootReturnsParseError() { + func `invalid root returns parse error`() { let json = """ [{ "total": 1 }] """ @@ -86,4 +85,17 @@ struct KimiK2UsageFetcherTests { return message == "Root JSON is not an object." } } + + @Test + func `converts api key credits into text only snapshot`() { + let usage = KimiK2UsageSummary( + consumed: 10, + remaining: 25, + averageTokens: nil, + updatedAt: Date()).toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.identity?.providerID == .kimik2) + #expect(usage.identity?.loginMethod == "Credits: 25 left") + } } diff --git a/Tests/CodexBarTests/KimiProviderTests.swift b/Tests/CodexBarTests/KimiProviderTests.swift index c8e375a434..2d2bd20014 100644 --- a/Tests/CodexBarTests/KimiProviderTests.swift +++ b/Tests/CodexBarTests/KimiProviderTests.swift @@ -2,48 +2,203 @@ import Foundation import Testing @testable import CodexBarCore -@Suite +private struct KimiStubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } +} + +private func makeKimiFetchContext(sourceMode: ProviderSourceMode) -> ProviderFetchContext { + let env: [String: String] = [:] + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: KimiStubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) +} + struct KimiSettingsReaderTests { @Test - func readsTokenFromEnvironmentVariable() { + func `reads token from environment variable`() { let env = ["KIMI_AUTH_TOKEN": "test.jwt.token"] let token = KimiSettingsReader.authToken(environment: env) #expect(token == "test.jwt.token") } @Test - func normalizesQuotedToken() { + func `reads API key from preferred environment variable`() { + let env = ["KIMI_CODE_API_KEY": "kimi-code-token"] + let token = KimiSettingsReader.apiKey(environment: env) + #expect(token == "kimi-code-token") + } + + @Test + func `does not consume generic Kimi K2 API key environment variable`() { + let env = ["KIMI_API_KEY": "'kimi-api-token'"] + let token = KimiSettingsReader.apiKey(environment: env) + #expect(token == nil) + } + + @Test + func `uses code specific API key when generic Kimi K2 key also exists`() { + let env = [ + "KIMI_API_KEY": "generic-kimi-token", + "KIMI_CODE_API_KEY": "kimi-code-token", + ] + let token = KimiSettingsReader.apiKey(environment: env) + #expect(token == "kimi-code-token") + } + + @Test + func `uses default code API base URL when override is absent`() throws { + let url = try KimiSettingsReader.codeAPIBaseURL(environment: [:]) + #expect(url == KimiSettingsReader.defaultCodeAPIBaseURL) + } + + @Test + func `uses custom code API base URL when valid`() throws { + let env = ["KIMI_CODE_BASE_URL": "https://proxy.example.com/kimi"] + let url = try KimiSettingsReader.codeAPIBaseURL(environment: env) + #expect(url.absoluteString == "https://proxy.example.com/kimi") + } + + @Test + func `rejects invalid code API base URL`() { + let env = ["KIMI_CODE_BASE_URL": "not a url"] + + #expect(throws: KimiAPIError.invalidRequest( + "Kimi Code API base URL must use HTTPS without user info")) + { + try KimiSettingsReader.codeAPIBaseURL(environment: env) + } + } + + @Test + func `rejects insecure code API base URL`() { + let env = ["KIMI_CODE_BASE_URL": "http://proxy.example.com/kimi"] + + #expect(throws: KimiAPIError.invalidRequest( + "Kimi Code API base URL must use HTTPS without user info")) + { + try KimiSettingsReader.codeAPIBaseURL(environment: env) + } + } + + @Test + func `rejects code API base URL containing user info`() { + let env = ["KIMI_CODE_BASE_URL": "https://api.kimi.com@proxy.example.com/kimi"] + + #expect(throws: KimiAPIError.invalidRequest( + "Kimi Code API base URL must use HTTPS without user info")) + { + try KimiSettingsReader.codeAPIBaseURL(environment: env) + } + } + + @Test + func `normalizes quoted token`() { let env = ["KIMI_AUTH_TOKEN": "\"test.jwt.token\""] let token = KimiSettingsReader.authToken(environment: env) #expect(token == "test.jwt.token") } @Test - func returnsNilWhenMissing() { + func `returns nil when missing`() { let env: [String: String] = [:] let token = KimiSettingsReader.authToken(environment: env) #expect(token == nil) } @Test - func returnsNilWhenEmpty() { + func `returns nil when empty`() { let env = ["KIMI_AUTH_TOKEN": ""] let token = KimiSettingsReader.authToken(environment: env) #expect(token == nil) } @Test - func normalizesLowercaseEnvironmentKey() { + func `normalizes lowercase environment key`() { let env = ["kimi_auth_token": "test.jwt.token"] let token = KimiSettingsReader.authToken(environment: env) #expect(token == "test.jwt.token") } } -@Suite +struct KimiAPIFetchStrategyTests { + @Test + func `auto mode falls back from invalid API key to web cookies`() { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .auto) + + #expect(strategy.shouldFallback(on: KimiAPIError.invalidAPIKey, context: context)) + } + + @Test + func `explicit API mode does not fall back from invalid API key`() { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .api) + + #expect(strategy.shouldFallback(on: KimiAPIError.invalidAPIKey, context: context) == false) + } + + @Test + func `explicit API mode reports API key remediation when key is missing`() async { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .api) + + await #expect(throws: KimiAPIError.missingAPIKey) { + try await strategy.fetch(context) + } + } + + @Test + func `auto mode falls back from API response decoding failure`() { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .auto) + let error = DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Unexpected Kimi payload")) + + #expect(strategy.shouldFallback(on: error, context: context)) + } + + @Test + func `explicit API mode surfaces response decoding failure`() { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .api) + let error = DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Unexpected Kimi payload")) + + #expect(strategy.shouldFallback(on: error, context: context) == false) + } + + @Test + func `auto mode does not start web fallback after cancellation`() { + let strategy = KimiAPIFetchStrategy() + let context = makeKimiFetchContext(sourceMode: .auto) + + #expect(strategy.shouldFallback(on: CancellationError(), context: context) == false) + #expect(strategy.shouldFallback(on: URLError(.cancelled), context: context) == false) + } +} + struct KimiUsageResponseParsingTests { @Test - func parsesValidResponse() throws { + func `parses valid response`() throws { let json = """ { "usages": [ @@ -92,7 +247,7 @@ struct KimiUsageResponseParsingTests { } @Test - func parsesResponseWithoutRateLimits() throws { + func `parses response without rate limits`() throws { let json = """ { "usages": [ @@ -115,7 +270,7 @@ struct KimiUsageResponseParsingTests { } @Test - func parsesResponseWithNullLimits() throws { + func `parses response with null limits`() throws { let json = """ { "usages": [ @@ -138,7 +293,138 @@ struct KimiUsageResponseParsingTests { } @Test - func throwsOnInvalidJson() { + func `parses code API usage response`() throws { + let json = """ + { + "usage": { + "limit": "2048", + "used": "375", + "remaining": "1673", + "resetTime": "2026-01-09T15:23:13.373329235Z" + }, + "limits": [ + { + "window": { + "duration": 300, + "timeUnit": "TIME_UNIT_MINUTE" + }, + "detail": { + "limit": "200", + "used": "19", + "remaining": "181", + "resetTime": "2026-01-06T15:05:24.374187075Z" + } + } + ] + } + """ + + let snapshot = try KimiUsageFetcher._parseCodeAPIUsageForTesting(Data(json.utf8)) + #expect(snapshot.weekly.limit == "2048") + #expect(snapshot.weekly.used == "375") + #expect(snapshot.rateLimit?.limit == "200") + #expect(snapshot.rateLimit?.used == "19") + } + + @Test + func `parses official numeric values and reset key variants`() throws { + let json = """ + { + "usage": { + "limit": 1000, + "used": 40, + "remaining": 960, + "resetAt": "2026-01-09T15:23:13Z" + }, + "limits": [ + { + "window": { + "duration": 300, + "timeUnit": "TIME_UNIT_MINUTE" + }, + "detail": { + "limit": 100, + "remaining": 99, + "reset_at": "2026-01-06T13:33:02Z" + } + } + ] + } + """ + + let snapshot = try KimiUsageFetcher._parseCodeAPIUsageForTesting(Data(json.utf8)) + + #expect(snapshot.weekly.limit == "1000") + #expect(snapshot.weekly.used == "40") + #expect(snapshot.weekly.remaining == "960") + #expect(snapshot.weekly.resetTime == "2026-01-09T15:23:13Z") + #expect(snapshot.rateLimit?.limit == "100") + #expect(snapshot.rateLimit?.used == nil) + #expect(snapshot.rateLimit?.remaining == "99") + #expect(snapshot.rateLimit?.resetTime == "2026-01-06T13:33:02Z") + } + + @Test + func `builds default code API usage endpoint`() throws { + let baseURL = try #require(URL(string: "https://api.kimi.com")) + let endpoint = KimiUsageFetcher._codeAPIUsageEndpointForTesting(baseURL: baseURL) + + #expect(endpoint.absoluteString == "https://api.kimi.com/coding/v1/usages") + } + + @Test + func `appends code API path to custom proxy root`() throws { + let baseURL = try #require(URL(string: "https://proxy.example.com/kimi")) + let endpoint = KimiUsageFetcher._codeAPIUsageEndpointForTesting(baseURL: baseURL) + + #expect(endpoint.absoluteString == "https://proxy.example.com/kimi/coding/v1/usages") + } + + @Test + func `does not duplicate code API path when base URL already includes it`() throws { + let baseURL = try #require(URL(string: "https://api.kimi.com/coding/v1")) + let endpoint = KimiUsageFetcher._codeAPIUsageEndpointForTesting(baseURL: baseURL) + + #expect(endpoint.absoluteString == "https://api.kimi.com/coding/v1/usages") + } + + @Test + func `does not duplicate code API path with trailing slash`() throws { + let baseURL = try #require(URL(string: "https://proxy.example.com/kimi/coding/v1/")) + let endpoint = KimiUsageFetcher._codeAPIUsageEndpointForTesting(baseURL: baseURL) + + #expect(endpoint.absoluteString == "https://proxy.example.com/kimi/coding/v1/usages") + } + + @Test + func `does not duplicate coding path prefix`() throws { + let baseURL = try #require(URL(string: "https://proxy.example.com/kimi/coding/")) + let endpoint = KimiUsageFetcher._codeAPIUsageEndpointForTesting(baseURL: baseURL) + + #expect(endpoint.absoluteString == "https://proxy.example.com/kimi/coding/v1/usages") + } + + @Test + func `rejects insecure code API base URL before sending bearer token`() async throws { + let baseURL = try #require(URL(string: "http://proxy.example.com/kimi")) + + await #expect(throws: KimiAPIError.invalidRequest( + "Kimi Code API base URL must use HTTPS without user info")) + { + _ = try await KimiUsageFetcher.fetchCodeAPIUsage(apiKey: "secret-token", baseURL: baseURL) + } + } + + @Test + func `maps code API authentication and permission errors separately`() { + #expect(KimiUsageFetcher._codeAPIErrorForTesting(statusCode: 401) == .invalidAPIKey) + #expect( + KimiUsageFetcher._codeAPIErrorForTesting(statusCode: 403) + == .apiError("HTTP 403 (permission or quota denied)")) + } + + @Test + func `throws on invalid json`() { let invalidJson = "{ invalid json }" #expect(throws: DecodingError.self) { @@ -147,7 +433,7 @@ struct KimiUsageResponseParsingTests { } @Test - func throwsOnMissingFeatureCodingScope() throws { + func `throws on missing feature coding scope`() throws { let json = """ { "usages": [ @@ -170,10 +456,9 @@ struct KimiUsageResponseParsingTests { } } -@Suite struct KimiUsageSnapshotConversionTests { @Test - func convertsToUsageSnapshotWithBothWindows() { + func `converts to usage snapshot with both windows`() { let now = Date() let weeklyDetail = KimiUsageDetail( limit: "2048", @@ -210,7 +495,7 @@ struct KimiUsageSnapshotConversionTests { } @Test - func convertsToUsageSnapshotWithoutRateLimit() { + func `converts to usage snapshot without rate limit`() { let now = Date() let weeklyDetail = KimiUsageDetail( limit: "2048", @@ -233,7 +518,7 @@ struct KimiUsageSnapshotConversionTests { } @Test - func handlesZeroValuesCorrectly() { + func `handles zero values correctly`() { let now = Date() let weeklyDetail = KimiUsageDetail( limit: "2048", @@ -251,7 +536,7 @@ struct KimiUsageSnapshotConversionTests { } @Test - func handlesHundredPercentCorrectly() { + func `handles hundred percent correctly`() { let now = Date() let weeklyDetail = KimiUsageDetail( limit: "2048", @@ -269,10 +554,9 @@ struct KimiUsageSnapshotConversionTests { } } -@Suite struct KimiTokenResolverTests { @Test - func resolvesTokenFromEnvironment() { + func `resolves token from environment`() { KeychainAccessGate.withTaskOverrideForTesting(true) { let env = ["KIMI_AUTH_TOKEN": "test.jwt.token"] let token = ProviderTokenResolver.kimiAuthToken(environment: env) @@ -281,7 +565,7 @@ struct KimiTokenResolverTests { } @Test - func resolvesTokenFromKeychainFirst() { + func `resolves token from keychain first`() { // This test would require mocking the keychain. KeychainAccessGate.withTaskOverrideForTesting(true) { let env = ["KIMI_AUTH_TOKEN": "test.env.token"] @@ -291,7 +575,7 @@ struct KimiTokenResolverTests { } @Test - func resolutionIncludesSource() { + func `resolution includes source`() { KeychainAccessGate.withTaskOverrideForTesting(true) { let env = ["KIMI_AUTH_TOKEN": "test.jwt.token"] let resolution = ProviderTokenResolver.kimiAuthResolution(environment: env) @@ -302,12 +586,14 @@ struct KimiTokenResolverTests { } } -@Suite struct KimiAPIErrorTests { @Test - func errorDescriptionsAreHelpful() { + func `error descriptions are helpful`() { #expect(KimiAPIError.missingToken.errorDescription?.contains("missing") == true) #expect(KimiAPIError.invalidToken.errorDescription?.contains("invalid") == true) + #expect(KimiAPIError.missingAPIKey.errorDescription?.contains("Settings > Providers > Kimi") == true) + #expect(KimiAPIError.missingAPIKey.errorDescription?.contains("KIMI_CODE_API_KEY") == true) + #expect(KimiAPIError.invalidAPIKey.errorDescription?.contains("API key") == true) #expect(KimiAPIError.invalidRequest("Bad request").errorDescription?.contains("Bad request") == true) #expect(KimiAPIError.networkError("Timeout").errorDescription?.contains("Timeout") == true) #expect(KimiAPIError.apiError("HTTP 500").errorDescription?.contains("HTTP 500") == true) diff --git a/Tests/CodexBarTests/KiroMenuCardModelTests.swift b/Tests/CodexBarTests/KiroMenuCardModelTests.swift new file mode 100644 index 0000000000..af7a2885ab --- /dev/null +++ b/Tests/CodexBarTests/KiroMenuCardModelTests.swift @@ -0,0 +1,108 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct KiroMenuCardModelTests { + @Test + func `kiro model shows account plan credits bonus and overages`() throws { + let now = Date() + let snapshot = KiroUsageSnapshot( + planName: "KIRO FREE", + accountEmail: "person@example.com", + authMethod: "Google", + creditsUsed: 0.17, + creditsTotal: 50, + creditsPercent: 0, + bonusCreditsUsed: 45.53, + bonusCreditsTotal: 2000, + bonusExpiryDays: 19, + overagesStatus: "Enabled billed at $0.04 per request", + overageCreditsUsed: 40.29, + estimatedOverageCostUSD: 1.61, + manageURL: "https://app.kiro.dev/account/usage", + contextUsage: KiroContextUsageSnapshot( + totalPercentUsed: 1.3, + contextFilesPercent: 0.5, + toolsPercent: 0.8, + kiroResponsesPercent: 0, + promptsPercent: 0), + resetsAt: now.addingTimeInterval(3600), + updatedAt: now).toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.kiro]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .kiro, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.email == "person@example.com") + #expect(model.planText == "Kiro Free") + #expect(model.metrics.map(\.title) == ["Credits", "Bonus"]) + #expect(model.metrics.first?.detailLeftText == "49.83 of 50 credits left") + #expect(model.metrics.dropFirst().first?.detailLeftText == "1954.47 of 2000 bonus credits left") + #expect(model.usageNotes.contains("Auth: Google")) + #expect(model.usageNotes.contains("Overages: Enabled billed at $0.04 per request")) + #expect(model.usageNotes.contains("Overage usage: 40.29 credits")) + #expect(model.usageNotes.contains("Overage cost: $1.61")) + #expect(model.usageNotes.contains { $0.localizedCaseInsensitiveContains("Context window") } == false) + } + + @Test + func `kiro model hides overage spend when overages are disabled`() throws { + let now = Date() + let snapshot = KiroUsageSnapshot( + planName: "KIRO FREE", + creditsUsed: 0.17, + creditsTotal: 50, + creditsPercent: 0, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + overagesStatus: "Disabled", + overageCreditsUsed: 40.29, + estimatedOverageCostUSD: 1.61, + resetsAt: nil, + updatedAt: now).toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.kiro]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .kiro, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.usageNotes.contains("Overages: Disabled")) + #expect(model.usageNotes.contains("Overage usage: 40.29 credits") == false) + #expect(model.usageNotes.contains("Overage cost: $1.61") == false) + } +} diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index e1b35be148..240bb2495c 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -1,13 +1,511 @@ import Foundation import Testing @testable import CodexBarCore +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +private func waitForFile(_ url: URL) async throws { + for _ in 0..<100 where !FileManager.default.fileExists(atPath: url.path) { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(FileManager.default.fileExists(atPath: url.path)) +} -@Suite +@Suite(.serialized) struct KiroStatusProbeTests { + @Test + func `fetch returns usage when account probe times out`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + sleep 5 + printf 'Logged in with Google\\nEmail: person@example.com\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n' + printf 'Credits (12.50 of 50 covered in plan)\\n' + printf '████████████████████ 25%%\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }, accountProbeTimeout: 0.2) + let snapshot = try await probe.fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50) + #expect(snapshot.accountEmail == nil) + #expect(snapshot.authMethod == nil) + } + + @Test + func `fetch preserves account info when account probe succeeds`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\\nEmail: person@example.com\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n' + printf 'Credits (12.50 of 50 covered in plan)\\n' + printf '████████████████████ 25%%\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let snapshot = try await probe.fetch() + + #expect(snapshot.accountEmail == "person@example.com") + #expect(snapshot.authMethod == "Google") + } + + @Test + func `fetch preserves not logged in when usage fails without auth detail`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Not logged in\\n' + exit 1 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + exit 1 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + await #expect { + _ = try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.notLoggedIn = error else { return false } + return true + } + } + + @Test + func `fetch preserves not logged in when usage output cannot be parsed`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Not logged in\\n' + exit 1 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + await #expect { + _ = try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.notLoggedIn = error else { return false } + return true + } + } + + @Test + func `fetch cancellation during context probe is preserved`() async throws { + let contextStarted = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-context-\(UUID().uuidString).started") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + : > '\(contextStarted.path)' + trap '' TERM + while true; do sleep 1; done + fi + + exit 1 + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: contextStarted) + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let task = Task { try await probe.fetch() } + defer { task.cancel() } + + try await waitForFile(contextStarted) + + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test + func `fetch cancellation while waiting for account probe is preserved`() async throws { + let accountStarted = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-account-\(UUID().uuidString).started") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + : > '\(accountStarted.path)' + trap '' TERM + while true; do sleep 1; done + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n' + printf 'Credits (12.50 of 50 covered in plan)\\n' + printf '████████████████████ 25%%\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: accountStarted) + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let task = Task { try await probe.fetch() } + defer { task.cancel() } + + try await waitForFile(accountStarted) + + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test + func `fetch returns when usage helper leaves inherited pipes open`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-pipe-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + let cliURL = root.appendingPathComponent("kiro-cli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } + + let script = """ + #!/bin/bash + set -e + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\\nEmail: person@example.com\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + /usr/bin/python3 -c ' + import os + import subprocess + import sys + + ready_read, ready_write = os.pipe() + child = subprocess.Popen( + [ + sys.executable, + "-c", + "import os,signal,sys,time; " + "signal.signal(signal.SIGHUP, signal.SIG_IGN); " + "signal.signal(signal.SIGTERM, signal.SIG_IGN); " + "handle=open(sys.argv[1], \\\"w\\\"); handle.write(str(os.getpid())); handle.close(); " + "os.write(int(sys.argv[2]), b\\\"1\\\"); os.close(int(sys.argv[2])); time.sleep(30)", + os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], + str(ready_write), + ], + start_new_session=True, + pass_fds=(ready_write,), + ) + os.close(ready_write) + if os.read(ready_read, 1) != b"1": + raise RuntimeError("detached helper exited before signaling readiness") + os.close(ready_read) + ' + test -s "$CODEXBAR_TEST_CHILD_PID_FILE" + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\\n' + printf 'Credits (12.50 of 50 covered in plan)\\n' + printf '████████████████████ 25%%\\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """ + try script.write(to: cliURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path) + + let previousPIDFile = ProcessInfo.processInfo.environment["CODEXBAR_TEST_CHILD_PID_FILE"] + setenv("CODEXBAR_TEST_CHILD_PID_FILE", childPIDFile.path, 1) + defer { + if let previousPIDFile { + setenv("CODEXBAR_TEST_CHILD_PID_FILE", previousPIDFile, 1) + } else { + unsetenv("CODEXBAR_TEST_CHILD_PID_FILE") + } + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let start = Date() + let snapshot = try await probe.fetch() + let elapsed = Date().timeIntervalSince(start) + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50) + #expect(elapsed < 8, "Kiro usage capture should return before the 10s idle timeout, took \(elapsed)s") + + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + let cleanupDeadline = Date().addingTimeInterval(1) + while kill(childPID, 0) == 0, Date() < cleanupDeadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(childPID, 0) == -1, "Kiro usage capture should terminate pipe-holding helpers") + } + + @Test + func `run command hard stops a process that ignores SIGTERM`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + trap '' TERM + printf 'partial output\\n' + while true; do sleep 1; done + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let start = Date() + let result = try await probe.runCommand(arguments: [], timeout: 2, idleTimeout: 0.1) + let elapsed = Date().timeIntervalSince(start) + + #expect(result.terminatedForIdle) + #expect(result.stdout.contains("partial output")) + #expect(result.terminationStatus != 0) + #expect(elapsed < 2, "Ignored SIGTERM should escalate to SIGKILL, took \(elapsed)s") + } + + @Test + func `run command kills a pipe holder that escapes the process group`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-escaped-\(UUID().uuidString).pid") + let cliURL = try self.makeCLI( + """ + #!/usr/bin/python3 + import subprocess + import sys + import time + + child = subprocess.Popen( + [ + sys.executable, + "-c", + "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)", + ], + start_new_session=True, + ) + with open(sys.argv[1], "w") as handle: + handle.write(str(child.pid)) + print("partial output", flush=True) + time.sleep(30) + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: childPIDFile) + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let result = try await probe.runCommand( + arguments: [childPIDFile.path], + timeout: 2, + idleTimeout: 0.1) + + #expect(result.terminatedForIdle) + #expect(result.stdout.contains("partial output")) + + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + defer { _ = kill(childPID, SIGKILL) } + + let cleanupDeadline = Date().addingTimeInterval(1) + while kill(childPID, 0) == 0, Date() < cleanupDeadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(childPID, 0) == -1) + } + + @Test + func `run command cleans a same group helper after normal exit`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-normal-exit-\(UUID().uuidString).pid") + let cliURL = try self.makeCLI( + """ + #!/usr/bin/python3 + import os + import signal + import sys + import time + + child = os.fork() + if child == 0: + os.close(1) + os.close(2) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(sys.argv[1], "w") as handle: + handle.write(str(os.getpid())) + time.sleep(30) + os._exit(0) + + while not os.path.exists(sys.argv[1]): + time.sleep(0.01) + print("parent complete", flush=True) + os._exit(0) + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: childPIDFile) + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let result = try await probe.runCommand(arguments: [childPIDFile.path], timeout: 2) + + #expect(result.terminationStatus == 0) + #expect(result.stdout.contains("parent complete")) + + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + defer { _ = kill(childPID, SIGKILL) } + #expect(kill(childPID, 0) == -1) + } + + @Test + func `run command preserves completed no-output failure status`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + exit 23 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let result = try await probe.runCommand(arguments: [], timeout: 2) + + #expect(result.stdout.isEmpty) + #expect(result.stderr.isEmpty) + #expect(result.terminationStatus == 23) + #expect(!result.terminatedForIdle) + } + + @Test + func `run command cancellation terminates the process`() async throws { + let pidFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-cancel-\(UUID().uuidString).pid") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + printf '%s\\n' "$$" > "$1" + trap '' TERM + while true; do sleep 1; done + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: pidFile) + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let task = Task { + try await probe.runCommand(arguments: [pidFile.path], timeout: 20) + } + defer { task.cancel() } + + var capturedProcessID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: pidFile, encoding: .utf8) { + capturedProcessID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let processID = try #require(capturedProcessID) + defer { _ = kill(processID, SIGKILL) } + + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + #expect(kill(processID, 0) == -1) + } +} + +extension KiroStatusProbeTests { // MARK: - Happy Path Parsing @Test - func parsesBasicUsageOutput() throws { + func `parses basic usage output`() throws { let output = """ | KIRO FREE | ████████████████████████████████████████████████████ 25% @@ -18,6 +516,7 @@ struct KiroStatusProbeTests { let snapshot = try probe.parse(output: output) #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.displayPlanName == "Kiro Free") #expect(snapshot.creditsPercent == 25) #expect(snapshot.creditsUsed == 12.50) #expect(snapshot.creditsTotal == 50) @@ -27,8 +526,18 @@ struct KiroStatusProbeTests { #expect(snapshot.resetsAt != nil) } + private func makeCLI(_ script: String) throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-cli-\(UUID().uuidString)", isDirectory: true) + let cliURL = root.appendingPathComponent("kiro-cli") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try script.write(to: cliURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cliURL.path) + return cliURL + } + @Test - func parsesOutputWithBonusCredits() throws { + func `parses output with bonus credits`() throws { let output = """ | KIRO PRO | ████████████████████████████████████████████████████ 80% @@ -40,6 +549,7 @@ struct KiroStatusProbeTests { let snapshot = try probe.parse(output: output) #expect(snapshot.planName == "KIRO PRO") + #expect(snapshot.displayPlanName == "Kiro Pro") #expect(snapshot.creditsPercent == 80) #expect(snapshot.creditsUsed == 40.00) #expect(snapshot.creditsTotal == 50) @@ -49,7 +559,7 @@ struct KiroStatusProbeTests { } @Test - func parsesOutputWithoutPercentFallbacksToCreditsRatio() throws { + func `parses output without percent fallbacks to credits ratio`() throws { let output = """ | KIRO FREE | (12.50 of 50 covered in plan), resets on 01/15 @@ -62,7 +572,7 @@ struct KiroStatusProbeTests { } @Test - func parsesBonusCreditsWithoutExpiry() throws { + func `parses bonus credits without expiry`() throws { let output = """ | KIRO FREE | ████████████████████████████████████████████████████ 60% @@ -79,7 +589,7 @@ struct KiroStatusProbeTests { } @Test - func parsesOutputWithANSICodes() throws { + func `parses output with ANSI codes`() throws { let output = """ \u{001B}[32m| KIRO FREE |\u{001B}[0m \u{001B}[38;5;11m████████████████████████████████████████████████████\u{001B}[0m 50% @@ -96,7 +606,7 @@ struct KiroStatusProbeTests { } @Test - func parsesOutputWithSingleDay() throws { + func `parses output with single day`() throws { let output = """ | KIRO FREE | ████████████████████████████████████████████████████ 10% @@ -111,7 +621,7 @@ struct KiroStatusProbeTests { } @Test - func rejectsOutputMissingUsageMarkers() throws { + func `rejects output missing usage markers`() throws { let output = """ | KIRO FREE | """ @@ -125,7 +635,7 @@ struct KiroStatusProbeTests { // MARK: - New Format (kiro-cli 1.24+, Q Developer) @Test - func parsesQDeveloperManagedPlan() throws { + func `parses Q developer managed plan`() throws { let output = """ Plan: Q Developer Pro Your plan is managed by admin @@ -145,7 +655,7 @@ struct KiroStatusProbeTests { } @Test - func parsesQDeveloperFreePlan() throws { + func `parses Q developer free plan`() throws { let output = """ Plan: Q Developer Free Your plan is managed by admin @@ -159,7 +669,7 @@ struct KiroStatusProbeTests { } @Test - func parsesNewFormatWithANSICodes() throws { + func `parses new format with ANSI codes`() throws { let output = """ \u{001B}[38;5;141mPlan: Q Developer Pro\u{001B}[0m Your plan is managed by admin @@ -172,7 +682,7 @@ struct KiroStatusProbeTests { } @Test - func rejectsHeaderOnlyNewFormatWithoutManagedMarker() { + func `rejects header only new format without managed marker`() { let output = """ Plan: Q Developer Pro Tip: to see context window usage, run /context @@ -185,7 +695,7 @@ struct KiroStatusProbeTests { } @Test - func preservesParsedUsageForManagedPlanWithMetrics() throws { + func `preserves parsed usage for managed plan with metrics`() throws { let output = """ Plan: Q Developer Enterprise Your plan is managed by admin @@ -203,10 +713,94 @@ struct KiroStatusProbeTests { #expect(snapshot.resetsAt != nil) } + @Test + func `parses kiro cli two usage format`() throws { + let output = """ + \u{001B}[1mEstimated Usage\u{001B}[0m | resets on 2026-06-01 | \u{001B}[mKIRO FREE\u{001B}[0m + + 🎁 Bonus credits: 45.53/2000 credits used, expires in 19 days + + \u{001B}[1mCredits\u{001B}[0m (0.17 of 50 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 0% + + Overages: \u{001B}[1mDisabled\u{001B}[0m + + To manage your plan or configure overages navigate to https://app.kiro.dev/account/usage + """ + + let probe = KiroStatusProbe() + let snapshot = try probe.parse( + output: output, + accountEmail: "person@example.com", + authMethod: "Google") + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.displayPlanName == "Kiro Free") + #expect(snapshot.accountEmail == "person@example.com") + #expect(snapshot.authMethod == "Google") + #expect(snapshot.creditsUsed == 0.17) + #expect(snapshot.creditsTotal == 50) + #expect(snapshot.creditsRemaining == 49.83) + #expect(snapshot.bonusCreditsUsed == 45.53) + #expect(snapshot.bonusCreditsTotal == 2000) + #expect(snapshot.bonusCreditsRemaining == 1954.47) + #expect(snapshot.bonusExpiryDays == 19) + #expect(snapshot.overagesStatus == "Disabled") + #expect(snapshot.manageURL == "https://app.kiro.dev/account/usage") + #expect(snapshot.resetsAt != nil) + } + + @Test + func `parses kiro overage credits and estimated cost`() throws { + let output = """ + Estimated Usage | resets on 2026-06-01 | KIRO PRO + Credits (1000.00 of 1000 covered in plan) + ████████████████████████████████████████████████████████████████████████████████ 100% + + Overages: Enabled billed at $0.04 per request + Credits used: 40.29 + Est. cost: $1.61 USD + + To manage your plan or configure overages navigate to https://app.kiro.dev/account/usage + """ + + let probe = KiroStatusProbe() + let snapshot = try probe.parse(output: output) + + #expect(snapshot.planName == "KIRO PRO") + #expect(snapshot.creditsUsed == 1000) + #expect(snapshot.creditsTotal == 1000) + #expect(snapshot.overagesStatus == "Enabled billed at $0.04 per request") + #expect(snapshot.overageCreditsUsed == 40.29) + #expect(snapshot.estimatedOverageCostUSD == 1.61) + } + + @Test + func `parses context usage`() throws { + let output = """ + Context window: 1.3% used (estimated) + ██████████████████████████████████████████████████████████████████████████████ 1.3% + + █ Context files 0.5% (estimated) + █ Tools 0.8% (estimated) + █ Kiro responses 0.0% (estimated) + █ Your prompts 0.0% (estimated) + """ + + let probe = KiroStatusProbe() + let context = try #require(probe.parseContextUsage(output: output)) + + #expect(context.totalPercentUsed == 1.3) + #expect(context.contextFilesPercent == 0.5) + #expect(context.toolsPercent == 0.8) + #expect(context.kiroResponsesPercent == 0) + #expect(context.promptsPercent == 0) + } + // MARK: - Snapshot Conversion @Test - func convertsSnapshotToUsageSnapshot() throws { + func `converts snapshot to usage snapshot`() throws { let now = Date() let resetDate = try #require(Calendar.current.date(byAdding: .day, value: 7, to: now)) @@ -226,12 +820,14 @@ struct KiroStatusProbeTests { #expect(usage.primary?.usedPercent == 25.0) #expect(usage.primary?.resetsAt == resetDate) #expect(usage.secondary?.usedPercent == 25.0) // 5/20 * 100 - #expect(usage.loginMethod(for: .kiro) == "KIRO PRO") - #expect(usage.accountOrganization(for: .kiro) == "KIRO PRO") + #expect(usage.loginMethod(for: .kiro) == nil) + #expect(usage.accountOrganization(for: .kiro) == nil) + #expect(usage.kiroUsage?.displayPlanName == "Kiro Pro") + #expect(usage.kiroUsage?.creditsRemaining == 75) } @Test - func convertsSnapshotWithoutBonusCredits() { + func `converts snapshot without bonus credits`() { let snapshot = KiroUsageSnapshot( planName: "KIRO FREE", creditsUsed: 10.0, @@ -252,7 +848,7 @@ struct KiroStatusProbeTests { // MARK: - Error Cases @Test - func emptyOutputThrowsParseError() { + func `empty output throws parse error`() { let probe = KiroStatusProbe() #expect(throws: KiroStatusProbeError.self) { @@ -261,7 +857,7 @@ struct KiroStatusProbeTests { } @Test - func warningOutputThrowsParseError() { + func `warning output throws parse error`() { let output = """ \u{001B}[38;5;11m⚠️ Warning: Could not retrieve usage information from backend \u{001B}[38;5;8mError: dispatch failure (io error): an i/o error occurred @@ -275,7 +871,7 @@ struct KiroStatusProbeTests { } @Test - func unrecognizedFormatThrowsParseError() { + func `unrecognized format throws parse error`() { // Simulates a CLI format change where none of the expected patterns match let output = """ Welcome to Kiro! @@ -294,7 +890,7 @@ struct KiroStatusProbeTests { } @Test - func loginPromptThrowsNotLoggedIn() { + func `login prompt throws not logged in`() { let output = """ Failed to initialize auth portal. Please try again with: kiro-cli login --use-device-flow @@ -314,7 +910,7 @@ struct KiroStatusProbeTests { // MARK: - WhoAmI Validation @Test - func whoamiNotLoggedInThrows() { + func `whoami not logged in throws`() { let probe = KiroStatusProbe() #expect { @@ -326,7 +922,7 @@ struct KiroStatusProbeTests { } @Test - func whoamiLoginRequiredThrows() { + func `whoami login required throws`() { let probe = KiroStatusProbe() #expect { @@ -338,7 +934,7 @@ struct KiroStatusProbeTests { } @Test - func whoamiEmptyOutputWithZeroStatusThrows() { + func `whoami empty output with zero status throws`() { let probe = KiroStatusProbe() #expect { @@ -350,7 +946,7 @@ struct KiroStatusProbeTests { } @Test - func whoamiNonZeroStatusWithMessageThrows() { + func `whoami non zero status with message throws`() { let probe = KiroStatusProbe() #expect { @@ -362,12 +958,70 @@ struct KiroStatusProbeTests { } @Test - func whoamiSuccessDoesNotThrow() throws { + func `whoami success does not throw`() throws { let probe = KiroStatusProbe() - try probe.validateWhoAmIOutput( + let account = try probe.validateWhoAmIOutput( + stdout: """ + Logged in with Google + Email: user@example.com + """, + stderr: "", + terminationStatus: 0) + + #expect(account.authMethod == "Google") + #expect(account.email == "user@example.com") + } + + @Test + func `whoami legacy bare email parses account`() throws { + let probe = KiroStatusProbe() + + let account = try probe.validateWhoAmIOutput( stdout: "user@example.com", stderr: "", terminationStatus: 0) + + #expect(account.authMethod == nil) + #expect(account.email == "user@example.com") + } +} + +extension KiroStatusProbeTests { + @Test + func `fetch cancellation while joining account after usage failure is preserved`() async throws { + let accountStarted = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-failed-usage-account-\(UUID().uuidString).started") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + : > '\(accountStarted.path)' + trap '' TERM + while true; do sleep 1; done + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + exit 1 + fi + + exit 1 + """) + defer { + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: accountStarted) + } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }, accountProbeTimeout: 2.0) + let task = Task { try await probe.fetch() } + defer { task.cancel() } + + try await waitForFile(accountStarted) + try await Task.sleep(for: .milliseconds(300)) + + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } } } diff --git a/Tests/CodexBarTests/LLMProxyUsageFetcherTests.swift b/Tests/CodexBarTests/LLMProxyUsageFetcherTests.swift new file mode 100644 index 0000000000..3c61b3016a --- /dev/null +++ b/Tests/CodexBarTests/LLMProxyUsageFetcherTests.swift @@ -0,0 +1,120 @@ +import CodexBarCore +import Foundation +import Testing + +struct LLMProxyUsageFetcherTests { + @Test + func `parses quota stats summary`() throws { + let json = """ + { + "providers": { + "openai": { + "credential_count": 3, + "active_count": 2, + "exhausted_count": 1, + "total_requests": 120, + "tokens": { + "input_cached": 1000, + "input_uncached": 2000, + "output": 3000 + }, + "approx_cost": 12.5, + "quota_groups": { + "default": { + "remaining_percent": 42, + "reset_time": "2026-05-18T12:00:00Z" + } + } + }, + "anthropic": { + "credential_count": 1, + "active_count": 1, + "exhausted_count": 0, + "total_requests": 40, + "tokens": { + "input_cached": 0, + "input_uncached": 500, + "output": 500 + }, + "approx_cost": 3.0, + "quota_groups": [ + { "remaining_percent": 80 } + ] + } + }, + "summary": { + "total_requests": 160, + "total_tokens": 7000, + "approx_cost": 15.5 + } + } + """ + + let parsed = try LLMProxyUsageFetcher._parseSnapshotForTesting( + Data(json.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + + #expect(parsed.providerCount == 2) + #expect(parsed.credentialCount == 4) + #expect(parsed.activeCredentialCount == 3) + #expect(parsed.exhaustedCredentialCount == 1) + #expect(parsed.totalRequests == 160) + #expect(parsed.totalTokens == 7000) + #expect(parsed.approximateCostUSD == 15.5) + #expect(parsed.minimumRemainingPercent == 42) + + let snapshot = parsed.toUsageSnapshot() + #expect(snapshot.identity?.providerID == .llmproxy) + #expect(snapshot.primary?.usedPercent == 58) + #expect(snapshot.secondary?.resetDescription == "160 requests") + #expect(snapshot.tertiary?.resetDescription == "7,000 tokens") + #expect(snapshot.providerCost?.used == 15.5) + } + + @Test + func `quota stats url accepts versioned or root base urls`() throws { + #expect( + try LLMProxyUsageFetcher + ._quotaStatsURLForTesting(baseURL: #require(URL(string: "https://proxy.example.com"))) + .absoluteString == "https://proxy.example.com/v1/quota-stats") + #expect( + try LLMProxyUsageFetcher + ._quotaStatsURLForTesting(baseURL: #require(URL(string: "https://proxy.example.com/v1"))) + .absoluteString == "https://proxy.example.com/v1/quota-stats") + } + + @Test + func `parses fractional second quota reset times`() throws { + let json = """ + { + "providers": { + "openai": { + "quota_groups": [ + { + "remaining_percent": 42, + "reset_time": "2026-05-18T12:00:00.123Z" + } + ] + } + } + } + """ + + let parsed = try LLMProxyUsageFetcher._parseSnapshotForTesting( + Data(json.utf8), + updatedAt: Date(timeIntervalSince1970: 1)) + let components = DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 5, + day: 18, + hour: 12, + minute: 0, + second: 0, + nanosecond: 123_000_000) + let expected = try #require(components.date) + + #expect(try abs(#require(parsed.nextResetAt).timeIntervalSince(expected)) < 0.001) + } +} diff --git a/Tests/CodexBarTests/LaunchAtLoginManagerTests.swift b/Tests/CodexBarTests/LaunchAtLoginManagerTests.swift new file mode 100644 index 0000000000..6248e07472 --- /dev/null +++ b/Tests/CodexBarTests/LaunchAtLoginManagerTests.swift @@ -0,0 +1,126 @@ +import ServiceManagement +import Testing +@testable import CodexBar + +@MainActor +struct LaunchAtLoginManagerTests { + @Test + func `set enabled skips registration when service is already enabled`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + true, + status: { .enabled }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 0) + } + + @Test + func `set enabled registers when service is not registered`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + true, + status: { .notRegistered }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 1) + #expect(unregisterCalls == 0) + } + + @Test + func `set enabled skips registration when service requires approval`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + true, + status: { .requiresApproval }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 0) + } + + @Test + func `set enabled registers when service is not found`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + true, + status: { .notFound }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 1) + #expect(unregisterCalls == 0) + } + + @Test + func `set disabled unregisters when service is enabled`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + false, + status: { .enabled }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 1) + } + + @Test + func `set disabled unregisters when service requires approval`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + false, + status: { .requiresApproval }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 1) + } + + @Test + func `set disabled skips unregister when service is not registered`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + false, + status: { .notRegistered }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 0) + } + + @Test + func `set disabled skips unregister when service is not found`() { + var registerCalls = 0 + var unregisterCalls = 0 + + LaunchAtLoginManager.setEnabled( + false, + status: { .notFound }, + register: { registerCalls += 1 }, + unregister: { unregisterCalls += 1 }) + + #expect(registerCalls == 0) + #expect(unregisterCalls == 0) + } +} diff --git a/Tests/CodexBarTests/LiteLLMMenuCardModelTests.swift b/Tests/CodexBarTests/LiteLLMMenuCardModelTests.swift new file mode 100644 index 0000000000..141c4ee3c7 --- /dev/null +++ b/Tests/CodexBarTests/LiteLLMMenuCardModelTests.swift @@ -0,0 +1,220 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct LiteLLMMenuCardModelTests { + @Test + func `litellm budget rows show spend detail with reset time`() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.litellm]) + let json = """ + { + "user_id": "user-123", + "user_info": { + "user_id": "user-123", + "max_budget": 900.0, + "spend": 403.99, + "budget_reset_at": "1970-01-07T00:00:00Z" + }, + "teams": [ + { + "team_alias": "Platform", + "team_id": "team-123", + "max_budget": 1000.0, + "spend": 70.0, + "budget_duration": "30d", + "budget_reset_at": "1970-01-07T00:00:00Z" + } + ] + } + """ + let snapshot = try LiteLLMUsageFetcher._parseUserInfoForTesting( + Data(json.utf8), + keyInfo: LiteLLMKeyInfoSnapshot( + userID: "user-123", + teamID: "team-123", + keyName: nil, + spendUSD: 403.99, + expiresAt: nil), + updatedAt: now) + .toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .litellm, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let personal = try #require(model.metrics.first { $0.id == "primary" }) + #expect(personal.title == "Personal budget") + #expect(personal.percentLabel == "55% left") + #expect(personal.resetText?.hasPrefix("Resets") == true) + #expect(personal.detailText == "$403.99 / $900.00") + + let team = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(team.title == "Team budget") + #expect(team.percentLabel == "93% left") + #expect(team.resetText?.hasPrefix("Resets") == true) + #expect(team.detailText == "Team Platform: $70.00 / $1,000.00") + + #expect(model.providerCost == nil) + } + + @Test + func `litellm budget row details redact team aliases when hiding personal info`() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.litellm]) + let teamAlias = "Private Workspace" + let json = """ + { + "user_id": "user-123", + "user_info": { + "user_id": "user-123", + "max_budget": 900.0, + "spend": 403.99 + }, + "teams": [ + { + "team_alias": "\(teamAlias)", + "team_id": "team-123", + "max_budget": 1000.0, + "spend": 70.0 + } + ] + } + """ + let snapshot = try LiteLLMUsageFetcher._parseUserInfoForTesting( + Data(json.utf8), + keyInfo: LiteLLMKeyInfoSnapshot( + userID: "user-123", + teamID: "team-123", + keyName: nil, + spendUSD: 403.99, + expiresAt: nil), + updatedAt: now) + .toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .litellm, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: true, + now: now)) + + let team = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(team.detailText == "Team Hidden: $70.00 / $1,000.00") + #expect(team.detailText?.contains(teamAlias) == false) + } + + @Test + func `litellm team-only budget stays on the team row`() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.litellm]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: "Team Platform: $250.00 / $1,000.00"), + providerCost: ProviderCostSnapshot( + used: 250, + limit: 1000, + currencyCode: "USD", + period: "Team budget", + updatedAt: now), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .litellm, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.id) == ["secondary"]) + #expect(model.metrics.first?.detailText == "Team Platform: $250.00 / $1,000.00") + #expect(model.providerCost == nil) + } + + @Test + func `litellm spend without budget remains visible`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.litellm]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 12.5, + limit: 0, + currencyCode: "USD", + period: "Personal spend", + updatedAt: now), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .litellm, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "API spend") + #expect(model.providerCost?.spendLine == "Personal spend: $12.50") + #expect(model.providerCost?.percentUsed == nil) + } +} diff --git a/Tests/CodexBarTests/LiteLLMUsageFetcherTests.swift b/Tests/CodexBarTests/LiteLLMUsageFetcherTests.swift new file mode 100644 index 0000000000..d21cf30ec5 --- /dev/null +++ b/Tests/CodexBarTests/LiteLLMUsageFetcherTests.swift @@ -0,0 +1,364 @@ +import CodexBarCore +import Foundation +import Testing + +struct LiteLLMUsageFetcherTests { + @Test + func `parses user usage with personal and team budgets`() throws { + let json = """ + { + "user_id": "user-123", + "user_info": { + "user_id": "user-123", + "user_alias": "litellm-user@example.com", + "max_budget": 300.0, + "spend": 212.3537162499998, + "user_email": "litellm-user@example.com", + "budget_reset_at": null, + "teams": ["team-456"], + "metadata": { + "source": "keycloak", + "preferred_username": "litellm-user@example.com", + "budget": 300, + "flags": { + "keycloak": true + } + } + }, + "keys": [ + { + "key_name": "sk-...OTHER", + "user_id": "user-123", + "team_id": "team-other" + }, + { + "key_name": "sk-...IAAw", + "spend": 212.3537162499998, + "expires": "2026-09-11T00:12:55.950000+00:00", + "user_id": "user-123", + "team_id": "team-456" + } + ], + "teams": [ + { + "team_alias": "unrelated", + "team_id": "team-other", + "max_budget": 5.0, + "spend": 4.0 + }, + { + "team_alias": "ai", + "team_id": "team-456", + "max_budget": 1000.0, + "spend": 215.3245658499998, + "budget_duration": "7d", + "budget_reset_at": "2026-06-15T00:00:00Z" + } + ] + } + """ + + let parsed = try LiteLLMUsageFetcher._parseUserInfoForTesting( + Data(json.utf8), + keyInfo: LiteLLMKeyInfoSnapshot( + userID: "user-123", + teamID: "team-456", + keyName: "sk-...IAAw", + spendUSD: 212.3537162499998, + expiresAt: Date(timeIntervalSince1970: 2)), + updatedAt: Date(timeIntervalSince1970: 1)) + + #expect(parsed.userID == "user-123") + #expect(parsed.accountEmail == "litellm-user@example.com") + #expect(abs(parsed.personalSpendUSD - 212.3537162499998) < 0.000001) + #expect(parsed.personalBudgetUSD == 300) + #expect(parsed.teamUsage?.alias == "ai") + #expect(parsed.teamUsage?.spendUSD == 215.3245658499998) + #expect(parsed.teamUsage?.budgetUSD == 1000) + #expect(parsed.keyName == "sk-...IAAw") + #expect(parsed.keyExpiresAt == Date(timeIntervalSince1970: 2)) + + let snapshot = parsed.toUsageSnapshot() + #expect(snapshot.identity?.providerID == .litellm) + #expect(snapshot.identity?.accountEmail == "litellm-user@example.com") + let primary = try #require(snapshot.primary) + #expect(abs(primary.usedPercent - 70.78457208333327) < 0.000001) + #expect(primary.resetDescription == "$212.35 / $300.00") + let secondary = try #require(snapshot.secondary) + #expect(abs(secondary.usedPercent - 21.53245658499998) < 0.000001) + #expect(secondary.resetDescription == "Team ai: $215.32 / $1,000.00") + #expect(snapshot.providerCost?.used == 212.3537162499998) + #expect(snapshot.providerCost?.limit == 300) + #expect(snapshot.providerCost?.period == "Personal budget") + } + + @Test + func `preserves personal spend when no budget is configured`() throws { + let json = """ + { + "user_id": "user-123", + "user_info": { + "user_id": "user-123", + "max_budget": null, + "spend": 12.5 + } + } + """ + + let parsed = try LiteLLMUsageFetcher._parseUserInfoForTesting( + Data(json.utf8), + keyInfo: LiteLLMKeyInfoSnapshot( + userID: "user-123", + teamID: nil, + keyName: "personal-key", + spendUSD: 12.5, + expiresAt: nil), + updatedAt: Date(timeIntervalSince1970: 1)) + + let snapshot = parsed.toUsageSnapshot() + #expect(snapshot.primary == nil) + #expect(snapshot.providerCost?.used == 12.5) + #expect(snapshot.providerCost?.limit == 0) + #expect(snapshot.providerCost?.period == "Personal spend") + } + + @Test + func `parses key info identity for user lookup`() throws { + let json = """ + { + "key": "sk-redacted", + "info": { + "key_name": "sk-...IAAw", + "spend": 212.3537162499998, + "expires": "2026-09-11T00:12:55.950000+00:00", + "user_id": "user-123", + "team_id": "team-456", + "max_budget": null + } + } + """ + + let parsed = try LiteLLMUsageFetcher._parseKeyInfoForTesting(Data(json.utf8)) + + #expect(parsed.userID == "user-123") + #expect(parsed.teamID == "team-456") + #expect(parsed.keyName == "sk-...IAAw") + #expect(parsed.spendUSD == 212.3537162499998) + } + + @Test + func `parses team-only key info without user identity`() throws { + let json = """ + { + "info": { + "key_name": "team-service-key", + "spend": 25.0, + "team_id": "team-456" + } + } + """ + + let parsed = try LiteLLMUsageFetcher._parseKeyInfoForTesting(Data(json.utf8)) + + #expect(parsed.userID == nil) + #expect(parsed.teamID == "team-456") + #expect(parsed.keyName == "team-service-key") + } + + @Test + func `management urls accept root or v1 base urls`() throws { + let root = try #require(URL(string: "https://litellm.example.com")) + let versioned = try #require(URL(string: "https://litellm.example.com/v1")) + let nestedVersioned = try #require(URL(string: "https://gateway.example.com/litellm/v1/")) + + #expect( + LiteLLMUsageFetcher + ._keyInfoURLForTesting(baseURL: root) + .absoluteString == "https://litellm.example.com/key/info") + #expect( + LiteLLMUsageFetcher + ._keyInfoURLForTesting(baseURL: versioned) + .absoluteString == "https://litellm.example.com/key/info") + #expect( + LiteLLMUsageFetcher + ._userInfoURLForTesting(baseURL: nestedVersioned, userID: "user-123") + .absoluteString == "https://gateway.example.com/litellm/user/info?user_id=user-123") + #expect( + LiteLLMUsageFetcher + ._teamInfoURLForTesting(baseURL: nestedVersioned, teamID: "team-456") + .absoluteString == "https://gateway.example.com/litellm/team/info?team_id=team-456") + } + + @Test + func `settings reader trims quoted environment values`() { + let environment = [ + "LITELLM_API_KEY": " 'sk-test' ", + "LITELLM_BASE_URL": #" "https://litellm.example.com/v1" "#, + ] + + #expect(LiteLLMSettingsReader.apiKey(environment: environment) == "sk-test") + #expect(LiteLLMSettingsReader.baseURL(environment: environment)? + .absoluteString == "https://litellm.example.com/v1") + } + + @Test + func `fetch trims api key before sending management requests`() async throws { + let baseURL = try #require(URL(string: "https://litellm.example.com/v1")) + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-test") + + let path = request.url?.path + let query = request.url?.query + let body: String + switch path { + case "/key/info": + #expect(query == nil) + body = """ + { + "info": { + "user_id": "user-123", + "team_id": "team-456", + "spend": 1 + } + } + """ + case "/user/info": + #expect(query == "user_id=user-123") + body = """ + { + "user_id": "user-123", + "user_info": { + "user_id": "user-123", + "max_budget": 10, + "spend": 1 + } + } + """ + default: + Issue.record("unexpected LiteLLM request path: \(path ?? "nil")") + body = "{}" + } + + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(body.utf8), response) + } + + let snapshot = try await LiteLLMUsageFetcher.fetchUsage( + apiKey: " sk-test\n", + baseURL: baseURL, + transport: transport) + + #expect(snapshot.userID == "user-123") + let requests = await transport.requests() + #expect(requests.count == 2) + } + + @Test + func `fetches team usage for team-only virtual keys`() async throws { + let baseURL = try #require(URL(string: "https://litellm.example.com/v1")) + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-team") + + let path = request.url?.path + let query = request.url?.query + let body: String + switch path { + case "/key/info": + #expect(query == nil) + body = """ + { + "info": { + "key_name": "team-service-key", + "team_id": "team-456", + "spend": 25 + } + } + """ + case "/team/info": + #expect(query == "team_id=team-456") + body = """ + { + "team_id": "team-456", + "team_info": { + "team_id": "team-456", + "team_alias": "platform", + "max_budget": 100, + "spend": 25, + "budget_duration": "30d", + "budget_reset_at": "2026-07-01T00:00:00Z" + } + } + """ + default: + Issue.record("unexpected LiteLLM request path: \(path ?? "nil")") + body = "{}" + } + + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil)) + return (Data(body.utf8), response) + } + + let snapshot = try await LiteLLMUsageFetcher.fetchUsage( + apiKey: "sk-team", + baseURL: baseURL, + transport: transport, + updatedAt: Date(timeIntervalSince1970: 1)) + + #expect(snapshot.userID == nil) + #expect(snapshot.teamUsage?.id == "team-456") + #expect(snapshot.teamUsage?.alias == "platform") + #expect(snapshot.teamUsage?.spendUSD == 25) + #expect(snapshot.teamUsage?.budgetUSD == 100) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 25) + #expect(usage.providerCost?.used == 25) + #expect(usage.providerCost?.limit == 100) + #expect(usage.providerCost?.period == "Team budget") + + let requests = await transport.requests() + #expect(requests.count == 2) + } + + @Test + func `fetch surfaces rejected virtual key`() async throws { + let baseURL = try #require(URL(string: "https://litellm.example.com")) + let transport = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-target") + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: nil, + headerFields: nil)) + return (Data(#"{"detail":"Unauthorized"}"#.utf8), response) + } + + do { + _ = try await LiteLLMUsageFetcher.fetchUsage( + apiKey: "sk-target", + baseURL: baseURL, + transport: transport) + Issue.record("expected LiteLLMUsageError.apiError") + } catch let LiteLLMUsageError.apiError(message) { + #expect(message.contains("HTTP 401")) + #expect(message.contains("Unauthorized")) + } catch { + Issue.record("expected LiteLLMUsageError.apiError, got \(error)") + } + + let requests = await transport.requests() + #expect(requests.count == 1) + } +} diff --git a/Tests/CodexBarTests/LiveAccountTests.swift b/Tests/CodexBarTests/LiveAccountTests.swift index 7622400322..a4d5e11892 100644 --- a/Tests/CodexBarTests/LiveAccountTests.swift +++ b/Tests/CodexBarTests/LiveAccountTests.swift @@ -3,10 +3,10 @@ import Foundation import Testing @testable import CodexBar -@Suite("Live RPC account checks", .serialized) +@Suite(.serialized) struct LiveAccountTests { @Test(.disabled("Set LIVE_TEST=1 to run live Codex account checks.")) - func codexAccountEmailIsPresent() async throws { + func `codex account email is present`() async throws { guard ProcessInfo.processInfo.environment["LIVE_TEST"] == "1" else { return } let fetcher = UsageFetcher() diff --git a/Tests/CodexBarTests/LoadingPatternTests.swift b/Tests/CodexBarTests/LoadingPatternTests.swift index d476b02e95..535f2b7413 100644 --- a/Tests/CodexBarTests/LoadingPatternTests.swift +++ b/Tests/CodexBarTests/LoadingPatternTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBar -@Suite struct LoadingPatternTests { @Test - func valuesStayWithinBounds() { + func `values stay within bounds`() { for pattern in LoadingPattern.allCases { for phase in stride(from: 0.0, through: Double.pi * 2, by: Double.pi / 6) { let v = pattern.value(phase: phase) @@ -15,7 +14,7 @@ struct LoadingPatternTests { } @Test - func knightRiderPingPongs() { + func `knight rider ping pongs`() { let pattern = LoadingPattern.knightRider let mid = pattern.value(phase: 0) // sin 0 = 0 => 50 let min = pattern.value(phase: -Double.pi / 2) // sin -pi/2 = -1 => 0 @@ -26,7 +25,7 @@ struct LoadingPatternTests { } @Test - func secondaryOffsetDiffers() { + func `secondary offset differs`() { let pattern = LoadingPattern.cylon let primary = pattern.value(phase: 0) let secondary = pattern.value(phase: pattern.secondaryOffset) diff --git a/Tests/CodexBarTests/LocalizationBundleCacheTests.swift b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift new file mode 100644 index 0000000000..7a7d2572c2 --- /dev/null +++ b/Tests/CodexBarTests/LocalizationBundleCacheTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import CodexBar + +/// Regression coverage for the localized-bundle caching added for #1347. +/// +/// The cache is process-global and these tests run in a parallel suite, so identity (`===`) assertions +/// would race against any other test that resolves a different language. Instead these assert the +/// concurrency-safe property that matters for correctness: every call resolves to the right `.lproj` +/// regardless of what is currently cached, so a language switch (and switch-back) is always honored and +/// the cache can never serve a stale localization. +struct LocalizationBundleCacheTests { + @Test + func `resolves the correct lproj per language and re-resolves on switch`() { + resetCodexBarLocalizationCacheForTesting() + + let fr = CodexBarLocalizationOverride.$appLanguage.withValue("fr") { + codexBarLocalizedBundleForTesting() + } + #expect(fr.bundleURL.lastPathComponent == "fr.lproj") + + // Switching language must re-resolve rather than return the cached French bundle. + let es = CodexBarLocalizationOverride.$appLanguage.withValue("es") { + codexBarLocalizedBundleForTesting() + } + #expect(es.bundleURL.lastPathComponent == "es.lproj") + + // Switching back must still produce the French bundle (cache key is the language). + let frAgain = CodexBarLocalizationOverride.$appLanguage.withValue("fr") { + codexBarLocalizedBundleForTesting() + } + #expect(frAgain.bundleURL.lastPathComponent == "fr.lproj") + } + + @Test + func `repeated same-language calls keep resolving the same lproj`() { + resetCodexBarLocalizationCacheForTesting() + + for _ in 0..<5 { + let bundle = CodexBarLocalizationOverride.$appLanguage.withValue("es") { + codexBarLocalizedBundleForTesting() + } + #expect(bundle.bundleURL.lastPathComponent == "es.lproj") + } + } + + @Test + func `unknown language falls back to en lproj`() { + resetCodexBarLocalizationCacheForTesting() + + let bundle = CodexBarLocalizationOverride.$appLanguage.withValue("zz-unknown") { + codexBarLocalizedBundleForTesting() + } + #expect(bundle.bundleURL.lastPathComponent == "en.lproj") + } + + @Test + func `resolution survives an explicit cache reset`() { + let first = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { + codexBarLocalizedBundleForTesting() + } + #expect(first.bundleURL.lastPathComponent == "uk.lproj") + + resetCodexBarLocalizationCacheForTesting() + + let afterReset = CodexBarLocalizationOverride.$appLanguage.withValue("uk") { + codexBarLocalizedBundleForTesting() + } + #expect(afterReset.bundleURL.lastPathComponent == "uk.lproj") + } +} diff --git a/Tests/CodexBarTests/LocalizationBundleTests.swift b/Tests/CodexBarTests/LocalizationBundleTests.swift new file mode 100644 index 0000000000..1bf64988ef --- /dev/null +++ b/Tests/CodexBarTests/LocalizationBundleTests.swift @@ -0,0 +1,125 @@ +import Foundation +import Testing +@testable import CodexBar + +struct LocalizationBundleTests { + @Test + func `packaged app resolves localization bundle from resources`() throws { + let fixture = try Self.makeAppBundleFixture(includeLocalizationBundle: true) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let bundle = codexBarLocalizationResourceBundle(mainBundle: fixture.appBundle) + + #expect(bundle.bundleURL.lastPathComponent == "CodexBar_CodexBar.bundle") + #expect(bundle.path(forResource: "en", ofType: "lproj") != nil) + } + + @Test + func `packaged app falls back to main bundle without touching SwiftPM module`() throws { + let fixture = try Self.makeAppBundleFixture(includeLocalizationBundle: false) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let bundle = codexBarLocalizationResourceBundle(mainBundle: fixture.appBundle) + + #expect(bundle.bundleURL == fixture.appBundle.bundleURL) + } + + @Test + func `packaged app resolves raw copied localization resources from main bundle`() throws { + let fixture = try Self.makeAppBundleFixture( + includeLocalizationBundle: false, + includeMainLocalization: true) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let bundle = codexBarLocalizationResourceBundle(mainBundle: fixture.appBundle) + + #expect(bundle.bundleURL == fixture.appBundle.bundleURL) + #expect(bundle.path(forResource: "en", ofType: "lproj") != nil) + } + + @Test + func `empty localized values fall back to English`() throws { + let fixture = try Self.makeAppBundleFixture( + includeLocalizationBundle: true, + includeEmptyChineseLocalization: true) + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let resourceBundle = codexBarLocalizationResourceBundle(mainBundle: fixture.appBundle) + let zhPath = try #require(resourceBundle.path(forResource: "zh-Hans", ofType: "lproj")) + let zhBundle = try #require(Bundle(path: zhPath)) + + #expect(codexBarLocalizedString("Settings", bundle: zhBundle, resourceBundle: resourceBundle) == "Settings") + #expect(codexBarLocalizedString("Missing", bundle: zhBundle, resourceBundle: resourceBundle) == "Missing") + } + + @Test + func `managed Codex login failure includes CLI recovery guidance`() { + let message = L("managed_login_failed") + + #expect(message.contains("codex --version")) + #expect(message.contains("@openai/codex@latest")) + } + + private static func makeAppBundleFixture( + includeLocalizationBundle: Bool, + includeMainLocalization: Bool = false, + includeEmptyChineseLocalization: Bool = false) throws -> (root: URL, appBundle: Bundle) + { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "codexbar-localization-\(UUID().uuidString)", + isDirectory: true) + let appURL = root.appendingPathComponent("CodexBar.app", isDirectory: true) + let contentsURL = appURL.appendingPathComponent("Contents", isDirectory: true) + let resourcesURL = contentsURL.appendingPathComponent("Resources", isDirectory: true) + try FileManager.default.createDirectory(at: resourcesURL, withIntermediateDirectories: true) + + let info = """ + + + + + CFBundleExecutableCodexBar + CFBundleIdentifiercom.steipete.codexbar.tests + CFBundleNameCodexBar + CFBundlePackageTypeAPPL + + + """ + try info.write( + to: contentsURL.appendingPathComponent("Info.plist"), + atomically: true, + encoding: .utf8) + + if includeMainLocalization { + try Self.writeEnglishLocalization(to: resourcesURL.appendingPathComponent("en.lproj", isDirectory: true)) + } + + if includeLocalizationBundle { + let bundleURL = resourcesURL.appendingPathComponent("CodexBar_CodexBar.bundle", isDirectory: true) + try Self.writeEnglishLocalization(to: bundleURL.appendingPathComponent("en.lproj", isDirectory: true)) + if includeEmptyChineseLocalization { + try Self.writeEmptyChineseLocalization( + to: bundleURL.appendingPathComponent("zh-Hans.lproj", isDirectory: true)) + } + } + + let appBundle = try #require(Bundle(url: appURL)) + return (root, appBundle) + } + + private static func writeEnglishLocalization(to lprojURL: URL) throws { + try FileManager.default.createDirectory(at: lprojURL, withIntermediateDirectories: true) + try "\"Settings\" = \"Settings\";\n".write( + to: lprojURL.appendingPathComponent("Localizable.strings"), + atomically: true, + encoding: .utf8) + } + + private static func writeEmptyChineseLocalization(to lprojURL: URL) throws { + try FileManager.default.createDirectory(at: lprojURL, withIntermediateDirectories: true) + try "\"Settings\" = \"\";\n".write( + to: lprojURL.appendingPathComponent("Localizable.strings"), + atomically: true, + encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift new file mode 100644 index 0000000000..d66f185213 --- /dev/null +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -0,0 +1,406 @@ +import Foundation +import Testing +@testable import CodexBar + +struct LocalizationLanguageCatalogTests { + private let languageKeys = [ + "language_system", + "language_english", + "language_german", + "language_spanish", + "language_catalan", + "language_chinese_simplified", + "language_chinese_traditional", + "language_portuguese_brazilian", + "language_swedish", + "language_french", + "language_dutch", + "language_ukrainian", + "language_italian", + "language_vietnamese", + "language_japanese", + "language_korean", + "language_turkish", + "language_indonesian", + "language_polish", + "language_arabic", + "language_persian", + "language_thai", + ] + + @Test + func `app language catalog includes Ukrainian`() { + #expect(AppLanguage.allCases.contains(.ukrainian)) + #expect(AppLanguage.ukrainian.rawValue == "uk") + } + + @Test + func `app language catalog includes Korean`() { + #expect(AppLanguage.allCases.contains(.korean)) + #expect(AppLanguage.korean.rawValue == "ko") + } + + @Test + func `app language catalog includes Turkish`() { + #expect(AppLanguage.allCases.contains(.turkish)) + #expect(AppLanguage.turkish.rawValue == "tr") + } + + @Test + func `app language catalog includes Italian`() { + #expect(AppLanguage.allCases.contains(.italian)) + #expect(AppLanguage.italian.rawValue == "it") + } + + @Test + func `app language catalog includes Indonesian`() { + #expect(AppLanguage.allCases.contains(.indonesian)) + #expect(AppLanguage.indonesian.rawValue == "id") + } + + @Test + func `app language catalog includes Polish`() { + #expect(AppLanguage.allCases.contains(.polish)) + #expect(AppLanguage.polish.rawValue == "pl") + } + + @Test + func `app language catalog includes Arabic Persian and Thai`() { + #expect(AppLanguage.arabic.rawValue == "ar") + #expect(AppLanguage.persian.rawValue == "fa") + #expect(AppLanguage.thai.rawValue == "th") + } + + @Test + func `new language bundles include representative native labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let expectations: [String: [String: String]] = [ + "ar": [ + "language_arabic": "العربية", + "tab_general": "عام", + "quit_app": "إنهاء CodexBar", + "usage_percent_suffix_left": "متبقٍ", + ], + "fa": [ + "language_persian": "فارسی", + "tab_general": "عمومی", + "quit_app": "خروج از CodexBar", + "usage_percent_suffix_left": "باقی مانده", + ], + "th": [ + "language_thai": "ไทย", + "tab_general": "ทั่วไป", + "quit_app": "ออกจาก CodexBar", + "usage_percent_suffix_left": "คงเหลือ", + ], + ] + + for (locale, expectedValues) in expectations { + let url = resourcesURL.appendingPathComponent("\(locale).lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for (key, expectedValue) in expectedValues { + #expect(catalog[key] == expectedValue, "\(locale).\(key)") + } + } + } + + @Test + func `localized catalogs include every app language label`() throws { + #expect(self.languageKeys.count == AppLanguage.allCases.count) + + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let contents = try String(contentsOf: stringsURL, encoding: .utf8) + for key in self.languageKeys { + #expect(contents.contains("\"\(key)\""), "Missing \(key) in \(catalogURL.lastPathComponent)") + } + } + } + + @Test + func `localized catalogs include workday pace setting copy`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let catalogs = try FileManager.default.contentsOfDirectory( + at: resourcesURL, + includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "lproj" } + + for catalogURL in catalogs { + let stringsURL = catalogURL.appendingPathComponent("Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: stringsURL) as? [String: String]) + let title = catalog["weekly_progress_work_days_title"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitle = catalog["weekly_progress_work_days_subtitle"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + + #expect(title?.isEmpty == false, "Missing workday title in \(catalogURL.lastPathComponent)") + #expect(subtitle?.isEmpty == false, "Missing workday subtitle in \(catalogURL.lastPathComponent)") + } + } + + @Test + func `ukrainian localization bundle exists and contains key UI labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let ukURL = root.appendingPathComponent("Sources/CodexBar/Resources/uk.lproj/Localizable.strings") + let contents = try String(contentsOf: ukURL, encoding: .utf8) + + let requiredKeys = [ + "\"language_title\"", + "\"language_subtitle\"", + "\"language_system\"", + "\"language_ukrainian\"", + "\"tab_general\"", + "\"quit_app\"", + ] + for key in requiredKeys { + #expect(contents.contains(key), "Missing localization key: \(key)") + } + } + + @Test + func `korean localization bundle includes representative native labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let koURL = root.appendingPathComponent("Sources/CodexBar/Resources/ko.lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: koURL) as? [String: String]) + + #expect(catalog["language_korean"] == "한국어") + #expect(catalog["tab_general"] == "일반") + #expect(catalog["quota_warning_session"] == "세션") + #expect(catalog["quota_warning_warn_at"] == "경고 기준") + #expect(catalog["quit_app"] == "CodexBar 종료") + } + + @Test + func `turkish localization matches English catalog and preserves format placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let enURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let trURL = resourcesURL.appendingPathComponent("tr.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: enURL) as? [String: String]) + let turkish = try #require(NSDictionary(contentsOf: trURL) as? [String: String]) + + #expect(Set(turkish.keys) == Set(english.keys)) + #expect(turkish["language_turkish"] == "Türkçe") + #expect(turkish["tab_general"] == "Genel") + #expect(turkish["quit_app"] == "CodexBar'dan Çık") + #expect(turkish["display_mode_percent_desc"]?.contains("%45") == true) + #expect(turkish["session_depleted_notification_body"]?.hasPrefix("0% kaldı.") == true) + + let format = try #require(turkish["quota_warning_notification_body"]) + let rendered = String( + format: format, + locale: Locale(identifier: "tr_TR"), + arguments: ["%20", 15, "oturum"]) + #expect(rendered.contains("15%")) + #expect(!rendered.contains("%2$d")) + + let historyFormat = try #require(turkish["%@: %@%% used"]) + let historyLabel = String( + format: historyFormat, + locale: Locale(identifier: "tr_TR"), + arguments: ["12 Haz", "45"]) + #expect(historyLabel == "12 Haz: 45% kullanıldı") + + let miniMaxFormat = try #require(turkish["minimax_used_percent_format"]) + let miniMaxLabel = String( + format: miniMaxFormat, + locale: Locale(identifier: "tr_TR"), + arguments: ["45%"]) + #expect(miniMaxLabel == "45% kullanıldı") + } + + @Test + func `italian localization matches English catalog and includes current UI labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let enURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let itURL = resourcesURL.appendingPathComponent("it.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: enURL) as? [String: String]) + let italian = try #require(NSDictionary(contentsOf: itURL) as? [String: String]) + + #expect(Set(italian.keys) == Set(english.keys)) + #expect(italian["Individual credits"] == "Crediti individuali") + #expect(italian["Workspace"] == "Spazio di lavoro") + #expect(italian["display_mode_reset_time"] == "Ora di reimpostazione") + #expect(italian["display_mode_reset_time_desc"]?.contains("↻ 15:56") == true) + #expect(italian["ory_session_…=…; csrftoken=…"] == "ory_session_…=…; csrftoken=…") + #expect(italian["quota_warning_notifications_subtitle"]?.contains("scende sotto") == true) + #expect(italian["metric_mistral_payg"] == "A consumo") + #expect(italian["metric_mistral_monthly_plan"] == "Piano mensile") + + let intentionallyUnchanged: Set = [ + "Account", + "Build", + "Chrome", + "Cookie: ...", + "Cookie: …", + "Deployment", + "Email", + "Endpoint", + "Gemini Flash", + "GitHub", + "Google OAuth", + "No", + "Oasis-Token", + "Password", + "Provider", + "Token", + "byte_unit_byte", + "byte_unit_gigabyte", + "byte_unit_kilobyte", + "byte_unit_megabyte", + "language_arabic", + "language_italian", + "language_persian", + "language_thai", + "link_email", + "link_github", + "ory_session_…=…; csrftoken=…", + ] + let unchanged = Set(english.keys.filter { italian[$0] == english[$0] }) + #expect(unchanged == intentionallyUnchanged) + + let warningFormat = try #require(italian["quota_warning_notification_body"]) + let warning = String( + format: warningFormat, + locale: Locale(identifier: "it_IT"), + arguments: ["20%", 15, "settimanale"]) + #expect(warning == "Rimane 20%. Hai raggiunto la soglia di avviso del 15% per la quota settimanale.") + + let titleFormat = try #require(italian["quota_warning_notification_title"]) + let title = String( + format: titleFormat, + locale: Locale(identifier: "it_IT"), + arguments: ["Codex", "settimanale"]) + #expect(title == "Quota settimanale di Codex quasi esaurita") + } + + @Test + func `indonesian localization matches English catalog and preserves format placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let enURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let idURL = resourcesURL.appendingPathComponent("id.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: enURL) as? [String: String]) + let indonesian = try #require(NSDictionary(contentsOf: idURL) as? [String: String]) + + #expect(Set(indonesian.keys) == Set(english.keys)) + #expect(indonesian["language_indonesian"] == "Bahasa Indonesia") + #expect(indonesian["tab_general"] == "Umum") + #expect(indonesian["quit_app"] == "Keluar CodexBar") + #expect(indonesian["30d"] == "30 hari") + #expect(indonesian["On"] == "Aktif") + #expect(indonesian["Off"] == "Nonaktif") + + let warningFormat = try #require(indonesian["quota_warning_notification_body"]) + let warning = String( + format: warningFormat, + locale: Locale(identifier: "id_ID"), + arguments: ["20%", 15, "sesi"]) + #expect(warning.contains("15%")) + #expect(!warning.contains("%2$d")) + + let historyFormat = try #require(indonesian["%@: %@%% used"]) + let historyLabel = String( + format: historyFormat, + locale: Locale(identifier: "id_ID"), + arguments: ["12 Jun", "45"]) + #expect(historyLabel == "12 Jun: 45% terpakai") + + let daysFormat = try #require(indonesian["%dd"]) + let daysLabel = String( + format: daysFormat, + locale: Locale(identifier: "id_ID"), + arguments: [30]) + #expect(daysLabel == "30 hari") + } + + @Test + func `polish localization matches English catalog and includes current UI labels`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let resourcesURL = root.appendingPathComponent("Sources/CodexBar/Resources") + let enURL = resourcesURL.appendingPathComponent("en.lproj/Localizable.strings") + let plURL = resourcesURL.appendingPathComponent("pl.lproj/Localizable.strings") + let english = try #require(NSDictionary(contentsOf: enURL) as? [String: String]) + let polish = try #require(NSDictionary(contentsOf: plURL) as? [String: String]) + + #expect(Set(polish.keys) == Set(english.keys)) + #expect(polish["Individual credits"] == "Kredyty indywidualne") + #expect(polish["Workspace"] == "Obszar roboczy") + #expect(polish["display_mode_reset_time"] == "Godzina resetu") + #expect(polish["display_mode_reset_time_desc"]?.contains("↻ 15:56") == true) + } + + @Test + func `japanese usage chart accessibility text preserves argument meanings`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let jaURL = root.appendingPathComponent("Sources/CodexBar/Resources/ja.lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: jaURL) as? [String: String]) + let format = try #require(catalog["%d days of usage data across %d services"]) + + let rendered = String( + format: format, + locale: Locale(identifier: "ja_JP"), + arguments: [7, 3]) + + #expect(rendered.contains("7日間")) + #expect(rendered.contains("3サービス")) + } + + @Test + func `korean usage chart accessibility text preserves argument meanings`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let koURL = root.appendingPathComponent("Sources/CodexBar/Resources/ko.lproj/Localizable.strings") + let catalog = try #require(NSDictionary(contentsOf: koURL) as? [String: String]) + let format = try #require(catalog["%d days of usage data across %d services"]) + + let rendered = String( + format: format, + locale: Locale(identifier: "ko_KR"), + arguments: [7, 3]) + + #expect(rendered.contains("7일간")) + #expect(rendered.contains("3개 서비스")) + } +} diff --git a/Tests/CodexBarTests/LoginNotificationLogicTests.swift b/Tests/CodexBarTests/LoginNotificationLogicTests.swift new file mode 100644 index 0000000000..68f4ab9111 --- /dev/null +++ b/Tests/CodexBarTests/LoginNotificationLogicTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct LoginNotificationLogicTests { + @Test + func `login success notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = LoginNotificationLogic.notificationCopy(providerName: "Codex") + + #expect(copy.title == "Codex 登入成功") + #expect(copy.body == "你可以回到 App;認證已完成。") + } + } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } +} diff --git a/Tests/CodexBarTests/MainThreadHangWatchdogTests.swift b/Tests/CodexBarTests/MainThreadHangWatchdogTests.swift new file mode 100644 index 0000000000..ed18749e94 --- /dev/null +++ b/Tests/CodexBarTests/MainThreadHangWatchdogTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing +@testable import CodexBar + +#if DEBUG +@Suite(.serialized) +struct MainThreadHangWatchdogTests { + @MainActor + @Test + func `breadcrumb tracks nested activity`() { + #expect(MainThreadActivityBreadcrumb.current == nil) + MainThreadActivityBreadcrumb.push("outer") + MainThreadActivityBreadcrumb.push("inner") + #expect(MainThreadActivityBreadcrumb.current == "inner") + MainThreadActivityBreadcrumb.pop() + #expect(MainThreadActivityBreadcrumb.current == "outer") + MainThreadActivityBreadcrumb.pop() + #expect(MainThreadActivityBreadcrumb.current == nil) + } + + @Test + func `watchdog reports a breadcrumb for a delayed main thread response`() throws { + let watchdog = MainThreadHangWatchdog( + pingInterval: 0.01, + hangThreshold: 0.05, + sampleThreshold: 60, + sampleCooldown: 3600) + + let reported = OSAllocatedBox<[(TimeInterval, [String])]>([]) + watchdog.onHangForTesting = { duration, activities in + reported.append((duration, activities)) + } + + MainThreadActivityBreadcrumb.push("testStall") + watchdog.traceHangForTesting(responseDelay: 0.25) + MainThreadActivityBreadcrumb.pop() + + let report = try #require(reported.get().first) + #expect(report.1.contains("testStall")) + #expect(report.0 >= 0.05) + } + + @Test + func `watchdog polling loop reports a delayed ping response`() { + let pingScheduled = DispatchSemaphore(value: 0) + let hangDetected = DispatchSemaphore(value: 0) + let reported = DispatchSemaphore(value: 0) + let pendingResponse = OSAllocatedBox<(@Sendable () -> Void)?>(nil) + let watchdog = MainThreadHangWatchdog( + pingInterval: 1, + hangThreshold: 0.02, + sampleThreshold: 60, + sampleCooldown: 3600, + schedulePing: { response in + pendingResponse.set(response) + pingScheduled.signal() + }) + watchdog.onHangForTesting = { _, _ in + reported.signal() + } + watchdog.onHangDetectionForTesting = { + hangDetected.signal() + } + + watchdog.start() + defer { watchdog.stop() } + + #expect(pingScheduled.wait(timeout: .now() + 10) == .success) + #expect(hangDetected.wait(timeout: .now() + 10) == .success) + pendingResponse.get()?() + #expect(reported.wait(timeout: .now() + 10) == .success) + } + + @Test + func `sample capture cannot inflate reported hang duration`() throws { + let sampleRequested = OSAllocatedBox(false) + let watchdog = MainThreadHangWatchdog( + pingInterval: 0.01, + hangThreshold: 0.03, + sampleThreshold: 0.05, + sampleCooldown: 3600, + sampleCaptureOverride: { + sampleRequested.set(true) + Thread.sleep(forTimeInterval: 1) + return "/tmp/codexbar-watchdog-test-sample.txt" + }) + + let reported = OSAllocatedBox<[(TimeInterval, [String])]>([]) + watchdog.onHangForTesting = { duration, activities in + reported.append((duration, activities)) + } + + MainThreadActivityBreadcrumb.push("sampledStall") + watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true) + MainThreadActivityBreadcrumb.pop() + + let report = try #require(reported.get().first) + #expect(sampleRequested.get()) + #expect(report.1.contains("sampledStall")) + #expect(report.0 >= 0.03) + #expect(report.0 < 0.75) + } + + @Test + func `failed sample capture is attempted once per hang`() { + let attempts = OSAllocatedBox(0) + let watchdog = MainThreadHangWatchdog( + pingInterval: 0.01, + hangThreshold: 0.01, + sampleThreshold: 0, + sampleCooldown: 3600, + sampleCaptureOverride: { + attempts.withValue { $0 += 1 } + return nil + }) + + watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true) + + #expect(attempts.get() == 1) + } + + @Test + func `missed sample window does not consume cooldown`() { + let attempts = OSAllocatedBox(0) + let watchdog = MainThreadHangWatchdog( + pingInterval: 0.01, + hangThreshold: 0.01, + sampleThreshold: 0.02, + sampleCooldown: 3600, + sampleCaptureOverride: { + attempts.withValue { $0 += 1 } + return nil + }) + + watchdog.traceHangForTesting(responseDelay: 0.05, responseBeforeTrace: true) + #expect(attempts.get() == 0) + + watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true) + #expect(attempts.get() == 1) + } + + @Test + func `cooldown blocked hang samples when cooldown expires`() { + let attempts = OSAllocatedBox(0) + let watchdog = MainThreadHangWatchdog( + pingInterval: 0.01, + hangThreshold: 0.01, + sampleThreshold: 0, + sampleCooldown: 0.2, + sampleCaptureOverride: { + attempts.withValue { $0 += 1 } + return nil + }) + + watchdog.traceHangForTesting(responseDelay: 0.05, waitForSampleAttempt: true) + watchdog.traceHangForTesting(responseDelay: 1) + + #expect(attempts.get() == 2) + } +} +#endif + +private final class OSAllocatedBox: @unchecked Sendable { + private let lock = NSLock() + private var value: T + + init(_ value: T) { + self.value = value + } + + func set(_ newValue: T) { + self.lock.lock() + self.value = newValue + self.lock.unlock() + } + + func get() -> T { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } +} + +extension OSAllocatedBox { + func append(_ element: Element) where T == [Element] { + self.lock.lock() + self.value.append(element) + self.lock.unlock() + } + + func withValue(_ body: (inout T) -> Void) { + self.lock.lock() + body(&self.value) + self.lock.unlock() + } +} diff --git a/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift b/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift new file mode 100644 index 0000000000..9e042d7ebb --- /dev/null +++ b/Tests/CodexBarTests/ManagedCodexAccountCoordinatorTests.swift @@ -0,0 +1,157 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct ManagedCodexAccountCoordinatorTests { + @Test + func `coordinator exposes in flight state and rejects overlapping managed authentication`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let existingAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let runner = BlockingManagedCodexLoginRunner() + let service = ManagedCodexAccountService( + store: InMemoryManagedCodexAccountStoreForCoordinatorTests( + accounts: ManagedCodexAccountSet(version: 1, accounts: [])), + homeFactory: CoordinatorTestManagedCodexHomeFactory(root: root), + loginRunner: runner, + identityReader: CoordinatorStubManagedCodexIdentityReader(email: "user@example.com")) + let coordinator = ManagedCodexAccountCoordinator(service: service) + + let authTask = Task { try await coordinator.authenticateManagedAccount(existingAccountID: existingAccountID) } + await runner.waitUntilStarted() + + #expect(coordinator.isAuthenticatingManagedAccount) + #expect(coordinator.authenticatingManagedAccountID == existingAccountID) + + await #expect(throws: ManagedCodexAccountCoordinatorError.authenticationInProgress) { + try await coordinator.authenticateManagedAccount() + } + + await runner.resume() + let account = try await authTask.value + + #expect(account.email == "user@example.com") + #expect(coordinator.isAuthenticatingManagedAccount == false) + #expect(coordinator.authenticatingManagedAccountID == nil) + } + + @Test + func `coordinator clears in flight state after managed login timeout`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let loginResult = CodexLoginRunner.Result(outcome: .timedOut, output: "timed out") + let existingAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let service = ManagedCodexAccountService( + store: InMemoryManagedCodexAccountStoreForCoordinatorTests( + accounts: ManagedCodexAccountSet(version: 1, accounts: [])), + homeFactory: CoordinatorTestManagedCodexHomeFactory(root: root), + loginRunner: TimedOutManagedCodexLoginRunner(result: loginResult), + identityReader: CoordinatorStubManagedCodexIdentityReader(email: "user@example.com")) + let coordinator = ManagedCodexAccountCoordinator(service: service) + + do { + _ = try await coordinator.authenticateManagedAccount(existingAccountID: existingAccountID, timeout: 0.2) + Issue.record("Expected managed login timeout to throw") + } catch let error as ManagedCodexAccountServiceError { + #expect(error == .loginFailed(loginResult)) + } catch { + Issue.record("Expected ManagedCodexAccountServiceError.loginFailed, got \(error)") + } + + #expect(coordinator.isAuthenticatingManagedAccount == false) + #expect(coordinator.authenticatingManagedAccountID == nil) + } +} + +private actor BlockingManagedCodexLoginRunner: ManagedCodexLoginRunning { + private var waiters: [CheckedContinuation] = [] + private var startedWaiters: [CheckedContinuation] = [] + private var didStart = false + + func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + self.didStart = true + self.startedWaiters.forEach { $0.resume() } + self.startedWaiters.removeAll() + return await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + + func waitUntilStarted() async { + if self.didStart { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append(continuation) + } + } + + func resume() { + let result = CodexLoginRunner.Result(outcome: .success, output: "ok") + self.waiters.forEach { $0.resume(returning: result) } + self.waiters.removeAll() + } +} + +private struct TimedOutManagedCodexLoginRunner: ManagedCodexLoginRunning { + let result: CodexLoginRunner.Result + + func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + self.result + } +} + +private final class InMemoryManagedCodexAccountStoreForCoordinatorTests: ManagedCodexAccountStoring, +@unchecked Sendable { + var snapshot: ManagedCodexAccountSet + + init(accounts: ManagedCodexAccountSet) { + self.snapshot = accounts + } + + func loadAccounts() throws -> ManagedCodexAccountSet { + self.snapshot + } + + func storeAccounts(_ accounts: ManagedCodexAccountSet) throws { + self.snapshot = accounts + } + + func ensureFileExists() throws -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } +} + +private final class CoordinatorTestManagedCodexHomeFactory: ManagedCodexHomeProducing, @unchecked Sendable { + let root: URL + + init(root: URL) { + self.root = root + } + + func makeHomeURL() -> URL { + self.root.appendingPathComponent(UUID().uuidString, isDirectory: true) + } + + func validateManagedHomeForDeletion(_ url: URL) throws { + try ManagedCodexHomeFactory(root: self.root).validateManagedHomeForDeletion(url) + } +} + +private final class CoordinatorStubManagedCodexIdentityReader: ManagedCodexIdentityReading, @unchecked Sendable { + let email: String + + init(email: String) { + self.email = email + } + + func loadAccountIdentity(homePath _: String) throws -> CodexAuthBackedAccount { + CodexAuthBackedAccount( + identity: CodexIdentityResolver.resolve(accountId: nil, email: self.email), + email: self.email, + plan: "Pro") + } +} diff --git a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift new file mode 100644 index 0000000000..44ba8a5e1a --- /dev/null +++ b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift @@ -0,0 +1,958 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct ManagedCodexAccountServiceTests { + @Test + func `upsert preserves uuid for matching canonical email`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("managed.json", isDirectory: false) + let store = FileManagedCodexAccountStore(fileURL: fileURL, fileManager: .default) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "account-live"), email: "user@example.com", plan: "Pro"), + .init(identity: .providerAccount(id: "account-live"), email: "user@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + let first = try await service.authenticateManagedAccount() + let second = try await service.authenticateManagedAccount() + let snapshot = try store.loadAccounts() + + #expect(first.id == second.id) + #expect(second.email == "user@example.com") + #expect(second.providerAccountID == "account-live") + #expect(snapshot.accounts.count == 1) + #expect(second.managedHomePath.hasPrefix(root.standardizedFileURL.path + "/")) + } + + @Test + func `new authentication appends managed account without implicit selection side effect`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let firstID = try #require(UUID(uuidString: "11111111-1111-1111-1111-111111111111")) + let firstAccount = ManagedCodexAccount( + id: firstID, + email: "first@example.com", + managedHomePath: root.appendingPathComponent("accounts/first", isDirectory: true).path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: 1, + accounts: [firstAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "account-second"), email: "second@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + let authenticated = try await service.authenticateManagedAccount() + + #expect(store.snapshot.accounts.count == 2) + #expect(authenticated.email == "second@example.com") + #expect(authenticated.providerAccountID == "account-second") + } + + @Test + func `same email provider backed workspaces coexist across sequential add account flows`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "workspace-personal"), email: "alice@example.com", plan: "Pro"), + .init(identity: .providerAccount(id: "workspace-team"), email: "alice@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver(identities: [ + "workspace-personal": CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-personal", + workspaceLabel: "Personal"), + "workspace-team": CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-team", + workspaceLabel: "Team"), + ])) + + let personal = try await service.authenticateManagedAccount() + let team = try await service.authenticateManagedAccount() + + let storedPersonal = try #require( + store.snapshot.account(email: "alice@example.com", providerAccountID: "workspace-personal")) + let storedTeam = try #require( + store.snapshot.account(email: "alice@example.com", providerAccountID: "workspace-team")) + #expect(store.snapshot.accounts.count == 2) + #expect(personal.id == storedPersonal.id) + #expect(team.id == storedTeam.id) + #expect(personal.id != team.id) + #expect(storedPersonal.providerAccountID == "workspace-personal") + #expect(storedPersonal.workspaceLabel == "Personal") + #expect(storedTeam.providerAccountID == "workspace-team") + #expect(storedTeam.workspaceLabel == "Team") + #expect(storedPersonal.managedHomePath != storedTeam.managedHomePath) + #expect(FileManager.default.fileExists(atPath: storedPersonal.managedHomePath)) + #expect(FileManager.default.fileExists(atPath: storedTeam.managedHomePath)) + } + + @Test + func `same workspace provider id with different emails does not overwrite existing account`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let existingHome = root.appendingPathComponent("accounts/existing", isDirectory: true) + try FileManager.default.createDirectory(at: existingHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let existingID = try #require(UUID(uuidString: "10101010-2020-3030-4040-505050505050")) + let existingAccount = ManagedCodexAccount( + id: existingID, + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + workspaceLabel: "4107", + workspaceAccountID: "team-4107", + managedHomePath: existingHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [existingAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "team-4107"), email: "mich.aelfmk5542@gmail.com", plan: "Team"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver(identities: [ + "team-4107": CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "team-4107", + workspaceLabel: "4107"), + ])) + + let added = try await service.authenticateManagedAccount() + + let original = try #require( + store.snapshot.account(email: "mi.chaelfmk5542@gmail.com", providerAccountID: "team-4107")) + let newAccount = try #require( + store.snapshot.account(email: "mich.aelfmk5542@gmail.com", providerAccountID: "team-4107")) + #expect(store.snapshot.accounts.count == 2) + #expect(original.id == existingID) + #expect(original.managedHomePath == existingHome.path) + #expect(newAccount.id == added.id) + #expect(newAccount.id != existingID) + #expect(newAccount.workspaceLabel == "4107") + #expect(FileManager.default.fileExists(atPath: existingHome.path)) + #expect(FileManager.default.fileExists(atPath: newAccount.managedHomePath)) + } + + @Test + func `selected workspace is persisted and used as account identity`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [])) + let workspaces = [ + CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-personal", + workspaceLabel: "Personal"), + CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-team", + workspaceLabel: "Team"), + ] + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: WritingManagedCodexLoginRunner( + credentials: CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: nil, + accountId: "workspace-personal", + lastRefresh: nil)), + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "workspace-personal"), email: "alice@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver( + identities: Dictionary(uniqueKeysWithValues: workspaces.map { ($0.workspaceAccountID, $0) }), + availableIdentities: workspaces), + workspaceSelector: StubManagedCodexWorkspaceSelector(selectedWorkspaceID: "workspace-team")) + + let account = try await service.authenticateManagedAccount() + let credentials = try CodexOAuthCredentialsStore.load(env: ["CODEX_HOME": account.managedHomePath]) + + #expect(account.providerAccountID == "workspace-team") + #expect(account.workspaceLabel == "Team") + #expect(credentials.accountId == "workspace-team") + #expect(store.snapshot.accounts.count == 1) + } + + @Test + func `reauth keeps previous home when store write fails`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let existingHome = root.appendingPathComponent("accounts/existing", isDirectory: true) + try FileManager.default.createDirectory(at: existingHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let existingAccountID = try #require(UUID(uuidString: "11111111-2222-3333-4444-555555555555")) + let existingAccount = ManagedCodexAccount( + id: existingAccountID, + email: "user@example.com", + managedHomePath: existingHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = FailingManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: 1, + accounts: [existingAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "account-live"), email: "user@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + await #expect(throws: TestManagedCodexAccountStoreError.writeFailed) { + try await service.authenticateManagedAccount() + } + + let newHome = root.appendingPathComponent("accounts/account-1", isDirectory: true) + #expect(FileManager.default.fileExists(atPath: existingHome.path)) + #expect(FileManager.default.fileExists(atPath: newHome.path) == false) + #expect(store.snapshot.accounts.count == 1) + #expect(store.snapshot.accounts.first?.managedHomePath == existingHome.path) + } + + @Test + func `reauth reconciles by provider account id before existing account id`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let alphaHome = root.appendingPathComponent("accounts/alpha", isDirectory: true) + let betaHome = root.appendingPathComponent("accounts/beta", isDirectory: true) + try FileManager.default.createDirectory(at: alphaHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: betaHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let alphaID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let betaID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-222222222222")) + let alphaAccount = ManagedCodexAccount( + id: alphaID, + email: "shared@example.com", + providerAccountID: "account-alpha", + managedHomePath: alphaHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let betaAccount = ManagedCodexAccount( + id: betaID, + email: "shared@example.com", + providerAccountID: "account-beta", + managedHomePath: betaHome.path, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2) + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: 1, + accounts: [alphaAccount, betaAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "account-beta"), email: "SHARED@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + let account = try await service.authenticateManagedAccount(existingAccountID: alphaAccount.id) + + let storedAlpha = try #require(store.snapshot.account(id: alphaAccount.id)) + let storedBeta = try #require(store.snapshot.account(id: betaAccount.id)) + #expect(account.id == betaAccount.id) + #expect(store.snapshot.accounts.count == 2) + #expect(storedAlpha.email == "shared@example.com") + #expect(storedAlpha.providerAccountID == "account-alpha") + #expect(storedAlpha.managedHomePath == alphaHome.path) + #expect(storedBeta.email == "shared@example.com") + #expect(storedBeta.providerAccountID == "account-beta") + #expect(storedBeta.managedHomePath.hasPrefix(root.standardizedFileURL.path + "/")) + #expect(storedBeta.managedHomePath != betaHome.path) + #expect(FileManager.default.fileExists(atPath: alphaHome.path)) + #expect(FileManager.default.fileExists(atPath: betaHome.path) == false) + #expect(FileManager.default.fileExists(atPath: storedBeta.managedHomePath)) + } + + @Test + func `reauth to different account does not overwrite existing account id match`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let aliceHome = root.appendingPathComponent("accounts/alice", isDirectory: true) + try FileManager.default.createDirectory(at: aliceHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let aliceID = try #require(UUID(uuidString: "12121212-3434-5656-7878-909090909090")) + let aliceAccount = ManagedCodexAccount( + id: aliceID, + email: "alice@example.com", + providerAccountID: "account-alice", + managedHomePath: aliceHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = InMemoryManagedCodexAccountStore(accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [aliceAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "account-bob"), email: "bob@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + let account = try await service.authenticateManagedAccount(existingAccountID: aliceID) + + let storedAlice = try #require(store.snapshot.account(id: aliceID)) + let storedBob = try #require(store.snapshot.account(email: "bob@example.com")) + #expect(account.id != aliceID) + #expect(account.id == storedBob.id) + #expect(store.snapshot.accounts.count == 2) + #expect(storedAlice.email == "alice@example.com") + #expect(storedAlice.providerAccountID == "account-alice") + #expect(storedAlice.managedHomePath == aliceHome.path) + #expect(storedBob.email == "bob@example.com") + #expect(storedBob.providerAccountID == "account-bob") + #expect(storedBob.managedHomePath.hasPrefix(root.standardizedFileURL.path + "/")) + #expect(storedBob.managedHomePath != aliceHome.path) + #expect(FileManager.default.fileExists(atPath: aliceHome.path)) + #expect(FileManager.default.fileExists(atPath: storedBob.managedHomePath)) + } + + @Test + func `reauth on same email different workspace does not overwrite selected workspace`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let personalHome = root.appendingPathComponent("accounts/personal", isDirectory: true) + try FileManager.default.createDirectory(at: personalHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let personalID = try #require(UUID(uuidString: "31313131-4242-5353-6464-757575757575")) + let personalAccount = ManagedCodexAccount( + id: personalID, + email: "alice@example.com", + providerAccountID: "workspace-personal", + workspaceLabel: "Personal", + workspaceAccountID: "workspace-personal", + managedHomePath: personalHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = InMemoryManagedCodexAccountStore(accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [personalAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "workspace-team"), email: "alice@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver(identities: [ + "workspace-team": CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "workspace-team", + workspaceLabel: "Team"), + ])) + + let account = try await service.authenticateManagedAccount(existingAccountID: personalID) + + let storedPersonal = try #require(store.snapshot.account(id: personalID)) + let storedTeam = try #require( + store.snapshot.account(email: "alice@example.com", providerAccountID: "workspace-team")) + #expect(account.id == storedTeam.id) + #expect(account.id != personalID) + #expect(store.snapshot.accounts.count == 2) + #expect(storedPersonal.providerAccountID == "workspace-personal") + #expect(storedPersonal.workspaceLabel == "Personal") + #expect(storedPersonal.managedHomePath == personalHome.path) + #expect(storedTeam.providerAccountID == "workspace-team") + #expect(storedTeam.workspaceLabel == "Team") + #expect(storedTeam.managedHomePath != personalHome.path) + #expect(FileManager.default.fileExists(atPath: personalHome.path)) + #expect(FileManager.default.fileExists(atPath: storedTeam.managedHomePath)) + } + + @Test + func `legacy row collapses onto provider backed row when provider id resolves elsewhere`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let legacyHome = root.appendingPathComponent("accounts/legacy", isDirectory: true) + let providerHome = root.appendingPathComponent("accounts/provider", isDirectory: true) + try FileManager.default.createDirectory(at: legacyHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: providerHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let legacyID = try #require(UUID(uuidString: "AAAAAAAA-1111-2222-3333-444444444444")) + let providerID = try #require(UUID(uuidString: "BBBBBBBB-1111-2222-3333-444444444444")) + let legacyAccount = ManagedCodexAccount( + id: legacyID, + email: "shared@example.com", + managedHomePath: legacyHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let providerAccount = ManagedCodexAccount( + id: providerID, + email: "shared@example.com", + providerAccountID: "account-real", + managedHomePath: providerHome.path, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2) + let store = InMemoryManagedCodexAccountStore(accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [legacyAccount, providerAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "account-real"), email: "shared@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + let account = try await service.authenticateManagedAccount(existingAccountID: legacyAccount.id) + + #expect(account.id == providerAccount.id) + #expect(store.snapshot.accounts.count == 1) + #expect(store.snapshot.accounts.first?.id == providerAccount.id) + #expect(store.snapshot.accounts.first?.providerAccountID == "account-real") + #expect(FileManager.default.fileExists(atPath: legacyHome.path) == false) + #expect(FileManager.default.fileExists(atPath: providerHome.path) == false) + #expect(FileManager.default.fileExists(atPath: account.managedHomePath)) + } + + @Test + func `fresh provider login removes stale legacy row without explicit existing account id`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let legacyHome = root.appendingPathComponent("accounts/legacy", isDirectory: true) + let providerHome = root.appendingPathComponent("accounts/provider", isDirectory: true) + try FileManager.default.createDirectory(at: legacyHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: providerHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let legacyID = try #require(UUID(uuidString: "CCCCCCCC-1111-2222-3333-444444444444")) + let providerID = try #require(UUID(uuidString: "DDDDDDDD-1111-2222-3333-444444444444")) + let legacyAccount = ManagedCodexAccount( + id: legacyID, + email: "shared@example.com", + managedHomePath: legacyHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let providerAccount = ManagedCodexAccount( + id: providerID, + email: "shared@example.com", + providerAccountID: "account-real", + managedHomePath: providerHome.path, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2) + let store = InMemoryManagedCodexAccountStore(accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [legacyAccount, providerAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "account-real"), email: "shared@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + let account = try await service.authenticateManagedAccount() + + #expect(account.id == providerAccount.id) + #expect(store.snapshot.accounts.count == 1) + #expect(store.snapshot.accounts.first?.id == providerAccount.id) + #expect(FileManager.default.fileExists(atPath: legacyHome.path) == false) + #expect(FileManager.default.fileExists(atPath: providerHome.path) == false) + #expect(FileManager.default.fileExists(atPath: account.managedHomePath)) + } + + @Test + func `authentication persists workspace metadata and tolerates missing workspace label`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: [])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init(identity: .providerAccount(id: "account-live"), email: "user@example.com", plan: "Pro"), + .init(identity: .providerAccount(id: "account-fallback"), email: "fallback@example.com", plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver(identities: [ + "account-live": CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "account-live", + workspaceLabel: "Team Alpha"), + "account-fallback": CodexOpenAIWorkspaceIdentity( + workspaceAccountID: "account-fallback", + workspaceLabel: nil), + ])) + + let labeled = try await service.authenticateManagedAccount() + let fallback = try await service.authenticateManagedAccount() + + #expect(labeled.providerAccountID == "account-live") + #expect(labeled.workspaceAccountID == "account-live") + #expect(labeled.workspaceLabel == "Team Alpha") + #expect(fallback.providerAccountID == "account-fallback") + #expect(fallback.workspaceAccountID == "account-fallback") + #expect(fallback.workspaceLabel == nil) + } + + @Test + func `reauth preserves stored provider metadata when refresh cannot resolve account id`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let existingHome = root.appendingPathComponent("accounts/existing", isDirectory: true) + try FileManager.default.createDirectory(at: existingHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let existingID = try #require(UUID(uuidString: "EEEEEEEE-1111-2222-3333-444444444444")) + let existingAccount = ManagedCodexAccount( + id: existingID, + email: "user@example.com", + providerAccountID: "account-live", + workspaceLabel: "Team Alpha", + workspaceAccountID: "account-live", + managedHomePath: existingHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = InMemoryManagedCodexAccountStore(accounts: ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [existingAccount])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.accounts([ + .init( + identity: .emailOnly(normalizedEmail: "user@example.com"), + email: "user@example.com", + plan: "Pro"), + ]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + let account = try await service.authenticateManagedAccount(existingAccountID: existingID) + let stored = try #require(store.snapshot.account(id: existingID)) + + #expect(account.id == existingID) + #expect(account.providerAccountID == "account-live") + #expect(account.workspaceAccountID == "account-live") + #expect(account.workspaceLabel == "Team Alpha") + #expect(stored.providerAccountID == "account-live") + #expect(stored.workspaceAccountID == "account-live") + #expect(stored.workspaceLabel == "Team Alpha") + #expect(FileManager.default.fileExists(atPath: existingHome.path) == false) + #expect(FileManager.default.fileExists(atPath: account.managedHomePath)) + } + + @Test + func `auth failure cleanup uses managed root safety check`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let outsideHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: outsideHome) + } + + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet(version: 1, accounts: [])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: UnsafeManagedCodexHomeFactory(root: root, homeURL: outsideHome), + loginRunner: StubManagedCodexLoginRunner( + result: CodexLoginRunner.Result(outcome: .failed(status: 1), output: "nope")), + identityReader: StubManagedCodexIdentityReader.emails([]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + await #expect(throws: ManagedCodexAccountServiceError.self) { + try await service.authenticateManagedAccount() + } + + #expect(FileManager.default.fileExists(atPath: outsideHome.path)) + #expect(store.snapshot.accounts.isEmpty) + } + + @Test + func `auth failure preserves codex login result output`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let loginResult = CodexLoginRunner.Result( + outcome: .failed(status: 42), + output: "OAuth callback used the wrong browser profile") + let service = ManagedCodexAccountService( + store: InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet(version: 1, accounts: [])), + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner(result: loginResult), + identityReader: StubManagedCodexIdentityReader.emails([]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + do { + _ = try await service.authenticateManagedAccount() + Issue.record("Expected managed Codex login failure") + } catch let error as ManagedCodexAccountServiceError { + guard case let .loginFailed(capturedResult) = error else { + Issue.record("Expected loginFailed, got \(error)") + return + } + #expect(capturedResult == loginResult) + #expect(error.userFacingMessage.contains("codex --version")) + #expect(error.userFacingMessage.contains("OAuth callback used the wrong browser profile")) + } + } + + @Test + func `remove deletes managed home under managed root`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let home = root.appendingPathComponent("accounts/account-a", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let accountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")) + let account = ManagedCodexAccount( + id: accountID, + email: "user@example.com", + managedHomePath: home.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet(version: 1, accounts: [account])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.emails([]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + try await service.removeManagedAccount(id: account.id) + + #expect(store.snapshot.accounts.isEmpty) + #expect(FileManager.default.fileExists(atPath: home.path) == false) + } + + @Test + func `remove keeps remaining managed account records`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let firstHome = root.appendingPathComponent("accounts/account-a", isDirectory: true) + let secondHome = root.appendingPathComponent("accounts/account-b", isDirectory: true) + try FileManager.default.createDirectory(at: firstHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let firstID = try #require(UUID(uuidString: "AAAAAAAA-1111-1111-1111-111111111111")) + let secondID = try #require(UUID(uuidString: "BBBBBBBB-2222-2222-2222-222222222222")) + let first = ManagedCodexAccount( + id: firstID, + email: "first@example.com", + managedHomePath: firstHome.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let second = ManagedCodexAccount( + id: secondID, + email: "second@example.com", + managedHomePath: secondHome.path, + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2) + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet( + version: 1, + accounts: [first, second])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.emails([]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + try await service.removeManagedAccount(id: second.id) + + #expect(store.snapshot.accounts.count == 1) + #expect(store.snapshot.accounts.first?.id == first.id) + #expect(FileManager.default.fileExists(atPath: secondHome.path) == false) + } + + @Test + func `remove keeps persisted account when store write fails`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let home = root.appendingPathComponent("accounts/account-a", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let accountID = try #require(UUID(uuidString: "CCCCCCCC-DDDD-EEEE-FFFF-000000000000")) + let account = ManagedCodexAccount( + id: accountID, + email: "user@example.com", + managedHomePath: home.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = FailingManagedCodexAccountStore( + accounts: ManagedCodexAccountSet(version: 1, accounts: [account])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.emails([]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + await #expect(throws: TestManagedCodexAccountStoreError.writeFailed) { + try await service.removeManagedAccount(id: account.id) + } + + #expect(store.snapshot.accounts.count == 1) + #expect(store.snapshot.accounts.first?.managedHomePath == home.path) + #expect(FileManager.default.fileExists(atPath: home.path)) + } + + @Test + func `remove drops account but leaves unsafe home untouched`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let outsideRoot = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try FileManager.default.createDirectory(at: outsideRoot, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: outsideRoot) + } + + let accountID = try #require(UUID(uuidString: "BBBBBBBB-CCCC-DDDD-EEEE-FFFFFFFFFFFF")) + let account = ManagedCodexAccount( + id: accountID, + email: "user@example.com", + managedHomePath: outsideRoot.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let store = InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet(version: 1, accounts: [account])) + let service = ManagedCodexAccountService( + store: store, + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner.success, + identityReader: StubManagedCodexIdentityReader.emails([]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + try await service.removeManagedAccount(id: account.id) + + #expect(store.snapshot.accounts.isEmpty) + #expect(FileManager.default.fileExists(atPath: outsideRoot.path)) + } +} + +private final class InMemoryManagedCodexAccountStore: ManagedCodexAccountStoring, @unchecked Sendable { + var snapshot: ManagedCodexAccountSet + + init(accounts: ManagedCodexAccountSet) { + self.snapshot = accounts + } + + func loadAccounts() throws -> ManagedCodexAccountSet { + self.snapshot + } + + func storeAccounts(_ accounts: ManagedCodexAccountSet) throws { + self.snapshot = accounts + } + + func ensureFileExists() throws -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } +} + +private final class FailingManagedCodexAccountStore: ManagedCodexAccountStoring, @unchecked Sendable { + var snapshot: ManagedCodexAccountSet + + init(accounts: ManagedCodexAccountSet) { + self.snapshot = accounts + } + + func loadAccounts() throws -> ManagedCodexAccountSet { + self.snapshot + } + + func storeAccounts(_ accounts: ManagedCodexAccountSet) throws { + _ = accounts + throw TestManagedCodexAccountStoreError.writeFailed + } + + func ensureFileExists() throws -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } +} + +private final class TestManagedCodexHomeFactory: ManagedCodexHomeProducing, @unchecked Sendable { + let root: URL + private let lock = NSLock() + private var index: Int = 0 + + init(root: URL) { + self.root = root + } + + private func nextPathComponent() -> String { + self.lock.lock() + defer { self.lock.unlock() } + self.index += 1 + return "accounts/account-\(self.index)" + } + + func makeHomeURL() -> URL { + self.root.appendingPathComponent(self.nextPathComponent(), isDirectory: true) + } + + func validateManagedHomeForDeletion(_ url: URL) throws { + try ManagedCodexHomeFactory(root: self.root).validateManagedHomeForDeletion(url) + } +} + +private struct UnsafeManagedCodexHomeFactory: ManagedCodexHomeProducing { + let root: URL + let homeURL: URL + + func makeHomeURL() -> URL { + self.homeURL + } + + func validateManagedHomeForDeletion(_ url: URL) throws { + try ManagedCodexHomeFactory(root: self.root).validateManagedHomeForDeletion(url) + } +} + +private struct StubManagedCodexLoginRunner: ManagedCodexLoginRunning { + let result: CodexLoginRunner.Result + + func run(homePath: String, timeout: TimeInterval) async -> CodexLoginRunner.Result { + self.result + } + + static let success = StubManagedCodexLoginRunner( + result: CodexLoginRunner.Result(outcome: .success, output: "ok")) +} + +private struct WritingManagedCodexLoginRunner: ManagedCodexLoginRunning { + let credentials: CodexOAuthCredentials + + func run(homePath: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + do { + try CodexOAuthCredentialsStore.save(self.credentials, env: ["CODEX_HOME": homePath]) + return CodexLoginRunner.Result(outcome: .success, output: "ok") + } catch { + return CodexLoginRunner.Result(outcome: .failed(status: 1), output: String(describing: error)) + } + } +} + +private enum TestManagedCodexAccountStoreError: Error, Equatable { + case writeFailed +} + +private final class StubManagedCodexIdentityReader: ManagedCodexIdentityReading, @unchecked Sendable { + private let lock = NSLock() + private var identities: [CodexAuthBackedAccount] + + init(identities: [CodexAuthBackedAccount]) { + self.identities = identities + } + + func loadAccountIdentity(homePath _: String) throws -> CodexAuthBackedAccount { + self.lock.lock() + defer { self.lock.unlock() } + guard !self.identities.isEmpty else { + return CodexAuthBackedAccount(identity: .unresolved, email: nil, plan: nil) + } + return self.identities.removeFirst() + } + + static func emails(_ emails: [String]) -> StubManagedCodexIdentityReader { + StubManagedCodexIdentityReader(identities: emails.map { email in + CodexAuthBackedAccount( + identity: CodexIdentityResolver.resolve(accountId: nil, email: email), + email: email, + plan: "Pro") + }) + } + + static func accounts(_ accounts: [CodexAuthBackedAccount]) -> StubManagedCodexIdentityReader { + StubManagedCodexIdentityReader(identities: accounts) + } +} + +private struct StubManagedCodexWorkspaceResolver: ManagedCodexWorkspaceResolving { + let identities: [String: CodexOpenAIWorkspaceIdentity] + let availableIdentities: [CodexOpenAIWorkspaceIdentity] + + init( + identities: [String: CodexOpenAIWorkspaceIdentity] = [:], + availableIdentities: [CodexOpenAIWorkspaceIdentity] = []) + { + self.identities = identities + self.availableIdentities = availableIdentities + } + + func resolveWorkspaceIdentity( + homePath _: String, + providerAccountID: String) async -> CodexOpenAIWorkspaceIdentity? + { + self.identities[providerAccountID] + } + + func availableWorkspaceIdentities(homePath _: String) async -> [CodexOpenAIWorkspaceIdentity] { + self.availableIdentities + } +} + +private struct StubManagedCodexWorkspaceSelector: ManagedCodexWorkspaceSelecting { + let selectedWorkspaceID: String? + + func selectWorkspace( + email _: String, + currentWorkspaceID _: String?, + workspaces: [CodexOpenAIWorkspaceIdentity]) async -> CodexOpenAIWorkspaceIdentity? + { + workspaces.first { $0.workspaceAccountID == self.selectedWorkspaceID } + } +} diff --git a/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift b/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift new file mode 100644 index 0000000000..274761cf53 --- /dev/null +++ b/Tests/CodexBarTests/ManagedCodexAccountStoreTests.swift @@ -0,0 +1,542 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Test +func `FileManagedCodexAccountStore round trip`() throws { + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("codexbar-managed-codex-accounts-test.json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let firstID = UUID() + let secondID = UUID() + let firstAccount = ManagedCodexAccount( + id: firstID, + email: " FIRST@Example.COM ", + providerAccountID: "account-first", + managedHomePath: "/tmp/managed-home-1", + createdAt: 1000, + updatedAt: 2000, + lastAuthenticatedAt: 3000) + let secondAccount = ManagedCodexAccount( + id: secondID, + email: "second@example.com", + providerAccountID: "account-second", + managedHomePath: "/tmp/managed-home-2", + createdAt: 4000, + updatedAt: 5000, + lastAuthenticatedAt: nil) + let payload = ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [firstAccount, secondAccount]) + let store = FileManagedCodexAccountStore(fileURL: fileURL) + + try store.storeAccounts(payload) + let contents = try String(contentsOf: fileURL, encoding: .utf8) + let loaded = try store.loadAccounts() + let accountsRange = try #require(contents.range(of: "\"accounts\"")) + let versionRange = try #require(contents.range(of: "\"version\"")) + + #expect(loaded.version == FileManagedCodexAccountStore.currentVersion) + #expect(loaded.accounts.count == 2) + #expect(loaded.accounts[0].email == "first@example.com") + #expect(loaded.accounts[0].providerAccountID == "account-first") + #expect(loaded.account(id: firstID)?.managedHomePath == "/tmp/managed-home-1") + #expect(loaded.account(email: "SECOND@example.com", providerAccountID: "account-second")?.id == secondID) + #expect(contents.contains("\n \"accounts\"")) + #expect(accountsRange.lowerBound < versionRange.lowerBound) + #expect(contents.contains("\"activeAccountID\"") == false) +} + +@Test +func `FileManagedCodexAccountStore missing file loads empty set`() throws { + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("codexbar-managed-codex-accounts-nil-active-test.json") + defer { try? FileManager.default.removeItem(at: fileURL) } + try? FileManager.default.removeItem(at: fileURL) + + let store = FileManagedCodexAccountStore(fileURL: fileURL) + let initial = try store.loadAccounts() + + #expect(initial.version == FileManagedCodexAccountStore.currentVersion) + #expect(initial.accounts.isEmpty) + + let account = ManagedCodexAccount( + id: UUID(), + email: "user@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 10, + updatedAt: 20, + lastAuthenticatedAt: nil) + let payload = ManagedCodexAccountSet( + version: 1, + accounts: [account]) + + try store.storeAccounts(payload) + let loaded = try store.loadAccounts() + + #expect(loaded.version == FileManagedCodexAccountStore.currentVersion) + #expect(loaded.accounts.count == 1) + #expect(loaded.account(email: "USER@example.com")?.id == account.id) +} + +@Test +func `FileManagedCodexAccountStore canonicalizes decoded emails`() throws { + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("codexbar-managed-codex-accounts-decode-test.json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let accountID = UUID() + let json = """ + { + "accounts" : [ + { + "createdAt" : 10, + "email" : " MIXED@Example.COM ", + "id" : "\(accountID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "/tmp/managed-home", + "updatedAt" : 20 + } + ], + "version" : 1 + } + """ + + try json.write(to: fileURL, atomically: true, encoding: .utf8) + + let store = FileManagedCodexAccountStore(fileURL: fileURL) + let loaded = try store.loadAccounts() + + #expect(loaded.version == FileManagedCodexAccountStore.currentVersion) + #expect(loaded.accounts.first?.email == "mixed@example.com") + #expect(loaded.account(email: "mixed@example.com")?.id == accountID) +} + +@Test +func `FileManagedCodexAccountStore drops duplicate canonical emails on load`() throws { + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("codexbar-managed-codex-accounts-duplicate-email-test.json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let firstID = UUID() + let secondID = UUID() + let json = """ + { + "accounts" : [ + { + "createdAt" : 10, + "email" : " First@Example.com ", + "id" : "\(firstID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "/tmp/managed-home-1", + "updatedAt" : 20 + }, + { + "createdAt" : 30, + "email" : "first@example.com", + "id" : "\(secondID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "/tmp/managed-home-2", + "updatedAt" : 40 + } + ], + "version" : 1 + } + """ + + try json.write(to: fileURL, atomically: true, encoding: .utf8) + + let store = FileManagedCodexAccountStore(fileURL: fileURL) + let loaded = try store.loadAccounts() + + #expect(loaded.accounts.count == 1) + #expect(loaded.accounts.first?.id == firstID) + #expect(loaded.accounts.first?.managedHomePath == "/tmp/managed-home-1") +} + +@Test +func `FileManagedCodexAccountStore keeps same email rows when hydrated provider account I Ds differ`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("managed.json", isDirectory: false) + let firstHome = root.appendingPathComponent("first-home", isDirectory: true) + let secondHome = root.appendingPathComponent("second-home", isDirectory: true) + try writeCodexAuthFile(homeURL: firstHome, email: "user@example.com", accountId: "account-alpha") + try writeCodexAuthFile(homeURL: secondHome, email: "user@example.com", accountId: "account-beta") + + let firstID = UUID() + let secondID = UUID() + let json = """ + { + "accounts" : [ + { + "createdAt" : 10, + "email" : " user@example.com ", + "id" : "\(firstID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "\(firstHome.path)", + "updatedAt" : 20 + }, + { + "createdAt" : 30, + "email" : "USER@example.com", + "id" : "\(secondID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "\(secondHome.path)", + "updatedAt" : 40 + } + ], + "version" : 1 + } + """ + + try json.write(to: fileURL, atomically: true, encoding: .utf8) + + let store = FileManagedCodexAccountStore(fileURL: fileURL) + let loaded = try store.loadAccounts() + + #expect(loaded.version == FileManagedCodexAccountStore.currentVersion) + #expect(loaded.accounts.count == 2) + #expect(loaded.account(email: "user@example.com", providerAccountID: "account-alpha")?.id == firstID) + #expect(loaded.account(email: "user@example.com", providerAccountID: "account-beta")?.id == secondID) +} + +@Test +func `managed account set keeps same provider account I D when emails differ`() { + let firstID = UUID() + let secondID = UUID() + let first = ManagedCodexAccount( + id: firstID, + email: "mi.chaelfmk5542@gmail.com", + providerAccountID: "team-4107", + managedHomePath: "/tmp/managed-home-1", + createdAt: 10, + updatedAt: 20, + lastAuthenticatedAt: nil) + let second = ManagedCodexAccount( + id: secondID, + email: "mich.aelfmk5542@gmail.com", + providerAccountID: "team-4107", + managedHomePath: "/tmp/managed-home-2", + createdAt: 30, + updatedAt: 40, + lastAuthenticatedAt: nil) + + let set = ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [first, second]) + + #expect(set.accounts.count == 2) + #expect(set.account(email: "mi.chaelfmk5542@gmail.com", providerAccountID: "team-4107")?.id == firstID) + #expect(set.account(email: "mich.aelfmk5542@gmail.com", providerAccountID: "team-4107")?.id == secondID) +} + +@Test +func `FileManagedCodexAccountStore hydrates provider account I D from id token when account field is absent`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("managed.json", isDirectory: false) + let home = root.appendingPathComponent("jwt-only-home", isDirectory: true) + try writeCodexAuthFile( + homeURL: home, + email: "user@example.com", + accountId: "account-jwt-only", + includeAccountIdField: false) + + let accountID = UUID() + let json = """ + { + "accounts" : [ + { + "createdAt" : 10, + "email" : "user@example.com", + "id" : "\(accountID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "\(home.path)", + "updatedAt" : 20 + } + ], + "version" : 1 + } + """ + + try json.write(to: fileURL, atomically: true, encoding: .utf8) + + let store = FileManagedCodexAccountStore(fileURL: fileURL) + let loaded = try store.loadAccounts() + + #expect(loaded.version == FileManagedCodexAccountStore.currentVersion) + #expect(loaded.accounts.count == 1) + #expect(loaded.accounts.first?.providerAccountID == "account-jwt-only") + #expect(loaded.account(email: "user@example.com", providerAccountID: "account-jwt-only")?.id == accountID) +} + +@Test +func `FileManagedCodexAccountStore drops duplicate IDs on load`() throws { + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("codexbar-managed-codex-accounts-duplicate-id-test.json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let sharedID = UUID() + let json = """ + { + "accounts" : [ + { + "createdAt" : 10, + "email" : "first@example.com", + "id" : "\(sharedID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "/tmp/managed-home-1", + "updatedAt" : 20 + }, + { + "createdAt" : 30, + "email" : "second@example.com", + "id" : "\(sharedID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "/tmp/managed-home-2", + "updatedAt" : 40 + } + ], + "version" : 1 + } + """ + + try json.write(to: fileURL, atomically: true, encoding: .utf8) + + let store = FileManagedCodexAccountStore(fileURL: fileURL) + let loaded = try store.loadAccounts() + + #expect(loaded.accounts.count == 1) + #expect(loaded.accounts.first?.id == sharedID) + #expect(loaded.accounts.first?.email == "first@example.com") + #expect(loaded.accounts.first?.managedHomePath == "/tmp/managed-home-1") +} + +@Test +func `FileManagedCodexAccountStore v1 upgrade keeps deleted home row with nil provider account I D`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + + let fileURL = root.appendingPathComponent("managed.json", isDirectory: false) + let missingHome = root.appendingPathComponent("missing-home", isDirectory: true) + let accountID = UUID() + let json = """ + { + "accounts" : [ + { + "createdAt" : 10, + "email" : "user@example.com", + "id" : "\(accountID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "\(missingHome.path)", + "updatedAt" : 20 + } + ], + "version" : 1 + } + """ + + try json.write(to: fileURL, atomically: true, encoding: .utf8) + + let store = FileManagedCodexAccountStore(fileURL: fileURL) + let loaded = try store.loadAccounts() + + #expect(loaded.version == FileManagedCodexAccountStore.currentVersion) + #expect(loaded.accounts.count == 1) + #expect(loaded.accounts.first?.id == accountID) + #expect(loaded.accounts.first?.providerAccountID == nil) +} + +@Test +func `FileManagedCodexAccountStore ignores legacy active account key on load`() throws { + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("codexbar-managed-codex-accounts-legacy-active-key-test.json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let accountID = UUID() + let danglingID = UUID() + let json = """ + { + "accounts" : [ + { + "createdAt" : 10, + "email" : "user@example.com", + "id" : "\(accountID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "/tmp/managed-home", + "updatedAt" : 20 + } + ], + "activeAccountID" : "\(danglingID.uuidString)", + "version" : 1 + } + """ + + try json.write(to: fileURL, atomically: true, encoding: .utf8) + + let store = FileManagedCodexAccountStore(fileURL: fileURL) + let loaded = try store.loadAccounts() + + #expect(loaded.accounts.count == 1) + #expect(loaded.account(id: accountID)?.email == "user@example.com") +} + +@Test +func `FileManagedCodexAccountStore upgrades v1 rows and writes readable v2 file without reauth`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let fileURL = root.appendingPathComponent("managed.json", isDirectory: false) + let firstHome = root.appendingPathComponent("first-home", isDirectory: true) + let secondHome = root.appendingPathComponent("second-home", isDirectory: true) + try writeCodexAuthFile(homeURL: firstHome, email: "user@example.com", accountId: "account-alpha") + try writeCodexAuthFile(homeURL: secondHome, email: "second@example.com", accountId: "account-beta") + + let firstID = UUID() + let secondID = UUID() + let json = """ + { + "accounts" : [ + { + "createdAt" : 10, + "email" : "user@example.com", + "id" : "\(firstID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "\(firstHome.path)", + "updatedAt" : 20 + }, + { + "createdAt" : 30, + "email" : "second@example.com", + "id" : "\(secondID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "\(secondHome.path)", + "updatedAt" : 40 + } + ], + "version" : 1 + } + """ + + try json.write(to: fileURL, atomically: true, encoding: .utf8) + + let store = FileManagedCodexAccountStore(fileURL: fileURL) + let loaded = try store.loadAccounts() + try store.storeAccounts(loaded) + let reloaded = try store.loadAccounts() + let contents = try String(contentsOf: fileURL, encoding: .utf8) + + #expect(loaded.accounts.count == 2) + #expect(loaded.account(email: "user@example.com", providerAccountID: "account-alpha")?.id == firstID) + #expect(loaded.account(email: "second@example.com", providerAccountID: "account-beta")?.id == secondID) + #expect(reloaded.accounts.count == 2) + #expect(contents.contains("account-alpha")) + #expect(contents.contains("account-beta")) + #expect(contents.contains("\"version\" : \(FileManagedCodexAccountStore.currentVersion)")) +} + +@Test +func `FileManagedCodexAccountStore rejects unsupported on disk versions`() throws { + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("codexbar-managed-codex-accounts-unsupported-version-test.json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let accountID = UUID() + let json = """ + { + "accounts" : [ + { + "createdAt" : 10, + "email" : "user@example.com", + "id" : "\(accountID.uuidString)", + "lastAuthenticatedAt" : null, + "managedHomePath" : "/tmp/managed-home", + "updatedAt" : 20 + } + ], + "version" : 999 + } + """ + + try json.write(to: fileURL, atomically: true, encoding: .utf8) + + let store = FileManagedCodexAccountStore(fileURL: fileURL) + + #expect(throws: FileManagedCodexAccountStoreError.unsupportedVersion(999)) { + try store.loadAccounts() + } +} + +@Test +func `FileManagedCodexAccountStore normalizes stored version to current schema`() throws { + let tempDir = FileManager.default.temporaryDirectory + let fileURL = tempDir.appendingPathComponent("codexbar-managed-codex-accounts-version-normalization-test.json") + defer { try? FileManager.default.removeItem(at: fileURL) } + + let accountID = UUID() + let account = ManagedCodexAccount( + id: accountID, + email: "user@example.com", + providerAccountID: "account-id", + managedHomePath: "/tmp/managed-home", + createdAt: 10, + updatedAt: 20, + lastAuthenticatedAt: nil) + let payload = ManagedCodexAccountSet( + version: 999, + accounts: [account]) + let store = FileManagedCodexAccountStore(fileURL: fileURL) + + try store.storeAccounts(payload) + let loaded = try store.loadAccounts() + let contents = try String(contentsOf: fileURL, encoding: .utf8) + + #expect(loaded.version == FileManagedCodexAccountStore.currentVersion) + #expect(contents.contains("\"version\" : \(FileManagedCodexAccountStore.currentVersion)")) + #expect(!contents.contains("\"version\" : 999")) +} + +private func writeCodexAuthFile( + homeURL: URL, + email: String, + accountId: String, + includeAccountIdField: Bool = true) throws +{ + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + var tokens: [String: Any] = [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": fakeJWT(email: email, accountId: accountId), + ] + if includeAccountIdField { + tokens["accountId"] = accountId + } + let data = try JSONSerialization.data(withJSONObject: ["tokens": tokens], options: [.sortedKeys]) + try data.write(to: homeURL.appendingPathComponent("auth.json")) +} + +private func fakeJWT(email: String, accountId: String) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "https://api.openai.com/auth": [ + "chatgpt_account_id": accountId, + "chatgpt_plan_type": "pro", + ], + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." +} diff --git a/Tests/CodexBarTests/ManusCookieHeaderTests.swift b/Tests/CodexBarTests/ManusCookieHeaderTests.swift new file mode 100644 index 0000000000..6f760d3d93 --- /dev/null +++ b/Tests/CodexBarTests/ManusCookieHeaderTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ManusCookieHeaderTests { + @Test + func `bare token resolves directly`() { + #expect(ManusCookieHeader.token(from: "abc123") == "abc123") + } + + @Test + func `extracts session_id from cookie header`() { + let header = "foo=bar; session_id=token-a; baz=qux" + #expect(ManusCookieHeader.token(from: header) == "token-a") + } + + @Test + func `extracts mixed case session id from cookie header`() { + let header = "foo=bar; Session_ID=token-b; baz=qux" + #expect(ManusCookieHeader.token(from: header) == "token-b") + } + + @Test + func `unsupported cookie header returns nil`() { + #expect(ManusCookieHeader.token(from: "foo=bar; hello=world") == nil) + } + + #if os(macOS) + @Test + func `importer session info extracts session token`() throws { + let cookies = try [ + #require(self.makeCookie(name: "session_id", value: "cookie-token")), + ] + let session = ManusCookieImporter.SessionInfo(cookies: cookies, sourceLabel: "Chrome") + #expect(session.sessionToken == "cookie-token") + } + + private func makeCookie(name: String, value: String) -> HTTPCookie? { + HTTPCookie(properties: [ + .domain: "manus.im", + .path: "/", + .name: name, + .value: value, + .secure: "TRUE", + ]) + } + #endif +} diff --git a/Tests/CodexBarTests/ManusProviderTests.swift b/Tests/CodexBarTests/ManusProviderTests.swift new file mode 100644 index 0000000000..be69fdf519 --- /dev/null +++ b/Tests/CodexBarTests/ManusProviderTests.swift @@ -0,0 +1,310 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ManusProviderTests { + private static let now = Date(timeIntervalSince1970: 1_744_000_000) + + private final class LockedArray: @unchecked Sendable { + private let lock = NSLock() + private var values: [Element] = [] + + func append(_ value: Element) { + self.lock.lock() + defer { self.lock.unlock() } + self.values.append(value) + } + + func snapshot() -> [Element] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext( + settings: ProviderSettingsSnapshot?, + env: [String: String] = [:]) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + private func stubResponse() -> ManusCreditsResponse { + ManusCreditsResponse( + totalCredits: 120, + freeCredits: 20, + periodicCredits: 80, + addonCredits: 10, + refreshCredits: 30, + maxRefreshCredits: 300, + proMonthlyCredits: 100, + eventCredits: 10, + nextRefreshTime: Date(timeIntervalSince1970: 1_744_003_600), + refreshInterval: "daily") + } + + private func withIsolatedCacheStore(operation: () async throws -> T) async rethrows -> T { + let service = "manus-provider-tests-\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await operation() + } + } + + @Test + func `off mode ignores environment session token`() async { + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + + #expect(await strategy.isAvailable(context) == false) + } + + @Test + func `manual mode invalid cookie does not fall back to cache or environment`() async { + await self.withIsolatedCacheStore { + CookieHeaderCache.store( + provider: .manus, + cookieHeader: "session_id=cached-token", + sourceLabel: "web") + + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .manual, + manualCookieHeader: "foo=bar")) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + + do { + _ = try await strategy.fetch(context) + Issue.record("Expected invalid manual cookie instead of falling back to cache/environment") + } catch let error as ManusAPIError { + #expect(error == .invalidCookie) + } catch { + Issue.record("Expected ManusAPIError.invalidCookie, got \(error)") + } + } + } + + @Test + func `environment token does not populate browser cache`() async throws { + try await self.withIsolatedCacheStore { + #if os(macOS) + ManusCookieImporter.importSessionsOverrideForTesting = { _, _ in + throw ManusCookieImportError.noCookies + } + ManusCookieImporter.importSessionOverrideForTesting = nil + defer { + ManusCookieImporter.importSessionsOverrideForTesting = nil + ManusCookieImporter.importSessionOverrideForTesting = nil + } + #endif + + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + #expect(token == "env-token") + return self.stubResponse() + } + + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(CookieHeaderCache.load(provider: .manus) == nil) + } + } + + #if os(macOS) + @Test + func `invalid browser token falls back to environment token`() async throws { + try await self.withIsolatedCacheStore { + let browserCookie = try #require(HTTPCookie(properties: [ + .domain: "manus.im", + .path: "/", + .name: "session_id", + .value: "browser-token", + .secure: "TRUE", + ])) + ManusCookieImporter.importSessionOverrideForTesting = { _, _ in + ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome") + } + ManusCookieImporter.importSessionsOverrideForTesting = nil + defer { + ManusCookieImporter.importSessionOverrideForTesting = nil + ManusCookieImporter.importSessionsOverrideForTesting = nil + } + + let attempts = LockedArray() + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["MANUS_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + attempts.append(token) + if token == "browser-token" { + throw ManusAPIError.invalidToken + } + #expect(token == "env-token") + return self.stubResponse() + } + + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(attempts.snapshot() == ["browser-token", "env-token"]) + #expect(CookieHeaderCache.load(provider: .manus) == nil) + } + } + + @Test + func `browser token populates cache after successful fetch`() async throws { + try await self.withIsolatedCacheStore { + let browserCookie = try #require(HTTPCookie(properties: [ + .domain: "manus.im", + .path: "/", + .name: "session_id", + .value: "browser-token", + .secure: "TRUE", + ])) + ManusCookieImporter.importSessionOverrideForTesting = { _, _ in + ManusCookieImporter.SessionInfo(cookies: [browserCookie], sourceLabel: "Chrome") + } + ManusCookieImporter.importSessionsOverrideForTesting = nil + defer { + ManusCookieImporter.importSessionOverrideForTesting = nil + ManusCookieImporter.importSessionsOverrideForTesting = nil + } + + let strategy = ManusWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + manus: ProviderSettingsSnapshot.ManusProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, Date) async throws -> ManusCreditsResponse = { token, _ in + #expect(token == "browser-token") + return self.stubResponse() + } + + _ = try await ManusUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + let cached = CookieHeaderCache.load(provider: .manus) + #expect(cached?.cookieHeader == "session_id=browser-token") + } + } + #endif + + @Test + func `settings reader accepts full cookie header from environment`() { + let env = ["MANUS_COOKIE": "foo=bar; session_id=env-cookie-token; baz=qux"] + #expect(ManusSettingsReader.sessionToken(environment: env) == "env-cookie-token") + } + + @Test + func `parse response tolerates sparse live payload`() throws { + let data = Data(""" + { + "totalCredits": 2869, + "freeCredits": 1500, + "periodicCredits": 1369, + "proMonthlyCredits": 4000, + "maxRefreshCredits": 300, + "nextRefreshTime": "2026-04-13T00:00:00Z", + "refreshInterval": "daily", + "userFlag": { "drc16": true } + } + """.utf8) + + let response = try ManusUsageFetcher.parseResponse(data) + #expect(response.totalCredits == 2869) + #expect(response.periodicCredits == 1369) + #expect(response.proMonthlyCredits == 4000) + #expect(response.refreshCredits == 0) + #expect(response.addonCredits == 0) + #expect(response.maxRefreshCredits == 300) + #expect(response.nextRefreshTime != nil) + + let snapshot = response.toUsageSnapshot(now: Self.now) + #expect(snapshot.providerCost == nil) + #expect(snapshot.primary?.usedPercent ?? 0 > 65) + #expect(snapshot.primary?.resetDescription == "Total 2,869 • Free 1,500") + #expect(snapshot.secondary?.usedPercent == 100) + #expect(snapshot.secondary?.resetDescription == "Daily: 0 / 300") + } + + @Test + func `parse response rejects payload without credits fields`() { + let data = Data(#"{"error":"unauthorized","message":"session expired"}"#.utf8) + + #expect(throws: ManusAPIError.self) { + try ManusUsageFetcher.parseResponse(data) + } + } + + @Test + func `parse response accepts wrapped envelope`() throws { + let data = Data(""" + { + "data": { + "totalCredits": 100, + "proMonthlyCredits": 200, + "periodicCredits": 50, + "maxRefreshCredits": 10, + "refreshCredits": 5 + } + } + """.utf8) + + let response = try ManusUsageFetcher.parseResponse(data) + #expect(response.totalCredits == 100) + #expect(response.periodicCredits == 50) + } +} diff --git a/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift b/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift new file mode 100644 index 0000000000..1f028030ea --- /dev/null +++ b/Tests/CodexBarTests/MemoryPressureCacheTrimTests.swift @@ -0,0 +1,217 @@ +import AppKit +import CodexBarCore +import Dispatch +import Testing +@testable import CodexBar + +@MainActor +struct MemoryPressureCacheTrimTests { + @Test + func `memory pressure monitor invokes app cache trim and allocator relief handlers`() async { + var handlerCalls = 0 + let releaseProbe = MemoryPressureReleaseProbe() + let monitor = MemoryPressureMonitor( + trimAppCaches: { + handlerCalls += 1 + return MemoryPressureCacheTrimSummary(menuCardHeights: 1) + }, + releaseFreeMallocPages: { + releaseProbe.signal() + }) + + monitor.handleMemoryPressureForTesting(isWarning: true, isCritical: false) + + #expect(handlerCalls == 1) + let releaseCompleted = await Task.detached { + releaseProbe.wait(timeout: .now() + 2) + }.value + #expect(releaseCompleted) + } + + @Test + func `memory pressure event handler runs from utility queue and hops to main actor`() async { + let probe = MemoryPressureEventHandlerProbe() + let handler = MemoryPressureMonitor.makeEventHandler( + eventReader: { [.warning] }, + handle: { isWarning, isCritical in + probe.record( + isWarning: isWarning, + isCritical: isCritical, + handledOnMainThread: Thread.isMainThread) + }) + + DispatchQueue.global(qos: .utility).async { + probe.recordInvocationThread(isMainThread: Thread.isMainThread) + handler() + } + + let completed = await Task.detached { + probe.wait(timeout: .now() + 2) + }.value + #expect(completed) + + let snapshot = probe.snapshot() + #expect(snapshot.invokedOnMainThread == false) + #expect(snapshot.isWarning) + #expect(!snapshot.isCritical) + #expect(snapshot.handledOnMainThread) + } + + @Test + func `status controller trims rebuildable menu caches on memory pressure`() { + let controller = self.makeController() + defer { controller.releaseStatusItemsForTesting() } + + let key = StatusItemController.MenuCardHeightCacheKey( + id: "card", + scope: UsageProvider.codex.rawValue, + width: 30000, + textScale: StatusItemController.menuCardHeightTextScaleToken(), + fingerprint: "content:stable") + let menu = NSMenu() + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + + controller.menuCardHeightCache[key] = 42 + controller.measuredStandardMenuWidthCache["width"] = 300 + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + controller.menuCardViewRecyclePool["card"] = NSView() + + let summary = controller.trimRebuildableCachesForMemoryPressure() + + #expect(summary.menuCardHeights == 1) + #expect(summary.menuWidths == 1) + #expect(summary.mergedSwitcherSelections == 2) + #expect(summary.recycledMenuCardViews == 1) + #expect(controller.menuCardHeightCache.isEmpty) + #expect(controller.measuredStandardMenuWidthCache.isEmpty) + #expect(controller.mergedSwitcherContentCaches.isEmpty) + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `usage store trims OpenAI web debug cache without interrupting active refresh state`() { + let store = self.makeStore() + let taskToken = UUID() + + store.openAIWebDebugLines = ["line 1", "line 2", "line 3"] + store.openAIDashboardCookieImportDebugLog = "line 1\nline 2\nline 3" + store.isRefreshing = true + store.refreshingProviders = [.codex] + store.tokenRefreshInFlight = [.codex] + store.openAIDashboardRefreshTaskKey = "codex@example.com:manual" + store.openAIDashboardRefreshTaskToken = taskToken + + let summary = store.trimRebuildableCachesForMemoryPressure() + + #expect(summary.openAIWebDebugLines == 3) + #expect(store.openAIWebDebugLines.isEmpty) + #expect(store.openAIDashboardCookieImportDebugLog == nil) + #expect(store.isRefreshing) + #expect(store.refreshingProviders == [.codex]) + #expect(store.tokenRefreshInFlight == [.codex]) + #expect(store.openAIDashboardRefreshTaskKey == "codex@example.com:manual") + #expect(store.openAIDashboardRefreshTaskToken == taskToken) + } + + private func makeController() -> StatusItemController { + let settings = self.makeSettings() + let store = self.makeStore(settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + private func makeStore(settings: SettingsStore? = nil) -> UsageStore { + let resolvedSettings = settings ?? self.makeSettings() + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: resolvedSettings) + } + + private func makeSettings() -> SettingsStore { + let suite = "MemoryPressureCacheTrimTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.providerDetectionCompleted = true + return settings + } +} + +private final class MemoryPressureReleaseProbe: @unchecked Sendable { + private let semaphore = DispatchSemaphore(value: 0) + + func signal() { + self.semaphore.signal() + } + + func wait(timeout: DispatchTime) -> Bool { + self.semaphore.wait(timeout: timeout) == .success + } +} + +private final class MemoryPressureEventHandlerProbe: @unchecked Sendable { + struct Snapshot { + let invokedOnMainThread: Bool? + let isWarning: Bool + let isCritical: Bool + let handledOnMainThread: Bool + } + + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var invokedOnMainThread: Bool? + private var isWarning = false + private var isCritical = false + private var handledOnMainThread = false + + func recordInvocationThread(isMainThread: Bool) { + self.lock.withLock { + self.invokedOnMainThread = isMainThread + } + } + + func record(isWarning: Bool, isCritical: Bool, handledOnMainThread: Bool) { + self.lock.withLock { + self.isWarning = isWarning + self.isCritical = isCritical + self.handledOnMainThread = handledOnMainThread + } + self.semaphore.signal() + } + + func wait(timeout: DispatchTime) -> Bool { + self.semaphore.wait(timeout: timeout) == .success + } + + func snapshot() -> Snapshot { + self.lock.withLock { + Snapshot( + invokedOnMainThread: self.invokedOnMainThread, + isWarning: self.isWarning, + isCritical: self.isCritical, + handledOnMainThread: self.handledOnMainThread) + } + } +} diff --git a/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift new file mode 100644 index 0000000000..e17b9508db --- /dev/null +++ b/Tests/CodexBarTests/MenuBarCountdownRefreshTests.swift @@ -0,0 +1,102 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct MenuBarCountdownRefreshTests { + @Test + func `countdown refresh delay follows the next displayed minute boundary`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + let delay = StatusItemController.menuBarCountdownRefreshDelay( + resetDates: [ + now.addingTimeInterval(2 * 3600 + 15 * 60 + 30), + now.addingTimeInterval(45), + ], + now: now) + + #expect(abs((delay ?? 0) - 30.05) < 0.001) + } + + @Test + func `countdown refresh ignores elapsed reset dates`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + + let delay = StatusItemController.menuBarCountdownRefreshDelay( + resetDates: [now.addingTimeInterval(-1)], + now: now) + + #expect(delay == nil) + } + + @Test + func `status item schedules countdown refresh only for countdown reset dates`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "MenuBarCountdownRefreshTests-scheduling"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .resetTime + settings.resetTimesShowAbsolute = false + if let metadata = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(90), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + settings.resetTimesShowAbsolute = true + controller.updateIcons() + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + + settings.resetTimesShowAbsolute = false + store._setSnapshotForTesting(nil, provider: .codex) + controller.updateIcons() + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(90), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + controller.updateIcons() + #expect(controller._test_isMenuBarCountdownRefreshScheduled()) + + controller.prepareForAppShutdown() + #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) + } +} diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift new file mode 100644 index 0000000000..26dfd63ccd --- /dev/null +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -0,0 +1,469 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuBarMetricWindowResolverTests { + @Test + func `automatic metric uses zai 5-hour token lane when it is most constrained`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 92, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .zai, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 92) + } + + @Test + func `automatic metric uses minimax weekly token lane when it is most constrained`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 97, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .minimax, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 97) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + + @Test + func `combined primary and secondary metric uses the most constrained lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 91, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .primaryAndSecondary, + provider: .codex, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 91) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + + @Test + func `automatic metric skips exhausted cursor subquota when total remains usable`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 67, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "Total"), + secondary: RateWindow( + usedPercent: 34, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Auto"), + tertiary: RateWindow(usedPercent: 100, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "API"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.remainingPercent == 33) + #expect(window?.resetDescription == "Total") + } + + @Test + func `automatic metric still reports cursor exhausted when every subquota is exhausted`() { + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Total"), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Auto"), + tertiary: RateWindow(usedPercent: 100, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "API"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.remainingPercent == 0) + } + + @Test + func `automatic metric keeps exhausted cursor total when a subquota remains usable`() { + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Total"), + secondary: RateWindow( + usedPercent: 60, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Auto"), + tertiary: nil, + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.remainingPercent == 0) + #expect(window?.resetDescription == "Total") + } + + @Test + func `automatic metric reports cursor exhausted when all present subquotas are exhausted`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 67, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "Total"), + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Auto"), + tertiary: RateWindow(usedPercent: 100, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "API"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.remainingPercent == 0) + } + + @Test + func `automatic metric preserves exhausted minimax session lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 97, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .minimax, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 100) + #expect(window?.windowMinutes == 300) + } + + @Test + func `automatic metric uses team budget for team-bound LiteLLM keys`() { + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Personal"), + secondary: RateWindow( + usedPercent: 80, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "Team"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .litellm, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 80) + #expect(window?.resetDescription == "Team") + } + + @Test + func `automatic metric uses constrained antigravity family lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 100) + #expect(window?.resetDescription == "Gemini Pro") + } + + @Test + func `automatic metric skips exhausted antigravity five hour bucket when another remains usable`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 67, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + window: RateWindow(usedPercent: 71, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude and GPT models Five Hour Limit", + window: RateWindow(usedPercent: 100, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude and GPT models Weekly Limit", + window: RateWindow(usedPercent: 67, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.remainingPercent == 29) + #expect(window?.windowMinutes == 300) + } + + @Test + func `automatic metric uses recognized antigravity gemini pool when claude gpt is reset only`() throws { + let resetOnlyReset = Date(timeIntervalSince1970: 1000) + let exhaustedReset = Date(timeIntervalSince1970: 2000) + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet 4.6", + modelId: "claude-sonnet-4-6", + remainingFraction: nil, + resetTime: resetOnlyReset, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro", + modelId: "gemini-3-1-pro", + remainingFraction: 0, + resetTime: exhaustedReset, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + let snapshot = try antigravitySnapshot.toUsageSnapshot() + #expect(snapshot.primary?.usedPercent == 100) + #expect(snapshot.primary?.resetsAt == exhaustedReset) + #expect(snapshot.secondary == nil) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 100) + #expect(window?.resetsAt == exhaustedReset) + } + + @Test + func `automatic metric uses unclassified antigravity compact fallback`() throws { + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Experimental Model", + modelId: "MODEL_PLACEHOLDER_NEW", + remainingFraction: 0.36, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + let snapshot = try antigravitySnapshot.toUsageSnapshot() + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 64) + } + + @Test + func `explicit antigravity metric keeps requested family lane`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + let primary = MenuBarMetricWindowResolver.rateWindow( + preference: .primary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + let secondary = MenuBarMetricWindowResolver.rateWindow( + preference: .secondary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + let tertiary = MenuBarMetricWindowResolver.rateWindow( + preference: .tertiary, + provider: .antigravity, + snapshot: snapshot, + supportsAverage: false) + + #expect(primary?.resetDescription == "Claude") + #expect(secondary?.resetDescription == "Gemini Pro") + #expect(tertiary?.resetDescription == "Gemini Flash") + } + + @Test + func `monthly plan metric selects Mistral subscription window`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "mistral-monthly-plan", + title: "Monthly Plan", + window: RateWindow(usedPercent: 42, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .monthlyPlan, + provider: .mistral, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 42) + } + + @Test + func `extra usage metric maps provider cost into a menu bar window`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 37.5, + limit: 150, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .extraUsage, + provider: .cursor, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 25) + } + + @Test + func `automatic metric uses claude enterprise spend limit`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Spend limit", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .claude, + snapshot: snapshot, + supportsAverage: false) + + #expect(abs((window?.usedPercent ?? 0) - 6.703) < 0.0001) + } + + @Test + func `automatic metric uses claude web spend limit placeholder`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .claude, + snapshot: snapshot, + supportsAverage: false) + + #expect(abs((window?.usedPercent ?? 0) - 6.703) < 0.0001) + } + + @Test + func `automatic metric keeps claude quota window when extra usage is optional`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .claude, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.usedPercent == 42) + } + + @Test + func `automatic metric keeps claude zero quota window when reset exists`() { + let reset = Date(timeIntervalSince1970: 1000) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: reset, resetDescription: "later"), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .claude, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetsAt == reset) + } +} diff --git a/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift new file mode 100644 index 0000000000..231e3b2e6d --- /dev/null +++ b/Tests/CodexBarTests/MenuBarResetTimeDisplayTests.swift @@ -0,0 +1,136 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuBarResetTimeDisplayTests { + @Test + func `reset time mode formats the selected window reset`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let resetsAt = now.addingTimeInterval(2 * 3600) + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true, + resetTimeDisplayStyle: .absolute, + now: now) + + #expect(text == "↻ \(UsageFormatter.resetDescription(from: resetsAt, now: now))") + } + + @Test + func `reset time mode uses countdown preference`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let resetsAt = now.addingTimeInterval(2 * 3600 + 15 * 60) + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: resetsAt, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true, + resetTimeDisplayStyle: .countdown, + now: now) + + #expect(text == "↻ in 2h 15m") + } + + @Test + func `reset time mode falls back to used percent without reset metadata`() { + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true) + + #expect(text == "42%") + } + + @Test + func `reset time mode uses text reset metadata`() { + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "in 2h 15m") + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true) + + #expect(text == "↻ in 2h 15m") + } + + @Test(arguments: [ + "Resets in 2h", + "tomorrow, 3:00 PM", + "next week", + "expires in 4d", + ]) + func `reset time mode accepts reset timing phrases`(_ description: String) { + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: description) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true) + + #expect(text == "↻ \(description)") + } + + @Test(arguments: [ + "250/1000 requests", + "160 requests", + "5 hours window", + "$10.00 available", + ]) + func `reset time mode rejects non-reset provider summaries`(_ description: String) { + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: description) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: true) + + #expect(text == "42%") + } + + @Test + func `reset time mode falls back to remaining percent without reset metadata`() { + let window = RateWindow( + usedPercent: 42, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + + let text = MenuBarDisplayText.displayText( + mode: .resetTime, + percentWindow: window, + showUsed: false) + + #expect(text == "58%") + } +} diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift new file mode 100644 index 0000000000..d61069995a --- /dev/null +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -0,0 +1,490 @@ +import CoreGraphics +import Foundation +import Testing +@testable import CodexBar + +struct MenuBarVisibilityWatcherTests { + @Test + func `does not flag intentionally hidden status item`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 0) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `flags visible item without attached window`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `flags visible item without button`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: false, + hasWindow: false, + hasScreen: false, + buttonWidth: 0) + + #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `flags visible item with zero width`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 0) + + #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `allows visible item attached to a screen with width`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `window probe matches autosave name and reports display bounds`() { + let snapshots = MenuBarStatusItemWindowProbe.snapshots( + matching: ["codexbar-merged"], + windowInfo: [[ + kCGWindowName as String: "codexbar-merged", + kCGWindowOwnerName as String: "Control Center", + kCGWindowIsOnscreen as String: true, + kCGWindowBounds as String: [ + "X": 1680, + "Y": 0, + "Width": 70, + "Height": 24, + ], + ]], + displayBounds: [CGRect(x: 0, y: 0, width: 2056, height: 1329)]) + + #expect(snapshots.count == 1) + #expect(snapshots.first?.name == "codexbar-merged") + #expect(snapshots.first?.ownerName == "Control Center") + #expect(snapshots.first?.isOnscreen == true) + #expect(snapshots.first?.isWithinDisplayBounds == true) + } + + @Test + func `window probe detects offscreen status item by bounds`() { + let snapshots = MenuBarStatusItemWindowProbe.snapshots( + matching: ["codexbar-merged"], + windowInfo: [[ + kCGWindowName as String: "codexbar-merged", + kCGWindowOwnerName as String: "Control Center", + kCGWindowIsOnscreen as String: true, + kCGWindowBounds as String: [ + "X": 2023, + "Y": 0, + "Width": 71, + "Height": 24, + ], + ]], + displayBounds: [CGRect(x: 0, y: 0, width: 2056, height: 1329)]) + + #expect(snapshots.count == 1) + #expect(snapshots.first?.isOnscreen == true) + #expect(snapshots.first?.isWithinDisplayBounds == false) + } + + @Test + func `window probe identifies Tahoe Control Center blocked proxy geometry`() { + let snapshot = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 0, y: -22, width: 76, height: 22), + isOnscreen: true, + displayBounds: nil) + + #expect(snapshot.isTahoeBlockedProxy) + } + + @Test + func `window probe does not classify generic offscreen manager placement as Tahoe proxy`() { + let snapshot = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 2023, y: 0, width: 71, height: 24), + isOnscreen: true, + displayBounds: nil) + + #expect(!snapshot.isTahoeBlockedProxy) + } + + @Test + func `window probe does not classify stale hidden Control Center record as Tahoe proxy`() { + let snapshot = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 0, y: -22, width: 76, height: 22), + isOnscreen: false, + displayBounds: nil) + + #expect(!snapshot.isTahoeBlockedProxy) + } + + @Test + func `allows visible item attached to a detached screen`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `classifies detached live item as displaced but not blocked`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + #expect(MenuBarVisibilityWatcher.isDisplacedSnapshot(snapshot: snapshot)) + } + + @Test + func `classifies stale screen live item as displaced but not blocked`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + #expect(MenuBarVisibilityWatcher.isDisplacedSnapshot(snapshot: snapshot)) + } + + @Test + func `guidance shows once then repeats after a day`() throws { + let defaults = try #require(UserDefaults(suiteName: "MenuBarVisibilityWatcherTests")) + defaults.removePersistentDomain(forName: "MenuBarVisibilityWatcherTests") + let now = Date(timeIntervalSince1970: 1000) + + #expect(MenuBarVisibilityWatcher.shouldShowGuidance(defaults: defaults, now: now)) + + MenuBarVisibilityWatcher.markGuidanceShown(defaults: defaults, now: now) + + #expect(!MenuBarVisibilityWatcher.shouldShowGuidance( + defaults: defaults, + now: now.addingTimeInterval(MenuBarVisibilityWatcher.guidanceRepeatInterval - 1))) + #expect(MenuBarVisibilityWatcher.shouldShowGuidance( + defaults: defaults, + now: now.addingTimeInterval(MenuBarVisibilityWatcher.guidanceRepeatInterval))) + } + + @Test + func `startup recovery triggers for blocked visible snapshot`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [blocked])) + } + + @Test + func `startup recovery retries detached Tahoe proxy corroborated by Control Center geometry`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let detachedProxy = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 76) + let blockedWindow = MenuBarStatusItemWindowSnapshot( + name: "codexbar-merged", + ownerName: "Control Center", + bounds: CGRect(x: 0, y: -22, width: 76, height: 22), + isOnscreen: true, + displayBounds: nil) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: detachedProxy)) + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [detachedProxy], + windowSnapshots: [blockedWindow], + detectTahoeBlockedProxy: true)) + } + + @Test + func `startup recovery ignores detached live item without Tahoe proxy corroboration`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let managed = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [managed], + detectTahoeBlockedProxy: true)) + } + + @Test + func `startup recovery ignores live item attached to a stale screen`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let managed = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [managed])) + } + + @Test + func `startup recovery triggers when one split status item is blocked`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let healthy = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [healthy, blocked])) + } + + @Test + func `startup recovery ignores stale checks`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(MenuBarVisibilityWatcher.startupFreshnessInterval + 1), + snapshots: [blocked])) + } + + @Test + func `startup recovery ignores healthy visible snapshot`() { + let launchedAt = Date(timeIntervalSince1970: 1000) + let healthy = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldAttemptStartupRecovery( + appLaunchedAt: launchedAt, + now: launchedAt.addingTimeInterval(2), + snapshots: [healthy])) + } + + @Test + func `screen change placement refresh ignores display removal with healthy status item`() { + let healthy = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, + currentScreenCount: 1, + snapshots: [healthy])) + } + + @Test + func `screen change placement refresh ignores display removal when no status item is visible`() { + let hidden = StatusItemVisibilitySnapshot( + isVisible: false, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, + currentScreenCount: 1, + snapshots: [hidden])) + } + + @Test + func `screen change recovery triggers for blocked status item without display count change`() { + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery(snapshots: [blocked])) + } + + @Test + func `screen change placement refresh triggers for detached live item after display removal`() { + let displaced = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, + currentScreenCount: 1, + snapshots: [displaced])) + } + + @Test + func `screen change placement refresh triggers for stale screen live item after display removal`() { + let displaced = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, + currentScreenCount: 1, + snapshots: [displaced])) + } + + @Test + func `screen change placement refresh ignores healthy item when display count does not shrink`() { + let healthy = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 1, + currentScreenCount: 2, + snapshots: [healthy])) + } + + @Test + func `screen change placement refresh triggers for displaced live item when display count is unchanged`() { + let displaced = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, + currentScreenCount: 2, + snapshots: [displaced])) + } + + @Test + func `manager parked item with live window is not blocked`() { + // A menu bar manager parks items off the active screen with the window intact. + // hasAnyBlockedVisibleSnapshot must return false so verifyScreenChangeRecoveryIfNeeded + // does not trigger repeated recreation that corrupts Control Center. + let managed = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot([managed])) + #expect(MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([managed])) + } + + @Test + func `manager parked item with live window on stale screen is not blocked`() { + let managed = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot([managed])) + #expect(MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([managed])) + } + + @Test + func `item without window is blocked regardless of screen state`() { + // A missing window cannot be caused by a manager parking the item; it signals + // a genuine system block and must trigger recovery. + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot([blocked])) + #expect(!MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([blocked])) + } +} diff --git a/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift new file mode 100644 index 0000000000..cd48764d91 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift @@ -0,0 +1,411 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardAntigravityTests { + @Test + func `antigravity identity only snapshot shows limits unavailable`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Paid")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + #expect(model.email == "user@example.com") + #expect(model.planText == "Paid") + } + + @Test + func `antigravity metrics omit missing groups`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 5, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.count == 1) + #expect(model.metrics.map(\.title) == ["Gemini Models"]) + #expect(model.metrics[0].percent == 95) + #expect(model.metrics[0].percentLabel == "95% left") + } + + @Test + func `antigravity untracked known row does not duplicate grouped summary`() throws { + let now = Date(timeIntervalSince1970: 1_735_000_000) + let resetTime = now.addingTimeInterval(3600) + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Thinking", + modelId: "MODEL_PLACEHOLDER_M35", + remainingFraction: 0.4, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M36", + remainingFraction: nil, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "MODEL_PLACEHOLDER_M47", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: "Pro") + let snapshot = try antigravitySnapshot.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Gemini Models", "Claude and GPT"]) + #expect(!model.metrics.contains { $0.title == "Gemini 3.1 Pro (Low)" }) + } + + @Test + func `antigravity metrics collapse complete per model quota windows`() throws { + let now = Date(timeIntervalSince1970: 1_735_000_000) + let resetTime = now.addingTimeInterval(3600) + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "GPT-OSS 120B (Medium)", + modelId: "MODEL_PLACEHOLDER_M55", + remainingFraction: 0.25, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M53", + remainingFraction: 0.5, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Opus 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M50", + remainingFraction: 0.75, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M52", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: "Pro", + source: .local) + let snapshot = try antigravitySnapshot.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == [ + "Gemini Models", + "Claude and GPT", + ]) + #expect(model.metrics.map(\.percentLabel) == [ + "50% left", + "25% left", + ]) + } + + @Test + func `antigravity distinct extra windows still render when optional extras are disabled`() throws { + // Regression: the optional-credits/extra-usage setting is Codex-specific and must NOT hide + // other providers' core extra windows. + let now = Date(timeIntervalSince1970: 1_735_000_000) + let resetTime = now.addingTimeInterval(3600) + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Experimental Tool", + modelId: "MODEL_PLACEHOLDER_UNKNOWN", + remainingFraction: 0.5, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M52", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: "Pro") + let snapshot = try antigravitySnapshot.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + // Distinct extra windows remain visible even with optional extras disabled. + #expect(model.metrics.contains { $0.title == "Experimental Tool" }) + #expect(model.metrics.contains { $0.title == "Gemini Models" }) + } + + @Test + func `antigravity quota summary renders named session and weekly rows`() throws { + let now = Date(timeIntervalSince1970: 1_735_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 27, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "You have used some of your 5-hour limit, it will fully refresh in 3 hours."), + secondary: RateWindow( + usedPercent: 18, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "You have used some of your weekly limit, it will fully refresh in 5 days."), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + window: RateWindow( + usedPercent: 9, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "You have used some of your 5-hour limit, it will fully refresh in " + + "4 hours.")), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow( + usedPercent: 18, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "You have used some of your weekly limit, it will fully refresh in 5 days.")), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude and GPT models Five Hour Limit", + window: RateWindow( + usedPercent: 27, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "You have used some of your 5-hour limit, it will fully refresh in " + + "3 hours.")), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude and GPT models Weekly Limit", + window: RateWindow( + usedPercent: 36, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "You have used some of your weekly limit, it will fully refresh in 6 days.")), + ], + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.id) == [ + "antigravity-quota-summary-gemini-5h", + "antigravity-quota-summary-gemini-weekly", + "antigravity-quota-summary-3p-5h", + "antigravity-quota-summary-3p-weekly", + ]) + #expect(model.metrics.map(\.title) == [ + "Gemini Models Five Hour Limit", + "Gemini Models Weekly Limit", + "Claude and GPT models Five Hour Limit", + "Claude and GPT models Weekly Limit", + ]) + #expect(model.metrics.map(\.percentLabel) == [ + "91% left", + "82% left", + "73% left", + "64% left", + ]) + #expect(model.metrics[2].resetText == "Resets in 3 hours") + } + + @Test + func `antigravity missing groups are omitted in used mode`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 5, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.count == 1) + #expect(model.metrics[0].title == "Gemini Models") + #expect(model.metrics[0].percent == 5) + #expect(model.metrics[0].percentLabel == "5% used") + } +} diff --git a/Tests/CodexBarTests/MenuCardCostHintTests.swift b/Tests/CodexBarTests/MenuCardCostHintTests.swift new file mode 100644 index 0000000000..086e9c6336 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardCostHintTests.swift @@ -0,0 +1,96 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardCostHintTests { + @Test + func `claude cost hint explains cache tokens and status line drift`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 1.23, + last30DaysTokens: 456, + last30DaysCostUSD: 78.9, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-05-14", + inputTokens: 1, + outputTokens: 2, + cacheReadTokens: 300, + cacheCreationTokens: 400, + totalTokens: 703, + costUSD: 1.23, + modelsUsed: ["claude-sonnet-4-6"], + modelBreakdowns: nil), + ], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.tokenUsage?.hintLine?.contains("cache read/write tokens") == true) + #expect(model.tokenUsage?.hintLine?.contains("Claude Code /status") == true) + } + + @Test + func `one day history label stays today`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 120, + sessionCostUSD: 1.2, + last30DaysTokens: 120, + last30DaysCostUSD: 1.2, + historyDays: 1, + daily: [ + .init( + date: "2026-05-14", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: 1.2, + modelsUsed: ["claude-sonnet-4-6"], + modelBreakdowns: nil), + ], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.tokenUsage?.monthLine.hasPrefix("Today: ") == true) + } +} diff --git a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift new file mode 100644 index 0000000000..3a17f9b107 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift @@ -0,0 +1,146 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardDeepSeekTests { + private static func sampleDeepSeekSummary(now: Date = Date()) -> DeepSeekUsageSummary { + DeepSeekUsageSummary( + todayTokens: 123, + currentMonthTokens: 456, + todayCost: 0.0123, + currentMonthCost: 0.0456, + requestCount: 7, + currentMonthRequestCount: 8, + topModel: "deepseek-chat", + categoryBreakdown: [ + DeepSeekCategoryBreakdown(category: .promptCacheHitToken, tokens: 10, cost: 0.001), + DeepSeekCategoryBreakdown(category: .promptCacheMissToken, tokens: 20, cost: 0.002), + DeepSeekCategoryBreakdown(category: .responseToken, tokens: 30, cost: 0.003), + ], + daily: [ + DeepSeekDailyUsage(date: "2026-05-26", totalTokens: 456, cost: 0.0456, requestCount: 8), + ], + currency: "CNY", + updatedAt: now) + } + + private static func makeSnapshot(now: Date, usageSummary: DeepSeekUsageSummary? = nil) -> UsageSnapshot { + DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 9.32, + grantedBalance: 0, + toppedUpBalance: 9.32, + usageSummary: usageSummary, + updatedAt: now) + .toUsageSnapshot() + } + + @Test + func `model shows balance as status text instead of percentage detail`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .deepseek, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$9.32 (Paid: $9.32 / Granted: $0.00)"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.title == "Balance") + #expect(primary.statusText == "$9.32 (Paid: $9.32 / Granted: $0.00)") + #expect(primary.detailText == nil) + #expect(primary.resetText == nil) + } + + @Test + func `model hides optional deepseek usage when extras disabled`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes.isEmpty) + } + + @Test + func `model shows optional deepseek usage when extras enabled`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.accessibilityLabel == "DeepSeek 30 day token usage trend") + #expect(model.usageNotes.contains { $0.contains("Today:") }) + } +} diff --git a/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift new file mode 100644 index 0000000000..e81a293315 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardHeightFingerprintTests.swift @@ -0,0 +1,73 @@ +import SwiftUI +import Testing +@testable import CodexBar + +struct MenuCardHeightFingerprintTests { + @Test + func `height fingerprint does not retain raw text fields`() { + let model = Self.model() + + let fingerprint = model.heightFingerprint(section: "card") + + #expect(!fingerprint.contains("very-secret@example.com")) + #expect(!fingerprint.contains("Secret Provider Name")) + #expect(!fingerprint.contains("Secret Metric")) + #expect(!fingerprint.contains("Secret note")) + } + + @Test + func `height fingerprint field distinguishes nil from empty string`() { + let nilField = UsageMenuCardView.Model.heightFingerprintField("storage", nil) + let emptyField = UsageMenuCardView.Model.heightFingerprintField("storage", "") + + #expect(nilField != emptyField) + } + + @Test + func `height fingerprint keeps cheap metric percent identity`() { + let left = Self.model(percent: 42, percentStyle: .left).heightFingerprint(section: "card") + let used = Self.model(percent: 42, percentStyle: .used).heightFingerprint(section: "card") + let changedPercent = Self.model(percent: 43, percentStyle: .left).heightFingerprint(section: "card") + + #expect(left != used) + #expect(left != changedPercent) + } + + private static func model( + percent: Double = 42, + percentStyle: UsageMenuCardView.Model.PercentStyle = .left) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Secret Provider Name", + email: "very-secret@example.com", + subtitleText: "Signed in as very-secret@example.com", + subtitleStyle: .info, + planText: "Secret Plan", + metrics: [ + .init( + id: "primary", + title: "Secret Metric", + percent: percent, + percentStyle: percentStyle, + statusText: "Secret status", + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true), + ], + usageNotes: ["Secret note"], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: nil, + progressColor: .blue) + } +} diff --git a/Tests/CodexBarTests/MenuCardKiloPassTests.swift b/Tests/CodexBarTests/MenuCardKiloPassTests.swift index a888ca4c8b..f15cfdcba9 100644 --- a/Tests/CodexBarTests/MenuCardKiloPassTests.swift +++ b/Tests/CodexBarTests/MenuCardKiloPassTests.swift @@ -3,10 +3,9 @@ import Foundation import Testing @testable import CodexBar -@Suite struct MenuCardKiloPassTests { @Test - func kiloModelShowsPassBeforeCreditsAndKeepsResetWithDetail() throws { + func `kilo model shows pass before credits and keeps reset with detail`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.kilo]) let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift new file mode 100644 index 0000000000..a1c0817e2e --- /dev/null +++ b/Tests/CodexBarTests/MenuCardModelCodexDegradedQuotaTests.swift @@ -0,0 +1,245 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardModelCodexDegradedQuotaTests { + @Test + func `codex local token usage keeps remote quota unavailable error visible`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [ + .init( + date: "2026-06-05", + inputTokens: 710_217, + outputTokens: 11749, + totalTokens: 721_966, + costUSD: 1.081155, + modelsUsed: ["gpt-5.5"], + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: "Codex usage is temporarily unavailable. Try refreshing.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == "Codex usage is temporarily unavailable. Try refreshing.") + #expect(model.usesStackedDetailLayout) + #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) + #expect(model.tokenUsage?.sessionLine.contains("tokens") == true) + #expect(model.tokenUsage?.monthLine.contains("$583.13") == true) + #expect(model.tokenUsage?.monthLine.contains("tokens") == true) + } + + @Test + func `codex remote quota unavailable error stays visible when token usage is hidden`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + let error = "Codex usage is temporarily unavailable. Try refreshing." + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: error, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == error) + #expect(model.tokenUsage == nil) + } + + @Test + func `codex local token usage preserves limits unavailable placeholder`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + #expect(model.tokenUsage != nil) + #expect(model.usesStackedDetailLayout) + } + + @Test + func `codex local token usage preserves sign-in guidance`() throws { + let model = try self.makeModel( + tokenCostUsageEnabled: true, + lastError: "Codex CLI is not signed in. Run `codex login --device-auth`, then refresh.") + + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText.contains("codex login")) + #expect(model.tokenUsage != nil) + #expect(model.usesStackedDetailLayout) + } + + @Test + func `codex local token usage preserves mapped transport error`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + let error = try #require(CodexUIErrorMapper.userFacingMessage("Codex connection failed: timed out.")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: error, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == "Codex usage is temporarily unavailable. Try refreshing.") + #expect(model.tokenUsage?.sessionLine.contains("$1.08") == true) + } + + @Test + func `credits select stacked detail layout without quota metrics`() { + let model = UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "user@example.com", + subtitleText: "Not fetched yet", + subtitleStyle: .info, + planText: nil, + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: "$12.34 remaining", + creditsRemaining: 12.34, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: "No usage yet", + progressColor: .blue) + + #expect(model.usesStackedDetailLayout) + } + + private func makeModel( + tokenCostUsageEnabled: Bool, + lastError: String?) throws -> UsageMenuCardView.Model + { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 721_966, + sessionCostUSD: 1.081155, + last30DaysTokens: 824_405_060, + last30DaysCostUSD: 583.1287345, + daily: [], + updatedAt: now) + + return UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: lastError, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: tokenCostUsageEnabled, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift new file mode 100644 index 0000000000..506f4b23e2 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift @@ -0,0 +1,838 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct MenuCardModelCodexProjectionTests { + @Test + func `codex weekly lane derives pace from its visible window`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(weekly.detailLeftText == "10% in reserve") + #expect(weekly.detailRightText == "Lasts until reset") + } + + @Test + func `codex weekly lane includes workday markers when workDaysPerWeek is set`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [], .weekly: []], + workDaysPerWeek: 5, + now: now)) + + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(weekly.warningMarkerPercents == [20.0, 40.0, 60.0, 80.0]) + + let session = try #require(model.metrics.first { $0.id == "primary" }) + #expect(session.warningMarkerPercents.isEmpty) + } + + @Test + func `codex weekly lane workday markers merge with quota warning markers`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [], .weekly: [50]], + workDaysPerWeek: 5, + now: now)) + + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(weekly.warningMarkerPercents == [20.0, 40.0, 50.0, 60.0, 80.0]) + } + + @Test + func `codex weekly lane workday markers not inverted by usageBarsShowUsed`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [], .weekly: []], + workDaysPerWeek: 5, + now: now)) + + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(weekly.warningMarkerPercents == [20.0, 40.0, 60.0, 80.0]) + } + + @Test + func `codex plan only snapshot shows limits unavailable placeholder`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.metrics.isEmpty) + #expect(model.subtitleStyle == .info) + #expect(!model.subtitleText.contains("Found sessions")) + #expect(model.planText == "Pro 20x") + } + + @Test + func `codex plan only snapshot keeps actionable refresh errors visible`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: "Codex connection failed: timed out.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == "Codex connection failed: timed out.") + #expect(model.planText == "Pro 20x") + } + + @Test + func `codex account fallback shows limits unavailable instead of no limits error`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "pro"), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == "Limits not available") + #expect(model.subtitleStyle == .info) + #expect(!model.subtitleText.contains("Found sessions")) + #expect(model.email == "user@example.com") + #expect(model.planText == "Pro 20x") + } + + @Test + func `codex no account fallback keeps no limits error visible`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.placeholder == nil) + #expect(model.subtitleStyle == .error) + #expect(model.subtitleText == UsageError.noRateLimitsFound.errorDescription) + } + + @Test + func `builds metrics using used percent when enabled`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3000), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6000), + resetDescription: nil), + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let dashboard = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: 73, + codeReviewLimit: RateWindow( + usedPercent: 27, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: now) + let codexProjection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: dashboard, + rawDashboardError: nil, + dashboardAttachmentAuthorized: true, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: codexProjection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.first?.title == "Session") + #expect(model.metrics.first?.percent == 22) + #expect(model.metrics.first?.percentLabel.contains("used") == true) + #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 27 }) + } + + @Test + func `shows code review metric when dashboard present`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let dashboard = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: 73, + codeReviewLimit: RateWindow( + usedPercent: 27, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: now) + let codexProjection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: dashboard, + rawDashboardError: nil, + dashboardAttachmentAuthorized: true, + dashboardRequiresLogin: false, + now: now)) + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: codexProjection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 73 }) + let codeReviewMetric = model.metrics.first { $0.id == "code-review" } + #expect(codeReviewMetric?.resetText?.contains("Resets") == true) + } + + @Test + func `uses semantic codex lanes when weekly duration drifts`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 25, + windowMinutes: 11040, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let codexProjection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: codexProjection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.count == 1) + #expect(model.metrics.first?.id == "secondary") + #expect(model.metrics.first?.title == "Weekly") + #expect(model.metrics.first?.percent == 75) + } + + @Test + func `renders codex spark as a named extra metric after the core lanes`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "codex-spark", + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil)), + NamedRateWindow( + id: "codex-spark-weekly", + title: "Codex Spark Weekly", + window: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let spark = try #require(model.metrics.first { $0.id == "codex-spark" }) + #expect(spark.title == "Codex Spark 5-hour") + #expect(spark.percent == 20) + #expect(spark.percentLabel == "20% left") + #expect(spark.resetText != nil) + #expect(spark.detailLeftText == "20% in deficit") + #expect(spark.detailRightText == "Projected empty in 45m") + let sparkWeekly = try #require(model.metrics.first { $0.id == "codex-spark-weekly" }) + #expect(sparkWeekly.title == "Codex Spark Weekly") + #expect(sparkWeekly.percent == 0) + #expect(sparkWeekly.percentLabel == "0% left") + #expect(sparkWeekly.resetText != nil) + #expect(sparkWeekly.detailLeftText == "86% in deficit") + #expect(sparkWeekly.detailRightText == "Runs out now") + // Spark trails the core session/weekly lanes rather than replacing them. + let sparkIndex = try #require(model.metrics.firstIndex { $0.id == "codex-spark" }) + let sparkWeeklyIndex = try #require(model.metrics.firstIndex { $0.id == "codex-spark-weekly" }) + let sessionIndex = try #require(model.metrics.firstIndex { $0.id == "primary" }) + #expect(sparkIndex > sessionIndex) + #expect(sparkWeeklyIndex > sparkIndex) + } + + @Test + func `hides codex credits when disabled`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let codexProjection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: codexProjection, + credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.creditsText == nil) + } + + @Test + func `hides codex spark extra metric when showOptionalCreditsAndExtraUsage is false`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "codex-spark", + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil)), + NamedRateWindow( + id: "codex-spark-weekly", + title: "Codex Spark Weekly", + window: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(!model.metrics.contains { $0.id == "codex-spark" }) + #expect(!model.metrics.contains { $0.id == "codex-spark-weekly" }) + #expect(model.metrics.contains { $0.id == "primary" }) + #expect(model.metrics.contains { $0.id == "secondary" }) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 48e2a78030..18dfc1443a 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -4,72 +4,783 @@ import SwiftUI import Testing @testable import CodexBar -@Suite -struct MenuCardModelTests { +struct OverviewMenuCardVisibilityTests { + @Test + func `overview hides cards that only contain an error`() throws { + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: "No Cursor session found.", + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date())) + + #expect(model.isOverviewErrorOnly) + } + + @Test + func `overview keeps cards with graceful unavailable placeholders`() throws { + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "pro"), + isRefreshing: false, + lastError: UsageError.noRateLimitsFound.errorDescription, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date())) + + #expect(model.placeholder == "Limits not available") + #expect(!model.isOverviewErrorOnly) + } +} + +struct ProviderInlineDashboardModelTests { + @Test + func `claude admin api usage gets inline dashboard`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 1.25, + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950, + costItems: [ + ClaudeAdminAPIUsageSnapshot.CostBreakdown(name: "Claude Sonnet Usage", costUSD: 1.25), + ], + models: [ + ClaudeAdminAPIUsageSnapshot.ModelBreakdown( + name: "claude-sonnet-4-20250514", + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950), + ]), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$1.25") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: $1.25") + #expect(model.inlineUsageDashboard?.detailLines + .contains { $0.hasPrefix("30d:") && $0.contains("tokens") } == true) + #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: claude-sonnet-4-20250514") == true) + #expect(model.planText == "Admin API") + } + + @Test + func `openrouter period usage gets inline dashboard`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let usage = OpenRouterUsageSnapshot( + totalCredits: 100, + totalUsage: 40, + balance: 60, + usedPercent: 40, + keyDataFetched: true, + keyLimit: 25, + keyUsage: 10, + keyUsageDaily: 1.25, + keyUsageWeekly: 7.5, + keyUsageMonthly: 18.75, + rateLimit: OpenRouterRateLimit(requests: 100, interval: "10s"), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$60.00") + #expect(model.inlineUsageDashboard?.points.map(\.label) == ["Today", "Week", "Month"]) + #expect(model.inlineUsageDashboard?.detailLines.contains("Rate limit: 100 / 10s") == true) + } + + @Test + func `local cost history gets inline dashboard`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let daily = [ + CostUsageDailyReport.Entry( + date: "2023-11-14", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 0.12, + modelsUsed: ["claude-sonnet-4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "claude-sonnet-4", + costUSD: 0.12, + totalTokens: 150), + ]), + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: ["claude-opus-4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "claude-opus-4", + costUSD: 0.25, + totalTokens: 275), + ]), + ] + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + daily: daily, + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.25") + #expect(model.inlineUsageDashboard?.points.count == 2) + #expect(model.inlineUsageDashboard?.detailLines.contains { $0.contains("claude-opus-4") } == true) + #expect(model.tokenUsage?.sessionLine.contains("$0.25") == true) + #expect(model.tokenUsage?.monthLine.contains("$0.37") == true) + } + + @Test + func `mistral daily buckets get inline dashboard`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + let snapshot = MistralUsageSnapshot( + totalCost: 1.5, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 50, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 50, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: 1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 50), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis.first?.value == "€1.50") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: €1.50") + #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: mistral-large") == true) + } + + @Test + func `mistral billing usage can show cost card summary`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + let snapshot = MistralUsageSnapshot( + totalCost: 1.5, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 50, + totalCachedTokens: 25, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 1.5, + inputTokens: 100, + cachedTokens: 25, + outputTokens: 50, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: 1.5, + inputTokens: 100, + cachedTokens: 25, + outputTokens: 50), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot.toCostUsageTokenSnapshot(historyDays: 30), + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(ProviderDescriptorRegistry.descriptor(for: .mistral).tokenCost.supportsTokenCost) + #expect(model.tokenUsage?.sessionLine == "Latest billing day (Nov 14): €1.50 · 175 tokens") + #expect(model.tokenUsage?.monthLine == "This month: €1.50 · 175 tokens") + #expect(model.tokenUsage?.hintLine == "Reported by Mistral billing usage.") + } + + @Test + func `zai hourly usage gets inline dashboard`() throws { + let now = try #require(Self.zaiDate("2023-11-15 12:00")) + let metadata = try #require(ProviderDefaults.metadata[.zai]) + let usage = ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: nil, + planName: "Pro", + modelUsage: ZaiModelUsageData( + xTime: ["2023-11-14 12:00", "2023-11-15 12:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [100, 200]), + ]), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .zai, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis.first?.value == "300") + #expect(model.inlineUsageDashboard?.points.map(\.label) == ["12", "12"]) + #expect(Set(model.inlineUsageDashboard?.points.map(\.id) ?? []).count == 2) + #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: glm-4.5") == true) + } + + private static func zaiDate(_ text: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.date(from: text) + } +} + +struct FactoryMenuCardModelTests { + @Test + func `factory token rate billing uses time window labels`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.factory]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .factory, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["5-hour", "Weekly", "Monthly"]) + } + + @Test + func `factory legacy billing keeps pool labels`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.factory]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .factory, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Standard", "Premium"]) + } + + @Test + func `factory extra usage balance renders as optional balance`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 25, + limit: 0, + currencyCode: "USD", + period: "Extra usage balance", + updatedAt: now), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.factory]) + + let visible = UsageMenuCardView.Model.make(.init( + provider: .factory, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + #expect(visible.providerCost?.title == "Extra usage") + #expect(visible.providerCost?.spendLine == "Balance: $25.00") + #expect(visible.providerCost?.percentUsed == nil) + #expect(visible.providerCost?.percentLine == nil) + + let hidden = UsageMenuCardView.Model.make(.init( + provider: .factory, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + #expect(hidden.providerCost == nil) + } +} + +struct MiniMaxMenuCardModelTests { + @Test + func `minimax service metrics use codex aligned quota copy`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 2, + limit: 10, + percent: 20, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let used = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(used.metrics.first?.title == "Text Generation") + #expect(used.metrics.first?.detailLeftText == "Usage: 2 / 10") + #expect(used.metrics.first?.detailRightText == nil) + #expect(used.metrics.first?.detailText == nil) + #expect(used.metrics.first?.percent == 20) + #expect(used.metrics.first?.cardStyle == false) + } + @Test - func buildsMetricsUsingRemainingPercent() throws { + func `text generation badge uses real window type when multiple windows exist`() throws { let now = Date() - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: "codex@example.com", - accountOrganization: nil, - loginMethod: "Plus Plan") - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 22, - windowMinutes: 300, - resetsAt: now.addingTimeInterval(3000), - resetDescription: nil), - secondary: RateWindow( - usedPercent: 40, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(6000), - resetDescription: nil), + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, updatedAt: now, - identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.codex]) - let updatedSnap = try UsageSnapshot( - primary: snapshot.primary, - secondary: RateWindow( - usedPercent: #require(snapshot.secondary?.usedPercent), - windowMinutes: #require(snapshot.secondary?.windowMinutes), - resetsAt: now.addingTimeInterval(3600), - resetDescription: nil), - tertiary: snapshot.tertiary, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Today", + timeRange: "2026/05/16 00:00 - 2026/05/17 00:00", + usage: 2, + limit: 10, + percent: 20, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "05/11 00:00 - 05/18 00:00(UTC+8)", + usage: 20, + limit: 100, + percent: 20, + resetsAt: now.addingTimeInterval(7200), + resetDescription: "Resets in 2 hours"), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, updatedAt: now, - identity: identity) + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) let model = UsageMenuCardView.Model.make(.init( - provider: .codex, + provider: .minimax, metadata: metadata, - snapshot: updatedSnap, - credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), + snapshot: snapshot, + credits: nil, creditsError: nil, dashboard: nil, dashboardError: nil, tokenSnapshot: nil, tokenError: nil, - account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), + account: AccountInfo(email: nil, plan: nil), isRefreshing: false, lastError: nil, - usageBarsShowUsed: false, + usageBarsShowUsed: true, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, now: now)) - #expect(model.providerName == "Codex") #expect(model.metrics.count == 2) - #expect(model.metrics.first?.percent == 78) + #expect(model.metrics[0].title == "Text Generation · Today") + #expect(model.metrics[1].title == "Text Generation · Weekly") + } + + @Test + func `minimax token plan model shows weekly quota and points balance`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Token Plan · TokenPlanPlus-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(4 * 3600), + resetDescription: "Resets in 4 hours"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ], + pointsBalance: 14000, + subscriptionRenewsAt: Date(timeIntervalSince1970: 1_810_569_600)) + let snapshot = minimax.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + #expect(model.planText == "Plus") - #expect(model.subtitleText.hasPrefix("Updated")) - #expect(model.progressColor != Color.clear) - #expect(model.metrics[1].resetText?.isEmpty == false) + #expect(model.metrics[0].title == "Text Generation · 5h") + #expect(model.metrics[1].title == "Text Generation · Weekly") + #expect(model.metrics[0].detailLeftText == "Usage: 4 / 100") + #expect(model.metrics[1].detailLeftText == "Usage: 1 / 100") + #expect(model.metrics[0].detailRightText == nil) + #expect(model.metrics[1].detailRightText == nil) + #expect(model.metrics[0].detailText == nil) + #expect(model.metrics[1].detailText == nil) + #expect(model.metrics[0].cardStyle == false) + #expect(model.metrics[1].cardStyle == false) + #expect(model.providerCost?.title == "Credits") + #expect(model.providerCost?.spendLine == "Balance: 14000") + #expect(model.usageNotes == [String(format: L("Renews: %@"), minimaxRenewDate(1_810_569_600))]) + } +} + +struct ClaudeMenuCardCostTests { + @Test + func `claude extra usage labels monthly denominator as cap`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + updatedAt: now), + updatedAt: now, + identity: nil) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.spendLine == "Monthly cap: $5.00 / $20.00") } +} +struct MenuCardModelTests { @Test - func buildsMetricsUsingUsedPercentWhenEnabled() throws { + func `builds metrics using remaining percent`() throws { let now = Date() let identity = ProviderIdentitySnapshot( providerID: .codex, @@ -90,77 +801,92 @@ struct MenuCardModelTests { updatedAt: now, identity: identity) let metadata = try #require(ProviderDefaults.metadata[.codex]) - - let dashboard = OpenAIDashboardSnapshot( - signedInEmail: "codex@example.com", - codeReviewRemainingPercent: 73, - creditEvents: [], - dailyBreakdown: [], - usageBreakdown: [], - creditsPurchaseURL: nil, - updatedAt: now) + let updatedSnap = try UsageSnapshot( + primary: snapshot.primary, + secondary: RateWindow( + usedPercent: #require(snapshot.secondary?.usedPercent), + windowMinutes: #require(snapshot.secondary?.windowMinutes), + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + tertiary: snapshot.tertiary, + updatedAt: now, + identity: identity) + let codexProjection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: updatedSnap, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) let model = UsageMenuCardView.Model.make(.init( provider: .codex, metadata: metadata, - snapshot: snapshot, - credits: nil, + snapshot: updatedSnap, + codexProjection: codexProjection, + credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), creditsError: nil, - dashboard: dashboard, + dashboard: nil, dashboardError: nil, tokenSnapshot: nil, tokenError: nil, account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), isRefreshing: false, lastError: nil, - usageBarsShowUsed: true, + usageBarsShowUsed: false, resetTimeDisplayStyle: .countdown, tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [25, 0]], now: now)) - #expect(model.metrics.first?.title == "Session") - #expect(model.metrics.first?.percent == 22) - #expect(model.metrics.first?.percentLabel.contains("used") == true) - #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 27 }) + #expect(model.providerName == "Codex") + #expect(model.metrics.count == 2) + #expect(model.metrics.first?.percent == 78) + #expect(model.metrics.first?.warningMarkerPercents == [50, 20]) + #expect(model.metrics[1].warningMarkerPercents == [25]) + #expect(model.planText == "Plus") + #expect(model.subtitleText.hasPrefix("Updated")) + #expect(model.progressColor != Color.clear) + #expect(model.metrics[1].resetText?.isEmpty == false) } @Test - func showsCodeReviewMetricWhenDashboardPresent() throws { + func `claude model hides weekly when unavailable`() throws { let now = Date() let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: "codex@example.com", + providerID: .claude, + accountEmail: nil, accountOrganization: nil, - loginMethod: nil) + loginMethod: "Max") let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + primary: RateWindow( + usedPercent: 2, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), secondary: nil, tertiary: nil, updatedAt: now, identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.codex]) - - let dashboard = OpenAIDashboardSnapshot( - signedInEmail: "codex@example.com", - codeReviewRemainingPercent: 73, - creditEvents: [], - dailyBreakdown: [], - usageBreakdown: [], - creditsPurchaseURL: nil, - updatedAt: now) + let metadata = try #require(ProviderDefaults.metadata[.claude]) let model = UsageMenuCardView.Model.make(.init( - provider: .codex, + provider: .claude, metadata: metadata, snapshot: snapshot, credits: nil, creditsError: nil, - dashboard: dashboard, + dashboard: nil, dashboardError: nil, tokenSnapshot: nil, tokenError: nil, - account: AccountInfo(email: "codex@example.com", plan: nil), + account: AccountInfo(email: "codex@example.com", plan: "plus"), isRefreshing: false, lastError: nil, usageBarsShowUsed: false, @@ -170,11 +896,13 @@ struct MenuCardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 73 }) + #expect(model.metrics.count == 1) + #expect(model.metrics.first?.title == "Session") + #expect(model.planText == "Max") } @Test - func claudeModelHidesWeeklyWhenUnavailable() throws { + func `claude model includes routines bar when present`() throws { let now = Date() let identity = ProviderIdentitySnapshot( providerID: .claude, @@ -187,8 +915,26 @@ struct MenuCardModelTests { windowMinutes: nil, resetsAt: now.addingTimeInterval(3600), resetDescription: nil), - secondary: nil, - tertiary: nil, + secondary: RateWindow( + usedPercent: 8, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + tertiary: RateWindow( + usedPercent: 16, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7800), + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 7, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(9200), + resetDescription: nil)), + ], updatedAt: now, identity: identity) let metadata = try #require(ProviderDefaults.metadata[.claude]) @@ -212,13 +958,11 @@ struct MenuCardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics.count == 1) - #expect(model.metrics.first?.title == "Session") - #expect(model.planText == "Max") + #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Daily Routines"]) } @Test - func showsErrorSubtitleWhenPresent() throws { + func `shows error subtitle when present`() throws { let metadata = try #require(ProviderDefaults.metadata[.codex]) let model = UsageMenuCardView.Model.make(.init( provider: .codex, @@ -246,7 +990,7 @@ struct MenuCardModelTests { } @Test - func costSectionIncludesLast30DaysTokens() throws { + func `cost section includes last30 days tokens`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.codex]) let snapshot = UsageSnapshot( @@ -283,10 +1027,11 @@ struct MenuCardModelTests { #expect(model.tokenUsage?.monthLine.contains("456") == true) #expect(model.tokenUsage?.monthLine.contains("tokens") == true) + #expect(model.tokenUsage?.hintLine == "Estimated from local Codex logs for the selected account.") } @Test - func claudeModelDoesNotLeakCodexPlan() throws { + func `claude model does not leak codex plan`() throws { let metadata = try #require(ProviderDefaults.metadata[.claude]) let model = UsageMenuCardView.Model.make(.init( provider: .claude, @@ -313,46 +1058,7 @@ struct MenuCardModelTests { } @Test - func hidesCodexCreditsWhenDisabled() throws { - let now = Date() - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: "codex@example.com", - accountOrganization: nil, - loginMethod: nil) - let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - secondary: nil, - tertiary: nil, - updatedAt: now, - identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.codex]) - - let model = UsageMenuCardView.Model.make(.init( - provider: .codex, - metadata: metadata, - snapshot: snapshot, - credits: CreditsSnapshot(remaining: 12, events: [], updatedAt: now), - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: "codex@example.com", plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: false, - hidePersonalInfo: false, - now: now)) - - #expect(model.creditsText == nil) - } - - @Test - func hidesClaudeExtraUsageWhenDisabled() throws { + func `hides claude extra usage when disabled`() throws { let now = Date() let identity = ProviderIdentitySnapshot( providerID: .claude, @@ -393,7 +1099,7 @@ struct MenuCardModelTests { @Test @MainActor - func openRouterModel_usesAPIKeyQuotaBarAndQuotaDetail() throws { + func `open router model uses API key quota bar and quota detail`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.openrouter]) let snapshot = OpenRouterUsageSnapshot( @@ -439,7 +1145,7 @@ struct MenuCardModelTests { } @Test - func openRouterModel_withoutKeyLimitShowsTextOnlySummary() throws { + func `open router model without key limit shows text only summary`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.openrouter]) let snapshot = OpenRouterUsageSnapshot( @@ -480,7 +1186,7 @@ struct MenuCardModelTests { } @Test - func openRouterModel_whenKeyFetchUnavailableShowsUnavailableNote() throws { + func `open router model when key fetch unavailable shows unavailable note`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.openrouter]) let snapshot = OpenRouterUsageSnapshot( @@ -519,7 +1225,7 @@ struct MenuCardModelTests { } @Test - func hidesEmailWhenPersonalInfoHidden() throws { + func `hides email when personal info hidden`() throws { let now = Date() let identity = ProviderIdentitySnapshot( providerID: .codex, @@ -561,7 +1267,7 @@ struct MenuCardModelTests { } @Test - func kiloModelSplitsPassAndActivityAndShowsFallbackNote() throws { + func `kilo model splits pass and activity and shows fallback note`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.kilo]) let snapshot = UsageSnapshot( @@ -607,7 +1313,7 @@ struct MenuCardModelTests { } @Test - func kiloModelTreatsAutoTopUpOnlyLoginAsActivity() throws { + func `kilo model treats auto top up only login as activity`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.kilo]) let snapshot = UsageSnapshot( @@ -646,7 +1352,7 @@ struct MenuCardModelTests { } @Test - func kiloModelDoesNotShowFallbackNoteWhenNotAutoToCLI() throws { + func `kilo model does not show fallback note when not auto to CLI`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.kilo]) let snapshot = UsageSnapshot( @@ -713,7 +1419,7 @@ struct MenuCardModelTests { } @Test - func kiloModelShowsPrimaryDetailWhenResetDateMissing() throws { + func `kilo model shows primary detail when reset date missing`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.kilo]) let snapshot = UsageSnapshot( @@ -757,7 +1463,7 @@ struct MenuCardModelTests { } @Test - func kiloModelKeepsZeroTotalEdgeStateVisible() throws { + func `kilo model keeps zero total edge state visible`() throws { let now = Date() let metadata = try #require(ProviderDefaults.metadata[.kilo]) let snapshot = KiloUsageSnapshot( @@ -796,7 +1502,7 @@ struct MenuCardModelTests { } @Test - func warpModelShowsPrimaryDetailWhenResetDateMissing() throws { + func `warp model shows primary detail when reset date missing`() throws { let now = Date() let identity = ProviderIdentitySnapshot( providerID: .warp, @@ -839,4 +1545,50 @@ struct MenuCardModelTests { #expect(primary.resetText == nil) #expect(primary.detailText == "10/100 credits") } + + @Test + func `mistral model surfaces monthly cost as primary detail text`() throws { + let now = Date() + let resetsAt = now.addingTimeInterval(3 * 24 * 60 * 60) + let identity = ProviderIdentitySnapshot( + providerID: .mistral, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: "€1.2345 this month"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.detailText == "€1.2345 this month") + #expect(primary.resetText?.hasPrefix("Resets") == true) + } } diff --git a/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift b/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift new file mode 100644 index 0000000000..7784c571d8 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardOptionalUsageModelTests.swift @@ -0,0 +1,97 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardOptionalUsageModelTests { + @Test + func `hides codex credits when disabled`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let credits = CreditsSnapshot(remaining: 12, events: [], updatedAt: now) + let codexProjection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: credits, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: true, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: codexProjection, + credits: credits, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.creditsText == nil) + } + + @Test + func `claude model does not show obsolete peak hours note`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.usageNotes.isEmpty) + } +} diff --git a/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift b/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift new file mode 100644 index 0000000000..08bcc94eeb --- /dev/null +++ b/Tests/CodexBarTests/MenuCardProviderRegressionTests.swift @@ -0,0 +1,88 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct MenuCardProviderRegressionTests { + @Test + func `elevenlabs progress color stays visible in light menus`() { + #expect(UsageMenuCardView.Model.progressColor(for: .elevenlabs) == Color(nsColor: .labelColor)) + } + + @Test + func `open router model shows daily and weekly key spend`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45.3895596325, + balance: 4.6104403675, + usedPercent: 90.779119265, + keyLimit: 20, + keyUsage: 0.5, + keyUsageDaily: 0.12, + keyUsageWeekly: 0.74, + rateLimit: nil, + updatedAt: now).toUsageSnapshot() + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.usageNotes == ["Today: $0.12 · This week: $0.74"]) + } + + @Test + func `copilot over quota usage keeps used percentage detail`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.copilot]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 115, windowMinutes: nil, resetsAt: nil, resetDescription: "115% used"), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: nil) + + let model = UsageMenuCardView.Model.make(.init( + provider: .copilot, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let metric = try #require(model.metrics.first) + #expect(metric.percent == 0) + #expect(metric.percentLabel == "0% left") + #expect(metric.detailLeftText == "115% used") + } +} diff --git a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift new file mode 100644 index 0000000000..89c8ca9e16 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift @@ -0,0 +1,131 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardQuotaWarningMarkerTests { + @Test + func `progress fill matches rounded edge labels`() { + #expect(UsageProgressBar.renderedFillPercent(0.4) == 0) + #expect(UsageProgressBar.renderedFillPercent(0.6) == 0.6) + #expect(UsageProgressBar.renderedFillPercent(99.4) == 99.4) + #expect(UsageProgressBar.renderedFillPercent(99.6) == 100) + } + + @Test + func `quota warning marker geometry is inset and hairline`() { + let rect = UsageProgressBar.warningMarkerRect( + x: 50, + size: CGSize(width: 100, height: 6), + scale: 2) + + #expect(rect.width == 1) + #expect(rect.height < 6) + #expect(rect.minY > 0) + #expect(abs(rect.midX - 50) <= 0.5) + } + + @Test + func `omits quota warning markers for disabled windows`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Plus Plan") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: now, + identity: identity) + let codexProjection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: codexProjection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50], .weekly: []], + now: now)) + + #expect(model.metrics.count == 2) + #expect(model.metrics.first?.warningMarkerPercents == [50]) + #expect(model.metrics[1].warningMarkerPercents.isEmpty) + } + + @Test + func `work day marker percents for 5-day week`() { + #expect(workDayMarkerPercents(workDays: 5, windowMinutes: 10080) == [20.0, 40.0, 60.0, 80.0]) + } + + @Test + func `work day marker percents for 4-day week`() { + #expect(workDayMarkerPercents(workDays: 4, windowMinutes: 10080) == [25.0, 50.0, 75.0]) + } + + @Test + func `work day marker percents for 7-day week`() { + let markers = workDayMarkerPercents(workDays: 7, windowMinutes: 10080) + #expect(markers.count == 6) + #expect(abs(markers[0] - 14.2857) < 0.001) + #expect(abs(markers[5] - 85.7143) < 0.001) + } + + @Test + func `work day marker percents nil work days returns empty`() { + #expect(workDayMarkerPercents(workDays: nil, windowMinutes: 10080).isEmpty) + } + + @Test + func `work day marker percents nil window minutes returns empty`() { + #expect(workDayMarkerPercents(workDays: 5, windowMinutes: nil).isEmpty) + } + + @Test + func `work day marker percents non-weekly window returns empty`() { + #expect(workDayMarkerPercents(workDays: 5, windowMinutes: 300).isEmpty) + #expect(workDayMarkerPercents(workDays: 5, windowMinutes: 1440).isEmpty) + } + + @Test + func `work day marker percents invalid work days returns empty`() { + #expect(workDayMarkerPercents(workDays: 1, windowMinutes: 10080).isEmpty) + #expect(workDayMarkerPercents(workDays: 0, windowMinutes: 10080).isEmpty) + #expect(workDayMarkerPercents(workDays: 8, windowMinutes: 10080).isEmpty) + #expect(workDayMarkerPercents(workDays: -1, windowMinutes: 10080).isEmpty) + } +} diff --git a/Tests/CodexBarTests/MenuCardRefreshTests.swift b/Tests/CodexBarTests/MenuCardRefreshTests.swift new file mode 100644 index 0000000000..3b57fa212c --- /dev/null +++ b/Tests/CodexBarTests/MenuCardRefreshTests.swift @@ -0,0 +1,74 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardRefreshTests { + private static func makeModel( + provider: UsageProvider, + snapshot: UsageSnapshot, + isRefreshing: Bool, + now: Date) throws -> UsageMenuCardView.Model + { + let metadata = try #require(ProviderDefaults.metadata[provider]) + return UsageMenuCardView.Model.make(.init( + provider: provider, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: isRefreshing, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + @Test + func `background refresh keeps quota timing current`() throws { + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + for provider in [UsageProvider.claude, .codex] { + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: updatedAt.addingTimeInterval(4 * 60 * 60 + 40 * 60), + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + let completedModel = try Self.makeModel( + provider: provider, + snapshot: snapshot, + isRefreshing: false, + now: updatedAt) + let refreshingModel = try Self.makeModel( + provider: provider, + snapshot: snapshot, + isRefreshing: true, + now: updatedAt.addingTimeInterval(10 * 60)) + + let completedMetric = try #require(completedModel.metrics.first) + let refreshingMetric = try #require(refreshingModel.metrics.first) + #expect(refreshingModel.subtitleText == "Refreshing…") + #expect(refreshingMetric.percentLabel == completedMetric.percentLabel) + #expect(completedMetric.resetText == "Resets in 4h 40m") + #expect(refreshingMetric.resetText == "Resets in 4h 30m") + #expect(refreshingMetric.detailLeftText != completedMetric.detailLeftText) + #expect(refreshingMetric.detailRightText != completedMetric.detailRightText) + #expect(refreshingMetric.pacePercent != completedMetric.pacePercent) + } + } +} diff --git a/Tests/CodexBarTests/MenuCardSubtitleTests.swift b/Tests/CodexBarTests/MenuCardSubtitleTests.swift new file mode 100644 index 0000000000..6cda8e0d51 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardSubtitleTests.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardSubtitleTests { + @Test + func `subtitle uses injected current time`() throws { + let updatedAt = Date(timeIntervalSinceReferenceDate: 0) + let now = updatedAt.addingTimeInterval(5 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.subtitleText == UsageFormatter.updatedString(from: updatedAt, now: now)) + } + + @Test + func `subtitle shows refreshing while cached snapshot remains visible`() throws { + let updatedAt = Date(timeIntervalSinceReferenceDate: 0) + let now = updatedAt.addingTimeInterval(5 * 3600) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "Plus Plan"), + isRefreshing: true, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.subtitleText == "Refreshing…") + #expect(model.subtitleStyle == .loading) + #expect(!model.metrics.isEmpty) + } +} diff --git a/Tests/CodexBarTests/MenuCardTestDateFormatting.swift b/Tests/CodexBarTests/MenuCardTestDateFormatting.swift new file mode 100644 index 0000000000..4f94f865d1 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardTestDateFormatting.swift @@ -0,0 +1,9 @@ +import Foundation + +func minimaxRenewDate(_ timestamp: TimeInterval) -> String { + let formatter = DateFormatter() + formatter.locale = .current + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return formatter.string(from: Date(timeIntervalSince1970: timestamp)) +} diff --git a/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift new file mode 100644 index 0000000000..70e30e4978 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardViewRecyclingTests.swift @@ -0,0 +1,716 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +private final class RecordingMenuHighlightView: NSView, MenuCardHighlighting { + private(set) var isHighlighted = false + + func setHighlighted(_ highlighted: Bool) { + self.isHighlighted = highlighted + } +} + +extension StatusMenuTests { + private func makeRecyclingController(settings: SettingsStore) -> StatusItemController { + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + return StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + } + + private func cardViewIdentities(in menu: NSMenu) -> [String: ObjectIdentifier] { + var identities: [String: ObjectIdentifier] = [:] + for item in menu.items { + guard let id = item.representedObject as? String else { continue } + guard let view = item.view, view is any MenuCardMeasuring else { continue } + identities[id] = ObjectIdentifier(view) + } + return identities + } + + @Test + func `menu card enabled state follows interaction affordances`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + for renderingEnabled in [false, true] { + StatusItemController.menuCardRenderingEnabled = renderingEnabled + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let informational = controller.makeMenuCardItem(Text("Info"), id: "info", width: 300) + let embedded = controller.makeMenuCardItem( + Text("Embedded"), + id: "embedded", + width: 300, + containsInteractiveControls: true) + let clickable = controller.makeMenuCardItem(Text("Click"), id: "click", width: 300, onClick: {}) + let submenu = controller.makeMenuCardItem( + Text("Submenu"), + id: "submenu", + width: 300, + submenu: NSMenu()) + + #expect(!informational.isEnabled) + #expect(embedded.isEnabled == renderingEnabled) + #expect(clickable.isEnabled) + #expect(submenu.isEnabled) + } + } + + @Test + func `embedded controls stay enabled without highlighting the card`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem( + Text("Embedded"), + id: "embedded", + width: 300, + containsInteractiveControls: true) + menu.addItem(item) + + controller.menu(menu, willHighlight: item) + + #expect(item.isEnabled) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil) + guard let hosting = item.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + #expect(!hosting.allowsMenuHighlight) + #expect(!hosting.highlightState.isHighlighted) + } + + @Test + func `merged menu width uses widest provider action set`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let narrow = [ + MenuDescriptor.Section(entries: [ + .action("Usage Dashboard", .dashboard), + ]), + ] + let wide = [ + MenuDescriptor.Section(entries: [ + .action(String(repeating: "W", count: 60), .dashboard), + ]), + ] + + let narrowWidth = controller.measuredMenuCardWidth(for: [narrow]) + let stableWidth = controller.measuredMenuCardWidth(for: [narrow, wide]) + + #expect(narrowWidth == StatusItemController.menuCardBaseWidth) + #expect(stableWidth > narrowWidth) + #expect(controller.measuredMenuCardWidth(for: [wide, narrow]) == stableWidth) + } + + @Test + func `menu width normalization includes usage history submenu row`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let usageHistoryItem = controller.makeMenuCardItem( + Text("Subscription Utilization"), + id: "usageHistorySubmenu", + width: StatusItemController.menuCardBaseWidth) + menu.addItem(usageHistoryItem) + menu.addItem(NSMenuItem( + title: String(repeating: "W", count: 60), + action: nil, + keyEquivalent: "")) + + let expectedWidth = controller.renderedMenuWidth(for: menu) + #expect(expectedWidth > StatusItemController.menuCardBaseWidth) + + controller.refreshMenuCardHeights(in: menu) + + #expect(abs((usageHistoryItem.view?.frame.width ?? 0) - expectedWidth) <= 0.5) + } + + @Test + func `rendered menu width keeps tracked window width after AppKit shrink`() { + let width = StatusItemController.resolvedRenderedMenuWidth( + menuWidth: 310, + trackedWindowWidth: 356) + + #expect(width == 356) + #expect(StatusItemController.resolvedRenderedMenuWidth( + menuWidth: 310, + trackedWindowWidth: nil) == 310) + } + + @Test + func `data only repopulate reuses menu card hosting views`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstPass = self.cardViewIdentities(in: menu) + #expect(!firstPass.isEmpty) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.populateMenu(menu, provider: .codex) + let secondPass = self.cardViewIdentities(in: menu) + + #expect(secondPass.keys.sorted() == firstPass.keys.sorted()) + for (id, identity) in firstPass { + #expect(secondPass[id] == identity, "card \(id) should reuse its hosting view") + } + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `merged data tick keeps row count and card views stable`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = false + let registry = ProviderRegistry.shared + let enabled: Set = [.codex, .claude] + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabled.contains(provider)) + } + } + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.selectedMenuProvider = .codex + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let itemCountBefore = menu.items.count + let cardViewsBefore = self.cardViewIdentities(in: menu) + #expect(!cardViewsBefore.isEmpty) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.populateMenu(menu, provider: .codex) + + #expect(menu.items.count == itemCountBefore, "data-only repopulate should keep row count stable") + let cardViewsAfter = self.cardViewIdentities(in: menu) + for (id, identity) in cardViewsBefore { + #expect(cardViewsAfter[id] == identity, "card \(id) should reuse its hosting view") + } + } + + @Test + func `reconcile keeps matching edge rows when the middle differs`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + func plainItem(_ title: String) -> NSMenuItem { + NSMenuItem(title: title, action: nil, keyEquivalent: "") + } + + let menu = NSMenu() + menu.addItem(controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300)) + menu.addItem(.separator()) + menu.addItem(plainItem("Old Provider Action")) + menu.addItem(plainItem("Old Provider Detail")) + menu.addItem(.separator()) + menu.addItem(plainItem("Settings")) + let cardItem = menu.items[0] + let cardView = cardItem.view + let settingsItem = menu.items[5] + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("other provider card"), id: "menuCard", width: 300)) + scratch.addItem(.separator()) + scratch.addItem(plainItem("New Provider Action")) + scratch.addItem(.separator()) + scratch.addItem(plainItem("Settings")) + + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items.count == 5) + #expect(menu.items[0] === cardItem, "card row should be updated in place") + #expect(menu.items[0].view === cardView, "card hosting view should be recycled in place") + #expect(menu.items[4] === settingsItem, "shared trailing row should be updated in place") + #expect(menu.items[2].title == "New Provider Action") + } + + @Test + func `cached provider content replaces native image rows and preserves switch back items`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let outgoing = NSMenuItem(title: "Status Page", action: nil, keyEquivalent: "") + outgoing.image = NSImage(size: NSSize(width: 16, height: 16)) + let incoming = NSMenuItem(title: "Dashboard", action: nil, keyEquivalent: "") + incoming.image = NSImage(size: NSSize(width: 16, height: 16)) + let menu = NSMenu() + menu.addItem(outgoing) + + let displacedOutgoing = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 0, + with: [incoming]) + + #expect(menu.items.first === incoming) + #expect(displacedOutgoing.first === outgoing) + + let displacedIncoming = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 0, + with: displacedOutgoing) + + #expect(menu.items.first === outgoing) + #expect(displacedIncoming.first === incoming) + } + + @Test + func `cached provider content swap preserves both item sets for switch back`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let switcher = NSMenuItem(title: "Switcher", action: nil, keyEquivalent: "") + let outgoing = [ + NSMenuItem(title: "Overview Card", action: nil, keyEquivalent: ""), + NSMenuItem.separator(), + NSMenuItem(title: "Overview Action", action: nil, keyEquivalent: ""), + ] + let incoming = [ + NSMenuItem(title: "Codex Card", action: nil, keyEquivalent: ""), + NSMenuItem.separator(), + NSMenuItem(title: "Codex Usage", action: nil, keyEquivalent: ""), + NSMenuItem(title: "Codex Settings", action: nil, keyEquivalent: ""), + ] + let menu = NSMenu() + menu.addItem(switcher) + outgoing.forEach(menu.addItem) + + let displacedOutgoing = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 1, + with: incoming) + + #expect(menu.items.first === switcher) + #expect(menu.items.dropFirst().map(\.title) == ["Codex Card", "", "Codex Usage", "Codex Settings"]) + #expect(Array(menu.items[1...3]).map(ObjectIdentifier.init) == outgoing.map(ObjectIdentifier.init)) + #expect(displacedOutgoing.map(ObjectIdentifier.init) == incoming.prefix(3).map(ObjectIdentifier.init)) + #expect(displacedOutgoing.map(\.title) == ["Overview Card", "", "Overview Action"]) + + let displacedIncoming = controller.replaceMenuContentKeepingRowsVisible( + menu, + fromIndex: 1, + with: displacedOutgoing) + + #expect(Array(menu.items.dropFirst()).map(ObjectIdentifier.init) == outgoing.map(ObjectIdentifier.init)) + #expect(displacedIncoming.map(ObjectIdentifier.init) == incoming.map(ObjectIdentifier.init)) + #expect(displacedIncoming.allSatisfy { $0.menu == nil }) + #expect(displacedIncoming.map(\.title) == ["Codex Card", "", "Codex Usage", "Codex Settings"]) + } + + @Test + func `reconcile preserves highlight on a retained custom action row`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = NSMenuItem() + liveItem.isEnabled = true + liveItem.representedObject = "action" + liveItem.view = RecordingMenuHighlightView() + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + + let replacementView = RecordingMenuHighlightView() + let replacementItem = NSMenuItem() + replacementItem.isEnabled = true + replacementItem.representedObject = "action" + replacementItem.view = replacementView + let scratch = NSMenu() + scratch.addItem(replacementItem) + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(liveItem.view === replacementView) + #expect(replacementView.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + } + + @Test + func `reconcile restores highlight on a retained recycled card`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300, onClick: {}) + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + guard let hosting = liveItem.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: nil, + preserveHighlightedItem: true) + defer { controller.clearMenuCardViewRecyclePool() } + #expect(!hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300, onClick: {})) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(liveItem.view === hosting) + #expect(hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + } + + @Test + func `reconcile clears highlight when a retained card becomes disabled`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300, onClick: {}) + menu.addItem(liveItem) + controller.menu(menu, willHighlight: liveItem) + guard let liveView = liveItem.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + #expect(liveView.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem) + + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + let scratch = NSMenu() + scratch.addItem(controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300)) + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items[0] === liveItem) + #expect(!liveItem.isEnabled) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil) + guard let rebuiltView = liveItem.view as? MenuCardItemHostingView> + else { + Issue.record("expected the rebuilt card hosting view") + return + } + #expect(!rebuiltView.highlightState.isHighlighted) + } + + @Test + func `harvesting consumes only the displaced selection cache entry`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300, onClick: {}) + menu.addItem(item) + + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: .provider(.codex)) + defer { controller.clearMenuCardViewRecyclePool() } + + #expect(controller.menuCardViewRecyclePool.count == 1) + #expect(item.view == nil) + let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] + #expect(remaining?[.provider(.codex)] == nil) + #expect(remaining?[.overview] != nil) + } + + @Test + func `harvesting consumes displaced cache when card rendering is disabled`() { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let entry = CachedMergedSwitcherMenuContent( + requiredMenuContentVersion: 0, + menuWidth: 300, + codexAccountDisplay: nil, + tokenAccountDisplay: nil, + localizationSignature: "", + items: []) + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [ + .overview: entry, + .provider(.codex): entry, + ] + + controller.harvestRecyclableMenuCardViews( + in: menu, + fromIndex: 0, + displacedSelection: .provider(.codex)) + + let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] + #expect(remaining?[.provider(.codex)] == nil) + #expect(remaining?[.overview] != nil) + } + + @Test + func `type compatible leftover is adopted across card identifiers`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("codex usage"), id: "menuCard-0", width: 300) + menu.addItem(original) + let originalView = original.view + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let switched = controller.makeMenuCardItem(Text("claude usage"), id: "menuCard", width: 300) + + #expect(switched.view === originalView) + #expect(controller.menuCardViewRecyclePool.isEmpty) + } + + @Test + func `recycled card keeps its hosting view and highlight state`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300) + menu.addItem(original) + guard let originalView = original.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let rebuilt = controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300) + + #expect(rebuilt.view === originalView) + guard let rebuiltView = rebuilt.view as? MenuCardItemHostingView> + else { + Issue.record("expected the recycled hosting view") + return + } + #expect(rebuiltView.highlightState === originalView.highlightState) + rebuiltView.setHighlighted(true) + #expect(rebuiltView.highlightState.isHighlighted) + rebuiltView.setHighlighted(false) + } + + @Test + func `recycled card clears button role when click action is removed`() { + let highlightState = MenuCardHighlightState() + let hosting = MenuCardItemHostingView( + rootView: Text("clickable"), + highlightState: highlightState, + allowsMenuHighlight: true, + onClick: {}) + + #expect(hosting.accessibilityRole() == .button) + + hosting.prepareForReuse( + rootView: Text("informational"), + allowsMenuHighlight: false, + onClick: nil) + + #expect(hosting.accessibilityRole() == .group) + } + + @Test + func `harvesting a highlighted card clears its highlight and tracking entry`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300, onClick: {}) + menu.addItem(item) + controller.menu(menu, willHighlight: item) + guard let hosting = item.view as? MenuCardItemHostingView> + else { + Issue.record("expected a card hosting view") + return + } + #expect(hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === item) + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + + #expect(!hosting.highlightState.isHighlighted) + #expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil) + + let rebuilt = controller.makeMenuCardItem(Text("rebuilt"), id: "menuCard", width: 300, onClick: {}) + #expect(rebuilt.view === hosting) + #expect(!hosting.highlightState.isHighlighted) + } + + @Test + func `same id with different content type builds a fresh view`() { + StatusItemController.setMenuRefreshEnabledForTesting(false) + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let controller = self.makeRecyclingController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let original = controller.makeMenuCardItem(Text("text card"), id: "menuCard", width: 300) + menu.addItem(original) + let originalView = original.view + + controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil) + defer { controller.clearMenuCardViewRecyclePool() } + let rebuilt = controller.makeMenuCardItem(Image(systemName: "clock"), id: "menuCard", width: 300) + + #expect(rebuilt.view != nil) + #expect(rebuilt.view !== originalView) + // The incompatible pool entry is consumed rather than left behind. + #expect(controller.menuCardViewRecyclePool.isEmpty) + } +} diff --git a/Tests/CodexBarTests/MenuCardWorkdayPaceTests.swift b/Tests/CodexBarTests/MenuCardWorkdayPaceTests.swift new file mode 100644 index 0000000000..b712a0ccb5 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardWorkdayPaceTests.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardWorkdayPaceTests { + @Test + func `codex weekly lane shows exhausted pace before first configured workday`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 7, + hour: 12))) + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + workDaysPerWeek: 5, + now: now)) + + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(weekly.detailLeftText == "100% in deficit") + #expect(weekly.detailRightText == "Runs out now") + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift b/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift new file mode 100644 index 0000000000..392c114d36 --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift @@ -0,0 +1,107 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorAntigravityTests { + @Test + func `antigravity identity only snapshot shows limits unavailable`() throws { + let suite = "MenuDescriptorAntigravityTests-unavailable" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Paid")) + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + let descriptor = MenuDescriptor.build( + provider: .antigravity, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("Limits not available")) + } + + @Test + func `antigravity menu does not add unavailable notes for missing families`() throws { + let suite = "MenuDescriptorAntigravityTests-missing-gemini" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + let descriptor = MenuDescriptor.build( + provider: .antigravity, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap( + \.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(!lines.contains("Gemini Pro unavailable.")) + #expect(!lines.contains("Gemini Flash unavailable.")) + #expect(!lines.contains("Limits not available")) + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorCodexManagedFallbackTests.swift b/Tests/CodexBarTests/MenuDescriptorCodexManagedFallbackTests.swift new file mode 100644 index 0000000000..218bb0abcd --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorCodexManagedFallbackTests.swift @@ -0,0 +1,181 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorCodexManagedFallbackTests { + @Test + func `codex account section prefers managed fallback over ambient account`() throws { + let suite = "MenuDescriptorCodexManagedFallbackTests" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.statusChecksEnabled = false + + let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + defer { + try? FileManager.default.removeItem(at: ambientHome) + try? FileManager.default.removeItem(at: managedHome) + } + + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "ambient@example.com", plan: "plus") + try Self.writeCodexAuthFile(homeURL: managedHome, email: "managed@example.com", plan: "enterprise") + settings.codexActiveSource = .managedAccount(id: UUID()) + settings._test_activeManagedCodexRemoteHomePath = managedHome.path + + let fetcher = UsageFetcher(environment: ["CODEX_HOME": ambientHome.path]) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: nil), + provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("Account: managed@example.com")) + #expect(lines.contains("Plan: Enterprise")) + #expect(!lines.contains("Account: ambient@example.com")) + #expect(!lines.contains("Plan: Plus")) + } + + @Test + func `codex weekly only window renders without session row`() throws { + let suite = "MenuDescriptorCodexManagedFallbackTests-weekly-only" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.statusChecksEnabled = false + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: "Apr 6, 2026"), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulanimation@gmail.com", + accountOrganization: nil, + loginMethod: "free")), + provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(!lines.contains(where: { $0.hasPrefix("Session:") })) + #expect(lines.contains(where: { $0.hasPrefix("Weekly:") })) + } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let auth = [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan), + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorKiloTests.swift b/Tests/CodexBarTests/MenuDescriptorKiloTests.swift index adf6f41d1e..a3dba9cb34 100644 --- a/Tests/CodexBarTests/MenuDescriptorKiloTests.swift +++ b/Tests/CodexBarTests/MenuDescriptorKiloTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct MenuDescriptorKiloTests { @Test - func kiloCreditsDetailDoesNotRenderAsResetLine() throws { + func `kilo credits detail does not render as reset line`() throws { let suite = "MenuDescriptorKiloTests-kilo-detail" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -59,7 +58,7 @@ struct MenuDescriptorKiloTests { } @Test - func kiloPassDetailKeepsResetLineWhenResetDateExists() throws { + func `kilo pass detail keeps reset line when reset date exists`() throws { let suite = "MenuDescriptorKiloTests-kilo-pass-reset" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -115,7 +114,7 @@ struct MenuDescriptorKiloTests { } @Test - func kiloAutoTopUpOnlyRendersActivityWithoutPlanLabel() throws { + func `kilo auto top up only renders activity without plan label`() throws { let suite = "MenuDescriptorKiloTests-kilo-activity-only" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift b/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift new file mode 100644 index 0000000000..d45b96429e --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift @@ -0,0 +1,92 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorOpenAIAPITests { + @Test + func `openai api admin usage appears in descriptor summaries`() throws { + let suite = "MenuDescriptorOpenAIAPITests-admin-summary" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let now = Date(timeIntervalSince1970: 1_700_179_200) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-13", + startTime: now.addingTimeInterval(-86400), + endTime: now, + costUSD: 5, + requests: 8, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 8, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150), + ]), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 12.5, + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2-codex", + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500), + ]), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let descriptor = MenuDescriptor.build( + provider: .openai, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("Today: $12.50 · 1.5K tokens")) + #expect(lines.contains("7d: $17.50 · 48 requests")) + #expect(lines.contains("30d: $17.50 · 48 requests")) + #expect(lines.contains("Top model: gpt-5.2-codex")) + #expect(!lines.contains("No usage yet")) + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorPoeTests.swift b/Tests/CodexBarTests/MenuDescriptorPoeTests.swift new file mode 100644 index 0000000000..1f5dc5b212 --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorPoeTests.swift @@ -0,0 +1,135 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorPoeTests { + @Test + func `poe balance renders as balance text not plan label`() throws { + let suite = "MenuDescriptorPoeTests-balance" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .poe, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: 1,500 points")) + store._setSnapshotForTesting(snapshot, provider: .poe) + + let descriptor = MenuDescriptor.build( + provider: .poe, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains(where: { $0.contains("Balance: 1,500 points") })) + #expect(!textLines.contains(where: { $0.contains("Plan: Balance:") })) + } + + @Test + func `poe usage history renders today week month and top breakdown`() throws { + let suite = "MenuDescriptorPoeTests-history" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + let now = Date(timeIntervalSince1970: 1_717_171_717) + let history = PoeUsageHistorySnapshot( + entries: [ + .init( + id: "a", + createdAt: now.addingTimeInterval(-1000), + model: "GPT-4o", + usageType: "chat", + points: 100, + costUSD: nil), + .init( + id: "b", + createdAt: now.addingTimeInterval(-86000), + model: "Claude-3.7-Sonnet", + usageType: "chat", + points: 200, + costUSD: nil), + ], + daily: [ + .init(day: "2026-05-30", points: 200, requests: 1, costUSD: nil), + .init(day: "2026-05-31", points: 100, requests: 1, costUSD: nil), + ], + updatedAt: now) + + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + poeUsage: history, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .poe, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: 300 points")) + store._setSnapshotForTesting(snapshot, provider: .poe) + + let descriptor = MenuDescriptor.build( + provider: .poe, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains(where: { $0.contains("Today: 100 points") })) + #expect(textLines.contains(where: { $0.contains("7d: 300 points") })) + #expect(textLines.contains(where: { $0.contains("30d: 300 points") })) + #expect(textLines.contains(where: { $0.contains("Top model: Claude-3.7-Sonnet") })) + #expect(textLines.contains(where: { $0.contains("Usage mix: chat: 300 points") })) + #expect(textLines.contains(where: { $0.contains("Recent activity:") })) + } +} diff --git a/Tests/CodexBarTests/MenuSessionCoordinatorTests.swift b/Tests/CodexBarTests/MenuSessionCoordinatorTests.swift new file mode 100644 index 0000000000..551e017fbd --- /dev/null +++ b/Tests/CodexBarTests/MenuSessionCoordinatorTests.swift @@ -0,0 +1,114 @@ +import Testing +@testable import CodexBar + +struct MenuSessionCoordinatorTests { + @Test + func `invalidation records data structural and required generations independently`() { + var coordinator = MenuSessionCoordinator() + + coordinator.invalidate(allowsStaleContent: false, requiresRebuild: true) + #expect(coordinator.contentVersion == 1) + #expect(coordinator.latestStructuralContentVersion == 1) + #expect(coordinator.latestRequiredRebuildVersion == 1) + #expect(coordinator.latestDataOnlyContentVersion == 0) + + coordinator.invalidate(allowsStaleContent: true, requiresRebuild: false) + #expect(coordinator.contentVersion == 2) + #expect(coordinator.latestDataOnlyContentVersion == 2) + #expect(coordinator.latestStructuralContentVersion == 1) + #expect(coordinator.latestRequiredRebuildVersion == 1) + + coordinator.invalidate(allowsStaleContent: false, requiresRebuild: false) + #expect(coordinator.contentVersion == 3) + #expect(coordinator.latestStructuralContentVersion == 3) + #expect(coordinator.latestRequiredRebuildVersion == 1) + } + + @Test + func `closed preparation distinguishes no work deferred work and required work`() { + var coordinator = MenuSessionCoordinator() + let menu = "menu" + + #expect(coordinator.closedPreparationPlan(for: [menu]) == .nonDeferred) + + coordinator.invalidate(allowsStaleContent: true, requiresRebuild: false) + #expect(coordinator.closedPreparationPlan(for: [menu]) == .none) + + coordinator.invalidate(allowsStaleContent: false, requiresRebuild: true) + #expect(coordinator.closedPreparationPlan(for: [menu]) == .required(version: 2)) + + coordinator.markFresh(menu) + #expect(coordinator.closedPreparationPlan(for: [menu]) == .nonDeferred) + } + + @Test + func `stale content survives only a data generation after latest structural render`() { + var coordinator = MenuSessionCoordinator() + let menu = "menu" + + coordinator.invalidate(allowsStaleContent: false, requiresRebuild: true) + coordinator.markFresh(menu) + coordinator.invalidate(allowsStaleContent: true, requiresRebuild: false) + #expect(coordinator.canPreserveStaleContent(for: menu)) + + coordinator.invalidate(allowsStaleContent: false, requiresRebuild: false) + coordinator.invalidate(allowsStaleContent: true, requiresRebuild: false) + #expect(!coordinator.canPreserveStaleContent(for: menu)) + } + + @Test + func `removing menu clears all menu scoped lifecycle state`() { + var coordinator = MenuSessionCoordinator() + let menu = "menu" + + coordinator.markFresh(menu) + coordinator.deferUntilNextOpen(menu) + coordinator.deferParentRebuild(menu) + coordinator.removeMenu(menu) + + #expect(coordinator.renderedVersion(for: menu) == nil) + #expect(!coordinator.isDeferredUntilNextOpen(menu)) + #expect(!coordinator.isParentRebuildDeferred(menu)) + } +} + +struct MenuRebuildRequestRegistryTests { + @Test + func `replacement request invalidates prior token without affecting other menus`() { + var registry = MenuRebuildRequestRegistry() + + let first = registry.replaceRequest(for: "parent") + let child = registry.replaceRequest(for: "child") + let replacement = registry.replaceRequest(for: "parent") + + #expect(!registry.isCurrent(first, for: "parent")) + #expect(registry.isCurrent(replacement, for: "parent")) + #expect(registry.isCurrent(child, for: "child")) + } + + @Test + func `stale completion cannot clear replacement request`() { + var registry = MenuRebuildRequestRegistry() + let stale = registry.replaceRequest(for: "menu") + let current = registry.replaceRequest(for: "menu") + + let staleDidFinish = registry.finish(stale, for: "menu") + #expect(!staleDidFinish) + #expect(registry.isCurrent(current, for: "menu")) + let currentDidFinish = registry.finish(current, for: "menu") + #expect(currentDidFinish) + #expect(!registry.isCurrent(current, for: "menu")) + } + + @Test + func `cancelling all requests keeps future tokens distinct`() { + var registry = MenuRebuildRequestRegistry() + let cancelled = registry.replaceRequest(for: "menu") + + registry.cancelAll() + let replacement = registry.replaceRequest(for: "menu") + + #expect(cancelled != replacement) + #expect(registry.isCurrent(replacement, for: "menu")) + } +} diff --git a/Tests/CodexBarTests/MiMoLocalUsageFallbackTests.swift b/Tests/CodexBarTests/MiMoLocalUsageFallbackTests.swift new file mode 100644 index 0000000000..6811b3d05f --- /dev/null +++ b/Tests/CodexBarTests/MiMoLocalUsageFallbackTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MiMoLocalUsageFallbackTests { + @Test + func `returns nil when cache file is missing`() { + let snap = MiMoLocalUsageFallback.snapshot( + cachePath: "/nonexistent/path/that/should/never/exist.json", + now: Date()) + #expect(snap == nil) + } + + @Test + func `returns nil when cache file is malformed JSON`() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-fallback-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("malformed.json") + try "{not json".write(to: file, atomically: true, encoding: .utf8) + + let snap = MiMoLocalUsageFallback.snapshot(cachePath: file.path, now: Date()) + #expect(snap == nil) + } + + @Test + func `returns nil when cache schema is incomplete`() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-fallback-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("incomplete.json") + try Data("{}".utf8).write(to: file) + + let snap = MiMoLocalUsageFallback.snapshot(cachePath: file.path, now: Date()) + #expect(snap == nil) + } + + @Test + func `parses all token buckets without fabricating a quota window`() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-fallback-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("usage.json") + let updatedAt = "2026-06-03T05:04:03.123456+00:00" + let payload: [String: Any] = [ + "updated_at": updatedAt, + "sessions_scanned": 1296, + "windows": [ + "today": ["input": 1500, "output": 500, "cache_read": 0, "cache_create": 250, "messages": 3], + "week": [ + "input": 30000, + "output": 10000, + "cache_read": 60000, + "cache_create": 10000, + "messages": 25, + ], + "all_time": [ + "input": 3_600_000, + "output": 1_100_000, + "cache_read": 16_100_000, + "cache_create": 2_000_000, + "messages": 1315, + ], + ], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: file) + + let snap = try #require(MiMoLocalUsageFallback.snapshot(cachePath: file.path, now: Date())) + + // planCode packs today/week/total/sessions in one row. + let plan = try #require(snap.planCode) + #expect(plan.contains("today")) + #expect(plan.contains("week")) + #expect(plan.contains("total")) + #expect(plan.contains("1296 sessions")) + #expect(plan.contains("110.0k week")) + #expect(plan.contains("22.8M total")) + #expect(snap.tokenUsed == 0) + #expect(snap.tokenLimit == 0) + #expect(snap.tokenPercent == 0) + let usage = snap.toUsageSnapshot(includeBalance: false) + #expect(usage.primary == nil) + #expect(usage.mimoUsage == nil) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + #expect(snap.updatedAt == formatter.date(from: updatedAt)) + } + + @Test + func `idle week keeps local accounting in the plan summary`() throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-fallback-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("idle.json") + let payload: [String: Any] = [ + "sessions_scanned": 100, + "windows": [ + "today": ["input": 0, "output": 0, "cache_read": 0], + "week": ["input": 0, "output": 0, "cache_read": 0], + "all_time": ["input": 500_000, "output": 250_000, "cache_read": 1_250_000], + ], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: file) + + let snap = try #require(MiMoLocalUsageFallback.snapshot(cachePath: file.path, now: Date())) + #expect(snap.tokenUsed == 0) + #expect(snap.tokenLimit == 0) + #expect(snap.tokenPercent == 0) + #expect(snap.toUsageSnapshot(includeBalance: false).mimoUsage == nil) + let plan = try #require(snap.planCode) + #expect(plan.hasPrefix("Local")) + #expect(!plan.contains("today")) + #expect(!plan.contains("week")) + #expect(plan.contains("total")) + #expect(plan.contains("100 sessions")) + } +} diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift new file mode 100644 index 0000000000..4d23d3da13 --- /dev/null +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -0,0 +1,1213 @@ +import Foundation +import SwiftUI +import Testing +@testable import CodexBar +@testable import CodexBarCore +#if os(macOS) +import SweetCookieKit +#endif + +@Suite(.serialized) +struct MiMoProviderTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `cookie header normalizer keeps required mimo cookies`() { + let raw = """ + curl 'https://platform.xiaomimimo.com/api/v1/balance' \ + -H 'Cookie: userId=123; api-platform_serviceToken=svc-token; ignored=value; api-platform_ph=ph-token' + """ + + let normalized = MiMoCookieHeader.normalizedHeader(from: raw) + + #expect(normalized == "api-platform_ph=ph-token; api-platform_serviceToken=svc-token; userId=123") + } + + @Test + func `cookie header normalizer rejects missing auth cookies`() { + let normalized = MiMoCookieHeader.normalizedHeader(from: "Cookie: userId=123") + + #expect(normalized == nil) + } + + @Test + func `cookie header builder keeps mimo auth cookies from one scope`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "root-user", + domain: "xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "platform-token", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "userId", + value: "platform-user", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "api-platform_ph", + value: "platform-ph", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_ph=platform-ph; api-platform_serviceToken=platform-token; userId=platform-user") + } + + @Test + func `cookie header builder prefers more specific matching cookie`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "root-user", + domain: "xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "userId", + value: "api-user", + domain: "platform.xiaomimimo.com", + path: "/api", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "platform-token", + domain: ".xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "irrelevant", + value: "ignored", + domain: "platform.xiaomimimo.com", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_serviceToken=platform-token; userId=api-user") + } + + @Test + func `cookie header builder rejects partial path prefix matches`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "partial-path-user", + domain: "platform.xiaomimimo.com", + path: "/api/v1/bal", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "userId", + value: "valid-user", + domain: "platform.xiaomimimo.com", + path: "/api", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "partial-path-token", + domain: "platform.xiaomimimo.com", + path: "/api/v1/bal", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "valid-token", + domain: "platform.xiaomimimo.com", + path: "/api", + expiresAt: Date(timeIntervalSince1970: 1_800_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_serviceToken=valid-token; userId=valid-user") + } + + @Test + func `cookie header builder accepts slash terminated path prefixes`() throws { + let cookies = try [ + self.makeCookie( + name: "userId", + value: "slash-user", + domain: "platform.xiaomimimo.com", + path: "/api/", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + self.makeCookie( + name: "api-platform_serviceToken", + value: "slash-token", + domain: "platform.xiaomimimo.com", + path: "/api/", + expiresAt: Date(timeIntervalSince1970: 1_900_000_000)), + ] + + let header = MiMoCookieHeader.header(from: cookies) + + #expect(header == "api-platform_serviceToken=slash-token; userId=slash-user") + } + + @Test + func `usage snapshot exposes balance without duplicating identity`() { + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.mimoUsage?.balanceDetail == "$25.51") + #expect(usage.loginMethod(for: .mimo) == nil) + } + + @Test + func `usage snapshot exposes paid and granted balance components`() { + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.mimoUsage?.balanceDetail == "$25.51 (Paid: $20.00 / Granted: $5.51)") + #expect(usage.loginMethod(for: .mimo) == nil) + } + + @Test + func `usage snapshot shows token plan as primary when available`() { + let resetDate = Date(timeIntervalSince1970: 1_778_025_599) + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + planPeriodEnd: resetDate, + planExpired: false, + tokenUsed: 10_100_158, + tokenLimit: 200_000_000, + tokenPercent: 0.0505, + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary != nil) + #expect(abs((usage.primary?.usedPercent ?? .nan) - 5.05) < 0.0001) + #expect(usage.primary?.resetDescription == "10,100,158 / 200,000,000 Credits") + #expect(usage.primary?.resetsAt == resetDate) + #expect(usage.secondary == nil) + #expect(usage.mimoUsage?.balanceDetail == "$25.51") + #expect(usage.loginMethod(for: .mimo) == "Standard") + } + + @Test + func `menu card shows balance as status text with and without token plan`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let metadata = try #require(ProviderDefaults.metadata[.mimo]) + let balanceOnly = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + updatedAt: now) + .toUsageSnapshot() + let withPlan = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + planCode: "standard", + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: now) + .toUsageSnapshot() + + let balanceModel = Self.makeMenuCardModel(snapshot: balanceOnly, metadata: metadata, now: now) + let planModel = Self.makeMenuCardModel(snapshot: withPlan, metadata: metadata, now: now) + + #expect(balanceModel.metrics.first?.title == "Balance") + #expect(balanceModel.metrics.first?.statusText == "$25.51 (Paid: $20.00 / Granted: $5.51)") + #expect(planModel.metrics.first?.title == "Credits") + #expect(planModel.metrics.last?.title == "Balance") + #expect(planModel.metrics.last?.statusText == "$25.51 (Paid: $20.00 / Granted: $5.51)") + } + + @Test + func `usage snapshot falls back to balance when no token plan`() { + let snapshot = MiMoUsageSnapshot( + balance: 0, + currency: "USD", + planCode: nil, + planPeriodEnd: nil, + planExpired: false, + tokenUsed: 0, + tokenLimit: 0, + tokenPercent: 0, + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.mimoUsage?.balanceDetail == "$0.00") + #expect(usage.loginMethod(for: .mimo) == nil) + } + + @Test + func `usage snapshot persists mimo balance details`() throws { + let usage = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + .toUsageSnapshot() + + let decoded = try JSONDecoder().decode(UsageSnapshot.self, from: JSONEncoder().encode(usage)) + + #expect(decoded.primary == nil) + #expect(decoded.mimoUsage?.balanceDetail == "$25.51 (Paid: $20.00 / Granted: $5.51)") + } + + @Test + func `balance does not participate in icon or switcher quota percentages`() { + let balanceOnly = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + updatedAt: Date()) + .toUsageSnapshot() + let withPlan = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: Date()) + .toUsageSnapshot() + + let balanceIcon = IconRemainingResolver.resolvedRemaining(snapshot: balanceOnly, style: .mimo) + let planIcon = IconRemainingResolver.resolvedRemaining(snapshot: withPlan, style: .mimo) + + #expect(balanceIcon.primary == nil) + #expect(balanceIcon.secondary == nil) + #expect(StatusItemController.switcherWeeklyMetricPercent( + for: .mimo, + snapshot: balanceOnly, + showUsed: false) == nil) + #expect(planIcon.primary == 90) + #expect(planIcon.secondary == nil) + #expect(StatusItemController.switcherWeeklyMetricPercent( + for: .mimo, + snapshot: withPlan, + showUsed: false) == 90) + } + + @Test + func `parses balance payload`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let json = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "frozenBalance": null, + "currency": "USD", + "overdraftLimit": null + } + } + """ + + let snapshot = try MiMoUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.balance == 25.51) + #expect(snapshot.currency == "USD") + #expect(snapshot.cashBalance == nil) + #expect(snapshot.giftBalance == nil) + #expect(snapshot.updatedAt == now) + } + + @Test + func `parses paid and granted balance fields when available`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let json = """ + { + "code": 0, + "message": "", + "data": { + "balance": "50.00", + "frozenBalance": null, + "currency": "USD", + "overdraftLimit": null, + "remainingOverdraftLimit": null, + "giftBalance": "20.00", + "cashBalance": "30.00" + } + } + """ + + let snapshot = try MiMoUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.balance == 50) + #expect(snapshot.cashBalance == 30) + #expect(snapshot.giftBalance == 20) + #expect(snapshot.currency == "USD") + } + + @Test + func `ignores malformed optional balance components`() throws { + let json = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD", + "giftBalance": "", + "cashBalance": "unknown" + } + } + """ + + let snapshot = try MiMoUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + #expect(snapshot.balance == 25.51) + #expect(snapshot.cashBalance == nil) + #expect(snapshot.giftBalance == nil) + } + + @Test + func `parses token plan detail payload`() throws { + let json = """ + { + "code": 0, + "message": "", + "data": { + "planCode": "standard", + "currentPeriodEnd": "2026-05-04 23:59:59", + "expired": false + } + } + """ + + let detail = try MiMoUsageFetcher.parseTokenPlanDetail(from: Data(json.utf8)) + + #expect(detail.planCode == "standard") + #expect(detail.expired == false) + #expect(detail.periodEnd != nil) + } + + @Test + func `parses token plan usage payload`() throws { + let json = """ + { + "code": 0, + "message": "", + "data": { + "monthUsage": { + "percent": 0.0505, + "items": [ + { + "name": "month_total_token", + "used": 10100158, + "limit": 200000000, + "percent": 0.0505 + } + ] + } + } + } + """ + + let usage = try MiMoUsageFetcher.parseTokenPlanUsage(from: Data(json.utf8)) + + #expect(usage.used == 10_100_158) + #expect(usage.limit == 200_000_000) + #expect(usage.percent == 0.0505) + } + + @Test + func `combined snapshot merges balance and token plan`() throws { + let now = Date(timeIntervalSince1970: 1_742_771_200) + let balanceJSON = """ + {"code":0,"message":"","data":{"balance":"25.51","currency":"USD","cashBalance":"20","giftBalance":"5.51"}} + """ + let detailJSON = """ + {"code":0,"message":"","data":{"planCode":"standard","currentPeriodEnd":"2026-05-04 23:59:59","expired":false}} + """ + let usageJSON = """ + { + "code": 0, + "message": "", + "data": { + "monthUsage": { + "percent": 0.0505, + "items": [ + { + "name": "month_total_token", + "used": 10100158, + "limit": 200000000, + "percent": 0.0505 + } + ] + } + } + } + """ + + let snapshot = try MiMoUsageFetcher.parseCombinedSnapshot( + balanceData: Data(balanceJSON.utf8), + tokenDetailData: Data(detailJSON.utf8), + tokenUsageData: Data(usageJSON.utf8), + now: now) + + #expect(snapshot.balance == 25.51) + #expect(snapshot.currency == "USD") + #expect(snapshot.cashBalance == 20) + #expect(snapshot.giftBalance == 5.51) + #expect(snapshot.planCode == "standard") + #expect(snapshot.tokenUsed == 10_100_158) + #expect(snapshot.tokenLimit == 200_000_000) + #expect(snapshot.tokenPercent == 0.0505) + } + + @Test + func `fetch usage hits mimo balance endpoint with browser headers`() async throws { + let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiMoStubURLProtocol.self) + } + MiMoStubURLProtocol.handler = nil + } + + let lock = NSLock() + var requestedPaths: [String] = [] + MiMoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + lock.withLock { + requestedPaths.append(url.path) + } + #expect(request.value(forHTTPHeaderField: "Cookie") == "api-platform_serviceToken=svc-token; userId=123") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + #expect(request.value(forHTTPHeaderField: "x-timeZone") == "UTC+01:00") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://platform.xiaomimimo.com/#/console/balance") + let body = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + + let snapshot = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: "Cookie: userId=123; api-platform_serviceToken=svc-token", + environment: ["MIMO_API_URL": "https://mimo.test/api/v1"], + now: Date(timeIntervalSince1970: 1_742_771_200)) + + #expect(snapshot.balance == 25.51) + #expect(snapshot.currency == "USD") + #expect(requestedPaths.contains("/api/v1/balance")) + } + + @Test + func `required balance failure cancels optional mimo requests promptly`() async throws { + let optionalStarted = MiMoOptionalRequestGate() + let transport = ProviderHTTPTransportStub { request in + let path = try #require(request.url?.path) + if path.hasSuffix("/balance") { + await optionalStarted.wait() + throw URLError(.userAuthenticationRequired) + } + + await optionalStarted.open() + try await Task.sleep(for: .seconds(5)) + let (response, data) = try Self.makeResponse(url: #require(request.url), body: "{}") + return (data, response) + } + + let startedAt = ContinuousClock.now + do { + _ = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: "userId=123; api-platform_serviceToken=svc-token", + environment: ["MIMO_API_URL": "https://mimo.test/api/v1"], + session: transport) + Issue.record("Expected required balance request to fail") + } catch let error as URLError { + #expect(error.code == .userAuthenticationRequired) + } + let elapsed = startedAt.duration(to: .now) + + #expect(elapsed < .seconds(1), "Required failure was delayed by optional requests: \(elapsed)") + } + + @Test + func `fetch usage treats auth redirect as login required`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let (response, data) = Self.makeResponse(url: url, body: "", statusCode: 302) + return (data, response) + } + + do { + _ = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: "userId=123; api-platform_serviceToken=expired-token", + environment: ["MIMO_API_URL": "https://mimo.test/api/v1"], + session: transport) + Issue.record("Expected MiMo auth redirect to require login") + } catch MiMoUsageError.loginRequired { + // Expected. + } + } +} + +private actor MiMoOptionalRequestGate { + private var isOpen = false + private var continuations: [CheckedContinuation] = [] + + func wait() async { + guard !self.isOpen else { return } + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func open() { + self.isOpen = true + let continuations = self.continuations + self.continuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } +} + +extension MiMoProviderTests { + @Test + @MainActor + func `provider detail plan row formats mimo as balance`() { + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + let legacyBalance = ProviderDetailView.planRow(provider: .mimo, planText: "Balance: $25.51") + let tokenPlan = ProviderDetailView.planRow(provider: .mimo, planText: "Standard") + + #expect(legacyBalance?.label == "Balance") + #expect(legacyBalance?.value == "$25.51") + #expect(tokenPlan?.label == "Plan") + #expect(tokenPlan?.value == "Standard") + } + } + + @Test(arguments: [UsageProvider.openrouter, .mimo]) + @MainActor + func `menu descriptor renders balance providers without duplicate prefix`(provider: UsageProvider) throws { + let suite = "MiMoProviderTests-menu-balance-\(provider.rawValue)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting(self.makeBalanceSnapshot(provider: provider), provider: provider) + + let descriptor = MenuDescriptor.build( + provider: provider, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("Balance: $25.51")) + #expect(!lines.contains("Balance: Balance: $25.51")) + if provider == .mimo { + #expect(!lines.contains(where: { $0.hasPrefix("Balance: 100%") })) + } + } + + @Test + @MainActor + func `menu descriptor renders mimo token detail without reset date`() throws { + let suite = "MiMoProviderTests-menu-token-detail" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: Date(timeIntervalSince1970: 1_742_771_200)) + .toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .mimo) + + let descriptor = MenuDescriptor.build( + provider: .mimo, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let lines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(lines.contains("10 / 100 Credits")) + #expect(!lines.contains("Resets 10 / 100 Credits")) + } + + @Test + func `mimo web strategy unavailable when cookie source is off`() async { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=svc-token; userId=123", + sourceLabel: "cached") + defer { CookieHeaderCache.clear(provider: .mimo) } + + let strategy = MiMoWebFetchStrategy() + let context = self.makeContext(settings: ProviderSettingsSnapshot.make( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: .off, + manualCookieHeader: nil))) + + let available = await strategy.isAvailable(context) + + #expect(available == false) + } + + @Test + func `mimo local strategy works when web cookies are disabled or invalid`() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-local-strategy-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("usage.json") + let payload: [String: Any] = [ + "updated_at": "2026-06-03T05:04:03+00:00", + "sessions_scanned": 2, + "windows": [ + "today": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + "week": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + "all_time": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + ], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: file) + + let settings = [ + ProviderSettingsSnapshot.make(mimo: .init(cookieSource: .off, manualCookieHeader: nil)), + ProviderSettingsSnapshot.make( + mimo: .init(cookieSource: .manual, manualCookieHeader: "Cookie: userId=123")), + ] + + for setting in settings { + let context = self.makeContext( + settings: setting, + environment: ["MIMO_LOCAL_USAGE_PATH": file.path]) + let outcome = await MiMoProviderDescriptor.descriptor.fetchPlan.fetchOutcome( + context: context, + provider: .mimo) + + switch outcome.result { + case let .success(result): + #expect(result.sourceLabel == "local") + #expect(result.strategyID == "mimo.local") + #expect(result.usage.primary == nil) + #expect(result.usage.mimoUsage == nil) + #expect(result.usage.loginMethod(for: .mimo)?.contains("Local") == true) + case let .failure(error): + Issue.record("Expected local MiMo fallback, got \(error)") + } + } + } + + @Test + func `mimo malformed local cache stays available and reports its cache error`() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-invalid-local-strategy-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("usage.json") + try Data("{}".utf8).write(to: file) + + let context = self.makeContext(environment: ["MIMO_LOCAL_USAGE_PATH": file.path]) + let strategy = MiMoLocalFetchStrategy() + + #expect(await strategy.isAvailable(context)) + await #expect(throws: MiMoLocalUsageError.self) { + try await strategy.fetch(context) + } + } + + @Test + func `mimo explicit web mode does not use local fallback`() async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-web-mode-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("usage.json") + let payload: [String: Any] = [ + "updated_at": "2026-06-03T05:04:03+00:00", + "sessions_scanned": 1, + "windows": [ + "today": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + "week": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + "all_time": ["input": 100, "output": 50, "cache_read": 0, "cache_create": 0], + ], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: file) + + let context = self.makeContext( + sourceMode: .web, + settings: ProviderSettingsSnapshot.make( + mimo: .init(cookieSource: .off, manualCookieHeader: nil)), + environment: ["MIMO_LOCAL_USAGE_PATH": file.path]) + let outcome = await MiMoProviderDescriptor.descriptor.fetchPlan.fetchOutcome( + context: context, + provider: .mimo) + + switch outcome.result { + case let .success(result): + Issue.record("Expected explicit web mode to reject local fallback, got \(result.strategyID)") + case .failure: + break + } + } + + @Test + func `mimo manual mode does not report available from cached browser session`() async { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=svc-token; userId=123", + sourceLabel: "cached") + defer { CookieHeaderCache.clear(provider: .mimo) } + + let strategy = MiMoWebFetchStrategy() + let context = self.makeContext(settings: ProviderSettingsSnapshot.make( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: .manual, + manualCookieHeader: "Cookie: userId=123"))) + + let available = await strategy.isAvailable(context) + + #expect(available == false) + } + + @Test + func `mimo manual mode rejects invalid header instead of falling back to cached session`() async { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=svc-token; userId=123", + sourceLabel: "cached") + defer { CookieHeaderCache.clear(provider: .mimo) } + + let strategy = MiMoWebFetchStrategy() + let context = self.makeContext(settings: ProviderSettingsSnapshot.make( + mimo: ProviderSettingsSnapshot.MiMoProviderSettings( + cookieSource: .manual, + manualCookieHeader: "Cookie: userId=123"))) + + await #expect(throws: MiMoSettingsError.invalidCookie) { + _ = try await strategy.fetch(context) + } + } + + @Test + func `mimo cookie importer surfaces safari access denial`() throws { + let detection = BrowserDetection( + homeDirectory: "/tmp/codexbar-mimo-browser-test", + cacheTTL: 0, + fileExists: { _ in false }, + directoryContents: { _ in nil }) + + do { + _ = try MiMoCookieImporter.importSessions( + browserDetection: detection, + loadRecords: { browser, _, _ in + throw BrowserCookieError.accessDenied( + browser: browser, + details: "Grant CodexBar Full Disk Access to read Safari cookies.") + }) + Issue.record("Expected Safari access denial") + } catch let error as MiMoSettingsError { + #expect(error.localizedDescription.contains("Full Disk Access")) + #expect(error.localizedDescription.contains("Safari")) + } + } + + @Test + func `mimo web strategy retries imported sessions after decode failure`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiMoStubURLProtocol.self) + } + MiMoStubURLProtocol.handler = nil + MiMoCookieImporter.importSessionsOverrideForTesting = nil + CookieHeaderCache.clear(provider: .mimo) + } + + CookieHeaderCache.clear(provider: .mimo) + CookieHeaderCache.store(provider: .mimo, cookieHeader: "invalid", sourceLabel: "invalid") + + MiMoCookieImporter.importSessionsOverrideForTesting = { _, _ in + [ + .init( + cookieHeader: "api-platform_serviceToken=expired-token; userId=111", + sourceLabel: "Expired Chrome"), + .init( + cookieHeader: "api-platform_serviceToken=valid-token; userId=222", + sourceLabel: "Active Chrome"), + ] + } + + let lock = NSLock() + var requestedCookies: [String] = [] + MiMoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let cookie = request.value(forHTTPHeaderField: "Cookie") ?? "" + lock.withLock { + requestedCookies.append(cookie) + } + + if cookie.contains("expired-token") { + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "text/html"])! + return (response, Data("login".utf8)) + } + + let body = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + + let strategy = MiMoWebFetchStrategy() + let result = try await strategy + .fetch(self.makeContext(environment: ["MIMO_API_URL": "https://mimo.test/api/v1"])) + + #expect(requestedCookies.count == 6) + #expect(requestedCookies.contains(where: { $0.contains("expired-token") })) + #expect(requestedCookies.contains(where: { $0.contains("valid-token") })) + #expect(result.usage.mimoUsage?.balanceDetail == "$25.51") + #expect(CookieHeaderCache.load(provider: .mimo)?.sourceLabel == "Active Chrome") + } + + @Test + func `mimo web strategy retries safari after stale chrome auth redirect`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + let registered = URLProtocol.registerClass(MiMoStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(MiMoStubURLProtocol.self) + } + MiMoStubURLProtocol.handler = nil + MiMoCookieImporter.importSessionsOverrideForTesting = nil + CookieHeaderCache.clear(provider: .mimo) + } + + CookieHeaderCache.clear(provider: .mimo) + CookieHeaderCache.store( + provider: .mimo, + cookieHeader: "api-platform_serviceToken=stale-chrome-token; userId=111", + sourceLabel: "Chrome") + + MiMoCookieImporter.importSessionsOverrideForTesting = { _, _ in + [ + .init( + cookieHeader: "api-platform_serviceToken=stale-chrome-token; userId=111", + sourceLabel: "Chrome"), + .init( + cookieHeader: "api-platform_serviceToken=valid-safari-token; userId=222", + sourceLabel: "Safari"), + ] + } + + let lock = NSLock() + var requestedCookies: [String] = [] + MiMoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let cookie = request.value(forHTTPHeaderField: "Cookie") ?? "" + lock.withLock { + requestedCookies.append(cookie) + } + + if cookie.contains("stale-chrome-token") { + return Self.makeResponse(url: url, body: "", statusCode: 302) + } + + let body = """ + { + "code": 0, + "message": "", + "data": { + "balance": "25.51", + "currency": "USD" + } + } + """ + return Self.makeResponse(url: url, body: body) + } + + let strategy = MiMoWebFetchStrategy() + let result = try await strategy + .fetch(self.makeContext(environment: ["MIMO_API_URL": "https://mimo.test/api/v1"])) + + #expect(requestedCookies.contains(where: { $0.contains("stale-chrome-token") })) + #expect(requestedCookies.contains(where: { $0.contains("valid-safari-token") })) + #expect(result.usage.mimoUsage?.balanceDetail == "$25.51") + #expect(CookieHeaderCache.load(provider: .mimo)?.sourceLabel == "Safari") + } + + #if os(macOS) + @Test + func `mimo importer merges profile stores before validating auth cookies`() { + let profile = BrowserProfile(id: "Default", name: "Default") + let primaryStore = BrowserCookieStore( + browser: .chrome, + profile: profile, + kind: .primary, + label: "Chrome Default", + databaseURL: nil) + let networkStore = BrowserCookieStore( + browser: .chrome, + profile: profile, + kind: .network, + label: "Chrome Default (Network)", + databaseURL: nil) + let expires = Date(timeIntervalSince1970: 1_900_000_000) + + let sessions = MiMoCookieImporter.sessionInfos(from: [ + BrowserCookieStoreRecords(store: primaryStore, records: [ + BrowserCookieRecord( + domain: "platform.xiaomimimo.com", + name: "userId", + path: "/", + value: "123", + expires: expires, + isSecure: true, + isHTTPOnly: false), + ]), + BrowserCookieStoreRecords(store: networkStore, records: [ + BrowserCookieRecord( + domain: "platform.xiaomimimo.com", + name: "api-platform_serviceToken", + path: "/", + value: "token", + expires: expires, + isSecure: true, + isHTTPOnly: true), + ]), + ]) + + #expect(sessions.count == 1) + #expect(sessions.first?.sourceLabel == "Chrome Default") + #expect(sessions.first?.cookieHeader == "api-platform_serviceToken=token; userId=123") + } + #endif + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int = 200) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func makeMenuCardModel( + snapshot: UsageSnapshot, + metadata: ProviderMetadata, + now: Date) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model.make(.init( + provider: .mimo, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + private func makeBalanceSnapshot(provider: UsageProvider) -> UsageSnapshot { + let updatedAt = Date(timeIntervalSince1970: 1_742_771_200) + switch provider { + case .openrouter: + return OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 24.49, + balance: 25.51, + usedPercent: 49, + keyDataFetched: false, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: updatedAt).toUsageSnapshot() + case .mimo: + return MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + updatedAt: updatedAt).toUsageSnapshot() + default: + Issue.record("Unexpected provider \(provider.rawValue)") + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: updatedAt) + } + } + + private func makeContext( + sourceMode: ProviderSourceMode = .auto, + settings: ProviderSettingsSnapshot? = nil, + environment: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: browserDetection) + } + + private func makeCookie( + name: String, + value: String, + domain: String, + path: String = "/", + expiresAt: Date) throws -> HTTPCookie + { + let properties: [HTTPCookiePropertyKey: Any] = [ + .name: name, + .value: value, + .domain: domain, + .path: path, + .expires: expiresAt, + .secure: "TRUE", + ] + return try #require(HTTPCookie(properties: properties)) + } +} + +final class MiMoStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "mimo.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/MiMoUsageScriptTests.swift b/Tests/CodexBarTests/MiMoUsageScriptTests.swift new file mode 100644 index 0000000000..27be4f4257 --- /dev/null +++ b/Tests/CodexBarTests/MiMoUsageScriptTests.swift @@ -0,0 +1,195 @@ +import Foundation +import Testing + +struct MiMoUsageScriptTests { + @Test + func `script keeps final cumulative streaming usage`() throws { + let rows = [ + self.assistantRow(outputTokens: 10), + self.assistantRow(outputTokens: 40), + self.assistantRow(outputTokens: 90), + ] + let allTime = try self.runScript(files: ["session.jsonl": rows]) + + self.assertUsage( + allTime, + expected: .init(input: 120, cacheCreate: 10, cacheRead: 5, output: 90, messages: 1)) + } + + @Test + func `script keeps final cumulative streaming usage without session id`() throws { + let rows = [ + self.assistantRow(outputTokens: 10, sessionID: nil), + self.assistantRow(outputTokens: 40, sessionID: nil), + self.assistantRow(outputTokens: 90, sessionID: nil), + ] + let allTime = try self.runScript(files: ["session.jsonl": rows]) + + self.assertUsage( + allTime, + expected: .init(input: 120, cacheCreate: 10, cacheRead: 5, output: 90, messages: 1)) + } + + @Test + func `script keeps final cumulative usage without request id`() throws { + let rows = [ + self.assistantRow(outputTokens: 10, requestID: nil), + self.assistantRow(outputTokens: 40, requestID: nil), + self.assistantRow(outputTokens: 90, requestID: nil), + ] + let allTime = try self.runScript(files: ["session.jsonl": rows]) + + self.assertUsage( + allTime, + expected: .init(input: 120, cacheCreate: 10, cacheRead: 5, output: 90, messages: 1)) + } + + @Test + func `script counts rows without session identity conservatively`() throws { + let rows = [ + self.assistantRow(outputTokens: 10, sessionID: nil, requestID: nil), + self.assistantRow(outputTokens: 40, sessionID: nil, requestID: nil), + self.assistantRow(outputTokens: 90, sessionID: nil, requestID: nil), + ] + let allTime = try self.runScript(files: ["session.jsonl": rows]) + + self.assertUsage( + allTime, + expected: .init(input: 360, cacheCreate: 30, cacheRead: 15, output: 140, messages: 3)) + } + + @Test + func `script keeps distinct requests sharing a message id`() throws { + let rows = [ + self.assistantRow(outputTokens: 40, requestID: "req_one"), + self.assistantRow(outputTokens: 90, requestID: "req_two"), + ] + let allTime = try self.runScript(files: ["session.jsonl": rows]) + + self.assertUsage( + allTime, + expected: .init(input: 240, cacheCreate: 20, cacheRead: 10, output: 130, messages: 2)) + } + + @Test + func `script deduplicates copied rows from the same session`() throws { + let rows = [self.assistantRow(outputTokens: 90)] + let allTime = try self.runScript(files: [ + "session.jsonl": rows, + "session-copy.jsonl": rows, + ]) + + self.assertUsage( + allTime, + expected: .init(input: 120, cacheCreate: 10, cacheRead: 5, output: 90, messages: 1)) + } + + @Test + func `script deduplicates copied requests from different sessions`() throws { + let allTime = try self.runScript(files: [ + "session-a.jsonl": [self.assistantRow(outputTokens: 90, sessionID: "session_a")], + "session-b.jsonl": [self.assistantRow(outputTokens: 90, sessionID: "session_b")], + ]) + + self.assertUsage( + allTime, + expected: .init(input: 120, cacheCreate: 10, cacheRead: 5, output: 90, messages: 1)) + } + + private func runScript(files: [String: [[String: Any]]]) throws -> [String: Any] { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("mimo-usage-script-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + + let mimoHome = root.appendingPathComponent("mimo") + let projects = mimoHome + .appendingPathComponent(".claude") + .appendingPathComponent("projects") + .appendingPathComponent("project-a") + try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true) + + for (name, rows) in files { + let session = projects.appendingPathComponent(name) + let jsonl = try rows + .map { try JSONSerialization.data(withJSONObject: $0) } + .map { try #require(String(bytes: $0, encoding: .utf8)) } + .joined(separator: "\n") + try jsonl.write(to: session, atomically: true, encoding: .utf8) + } + + let cache = root.appendingPathComponent("usage.json") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["python3", self.scriptURL.path, "--update"] + process.environment = ProcessInfo.processInfo.environment.merging([ + "MIMO_CLAUDE_HOME": mimoHome.path, + "MIMO_LOCAL_USAGE_PATH": cache.path, + ]) { _, new in new } + let stderr = Pipe() + process.standardError = stderr + + try process.run() + process.waitUntilExit() + + let errorText = try #require(String( + bytes: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8)) + #expect(process.terminationStatus == 0, Comment(rawValue: errorText)) + + let payload = try #require( + JSONSerialization.jsonObject(with: Data(contentsOf: cache)) as? [String: Any]) + let windows = try #require(payload["windows"] as? [String: Any]) + return try #require(windows["all_time"] as? [String: Any]) + } + + private func assertUsage(_ allTime: [String: Any], expected: UsageExpectation) { + #expect(allTime["input"] as? Int == expected.input) + #expect(allTime["cache_create"] as? Int == expected.cacheCreate) + #expect(allTime["cache_read"] as? Int == expected.cacheRead) + #expect(allTime["output"] as? Int == expected.output) + #expect(allTime["messages"] as? Int == expected.messages) + } + + private var scriptURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Scripts/mimo-usage.py") + } + + private func assistantRow( + outputTokens: Int, + sessionID: String? = "session_stream", + requestID: String? = "req_stream") -> [String: Any] + { + var row: [String: Any] = [ + "type": "assistant", + "timestamp": ISO8601DateFormatter().string(from: Date()), + "message": [ + "id": "msg_stream", + "usage": [ + "input_tokens": 120, + "cache_creation_input_tokens": 10, + "cache_read_input_tokens": 5, + "output_tokens": outputTokens, + ], + ], + ] + if let sessionID { + row["sessionId"] = sessionID + } + if let requestID { + row["requestId"] = requestID + } + return row + } + + private struct UsageExpectation { + let input: Int + let cacheCreate: Int + let cacheRead: Int + let output: Int + let messages: Int + } +} diff --git a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift index e427484f86..09145a3323 100644 --- a/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift +++ b/Tests/CodexBarTests/MiniMaxAPITokenFetchTests.swift @@ -5,15 +5,12 @@ import Testing @Suite(.serialized) struct MiniMaxAPITokenFetchTests { @Test - func retriesChinaHostWhenGlobalRejectsToken() async throws { - let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self) + func `retries china host when global rejects token`() async throws { defer { - if registered { - URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self) - } MiniMaxAPITokenStubURLProtocol.handler = nil MiniMaxAPITokenStubURLProtocol.requests = [] } + MiniMaxAPITokenStubURLProtocol.requests = [] MiniMaxAPITokenStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } @@ -45,24 +42,32 @@ struct MiniMaxAPITokenFetchTests { } let now = Date(timeIntervalSince1970: 1_700_000_000) - let snapshot = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .global, now: now) + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .global, + now: now, + session: Self.makeSession()) #expect(snapshot.planName == "Max") - #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) - #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") - #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + ]) + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + ]) } @Test - func preservesInvalidCredentialsWhenChinaRetryFailsTransport() async throws { - let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self) + func `preserves invalid credentials when china retry fails transport`() async throws { defer { - if registered { - URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self) - } MiniMaxAPITokenStubURLProtocol.handler = nil MiniMaxAPITokenStubURLProtocol.requests = [] } + MiniMaxAPITokenStubURLProtocol.requests = [] MiniMaxAPITokenStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } @@ -78,24 +83,65 @@ struct MiniMaxAPITokenFetchTests { let now = Date(timeIntervalSince1970: 1_700_000_000) await #expect(throws: MiniMaxUsageError.invalidCredentials) { - _ = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .global, now: now) + _ = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .global, + now: now, + session: Self.makeSession()) } - #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 2) - #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimax.io") - #expect(MiniMaxAPITokenStubURLProtocol.requests.last?.url?.host == "api.minimaxi.com") + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + "api.minimaxi.com", + ]) + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) } @Test - func doesNotRetryWhenRegionIsChinaMainland() async throws { - let registered = URLProtocol.registerClass(MiniMaxAPITokenStubURLProtocol.self) + func `explicit china region preserves structured invalid credentials across legacy fallback`() async throws { defer { - if registered { - URLProtocol.unregisterClass(MiniMaxAPITokenStubURLProtocol.self) + MiniMaxAPITokenStubURLProtocol.handler = nil + MiniMaxAPITokenStubURLProtocol.requests = [] + } + MiniMaxAPITokenStubURLProtocol.requests = [] + + MiniMaxAPITokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/v1/token_plan/remains" { + return Self.makeResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"invalid api key"}}"#) } + return Self.makeResponse(url: url, body: "{}", statusCode: 404) + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + _ = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .chinaMainland, + session: Self.makeSession()) + } + + #expect(MiniMaxAPITokenStubURLProtocol.requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `does not retry when region is china mainland`() async throws { + defer { MiniMaxAPITokenStubURLProtocol.handler = nil MiniMaxAPITokenStubURLProtocol.requests = [] } + MiniMaxAPITokenStubURLProtocol.requests = [] MiniMaxAPITokenStubURLProtocol.handler = { request in guard let url = request.url else { throw URLError(.badURL) } @@ -124,12 +170,22 @@ struct MiniMaxAPITokenFetchTests { } let now = Date(timeIntervalSince1970: 1_700_000_000) - _ = try await MiniMaxUsageFetcher.fetchUsage(apiToken: "sk-cp-test", region: .chinaMainland, now: now) + _ = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .chinaMainland, + now: now, + session: Self.makeSession()) #expect(MiniMaxAPITokenStubURLProtocol.requests.count == 1) #expect(MiniMaxAPITokenStubURLProtocol.requests.first?.url?.host == "api.minimaxi.com") } + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MiniMaxAPITokenStubURLProtocol.self] + return URLSession(configuration: config) + } + private static func makeResponse( url: URL, body: String, diff --git a/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift new file mode 100644 index 0000000000..8a1d1c9999 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxCurrentTokenPlanResponseTests.swift @@ -0,0 +1,218 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct MiniMaxCurrentTokenPlanResponseTests { + @Test + func `coarse html plan name does not replace remains api plan name`() { + let remainsSnapshot = MiniMaxUsageSnapshot( + planName: "Token Plan Pro", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: Date()) + + let enriched = remainsSnapshot.withPlanNameIfMissing("Plus") + + #expect(enriched.planName == "Token Plan Pro") + } + + @Test + func `parses token plan boosted weekly lane with permille spelling`() throws { + let now = Date(timeIntervalSince1970: 1_782_050_596) + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(Self.currentTokenPlanRemainsJSON.utf8), + now: now) + let services = try #require(snapshot.services) + + #expect(services.map(\.windowType) == ["5 hours", "Weekly"]) + #expect(services[0].usage == 0) + #expect(services[0].limit == 100) + #expect(services[0].percent == 0) + #expect(services[1].usage == 45) + #expect(services[1].limit == 150) + #expect(services[1].percent == 30) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 0) + #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 30) + } + + @Test + func `web usage fetch enriches parsed html without service quota data from remains api`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan Plus available usage 1000 prompts 5 hours
", + contentType: "text/html") + } + #expect(url.host == "platform.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.services?.count == 2) + #expect(snapshot.planName == "Plus") + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.host } == [ + "platform.minimaxi.com", + "platform.minimaxi.com", + ]) + } + + @Test + func `web usage fetch preserves auth failure from parseable html remains fallback`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan Plus available usage 1000 prompts / 5 hours
", + contentType: "text/html") + } + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [ + "/user-center/payment/coding-plan", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `web usage fetch preserves cancellation from parseable html remains fallback`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan Plus available usage 1000 prompts / 5 hours
", + contentType: "text/html") + } + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [ + "/user-center/payment/coding-plan", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + private static let currentTokenPlanRemainsJSON = """ + { + "model_remains": [ + { + "start_time": 1782043200000, + "end_time": 1782057600000, + "remains_time": 7003536, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1781452800000, + "weekly_end_time": 1782057600000, + "weekly_remains_time": 7003536, + "current_interval_status": 1, + "current_interval_remaining_percent": 100, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 70, + "weekly_boost_permille": 1500 + }, + { + "start_time": 1781971200000, + "end_time": 1782057600000, + "remains_time": 7003536, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1781452800000, + "weekly_end_time": 1782057600000, + "weekly_remains_time": 7003536, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + private static let percentBasedRemainsJSON = """ + { + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/MiniMaxLocalStorageImporterTests.swift b/Tests/CodexBarTests/MiniMaxLocalStorageImporterTests.swift index d35b65024c..e8a188d78e 100644 --- a/Tests/CodexBarTests/MiniMaxLocalStorageImporterTests.swift +++ b/Tests/CodexBarTests/MiniMaxLocalStorageImporterTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct MiniMaxLocalStorageImporterTests { @Test - func extractsAccessTokensFromJSONPreferringLongTokens() { + func `extracts access tokens from JSON preferring long tokens`() { let shortToken = String(repeating: "b", count: 24) let longToken = String(repeating: "a", count: 72) let payload = """ @@ -19,7 +18,7 @@ struct MiniMaxLocalStorageImporterTests { } @Test - func extractsGroupIDFromJSONString() { + func `extracts group ID from JSON string`() { let payload = """ {"user":{"groupId":"98765"}} """ @@ -30,7 +29,7 @@ struct MiniMaxLocalStorageImporterTests { } @Test - func resolvesGroupIDFromJWTClaims() { + func `resolves group ID from JWT claims`() { let token = Self.makeJWT(payload: [ "iss": "minimax", "group_id": "12345", @@ -42,7 +41,7 @@ struct MiniMaxLocalStorageImporterTests { } @Test - func rejectsNonMiniMaxJWTsWithoutSignal() { + func `rejects non mini max JW ts without signal`() { let token = Self.makeJWT(payload: [ "iss": "other", "pad": String(repeating: "y", count: 80), diff --git a/Tests/CodexBarTests/MiniMaxLogRedactorTests.swift b/Tests/CodexBarTests/MiniMaxLogRedactorTests.swift new file mode 100644 index 0000000000..d37ed2251e --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxLogRedactorTests.swift @@ -0,0 +1,105 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct MiniMaxLogRedactorTests { + private static var miniMaxCpPlaceholder: String { + ["sk", "cp", "placeholder"].joined(separator: "-") + } + + private static var miniMaxApiPlaceholder: String { + ["sk", "api", "placeholder"].joined(separator: "-") + } + + @Test + func `sk-cp token is redacted`() { + let input = Self.miniMaxCpPlaceholder + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("sk-cp-") == false) + #expect(redacted.contains("")) + #expect(redacted.contains("placeholder") == false) + } + + @Test + func `sk-api token is redacted`() { + let input = Self.miniMaxApiPlaceholder + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("sk-api-") == false) + #expect(redacted.contains("")) + #expect(redacted.contains("placeholder") == false) + } + + @Test + func `cookie header is redacted`() { + let input = "Cookie: session=cookie-session-placeholder; token=\(Self.miniMaxCpPlaceholder)" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("session=cookie-session-placeholder") == false) + #expect(redacted.contains(Self.miniMaxCpPlaceholder) == false) + #expect(redacted.contains("Cookie: ")) + } + + @Test + func `authorization header value is redacted`() { + // Short obvious placeholder, not JWT-like + let input = "Authorization: Bearer fake-bearer-token" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("fake-bearer-token") == false) + #expect(redacted.contains("Authorization:")) + } + + @Test + func `bearer token is not present in raw form`() { + let input = "Authorization: bearer \(Self.miniMaxApiPlaceholder)" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains(Self.miniMaxApiPlaceholder) == false) + } + + @Test + func `email is redacted`() { + let input = "Contact: user@example.com" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("user@example.com") == false) + #expect(redacted.contains("")) + } + + @Test + func `minimax token in cookie is not present in raw form`() { + let input = "Cookie: session=session-placeholder; token=\(Self.miniMaxCpPlaceholder)" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("session=session-placeholder") == false) + #expect(redacted.contains(Self.miniMaxCpPlaceholder) == false) + } + + @Test + func `redacted text no longer matches original token pattern`() { + let originalToken = Self.miniMaxCpPlaceholder + let input = "Token: \(originalToken)" + let redacted = LogRedactor.redact(input) + + #expect(redacted.contains(originalToken) == false) + #expect(redacted.contains("")) + } + + @Test + func `minimax token with punctuation suffix is fully redacted`() { + let punctuatedToken = "\(Self.miniMaxApiPlaceholder).suffix-more" + let input = "Error: token=\(punctuatedToken)" + let redacted = LogRedactor.redact(input) + + #expect(redacted.contains("sk-api-") == false) + #expect(redacted.contains("suffix-more") == false) + #expect(redacted.contains("")) + } + + @Test + func `authorization header minimax token leaves no suffix fragment`() { + let punctuatedToken = "\(Self.miniMaxCpPlaceholder)-part.two" + let input = "Authorization: Bearer \(punctuatedToken)" + let redacted = LogRedactor.redact(input) + + #expect(redacted.contains("sk-cp-") == false) + #expect(redacted.contains("part.two") == false) + #expect(redacted.contains("Authorization: ")) + } +} diff --git a/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift new file mode 100644 index 0000000000..461c7f66ae --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxMenuCardBillingTests.swift @@ -0,0 +1,103 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MiniMaxMenuCardBillingTests { + @Test + func `minimax billing history renders inline dashboard`() throws { + let now = Date() + let billing = MiniMaxBillingSummary( + todayTokens: 1234, + last30DaysTokens: 5678, + todayCash: 1.5, + last30DaysCash: 4.25, + daily: [ + MiniMaxBillingDay(day: "2026-05-16", tokens: 1111, cash: 2.75), + MiniMaxBillingDay(day: "2026-05-17", tokens: 1234, cash: 1.5), + ], + topMethods: [MiniMaxBillingBreakdown(name: "chat", tokens: 2345, cash: 4.25)], + topModels: [MiniMaxBillingBreakdown(name: "MiniMax-M1", tokens: 2345, cash: 4.25)], + updatedAt: now) + let minimax = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Today", + timeRange: "2026/05/17 00:00 - 2026/05/18 00:00", + usage: 2, + limit: 10, + percent: 20, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + ], + billingSummary: billing) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 1440, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.accessibilityLabel == "MiniMax 30 day token usage trend") + #expect(model.inlineUsageDashboard?.kpis.first?.value == "1.2K") + #expect(model.inlineUsageDashboard?.points.count == 2) + #expect(model.usageNotes.contains("Last 30 days: 5.7K tokens")) + + let hiddenModel = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(hiddenModel.inlineUsageDashboard == nil) + #expect(!hiddenModel.usageNotes.contains("Last 30 days: 5.7K tokens")) + } +} diff --git a/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift new file mode 100644 index 0000000000..23740cb502 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxMenuCardModelPlanTests.swift @@ -0,0 +1,286 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +struct MiniMaxMenuCardModelPlanTests { + @Test + func `minimax loginMethod maps to planText in MenuCardModel`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "MiniMax Star", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "MiniMax Star")) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.planText == "MiniMax Star") + } + + @Test + func `minimax nil loginMethod results in nil planText`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: nil, + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + minimaxUsage: minimax, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.planText == nil) + } + + @Test + func `minimax quota rows include configured warning markers`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanPlus-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 31, + limit: 100, + percent: 31, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + now: now)) + + #expect(model.metrics.map(\.warningMarkerPercents) == [[50, 80], [50, 80]]) + } + + @Test + func `minimax quota rows use canonical general first order`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "TokenPlanMax-年度会员", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "video", + windowType: "Today", + timeRange: "06/01 00:00 - 06/02 00:00(UTC+8)", + usage: 70, + limit: 100, + percent: 70, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 4, + limit: 100, + percent: 4, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 1, + limit: 100, + percent: 1, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["General · 5h", "General · Weekly", "Video"]) + #expect(model.metrics.map(\.percent) == [4, 1, 70]) + } + + @Test + func `minimax unlimited quota rows omit usage copy and warning markers`() throws { + let now = Date() + let minimax = MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "general", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: 2, + limit: 200, + percent: 2, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "general", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: 0, + limit: 0, + percent: 0, + isUnlimited: true, + resetsAt: nil, + resetDescription: "Unlimited"), + ]) + let metadata = try #require(ProviderDefaults.metadata[.minimax]) + let model = UsageMenuCardView.Model.make(.init( + provider: .minimax, + metadata: metadata, + snapshot: minimax.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [50, 20], .weekly: [50, 20]], + now: now)) + + #expect(model.metrics.count == 2) + #expect(model.metrics[1].title == "General · Weekly") + #expect(model.metrics[1].statusText == "∞ Unlimited") + #expect(model.metrics[1].detailLeftText == nil) + #expect(model.metrics[1].warningMarkerPercents == []) + } +} diff --git a/Tests/CodexBarTests/MiniMaxProviderTests.swift b/Tests/CodexBarTests/MiniMaxProviderTests.swift index ff9eb83970..03dd11c874 100644 --- a/Tests/CodexBarTests/MiniMaxProviderTests.swift +++ b/Tests/CodexBarTests/MiniMaxProviderTests.swift @@ -2,38 +2,265 @@ import Foundation import Testing @testable import CodexBarCore -@Suite +struct MiniMaxAPISettingsReaderTests { + @Test + func `api token prefers coding plan specific environment key`() { + let token = MiniMaxAPISettingsReader.apiToken(environment: [ + "MINIMAX_API_KEY": "sk-api-standard", + "MINIMAX_CODING_API_KEY": "sk-cp-coding-plan", + ]) + + #expect(token == "sk-cp-coding-plan") + #expect(MiniMaxAPISettingsReader.apiKeyKind(token: token) == .codingPlan) + } + + @Test + func `api token falls back to generic environment key`() { + let token = MiniMaxAPISettingsReader.apiToken(environment: [ + "MINIMAX_API_KEY": "\"sk-api-standard\"", + ]) + + #expect(token == "sk-api-standard") + #expect(MiniMaxAPISettingsReader.apiKeyKind(token: token) == .standard) + } +} + +struct MiniMaxEndpointOverrideSettingsTests { + @Test + func `strict endpoint overrides reject non MiniMax hosts`() { + let env = [ + MiniMaxSettingsReader.requireProviderEndpointOverridesKey: "true", + MiniMaxSettingsReader.hostKey: "https://attacker.example", + MiniMaxSettingsReader.codingPlanURLKey: "https://attacker.example/coding-plan", + MiniMaxSettingsReader.remainsURLKey: "https://attacker.example/remains", + MiniMaxSettingsReader.billingHistoryURLKey: "https://attacker.example/account/amount", + ] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == nil) + #expect(MiniMaxSettingsReader.codingPlanURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.remainsURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.billingHistoryURL(environment: env) == nil) + } + + @Test + func `endpoint overrides reject encoded host delimiters before suffix matching`() { + let encodedSlash = "https://attacker.example%2f.platform.minimax.io" + let doubleEncodedSlash = "https://attacker.example%252f.platform.minimax.io" + let env = [ + MiniMaxSettingsReader.hostKey: encodedSlash, + MiniMaxSettingsReader.codingPlanURLKey: "\(encodedSlash)/coding-plan", + MiniMaxSettingsReader.remainsURLKey: "\(encodedSlash)/remains", + MiniMaxSettingsReader.billingHistoryURLKey: "\(encodedSlash)/account/amount", + ] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == nil) + #expect(MiniMaxSettingsReader.codingPlanURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.remainsURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.billingHistoryURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.hostOverride(environment: [ + MiniMaxSettingsReader.hostKey: doubleEncodedSlash, + ]) == nil) + } + + @Test + func `endpoint overrides reject whitespace and control characters in hosts`() { + for host in ["https://bad host", "https://bad%20host", "https://bad%09host"] { + #expect(MiniMaxSettingsReader.hostOverride(environment: [ + MiniMaxSettingsReader.hostKey: host, + ]) == nil) + #expect(MiniMaxSettingsReader.remainsURL(environment: [ + MiniMaxSettingsReader.remainsURLKey: "\(host)/remains", + ]) == nil) + } + } + + @Test + func `endpoint overrides require https and no userinfo`() { + #expect(MiniMaxSettingsReader.hostOverride(environment: [ + MiniMaxSettingsReader.hostKey: "http://platform.minimax.io", + ]) == nil) + #expect(MiniMaxSettingsReader.remainsURL(environment: [ + MiniMaxSettingsReader.remainsURLKey: "https://user:pass@platform.minimax.io/remains", + ]) == nil) + #expect(MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: [ + MiniMaxSettingsReader.hostKey: ":443", + ]) == MiniMaxSettingsReader.hostKey) + } + + @Test + func `endpoint overrides allow MiniMax and custom https hosts`() { + let env = [ + MiniMaxSettingsReader.hostKey: "platform.minimaxi.com", + MiniMaxSettingsReader.remainsURLKey: "https://platform.minimax.io/custom/remains", + ] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == "platform.minimaxi.com") + #expect(MiniMaxSettingsReader.remainsURL(environment: env)?.host == "platform.minimax.io") + + let customEnv = [ + MiniMaxSettingsReader.hostKey: "proxy.example.test", + MiniMaxSettingsReader.remainsURLKey: "https://proxy.example.test/custom/remains", + ] + #expect(MiniMaxSettingsReader.hostOverride(environment: customEnv) == "proxy.example.test") + #expect(MiniMaxSettingsReader.remainsURL(environment: customEnv)?.host == "proxy.example.test") + #expect(MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: customEnv) == nil) + } + + @Test + func `host endpoint overrides preserve explicit port`() { + let env = [MiniMaxSettingsReader.hostKey: "proxy.example.test:8443"] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == "proxy.example.test:8443") + #expect( + MiniMaxUsageFetcher.resolveCodingPlanURL(region: .global, environment: env).absoluteString == + "https://proxy.example.test:8443/user-center/payment/coding-plan?cycle_type=3") + #expect( + MiniMaxUsageFetcher.resolveRemainsURL(region: .global, environment: env).absoluteString == + "https://proxy.example.test:8443/v1/api/openplatform/coding_plan/remains") + } + + @Test + func `subscription metadata accepts host names beginning with http`() throws { + let url = try MiniMaxSubscriptionMetadataFetcher.resolveComboURL( + region: .global, + environment: [MiniMaxSettingsReader.hostKey: "https://http-proxy.example.test"]) + + #expect(url.host == "http-proxy.example.test") + #expect(url.scheme == "https") + } + + @Test + func `scheme less endpoint preserves colon in path`() { + let env = [MiniMaxSettingsReader.remainsURLKey: "proxy.example.test/api:v1"] + + #expect( + MiniMaxSettingsReader.remainsURL(environment: env)?.absoluteString == + "https://proxy.example.test/api:v1") + } + + @Test + func `custom https endpoints allow bracketed IPv6 literals`() { + let env = [ + MiniMaxSettingsReader.hostKey: "[::1]:8443", + MiniMaxSettingsReader.remainsURLKey: "https://[::1]:8443/remains", + ] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == "[::1]:8443") + #expect(MiniMaxSettingsReader.remainsURL(environment: env)?.host == "::1") + #expect(MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: env) == nil) + } + + @Test + func `strict provider endpoint mode rejects custom hosts`() { + let env = [ + MiniMaxSettingsReader.requireProviderEndpointOverridesKey: "true", + MiniMaxSettingsReader.hostKey: "proxy.example.test", + MiniMaxSettingsReader.remainsURLKey: "https://proxy.example.test/custom/remains", + ] + + #expect(MiniMaxSettingsReader.hostOverride(environment: env) == nil) + #expect(MiniMaxSettingsReader.remainsURL(environment: env) == nil) + #expect(MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: env) == MiniMaxSettingsReader.hostKey) + } + + @Test + func `custom https compatibility mode still rejects http and userinfo`() { + #expect(MiniMaxSettingsReader.hostOverride(environment: [ + MiniMaxSettingsReader.hostKey: "http://proxy.example.test", + ]) == nil) + #expect(MiniMaxSettingsReader.rejectedEndpointOverrideKey(environment: [ + MiniMaxSettingsReader.remainsURLKey: "https://user:pass@proxy.example.test/remains", + ]) == MiniMaxSettingsReader.remainsURLKey) + } + + @Test + func `explicit endpoint override rejects invalid scheme before network`() async { + await #expect(throws: ProviderEndpointOverrideError.minimax(MiniMaxSettingsReader.codingPlanURLKey)) { + _ = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "session=abc123", + environment: [MiniMaxSettingsReader.codingPlanURLKey: "http://platform.minimax.io/coding-plan"], + includeBillingHistory: false) + } + } +} + +struct MiniMaxProviderStrategyTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `browser cookie import is user initiated app only`() { + let appContext = self.makeContext(runtime: .app) + let cliContext = self.makeContext(runtime: .cli) + + #expect(MiniMaxCodingPlanFetchStrategy.allowsBrowserCookieImport(context: appContext) == false) + #expect(MiniMaxCodingPlanFetchStrategy.allowsBrowserCookieImport(context: cliContext) == false) + + ProviderInteractionContext.$current.withValue(.userInitiated) { + #expect(MiniMaxCodingPlanFetchStrategy.allowsBrowserCookieImport(context: appContext)) + #expect(MiniMaxCodingPlanFetchStrategy.allowsBrowserCookieImport(context: cliContext) == false) + } + } + + private func makeContext(runtime: ProviderRuntime) -> ProviderFetchContext { + let env: [String: String] = [:] + return ProviderFetchContext( + runtime: runtime, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } +} + struct MiniMaxCookieHeaderTests { @Test - func normalizesRawCookieHeader() { + func `normalizes raw cookie header`() { let raw = "foo=bar; session=abc123" let normalized = MiniMaxCookieHeader.normalized(from: raw) #expect(normalized == "foo=bar; session=abc123") } @Test - func extractsFromCookieHeaderLine() { + func `extracts from cookie header line`() { let raw = "Cookie: foo=bar; session=abc123" let normalized = MiniMaxCookieHeader.normalized(from: raw) #expect(normalized == "foo=bar; session=abc123") } @Test - func extractsFromCurlHeader() { + func `extracts from curl header`() { let raw = "curl https://platform.minimax.io -H 'Cookie: foo=bar; session=abc123' -H 'accept: */*'" let normalized = MiniMaxCookieHeader.normalized(from: raw) #expect(normalized == "foo=bar; session=abc123") } @Test - func extractsFromCurlCookieFlag() { + func `extracts from curl cookie flag`() { let raw = "curl https://platform.minimax.io --cookie 'foo=bar; session=abc123'" let normalized = MiniMaxCookieHeader.normalized(from: raw) #expect(normalized == "foo=bar; session=abc123") } @Test - func extractsAuthAndGroupIDFromCurl() { + func `extracts auth and group ID from curl`() { let raw = """ curl 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains?GroupId=123456' \ -H 'authorization: Bearer token123' \ @@ -46,7 +273,7 @@ struct MiniMaxCookieHeaderTests { } @Test - func extractsAuthFromUppercaseHeader() { + func `extracts auth from uppercase header`() { let raw = """ curl 'https://platform.minimax.io/v1/api/openplatform/coding_plan/remains?GROUP_ID=98765' \ -H 'Authorization: Bearer token-abc' \ @@ -56,12 +283,154 @@ struct MiniMaxCookieHeaderTests { #expect(override?.authorizationToken == "token-abc") #expect(override?.groupID == "98765") } + + @Test + func `extracts group ID from combo curl header and cookie`() { + let raw = """ + curl 'https://www.minimaxi.com/v1/api/openplatform/charge/combo/cycle_audio_resource_package' \ + -b 'foo=bar; minimax_group_id_v2=2013894056999916075' \ + -H 'x-group-id: 2013894056999916075' + """ + let override = MiniMaxCookieHeader.override(from: raw) + #expect(override?.cookieHeader == "foo=bar; minimax_group_id_v2=2013894056999916075") + #expect(override?.groupID == "2013894056999916075") + } } -@Suite struct MiniMaxUsageParserTests { @Test - func parsesCodingPlanSnapshot() throws { + func `signed out check ignores login copy inside scripts`() { + let html = """ + + + + +
Coding Plan
+ + """ + + #expect(!MiniMaxUsageFetcher._looksSignedOutForTesting(html: html)) + } + + @Test + func `signed out check still detects visible login copy`() { + let html = """ + + +
Log in
+ + """ + + #expect(MiniMaxUsageFetcher._looksSignedOutForTesting(html: html)) + } + + @Test + func `parses planName from concrete fields in remains response`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + + // 1. plan_name + let jsonPlanName = """ + { + "base_resp": { "status_code": 0 }, + "plan_name": "MiniMax Star", + "model_remains": [{"model_name": "abab6.5"}] + } + """ + let snapshot1 = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(jsonPlanName.utf8), now: now) + #expect(snapshot1.planName == "MiniMax Star") + + // 2. current_plan_title + let jsonCurrentPlan = """ + { + "base_resp": { "status_code": 0 }, + "current_plan_title": "Coding Plan Pro", + "model_remains": [{"model_name": "abab6.5"}] + } + """ + let snapshot2 = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(jsonCurrentPlan.utf8), now: now) + #expect(snapshot2.planName == "Coding Plan Pro") + + // 3. current_subscribe_title + let jsonSubscribe = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [{"model_name": "abab6.5"}] + } + """ + let snapshot3 = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(jsonSubscribe.utf8), now: now) + #expect(snapshot3.planName == "Max") + + // 4. combo_title + let jsonCombo = """ + { + "base_resp": { "status_code": 0 }, + "combo_title": "Combo Star", + "model_remains": [{"model_name": "abab6.5"}] + } + """ + let snapshot4 = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(jsonCombo.utf8), now: now) + #expect(snapshot4.planName == "Combo Star") + + // 5. current_combo_card.title + let jsonComboCard = """ + { + "base_resp": { "status_code": 0 }, + "current_combo_card": { "title": "Card Title" }, + "model_remains": [{"model_name": "abab6.5"}] + } + """ + let snapshot5 = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(jsonComboCard.utf8), now: now) + #expect(snapshot5.planName == "Card Title") + } + + @Test + func `toUsageSnapshot maps planName to loginMethod`() { + let now = Date() + let snapshot1 = MiniMaxUsageSnapshot( + planName: "MiniMax Star", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now) + let usage1 = snapshot1.toUsageSnapshot() + #expect(usage1.identity?.loginMethod == "MiniMax Star") + + let snapshot2 = MiniMaxUsageSnapshot( + planName: nil, + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now) + let usage2 = snapshot2.toUsageSnapshot() + #expect(usage2.identity?.loginMethod == nil) + } + + @Test + func `parses coding plan snapshot`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """
Coding Plan
@@ -85,7 +454,7 @@ struct MiniMaxUsageParserTests { } @Test - func parsesCodingPlanRemainsResponse() throws { + func `parses coding plan remains response`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let start = 1_700_000_000_000 let end = start + 5 * 60 * 60 * 1000 @@ -118,7 +487,159 @@ struct MiniMaxUsageParserTests { } @Test - func parsesCodingPlanRemainsFromDataWrapper() throws { + func `parses model remains services using used quota semantics`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [ + { + "model_name": "M2.7-highspeed", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let service = try #require(snapshot.services?.first) + + #expect(service.displayName == "Text Generation") + #expect(service.usage == 750) + #expect(service.remaining == 250) + #expect(service.limit == 1000) + #expect(service.percent == 75) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 75) + } + + @Test + func `text generation includes weekly window when provided`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let weekStart = start - 2 * 24 * 60 * 60 * 1000 + let weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "current_weekly_total_count": 6000, + "current_weekly_usage_count": 5376, + "weekly_start_time": \(weekStart), + "weekly_end_time": \(weekEnd) + } + ] + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + #expect(services.count == 2) + #expect(services[0].serviceType == "Text Generation") + #expect(services[0].windowType == "5 hours") + #expect(services[1].serviceType == "Text Generation") + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 624) + #expect(services[1].limit == 6000) + #expect(services[1].timeRange.contains("/")) + #expect(services[1].timeRange.contains("UTC+8")) + #expect(!services[1].timeRange.hasPrefix("10:00-10:00")) + } + + @Test + func `legacy plan hides weekly when weekly total is missing or zero`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0 + } + ] + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + #expect(services.count == 1) + #expect(services[0].windowType == "5 hours") + } + + @Test + func `parses multi service payload and utc offset reset`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 8 * 3600)) + let now = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 3, + day: 25, + hour: 11, + minute: 0))) + let expectedReset = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 3, + day: 25, + hour: 15, + minute: 0))) + let json = """ + { + "data": { + "services": [ + { + "service_type": "Text Generation", + "window_type": "5 hours", + "time_range": "10:00-15:00(UTC+8)", + "usage": 2, + "limit": 10 + }, + { + "service_type": "Image", + "window_type": "Today", + "time_range": "2026/03/25 00:00 - 2026/03/26 00:00", + "usage": "5", + "limit": "50", + "percent": "10" + } + ] + } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(services.count == 2) + #expect(services[0].usage == 2) + #expect(services[0].remaining == 8) + #expect(services[0].percent == 20) + #expect(services[0].resetsAt == expectedReset) + #expect(services[1].usage == 5) + #expect(services[1].remaining == 45) + #expect(services[1].percent == 10) + } + + @Test + func `parses coding plan remains from data wrapper`() throws { let now = Date(timeIntervalSince1970: 1_700_000_100) let start = 1_700_000_000_000 let end = start + 5 * 60 * 60 * 1000 @@ -154,7 +675,7 @@ struct MiniMaxUsageParserTests { } @Test - func parsesCodingPlanFromNextData() throws { + func `parses coding plan from next data`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let start = 1_700_000_000_000 let end = start + 5 * 60 * 60 * 1000 @@ -198,7 +719,7 @@ struct MiniMaxUsageParserTests { } @Test - func parsesHTMLWithUsedPrefixAndResetTime() throws { + func `parses HTML with used prefix and reset time`() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "UTC") ?? .current let now = try #require(calendar.date(from: DateComponents(year: 2025, month: 1, day: 1, hour: 10, minute: 0))) @@ -226,7 +747,7 @@ struct MiniMaxUsageParserTests { } @Test - func throwsOnMissingCookieResponse() { + func `throws on missing cookie response`() { let json = """ { "base_resp": { "status_code": 1004, "status_msg": "cookie is missing, log in again" } @@ -239,7 +760,7 @@ struct MiniMaxUsageParserTests { } @Test - func throwsOnStringStatusCodeWhenLoggedOut() { + func `throws on string status code when logged out`() { let json = """ { "base_resp": { "status_code": "1004", "status_msg": "login required" } @@ -252,7 +773,7 @@ struct MiniMaxUsageParserTests { } @Test - func throwsOnErrorInDataWrapper() { + func `throws on error in data wrapper`() { let json = """ { "data": { @@ -265,12 +786,385 @@ struct MiniMaxUsageParserTests { try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8)) } } + + @Test + func `billing history aggregates records locally`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let json = """ + { + "base_resp": { "status_code": 0 }, + "consume_token_sum": 999999, + "total_cnt": 4, + "charge_records": [ + { + "consume_token": 1000, + "consume_cash_after_voucher": 1.25, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1" + }, + { + "consume_token": "2000", + "consume_cash": "2.50", + "ymd": "2026-05-16", + "method": "chat", + "model": "MiniMax-M2" + }, + { + "consume_input_token": 1200, + "consume_output_token": 1800, + "ymd": "2026-04-18", + "method": "audio", + "model": "speech-2.8" + }, + { + "consume_token": 4000, + "ymd": "2026-04-17", + "method": "old", + "model": "ignored" + } + ] + } + """ + + let summary = try MiniMaxBillingHistoryParser.parse( + data: Data(json.utf8), + now: now, + calendar: calendar) + + #expect(summary.todayTokens == 1000) + #expect(summary.last30DaysTokens == 6000) + #expect(summary.todayCash == 1.25) + #expect(summary.last30DaysCash == 3.75) + #expect(summary.daily.map(\.day) == ["2026-04-18", "2026-05-16", "2026-05-17"]) + #expect(summary.topMethods.first?.name == "audio") + #expect(summary.topModels.first?.name == "speech-2.8") + } + + @Test + func `billing history preserves date only days in local calendar`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: -7 * 60 * 60)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let json = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 1, + "charge_records": [ + { + "consume_token": 1234, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1" + } + ] + } + """ + + let summary = try MiniMaxBillingHistoryParser.parse( + data: Data(json.utf8), + now: now, + calendar: calendar) + + #expect(summary.todayTokens == 1234) + #expect(summary.daily.map(\.day) == ["2026-05-17"]) + } + + @Test + func `billing history filters failed records`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let json = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 5, + "charge_records": [ + { + "consume_token": 1000, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1", + "result": "SUCCESS" + }, + { + "consume_token": 2000, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1", + "result": "FAILED" + }, + { + "consume_token": 3000, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1", + "status": "fail" + }, + { + "consume_token": 4000, + "ymd": "2026-05-17", + "method": "audio", + "model": "speech-2.8" + }, + { + "consume_token": 5000, + "ymd": "2026-05-17", + "method": "video", + "model": "video-1", + "status": 0 + } + ] + } + """ + + let summary = try MiniMaxBillingHistoryParser.parse( + data: Data(json.utf8), + now: now, + calendar: calendar) + + // Only SUCCESS (1000) and missing/empty result status (4000) should be included. + // FAILED (2000), status "fail" (3000), and numeric status 0 (5000) should be skipped. + #expect(summary.todayTokens == 5000) + #expect(summary.last30DaysTokens == 5000) + #expect(summary.daily.map(\.day) == ["2026-05-17"]) + + // Top methods should aggregate only SUCCESS/missing records. + #expect(summary.topMethods.count == 2) + #expect(summary.topMethods[0].name == "audio") + #expect(summary.topMethods[0].tokens == 4000) + #expect(summary.topMethods[1].name == "chat") + #expect(summary.topMethods[1].tokens == 1000) + } + + @Test + func `web usage fetch attaches billing history when available`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + #expect(url.path == "/account/amount") + #expect(url.query?.contains("aggregate=false") == true) + #expect(request.value(forHTTPHeaderField: "Cookie") == "HERTZ-SESSION=abc") + let body = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 1, + "charge_records": [ + { + "consume_token": 1234, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1" + } + ] + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + + #expect(snapshot.currentPrompts == 2) + #expect(snapshot.billingSummary?.todayTokens == 1234) + #expect(snapshot.billingSummary?.last30DaysTokens == 1234) + } + + @Test + func `web usage fetch keeps paginating billing history until 30 day cutoff`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + let page = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first { $0.name == "page" }? + .value ?? "1" + let recordDay = page == "3" ? "2026-04-17" : "2026-05-17" + let records = (0..<100) + .map { _ in + """ + {"consume_token":1,"ymd":"\(recordDay)","method":"chat","model":"MiniMax-M1"} + """ + } + .joined(separator: ",") + let body = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 250, + "charge_records": [\(records)] + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + + let billingRequests = await transport.requests().filter { $0.url?.path == "/account/amount" } + #expect(billingRequests.count == 3) + #expect(snapshot.billingSummary?.last30DaysTokens == 200) + } + + @Test + func `web usage fetch skips billing history when optional usage is disabled`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.path.contains("coding-plan")) + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + + let requests = await transport.requests() + #expect(snapshot.currentPrompts == 2) + #expect(snapshot.billingSummary == nil) + #expect(requests.count == 1) + } + + @Test + func `web usage fetch keeps quota when billing history is forbidden`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + return Self.httpResponse(url: url, body: "{}", statusCode: 403, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + + #expect(snapshot.currentPrompts == 2) + #expect(snapshot.billingSummary == nil) + } + + @Test + func `web usage fetch preserves stale bearer failure during billing history`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer stale") + return Self.httpResponse(url: url, body: "{}", statusCode: 403, contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + authorizationToken: "stale", + region: .global, + environment: [:], + session: transport, + now: now) + } + } + + @Test + func `web usage fetch preserves billing history cancellation`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + } + } + + private static let codingPlanJSON = """ + { + "base_resp": { "status_code": 0 }, + "data": { + "plan_name": "Max", + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 10, + "current_interval_usage_count": 8, + "start_time": 1779019200, + "end_time": 1779037200, + "remains_time": 3600 + } + ] + } + } + """ + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } } -@Suite struct MiniMaxAPIRegionTests { @Test - func defaultsToGlobalHosts() { + func `defaults to global hosts`() { let codingPlan = MiniMaxUsageFetcher.resolveCodingPlanURL(region: .global, environment: [:]) let remains = MiniMaxUsageFetcher.resolveRemainsURL(region: .global, environment: [:]) #expect(codingPlan.host == "platform.minimax.io") @@ -278,7 +1172,7 @@ struct MiniMaxAPIRegionTests { } @Test - func usesChinaMainlandHosts() { + func `uses china mainland hosts`() { let codingPlan = MiniMaxUsageFetcher.resolveCodingPlanURL(region: .chinaMainland, environment: [:]) let remains = MiniMaxUsageFetcher.resolveRemainsURL(region: .chinaMainland, environment: [:]) #expect(codingPlan.host == "platform.minimaxi.com") @@ -287,7 +1181,25 @@ struct MiniMaxAPIRegionTests { } @Test - func hostOverrideWinsForRemainsAndCodingPlan() { + func `resolves web remains fallback hosts`() { + let global = MiniMaxUsageFetcher.resolveRemainsURLs(region: .global, environment: [:]) + let china = MiniMaxUsageFetcher.resolveRemainsURLs(region: .chinaMainland, environment: [:]) + + #expect(global.map(\.host).contains("platform.minimax.io")) + #expect(global.map(\.host).contains("www.minimax.io")) + #expect(china.map(\.host).contains("platform.minimaxi.com")) + #expect(china.map(\.host).contains("www.minimaxi.com")) + } + + @Test + func `resolves official token plan remains URL`() { + let url = MiniMaxUsageFetcher.resolveTokenPlanRemainsURL(region: .chinaMainland) + #expect(url.host == "api.minimaxi.com") + #expect(url.path == "/v1/token_plan/remains") + } + + @Test + func `host override wins for remains and coding plan`() { let env = [MiniMaxSettingsReader.hostKey: "api.minimaxi.com"] let codingPlan = MiniMaxUsageFetcher.resolveCodingPlanURL(region: .global, environment: env) let remains = MiniMaxUsageFetcher.resolveRemainsURL(region: .global, environment: env) @@ -296,14 +1208,24 @@ struct MiniMaxAPIRegionTests { } @Test - func remainsUrlOverrideBeatsHost() { + func `billing history url uses account amount endpoint`() { + let url = MiniMaxUsageFetcher.resolveBillingHistoryURL(region: .chinaMainland, environment: [:], page: 2) + #expect(url.host == "platform.minimaxi.com") + #expect(url.path == "/account/amount") + #expect(url.query?.contains("page=2") == true) + #expect(url.query?.contains("limit=100") == true) + #expect(url.query?.contains("aggregate=false") == true) + } + + @Test + func `remains url override beats host`() { let env = [MiniMaxSettingsReader.remainsURLKey: "https://platform.minimaxi.com/custom/remains"] let remains = MiniMaxUsageFetcher.resolveRemainsURL(region: .global, environment: env) #expect(remains.absoluteString == "https://platform.minimaxi.com/custom/remains") } @Test - func originUsesCodingPlanOverrideHost() { + func `origin uses coding plan override host`() { let env = [MiniMaxSettingsReader.codingPlanURLKey: "https://api.minimaxi.com/custom/path?cycle_type=3"] let codingPlan = MiniMaxUsageFetcher.resolveCodingPlanURL(region: .global, environment: env) let origin = MiniMaxUsageFetcher.originURL(from: codingPlan) @@ -311,7 +1233,7 @@ struct MiniMaxAPIRegionTests { } @Test - func originStripsHostOverridePath() { + func `origin strips host override path`() { let env = [MiniMaxSettingsReader.hostKey: "https://api.minimaxi.com/custom/path"] let codingPlan = MiniMaxUsageFetcher.resolveCodingPlanURL(region: .global, environment: env) let origin = MiniMaxUsageFetcher.originURL(from: codingPlan) diff --git a/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift new file mode 100644 index 0000000000..b7a0045722 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxTokenPlanChangeTests.swift @@ -0,0 +1,791 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct MiniMaxTokenPlanChangeTests { + @Test + func `parses percent based general token plan remains`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: Data(Self.percentBasedRemainsJSON.utf8), + now: now) + let services = try #require(snapshot.services) + + #expect(snapshot.availablePrompts == nil) + #expect(snapshot.currentPrompts == nil) + #expect(snapshot.remainingPrompts == nil) + #expect(snapshot.usedPercent == 4) + #expect(services.count == 2) + #expect(services[0].serviceType == "general") + #expect(services[0].displayName == "General") + #expect(services[0].windowType == "5 hours") + #expect(services[0].usage == 4) + #expect(services[0].limit == 100) + #expect(services[0].percent == 4) + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 1) + #expect(services[1].limit == 100) + #expect(services[1].percent == 1) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + } + + @Test + func `zero count fields do not suppress percent based quota windows`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": "0" }, + "data": { + "current_subscribe_title": "Token Plan · TokenPlanPlus-年度会员", + "points_balance": "14000", + "model_remains": [ + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": "96", + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": "99", + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Token Plan · TokenPlanPlus-年度会员") + #expect(snapshot.pointsBalance == 14000) + #expect(snapshot.services?.count == 2) + #expect(snapshot.toUsageSnapshot().providerCost?.used == 14000) + } + + @Test + func `video first token plan still uses general quota as primary and weekly secondary`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": "0" }, + "model_remains": [ + { + "model_name": "video", + "current_interval_total_count": 100, + "current_interval_usage_count": 70, + "current_interval_remaining_percent": 30, + "start_time": 1780243200000, + "end_time": 1780329600000 + }, + { + "model_name": "general", + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "current_interval_remaining_percent": 96, + "start_time": 1780279200000, + "end_time": 1780297200000, + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "current_weekly_remaining_percent": 99, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.services?.map(\.serviceType) == ["video", "general", "general"]) + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 70) + } + + @Test + func `plus token plan omits unavailable video quota lane`() throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let json = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + }, + { + "start_time": 1780243200000, + "end_time": 1780329600000, + "remains_time": 49059830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(snapshot.planName == "Plus") + #expect(snapshot.toUsageSnapshot().identity?.loginMethod == "Plus") + #expect(services.map(\.serviceType) == ["general", "general"]) + #expect(services.map(\.displayName) == ["General", "General"]) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().tertiary == nil) + } + + @Test + func `plus token plan renders boosted interval and unlimited weekly lane`() throws { + let now = Date(timeIntervalSince1970: 1_780_347_620) + let json = """ + { + "model_remains": [ + { + "start_time": 1780347600000, + "end_time": 1780365600000, + "remains_time": 4650822, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 487050822, + "current_interval_status": 1, + "current_interval_remaining_percent": 99, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100, + "interval_boost_permill": 2000, + "weekly_boost_permill": 2000 + }, + { + "start_time": 1780329600000, + "end_time": 1780416000000, + "remains_time": 55050822, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "video", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 487050822, + "current_interval_status": 3, + "current_interval_remaining_percent": 100, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 100 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(services.count == 2) + #expect(services[0].serviceType == "general") + #expect(services[0].displayName == "General") + #expect(services[0].windowType == "5 hours") + #expect(services[0].usage == 2) + #expect(services[0].limit == 200) + #expect(services[0].percent == 1) + #expect(services[0].isUnlimited == false) + #expect(services[1].serviceType == "general") + #expect(services[1].displayName == "General") + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 0) + #expect(services[1].limit == 0) + #expect(services[1].percent == 0) + #expect(services[1].isUnlimited) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "Unlimited") + } + + @Test + func `web usage fetch falls back to www remains host after platform parse failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: "not json", contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.contains { + $0.url?.host == "platform.minimaxi.com" && $0.url?.path.contains("remains") == true + }) + #expect(requests.contains { + $0.url?.host == "www.minimaxi.com" && $0.url?.path.contains("remains") == true + }) + } + + @Test + func `web usage fetch falls back to www remains host after platform transport failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.host == "platform.minimaxi.com", url.path.contains("coding_plan/remains") { + throw URLError(.timedOut) + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.host } == [ + "platform.minimaxi.com", + "platform.minimaxi.com", + "www.minimaxi.com", + ]) + } + + @Test + func `web usage fetch preserves coding plan json auth failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.path.contains("coding-plan")) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.count == 1) + } + + @Test + func `web usage fetch preserves remains json auth failure`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + #expect(url.path.contains("coding_plan/remains")) + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=expired", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport) + } + let requests = await transport.requests() + #expect(requests.map { $0.url?.path } == [ + "/user-center/payment/coding-plan", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch uses official token plan remains endpoint`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(url.path == "/v1/token_plan/remains") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-cp-test") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .chinaMainland, + now: now, + session: transport) + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official auth failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `global api token fetch preserves structured credential failure across legacy error`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-cp-test") + + switch (url.host, url.path) { + case ("api.minimax.io", "/v1/token_plan/remains"): + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1001,"status_msg":"invalid api key"}}"#, + contentType: "application/json") + case ("api.minimax.io", "/v1/api/openplatform/coding_plan/remains"): + return Self.httpResponse( + url: url, + body: #"{"error":"legacy endpoint unavailable"}"#, + statusCode: 404, + contentType: "application/json") + case ("api.minimaxi.com", "/v1/token_plan/remains"): + return Self.httpResponse( + url: url, + body: Self.percentBasedRemainsJSON, + contentType: "application/json") + default: + Issue.record("Unexpected MiniMax API request: \(url.absoluteString)") + return Self.httpResponse(url: url, body: "{}", statusCode: 500, contentType: "application/json") + } + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-cp-test", + region: .global, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.host } == [ + "api.minimax.io", + "api.minimax.io", + "api.minimaxi.com", + ]) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + "/v1/token_plan/remains", + ]) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official parse failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + return Self.httpResponse(url: url, body: "{}", contentType: "application/json") + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch falls back to legacy coding plan endpoint after official transport failure`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-standard-test") + if url.path == "/v1/token_plan/remains" { + throw URLError(.timedOut) + } + #expect(url.path == "/v1/api/openplatform/coding_plan/remains") + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + now: now, + session: transport) + let requests = await transport.requests() + + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `api token fetch rejects after official and legacy endpoint auth failures`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.host == "api.minimaxi.com") + #expect([ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ].contains(url.path)) + return Self.httpResponse(url: url, body: "{}", statusCode: 401, contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + apiToken: "sk-standard-test", + region: .chinaMainland, + session: transport) + } + let requests = await transport.requests() + + #expect(requests.map { $0.url?.path } == [ + "/v1/token_plan/remains", + "/v1/api/openplatform/coding_plan/remains", + ]) + } + + @Test + func `combo metadata parser extracts token plan subscription label`() throws { + let metadata = try MiniMaxSubscriptionMetadataFetcher.parse(data: Data(Self.comboMetadataJSON.utf8)) + #expect(metadata.planName == "TokenPlanMax-年度会员") + #expect(metadata.subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(metadata.subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + } + + @Test + func `combo metadata parser prefers current subscription over package catalog`() throws { + let json = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe": { + "current_subscribe_title": "TokenPlanUltra-年度会员" + }, + "packages": [ + { "resource_package_name": "TokenPlanPlus" }, + { "resource_package_name": "TokenPlanMax" }, + { "resource_package_name": "TokenPlanUltra" } + ] + } + } + """ + + let metadata = try MiniMaxSubscriptionMetadataFetcher.parse(data: Data(json.utf8)) + + #expect(metadata.planName == "TokenPlanUltra-年度会员") + } + + @Test + func `web usage fetch merges combo subscription metadata`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + #expect(url.host == "www.minimaxi.com") + #expect(url.path == "/v1/api/openplatform/charge/combo/cycle_audio_resource_package") + #expect(url.query?.contains("biz_line=2") == true) + #expect(request.value(forHTTPHeaderField: "x-group-id") == "2013894056999916075") + #expect(request.value(forHTTPHeaderField: "origin") == "https://platform.minimaxi.com") + return Self.httpResponse(url: url, body: Self.comboMetadataJSON, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc; minimax_group_id_v2=2013894056999916075", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + let requests = await transport.requests() + + #expect(snapshot.planName == "TokenPlanMax-年度会员") + #expect(snapshot.subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(snapshot.subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + #expect(snapshot.toUsageSnapshot().subscriptionExpiresAt == Date(timeIntervalSince1970: 1_810_656_000)) + #expect(snapshot.toUsageSnapshot().subscriptionRenewsAt == Date(timeIntervalSince1970: 1_810_569_600)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + #expect(requests.contains { $0.url?.path.contains("cycle_audio_resource_package") == true }) + } + + @Test + func `combo metadata rejects non https host override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return Self.httpResponse( + url: URL(string: "https://unused.example")!, + body: "{}", + contentType: "application/json") + } + + await #expect(throws: ProviderEndpointOverrideError.minimax(MiniMaxSettingsReader.hostKey)) { + try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: "_token=secret", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [MiniMaxSettingsReader.hostKey: "http://metadata.test"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + @Test + func `combo metadata rejects malformed host override before sending credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + Issue.record("Unexpected request to \(request.url?.absoluteString ?? "")") + return Self.httpResponse( + url: URL(string: "https://unused.example")!, + body: "{}", + contentType: "application/json") + } + + await #expect(throws: ProviderEndpointOverrideError.minimax(MiniMaxSettingsReader.hostKey)) { + try await MiniMaxSubscriptionMetadataFetcher.fetch( + cookieHeader: "_token=secret", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [MiniMaxSettingsReader.hostKey: "bad host"], + transport: transport) + } + + let requests = await transport.requests() + #expect(requests.isEmpty) + } + + @Test + func `web usage fetch preserves combo metadata cancellation`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + } + } + + @Test + func `combo metadata failure does not block quota rendering`() async throws { + let now = Date(timeIntervalSince1970: 1_780_282_340) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: "
Coding Plan
", + contentType: "text/html") + } + if url.path.contains("coding_plan/remains") { + return Self.httpResponse(url: url, body: Self.percentBasedRemainsJSON, contentType: "application/json") + } + return Self.httpResponse( + url: url, + body: #"{"base_resp":{"status_code":1004,"status_msg":"cookie is missing, log in again"}}"#, + contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "_token=abc", + groupID: "2013894056999916075", + region: .chinaMainland, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + + #expect(snapshot.planName == nil) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 4) + } + + private static let comboMetadataJSON = """ + { + "base_resp": { "status_code": 0, "status_msg": "success" }, + "data": { + "current_subscribe": { + "current_subscribe_title": "TokenPlanMax-年度会员", + "current_subscribe_end_time": "05/19/2027", + "renewal_date": "05/18/2027", + "current_subscribe_end_time_ts": 1810656000000, + "renewal_trigger_time_ts": 1810569600000 + }, + "packages": [ + { + "resource_package_name": "TokenPlanMax", + "display_name": "Token Plan · TokenPlanMax-年度会员" + } + ] + } + } + """ + + private static let percentBasedRemainsJSON = """ + { + "model_remains": [ + { + "start_time": 1780279200000, + "end_time": 1780297200000, + "remains_time": 16659830, + "current_interval_total_count": 0, + "current_interval_usage_count": 0, + "model_name": "general", + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0, + "weekly_start_time": 1780243200000, + "weekly_end_time": 1780848000000, + "weekly_remains_time": 567459830, + "current_interval_status": 1, + "current_interval_remaining_percent": 96, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 99 + } + ], + "base_resp": { "status_code": 0, "status_msg": "success" } + } + """ + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } +} diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift new file mode 100644 index 0000000000..b380175365 --- /dev/null +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -0,0 +1,400 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MistralUsageParserTests { + // swiftlint:disable line_length + + private static let novemberResponseJSON = """ + {"completion":{"models":{"mistral-large-latest::mistral-large-2411":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"input","timestamp":"2025-11-14","value":11121,"value_paid":11121}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"output","timestamp":"2025-11-14","value":1115,"value_paid":1115}]},"mistral-small-latest::mistral-small-2506":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-14","value":20,"value_paid":20},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-24","value":100,"value_paid":100}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-14","value":500,"value_paid":500},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-24","value":2482,"value_paid":2482}]}}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2025-11-01T00:00:00Z","previous_month":"2025-10","next_month":"2025-12","start_date":"2025-11-01T00:00:00Z","end_date":"2025-11-30T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"input","price":"0.0000017000"},{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"output","price":"0.0000051000"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"input","price":"8.50E-8"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"output","price":"2.550E-7"}]} + """ + + private static let emptyResponseJSON = """ + {"completion":{"models":{}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2026-02-01T00:00:00Z","previous_month":"2026-01","next_month":"2026-03","start_date":"2026-02-01T00:00:00Z","end_date":"2026-02-28T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[]} + """ + + // swiftlint:enable line_length + + @Test + func `parses response with usage data and computes token totals`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + // mistral-large input: 11121, mistral-small input: 20+100=120 + #expect(snapshot.totalInputTokens == 11121 + 120) + // mistral-large output: 1115, mistral-small output: 500+2482=2982 + #expect(snapshot.totalOutputTokens == 1115 + 2982) + #expect(snapshot.totalCachedTokens == 0) + #expect(snapshot.modelCount == 2) + #expect(snapshot.currency == "EUR") + #expect(snapshot.currencySymbol == "€") + #expect(snapshot.daily.map(\.day) == ["2025-11-14", "2025-11-24"]) + #expect(snapshot.daily.first?.totalTokens == 11121 + 1115 + 20 + 500) + #expect(snapshot.daily.first?.models.first?.name == "mistral-large-latest") + } + + @Test + func `computes cost from tokens and prices`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + // mistral-large-2411 input: 11121 * 0.0000017 = 0.0189057 + // mistral-large-2411 output: 1115 * 0.0000051 = 0.0056865 + // mistral-small-2506 input: 120 * 0.000000085 = 0.0000102 + // mistral-small-2506 output: 2982 * 0.000000255 = 0.00076041 + let expectedCost = 0.0189057 + 0.0056865 + 0.0000102 + 0.00076041 + #expect(abs(snapshot.totalCost - expectedCost) < 0.0001) + #expect(snapshot.totalCost > 0) + } + + @Test + func `parses empty response with no usage`() throws { + let data = try #require(Self.emptyResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + #expect(snapshot.totalInputTokens == 0) + #expect(snapshot.totalOutputTokens == 0) + #expect(snapshot.totalCost == 0) + #expect(snapshot.modelCount == 0) + #expect(snapshot.currency == "EUR") + } + + @Test + func `daily spend keeps non token Mistral units out of token totals`() throws { + let json = """ + { + "libraries_api": { + "pages": { + "models": { + "mistral-ocr-latest": { + "input": [ + { + "billing_metric": "pages", + "billing_display_name": "OCR pages", + "billing_group": "input", + "timestamp": "2025-11-15", + "value": 42, + "value_paid": 42 + } + ] + } + } + } + }, + "currency": "EUR", + "currency_symbol": "€", + "prices": [ + { + "billing_metric": "pages", + "billing_group": "input", + "price": "0.01" + } + ] + } + """ + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: Date()) + + #expect(abs(snapshot.totalCost - 0.42) < 0.0001) + #expect(snapshot.totalInputTokens == 0) + #expect(abs((snapshot.daily.first?.cost ?? 0) - 0.42) < 0.0001) + #expect(snapshot.daily.first?.totalTokens == 0) + #expect(abs((snapshot.daily.first?.models.first?.cost ?? 0) - 0.42) < 0.0001) + #expect(snapshot.daily.first?.models.first?.totalTokens == 0) + } + + @Test + func `parses dates from response`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + #expect(snapshot.startDate != nil) + #expect(snapshot.endDate != nil) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + if let start = snapshot.startDate { + #expect(calendar.component(.month, from: start) == 11) + #expect(calendar.component(.year, from: start) == 2025) + } + } + + @Test + func `throws parseFailed for invalid JSON`() { + let data = Data("not json".utf8) + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + } + } +} + +struct MistralUsageSnapshotConversionTests { + @Test + func `converts cost into text only current month api spend`() { + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: Date(), + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.identity?.providerID == .mistral) + #expect(usage.identity?.loginMethod == "API spend: €1.2345 this month") + #expect(usage.providerCost == nil) + } + + @Test + func `converts zero cost into zero spend text`() { + let snapshot = MistralUsageSnapshot( + totalCost: 0, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + startDate: nil, + endDate: nil, + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.identity?.loginMethod == "API spend: $0.0000 this month") + } + + @Test + func `converts billing usage into cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: 1.75, + currency: "eur", + currencySymbol: "€", + totalInputTokens: 300, + totalOutputTokens: 150, + totalCachedTokens: 50, + modelCount: 2, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 1.5, + inputTokens: 100, + cachedTokens: 20, + outputTokens: 50, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: 1.5, + inputTokens: 100, + cachedTokens: 20, + outputTokens: 50), + ]), + MistralDailyUsageBucket( + day: "2023-11-15", + cost: 0.25, + inputTokens: 200, + cachedTokens: 30, + outputTokens: 100, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-small", + cost: 0.25, + inputTokens: 200, + cachedTokens: 30, + outputTokens: 100), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 1) + #expect(cost.currencyCode == "EUR") + #expect(cost.historyLabel == "This month") + #expect(cost.historyDays == 2) + #expect(cost.sessionCostUSD == 0.25) + #expect(cost.sessionTokens == 330) + #expect(cost.last30DaysCostUSD == 1.75) + #expect(cost.last30DaysTokens == 500) + #expect(cost.daily.count == 2) + #expect(cost.daily.last?.modelsUsed == ["mistral-small"]) + } + + @Test + func `clamps negative billing adjustments in cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: -2, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 25, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: -1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: -1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.sessionCostUSD == 0) + #expect(cost.last30DaysCostUSD == 0) + #expect(cost.daily.first?.costUSD == 0) + #expect(cost.daily.first?.modelBreakdowns?.first?.costUSD == 0) + } + + @Test + func `preserves net monthly cost when billing includes credits`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: 8, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 25, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 10, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25, + models: []), + MistralDailyUsageBucket( + day: "2023-11-15", + cost: -2, + inputTokens: 0, + cachedTokens: 0, + outputTokens: 0, + models: []), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.last30DaysCostUSD == 8) + #expect(cost.sessionCostUSD == 0) + #expect(cost.daily.map(\.costUSD) == [10, 0]) + } +} + +struct MistralStrategyTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext( + sourceMode: ProviderSourceMode = .auto, + settings: ProviderSettingsSnapshot? = nil, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: browserDetection) + } + + @Test + func `strategy is unavailable when cookie source is off`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == false) + } + + @Test + func `strategy is available when cookie source is auto`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == true) + } + + @Test + func `strategy is available when cookie source is manual`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .manual, + manualCookieHeader: "ory_session_x=abc; csrftoken=xyz")) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == true) + } + + @Test + func `strategy never falls back (single strategy provider)`() { + let strategy = MistralWebFetchStrategy() + let context = self.makeContext() + let shouldFallback = strategy.shouldFallback( + on: MistralUsageError.invalidCredentials, + context: context) + #expect(shouldFallback == false) + } + + @Test + func `descriptor metadata is correct`() { + let descriptor = MistralProviderDescriptor.descriptor + #expect(descriptor.id == .mistral) + #expect(descriptor.metadata.displayName == "Mistral") + #expect(descriptor.metadata.cliName == "mistral") + #expect(descriptor.metadata.defaultEnabled == false) + #expect(descriptor.cli.name == "mistral") + #expect(descriptor.fetchPlan.sourceModes == [.auto, .web]) + #expect(descriptor.branding.iconResourceName == "ProviderIcon-mistral") + #expect(descriptor.tokenCost.supportsTokenCost) + } +} diff --git a/Tests/CodexBarTests/MistralVibeUsageTests.swift b/Tests/CodexBarTests/MistralVibeUsageTests.swift new file mode 100644 index 0000000000..ee81be58d4 --- /dev/null +++ b/Tests/CodexBarTests/MistralVibeUsageTests.swift @@ -0,0 +1,153 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +private final class MistralRequestCapture: @unchecked Sendable { + private let lock = NSLock() + private var storedRequest: URLRequest? + + var request: URLRequest? { + self.lock.withLock { self.storedRequest } + } + + func record(_ request: URLRequest) { + self.lock.withLock { self.storedRequest = request } + } +} + +struct MistralVibeUsageTests { + @Test + func `parses subscription percentage and reset`() throws { + let data = Data(Self.responseJSON(usagePercentage: 2.8141356666666666).utf8) + + let result = try MistralUsageFetcher.parseVibeUsage(data: data) + + #expect(result.usagePercentage == 2.8141356666666666) + #expect(result.resetAt == ISO8601DateFormatter().date(from: "2026-07-01T00:00:00Z")) + } + + @Test + func `rejects subscription percentages outside rate window range`() { + let data = Data(Self.responseJSON(usagePercentage: 101).utf8) + + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.parseVibeUsage(data: data) + } + } + + @Test + func `subscription request sends only csrf cookie`() async throws { + let capture = MistralRequestCapture() + let data = Data(Self.responseJSON(usagePercentage: 12.5).utf8) + let transport = ProviderHTTPTransportHandler { request in + capture.record(request) + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + else { + throw URLError(.badURL) + } + return (data, response) + } + + let result = try await MistralUsageFetcher.fetchVibeUsage( + csrfToken: " csrf-value ", + timeout: 2, + transport: transport) + let request = try #require(capture.request) + + #expect(result.usagePercentage == 12.5) + #expect(request.url?.host == "console.mistral.ai") + #expect(request.timeoutInterval == 2) + #expect(request.httpShouldHandleCookies == false) + #expect(request.value(forHTTPHeaderField: "Cookie") == "csrftoken=csrf-value") + #expect(request.value(forHTTPHeaderField: "X-CSRFToken") == "csrf-value") + #expect(request.allHTTPHeaderFields?.values.contains { $0.contains("ory_session") } != true) + } + + @Test + func `rejects csrf values that could add cookies or headers`() { + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.vibeCookieHeader(csrfToken: "csrf; ory_session_secret=leak") + } + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.vibeCookieHeader(csrfToken: "csrf\r\nX-Leak: value") + } + } + + @Test + func `optional subscription request propagates in flight cancellation`() async throws { + let started = AsyncStream.makeStream(of: Void.self) + let transport = ProviderHTTPTransportHandler { _ in + started.continuation.yield(()) + try await Task.sleep(for: .seconds(30)) + throw URLError(.timedOut) + } + let task = Task { + try await MistralWebFetchStrategy.fetchOptionalVibeUsage( + csrfToken: "csrf-value", + timeout: 30, + transport: transport) + } + + var iterator = started.stream.makeAsyncIterator() + _ = await iterator.next() + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + started.continuation.finish() + } + + @Test + func `optional subscription request ignores ordinary endpoint failures`() async throws { + let transport = ProviderHTTPTransportHandler { _ in + throw URLError(.cannotConnectToHost) + } + + let result = try await MistralWebFetchStrategy.fetchOptionalVibeUsage( + csrfToken: "csrf-value", + timeout: 2, + transport: transport) + + #expect(result == nil) + } + + @Test + func `monthly plan window preserves existing extras`() { + let existing = NamedRateWindow( + id: "existing", + title: "Existing", + window: RateWindow(usedPercent: 5, windowMinutes: nil, resetsAt: nil, resetDescription: nil)) + let usage = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [existing], + updatedAt: Date()) + + let updated = MistralWebFetchStrategy.attachVibeWindow( + to: usage, + vibeResult: .init(usagePercentage: 25, resetAt: nil)) + + #expect(updated.extraRateWindows?.map(\.id) == ["existing", "mistral-monthly-plan"]) + #expect(updated.extraRateWindows?.last?.window.usedPercent == 25) + } + + private static func responseJSON(usagePercentage: Double) -> String { + """ + [{"result":{"data":{"json":{ + "usage_percentage":\(usagePercentage), + "quota_changed_this_month":false, + "payg_enabled":false, + "reset_at":"2026-07-01T00:00:00Z" + }}}}] + """ + } +} diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift new file mode 100644 index 0000000000..d1a2fdcf91 --- /dev/null +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -0,0 +1,1058 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct ModelsDevPricingTests { + @Test + func `parses models dev subset`() throws { + let catalog = try Self.fixtureCatalog() + + #expect(catalog.providers["openai"]?.name == "OpenAI") + #expect(catalog.providers["anthropic"]?.models["claude-sonnet-4-6"]?.cost?.cacheWrite == 3.75) + #expect(catalog.providers["anthropic"]?.models["claude-sonnet-4-6"]?.limit?.context == 1_000_000) + } + + @Test + func `looks up pricing by provider and model`() throws { + let catalog = try Self.fixtureCatalog() + + let openAI = try #require(catalog.pricing(providerID: "openai", modelID: "shared-model")) + let anthropic = try #require(catalog.pricing(providerID: "anthropic", modelID: "shared-model")) + + #expect(openAI.pricing.inputCostPerToken == 1 / 1_000_000.0) + #expect(openAI.pricing.outputCostPerToken == 2 / 1_000_000.0) + #expect(anthropic.pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(anthropic.pricing.outputCostPerToken == 4 / 1_000_000.0) + } + + @Test + func `does not fall back across providers`() throws { + let catalog = try Self.fixtureCatalog() + + #expect(catalog.pricing(providerID: "openai", modelID: "claude-sonnet-4-6") == nil) + #expect(catalog.pricing(providerID: "anthropic", modelID: "gpt-4o-mini") == nil) + } + + @Test + func `supports provider scoped model normalization`() throws { + let catalog = try Self.fixtureCatalog() + + let anthropic = try #require(catalog.pricing( + providerID: "anthropic", + modelID: "us.anthropic.claude-sonnet-4-6")) + let vertex = try #require(catalog.pricing( + providerID: "google-vertex-anthropic", + modelID: "claude-sonnet-4-6")) + + #expect(anthropic.normalizedModelID == "claude-sonnet-4-6") + #expect(vertex.normalizedModelID == "claude-sonnet-4-6") + #expect(vertex.pricing.inputCostPerToken == 3.1 / 1_000_000.0) + } + + @Test + func `converts models dev per million token prices to per token prices`() throws { + let pricing = try #require(try Self.fixtureCatalog().pricing( + providerID: "anthropic", + modelID: "claude-sonnet-4-6")? + .pricing) + + #expect(pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(pricing.outputCostPerToken == 15 / 1_000_000.0) + #expect(pricing.cacheReadInputCostPerToken == 0.3 / 1_000_000.0) + #expect(pricing.cacheCreationInputCostPerToken == 3.75 / 1_000_000.0) + #expect(pricing.thresholdTokens == 200_000) + #expect(pricing.inputCostPerTokenAboveThreshold == 6 / 1_000_000.0) + #expect(pricing.outputCostPerTokenAboveThreshold == 22.5 / 1_000_000.0) + #expect(pricing.cacheReadInputCostPerTokenAboveThreshold == 0.6 / 1_000_000.0) + #expect(pricing.cacheCreationInputCostPerTokenAboveThreshold == 7.5 / 1_000_000.0) + } + + @Test + func `stale cache is still readable`() throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let load = ModelsDevCache.load( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root) + + #expect(load.artifact != nil) + #expect(load.isStale) + #expect(load.error == nil) + } + + @Test + func `pipeline lookup reads cached pricing`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `network failure preserves last valid cache`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport(result: .failure(MockError.failed)))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `refresh preserves cache when fetched catalog drops cached provider`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let partialCatalog = Data(""" + { + "openai": { "id": 7, "models": [] }, + "anthropic": { + "id": "anthropic", + "models": { + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((partialCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } +} + +extension ModelsDevPricingTests { + @Test + func `refresh accepts model churn and preserves removed pricing as fallback`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let partialCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + }, + "provider-a-new": { + "id": "provider-a-new", + "cost": { "input": 7, "output": 8 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((partialCatalog, Self.response(status: 200)))))) + + let oldLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + let newLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "provider-a-new", + cacheRoot: root)) + let updatedLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "shared-model", + cacheRoot: root)) + + #expect(oldLookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + #expect(newLookup.pricing.inputCostPerToken == 7 / 1_000_000.0) + #expect(updatedLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `accumulated fallback models do not freeze later refreshes`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "provider-a-old": { "id": "provider-a-old", "cost": { "input": 1, "output": 2 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-old": { "id": "provider-b-old", "cost": { "input": 3, "output": 4 } } + } + }, + "stale-a": { + "id": "stale-a", + "models": { + "model-a": { "id": "model-a", "cost": { "input": 5, "output": 6 } } + } + }, + "stale-b": { + "id": "stale-b", + "models": { + "model-b": { "id": "model-b", "cost": { "input": 7, "output": 8 } } + } + }, + "stale-c": { + "id": "stale-c", + "models": { + "model-c": { "id": "model-c", "cost": { "input": 9, "output": 10 } } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "provider-a-new": { "id": "provider-a-new", "cost": { "input": 11, "output": 12 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-new": { "id": "provider-b-new", "cost": { "input": 13, "output": 14 } } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let newLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "provider-a-new", + cacheRoot: root)) + let fallbackLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "stale-a", + modelID: "model-a", + cacheRoot: root)) + + #expect(newLookup.pricing.inputCostPerToken == 11 / 1_000_000.0) + #expect(fallbackLookup.pricing.inputCostPerToken == 5 / 1_000_000.0) + } + + @Test + func `historical fallback does not overwrite a refreshed model that reuses its map key`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "rolling": { "id": "provider-a-old", "cost": { "input": 1, "output": 2 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { "id": "provider-b-anchor", "cost": { "input": 3, "output": 4 } } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "rolling": { "id": "provider-a-new", "cost": { "input": 99, "output": 100 } } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { "id": "provider-b-anchor", "cost": { "input": 3, "output": 4 } } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let freshLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "provider-a-new", + cacheRoot: root)) + let fallbackLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "provider-a-old", + cacheRoot: root)) + + #expect(freshLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(fallbackLookup.pricing.inputCostPerToken == 1 / 1_000_000.0) + } + + @Test + func `refresh updates cache when fetched catalog renames model key but keeps id`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let renamedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "renamed-model-key": { + "id": "gpt-4o-mini", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "renamed-vertex-key": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((renamedCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.normalizedModelID == "gpt-4o-mini") + #expect(lookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `refresh preserves cache when fetched matching model is not priceable`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let partialCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((partialCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + let updatedLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "shared-model", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + #expect(updatedLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `refresh updates cache when fetched catalog canonicalizes alias model id`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 0.15, "output": 0.6 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 3, "output": 15 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "snapshot-model@20250101": { + "id": "snapshot-model@20250101", + "cost": { "input": 3.1, "output": 15.1 } + } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let canonicalizedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "snapshot-model-20250101": { + "id": "snapshot-model-20250101", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((canonicalizedCatalog, Self.response(status: 200)))))) + + let aliasLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "snapshot-model@20250101", + cacheRoot: root)) + let canonicalLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "snapshot-model-20250101", + cacheRoot: root)) + + #expect(aliasLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(canonicalLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `fallback merge treats default alias as the canonical base model`() throws { + let cachedCatalog = try Self.catalog(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "base-model@default": { + "id": "base-model@default", + "cost": { "input": 3, "output": 15 } + } + } + } + } + """) + let refreshedCatalog = try Self.catalog(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "base-model": { + "id": "base-model", + "cost": { "input": 99, "output": 100 } + } + } + } + } + """) + + let merged = refreshedCatalog.mergingFallbackPricing(from: cachedCatalog) + let aliasLookup = try #require(merged.pricing( + providerID: "anthropic", + modelID: "base-model@default")) + + #expect(aliasLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(merged.providers["anthropic"]?.models.count == 1) + } + + @Test + func `fallback merge treats provider version alias as the canonical base model`() throws { + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "openai/base-model-v1:0": { + "id": "openai/base-model-v1:0", + "cost": { "input": 3, "output": 15 } + } + } + } + } + """) + let refreshedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "base-model": { + "id": "base-model", + "cost": { "input": 99, "output": 100 } + } + } + } + } + """) + + let merged = refreshedCatalog.mergingFallbackPricing(from: cachedCatalog) + let aliasLookup = try #require(merged.pricing( + providerID: "openai", + modelID: "openai/base-model-v1:0")) + + #expect(aliasLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(merged.providers["openai"]?.models.count == 1) + } + + @Test + func `refresh keeps historical pinned pricing while accepting a new snapshot`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "snapshot-model@20250101": { + "id": "snapshot-model@20250101", + "cost": { "input": 3, "output": 15 } + } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "provider-a-anchor": { + "id": "provider-a-anchor", + "cost": { "input": 1, "output": 2 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { + "id": "provider-b-anchor", + "cost": { "input": 3, "output": 4 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "snapshot-model@20250201": { + "id": "snapshot-model@20250201", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let oldLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "snapshot-model@20250101", + cacheRoot: root)) + let newLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "snapshot-model@20250201", + cacheRoot: root)) + + #expect(oldLookup.pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(newLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `refresh preserves dated snapshot when fetched catalog only keeps base model`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "historical-map-key": { + "id": "snapshot-model-2025-01-01", + "cost": { "input": 3, "output": 15 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { + "id": "provider-b-anchor", + "cost": { "input": 3, "output": 4 } + } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "snapshot-model": { + "id": "snapshot-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { + "id": "provider-b-anchor", + "cost": { "input": 3, "output": 4 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let snapshotLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "snapshot-model-2025-01-01", + cacheRoot: root)) + let baseLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "snapshot-model", + cacheRoot: root)) + + #expect(snapshotLookup.pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(baseLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `compact snapshot alias prefers snapshot pricing over base pricing`() throws { + let catalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "snapshot-model": { + "id": "snapshot-model", + "cost": { "input": 99, "output": 100 } + }, + "snapshot-model-20250101": { + "id": "snapshot-model-20250101", + "cost": { "input": 3, "output": 15 } + } + } + } + } + """) + + let lookup = try #require(catalog.pricing( + providerID: "openai", + modelID: "snapshot-model@20250101")) + + #expect(lookup.pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(lookup.normalizedModelID == "snapshot-model-20250101") + } + + @Test + func `refresh ignores unpriceable models in old cache continuity check`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 0.15, "output": 0.6 } + }, + "unpriced-model": { + "id": "unpriced-model" + } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "provider-b-anchor": { + "id": "provider-b-anchor", + "cost": { "input": 3, "output": 4 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `fresh cache does not refresh`() async throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(), + cacheRoot: root, + client: ModelsDevClient(transport: transport)) + + #expect(transport.calls == 0) + } + + @Test + func `corrupt cache is ignored safely`() throws { + let root = try Self.cacheRoot() + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("not json".utf8).write(to: url) + + let load = ModelsDevCache.load(cacheRoot: root) + + #expect(load.artifact == nil) + #expect(load.isStale) + #expect(load.error == .invalidJSON) + } + + @Test + func `serves decoded catalog from memo while the file is unchanged`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + + // Pin a whole-second modification date so the memo key (which compares modification dates) round-trips + // deterministically through the filesystem. + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + + // Prime the in-memory memo with a successful decode. + let primed = ModelsDevCache.load(cacheRoot: root) + let cachedArtifact = try #require(primed.artifact) + + // Corrupt the file contents while preserving its size and modification date, so the on-disk identity + // the memo keys on is unchanged. A re-decode would now fail; a memo hit returns the cached artifact. + let size = try #require( + try (FileManager.default.attributesOfItem(atPath: url.path)[.size]) as? NSNumber).intValue + try Data(repeating: 0, count: size).write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == nil) + #expect(reloaded.artifact == cachedArtifact) + } + + @Test + func `saving a new catalog invalidates the memo`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + #expect(ModelsDevCache.load(cacheRoot: root).artifact?.catalog.providers["openai"] != nil) + + // Overwriting the cache must drop the memo so the next load reflects the freshly written catalog. + ModelsDevCache.save(catalog: ModelsDevCatalog(providers: [:]), fetchedAt: Date(), cacheRoot: root) + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == nil) + #expect(reloaded.artifact?.catalog.providers.isEmpty == true) + } + + @Test + func `serves a failed load from memo while the file is unchanged`() throws { + let root = try Self.cacheRoot() + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let validData = try Self.encodedArtifactData() + + // Write invalid JSON of the same size as a valid encoding, with a pinned modification date, then prime + // the memo with the resulting failure. + let pinnedDate = Date(timeIntervalSince1970: 1_700_000_000) + try Data(repeating: 0x7B, count: validData.count).write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + #expect(ModelsDevCache.load(cacheRoot: root).error == .invalidJSON) + + // Replace the bytes with a valid encoding of identical size + modification date. A re-read would now + // succeed, so a returned failure proves the unchanged-identity file was not read and decoded again. + try validData.write(to: url) + try FileManager.default.setAttributes([.modificationDate: pinnedDate], ofItemAtPath: url.path) + let reloaded = ModelsDevCache.load(cacheRoot: root) + + #expect(reloaded.error == .invalidJSON) + #expect(reloaded.artifact == nil) + } + + @Test + func `client fetches with mock transport`() async throws { + let data = try Self.fixtureData() + let client = ModelsDevClient(transport: MockTransport(result: .success((data, Self.response(status: 200))))) + + let catalog = try await client.fetchCatalog() + + #expect(catalog.providers["google-vertex-anthropic"]?.models["claude-sonnet-4-6"]?.cost?.input == 3.1) + } + + @Test + func `client reports http and json failures`() async throws { + let data = try Self.fixtureData() + let httpClient = ModelsDevClient(transport: MockTransport(result: .success((data, Self.response(status: 500))))) + let jsonClient = ModelsDevClient(transport: MockTransport( + result: .success((Data("not json".utf8), Self.response(status: 200))))) + + await #expect(throws: ModelsDevClient.Error.httpStatus(500)) { + _ = try await httpClient.fetchCatalog() + } + await #expect(throws: ModelsDevClient.Error.invalidJSON) { + _ = try await jsonClient.fetchCatalog() + } + } + + private static func fixtureData() throws -> Data { + let url = try #require(Bundle.module.url( + forResource: "models-dev-subset", + withExtension: "json", + subdirectory: "Fixtures")) + return try Data(contentsOf: url) + } + + private static func fixtureCatalog() throws -> ModelsDevCatalog { + try JSONDecoder().decode(ModelsDevCatalog.self, from: self.fixtureData()) + } + + /// A valid `ModelsDevCacheArtifact` encoding, written the same way `ModelsDevCache.save` writes the file. + private static func encodedArtifactData() throws -> Data { + let artifact = try ModelsDevCacheArtifact( + version: ModelsDevCache.artifactVersion, + fetchedAt: Date(timeIntervalSince1970: 0), + catalog: self.fixtureCatalog()) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return try encoder.encode(artifact) + } + + private static func catalog(_ json: String) throws -> ModelsDevCatalog { + try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func cacheRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-modelsdev-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func response(status: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: URL(string: "https://models.dev/api.json")!, + statusCode: status, + httpVersion: nil, + headerFields: nil)! + } +} + +private enum MockError: Error { + case failed +} + +private struct MockTransport: ModelsDevHTTPTransport { + let result: Result<(Data, URLResponse), Error> + + func data(for _: URLRequest) async throws -> (Data, URLResponse) { + try self.result.get() + } +} + +private final class TrackingTransport: ModelsDevHTTPTransport, @unchecked Sendable { + private(set) var calls = 0 + let result: Result<(Data, URLResponse), Error> + + init(result: Result<(Data, URLResponse), Error>) { + self.result = result + } + + func data(for _: URLRequest) async throws -> (Data, URLResponse) { + self.calls += 1 + return try self.result.get() + } +} diff --git a/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift b/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift new file mode 100644 index 0000000000..3188f5dd64 --- /dev/null +++ b/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift @@ -0,0 +1,68 @@ +import CodexBarCore +import Testing + +struct MoonshotSettingsReaderTests { + @Test + func `api key prefers MOONSHOT API KEY`() { + let env = [ + "MOONSHOT_API_KEY": "primary-token", + "MOONSHOT_KEY": "fallback-token", + ] + + #expect(MoonshotSettingsReader.apiKey(environment: env) == "primary-token") + } + + @Test + func `api key strips quotes`() { + let env = ["MOONSHOT_KEY": "\"quoted-token\""] + + #expect(MoonshotSettingsReader.apiKey(environment: env) == "quoted-token") + } + + @Test + func `region parses china`() { + let env = ["MOONSHOT_REGION": "china"] + + #expect(MoonshotSettingsReader.region(environment: env) == .china) + } + + @Test + func `default settings snapshot does not mask environment region`() { + let settings = ProviderSettingsSnapshot.MoonshotProviderSettings() + + #expect(settings.region == nil) + } + + @Test + func `region defaults to international for unknown values`() { + let env = ["MOONSHOT_REGION": "moon"] + + #expect(MoonshotSettingsReader.region(environment: env) == .international) + } +} + +struct MoonshotProviderTokenResolverTests { + @Test + func `resolves from environment`() { + let env = ["MOONSHOT_API_KEY": "env-token"] + let resolution = ProviderTokenResolver.moonshotResolution(environment: env) + + #expect(resolution?.token == "env-token") + #expect(resolution?.source == .environment) + } + + @Test + func `uses kimi branding icon`() { + let branding = MoonshotProviderDescriptor.descriptor.branding + + #expect(branding.iconStyle == .kimi) + #expect(branding.iconResourceName == "ProviderIcon-kimi") + } + + @Test + func `dashboard url opens account console`() { + #expect( + MoonshotProviderDescriptor.descriptor.metadata.dashboardURL + == "https://platform.moonshot.ai/console/account") + } +} diff --git a/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift b/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift new file mode 100644 index 0000000000..7d69af2af9 --- /dev/null +++ b/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift @@ -0,0 +1,220 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct MoonshotUsageFetcherTests { + @Test + func `parses documented response`() throws { + let json = """ + { + "code": 0, + "data": { + "available_balance": 49.58, + "voucher_balance": 50.00, + "cash_balance": 12.34 + }, + "scode": "0x0", + "status": true + } + """ + + let summary = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + + #expect(summary.availableBalance == 49.58) + #expect(summary.voucherBalance == 50.00) + #expect(summary.cashBalance == 12.34) + + let usage = MoonshotUsageSnapshot(summary: summary).toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.loginMethod(for: .moonshot) == "Balance: $49.58") + } + + @Test + func `negative cash balance is surfaced as deficit`() throws { + let json = """ + { + "code": 0, + "data": { + "available_balance": 49.58, + "voucher_balance": 50.00, + "cash_balance": -0.42 + }, + "scode": "0x0", + "status": true + } + """ + + let summary = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + let usage = MoonshotUsageSnapshot(summary: summary).toUsageSnapshot() + + #expect(summary.cashBalance == -0.42) + #expect(usage.loginMethod(for: .moonshot)?.contains("in deficit") == true) + } + + @Test + func `invalid root returns parse error`() { + let json = """ + [{ "available_balance": 1 }] + """ + + #expect { + _ = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + } throws: { error in + guard case MoonshotUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `api code failure returns api error`() { + let json = """ + { + "code": 401, + "data": { + "available_balance": 0, + "voucher_balance": 0, + "cash_balance": 0 + }, + "scode": "unauthorized", + "status": false + } + """ + + #expect { + _ = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + } throws: { error in + guard case let MoonshotUsageError.apiError(message) = error else { return false } + return message == "code 401, scode unauthorized" + } + } + + @Test + func `international host uses moonshot ai`() { + let url = MoonshotUsageFetcher.resolveBalanceURL(region: .international) + + #expect(url.absoluteString == "https://api.moonshot.ai/v1/users/me/balance") + } + + @Test + func `china host uses moonshot cn`() { + let url = MoonshotUsageFetcher.resolveBalanceURL(region: .china) + + #expect(url.absoluteString == "https://api.moonshot.cn/v1/users/me/balance") + } + + @Test + func `fetch usage sends bearer token and bounded request`() async throws { + defer { + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MoonshotStubURLProtocol.self] + let session = URLSession(configuration: config) + + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(url.absoluteString == "https://api.moonshot.cn/v1/users/me/balance") + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer live-token") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.timeoutInterval == 15) + + let body = """ + { + "code": 0, + "data": { + "available_balance": 9.87, + "voucher_balance": 1.23, + "cash_balance": 8.64 + }, + "scode": "0x0", + "status": true + } + """ + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + let snapshot = try await MoonshotUsageFetcher.fetchUsage( + apiKey: " live-token ", + region: .china, + session: session) + + #expect(MoonshotStubURLProtocol.requests.count == 1) + #expect(snapshot.summary.availableBalance == 9.87) + #expect(snapshot.toUsageSnapshot().loginMethod(for: .moonshot) == "Balance: $9.87") + } + + @Test + func `fetch usage surfaces http failure without leaking body`() async throws { + defer { + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MoonshotStubURLProtocol.self] + let session = URLSession(configuration: config) + + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, Data(#"{"error":"secret-ish provider body"}"#.utf8)) + } + + await #expect { + _ = try await MoonshotUsageFetcher.fetchUsage( + apiKey: "live-token", + session: session) + } throws: { error in + guard case let MoonshotUsageError.apiError(message) = error else { return false } + return message == "HTTP 401" + } + } +} + +final class MoonshotStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var requests: [URLRequest] = [] + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with _: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift index ceff208135..cb42bccce0 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift @@ -4,8 +4,115 @@ import Testing @Suite(.serialized) struct OllamaUsageFetcherRetryMappingTests { + private func makeContext( + sourceMode: ProviderSourceMode, + env: [String: String] = [:], + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `api key reader trims configured environment key`() { + let token = OllamaAPISettingsReader.apiKey(environment: ["OLLAMA_API_KEY": " 'ollama-test' "]) + + #expect(token == "ollama-test") + } + + @Test + func `api tags response maps to API key identity`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = try OllamaAPIUsageFetcher._parseTagsForTesting( + Data(#"{"models":[{"name":"gpt-oss:120b"}]}"#.utf8), + now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.modelCount == 1) + #expect(usage.primary == nil) + #expect(usage.identity?.providerID == .ollama) + #expect(usage.identity?.loginMethod == "API key") + #expect(usage.updatedAt == now) + } + + @Test + func `auto mode keeps web quota strategy before api key verification`() async { + let descriptor = OllamaProviderDescriptor.makeDescriptor() + let context = self.makeContext( + sourceMode: .auto, + env: ["OLLAMA_API_KEY": "ollama-test"], + settings: ProviderSettingsSnapshot.make( + ollama: .init(cookieSource: .auto, manualCookieHeader: nil))) + + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["ollama.web", "ollama.api"]) + } + + @Test + func `auto mode uses api only when ollama cookies are off`() async { + let descriptor = OllamaProviderDescriptor.makeDescriptor() + let context = self.makeContext( + sourceMode: .auto, + env: ["OLLAMA_API_KEY": "ollama-test"], + settings: ProviderSettingsSnapshot.make( + ollama: .init(cookieSource: .off, manualCookieHeader: nil))) + + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["ollama.api"]) + } + + @Test + func `web strategy falls back to api key in auto mode`() { + let context = self.makeContext( + sourceMode: .auto, + env: ["OLLAMA_API_KEY": "ollama-test"]) + let strategy = OllamaStatusFetchStrategy() + + #expect(strategy.shouldFallback(on: OllamaUsageError.parseFailed("missing"), context: context)) + } + + @Test + func `api fetch sends bearer token and rejects unauthorized key`() async throws { + let url = try #require(URL(string: "https://ollama.com/api/tags")) + let transport = ProviderHTTPTransportHandler { request in + #expect(request.url == url) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer ollama-test") + let response = HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (Data("{}".utf8), response) + } + + do { + _ = try await OllamaAPIUsageFetcher.fetchUsage(apiKey: "ollama-test", transport: transport) + Issue.record("Expected unauthorized API error") + } catch let error as OllamaUsageError { + guard case .apiUnauthorized = error else { + Issue.record("Expected apiUnauthorized, got \(error)") + return + } + } catch { + Issue.record("Expected OllamaUsageError.apiUnauthorized, got \(error)") + } + } + @Test - func missingUsageShapeSurfacesPublicParseFailedMessage() async { + func `missing usage shape surfaces public parse failed message`() async { defer { OllamaRetryMappingStubURLProtocol.handler = nil } OllamaRetryMappingStubURLProtocol.handler = { request in diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index 8020638f8b..e25283f5b0 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -2,24 +2,30 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct OllamaUsageFetcherTests { @Test - func attachesCookieForOllamaHosts() { + func `attaches cookie for ollama hosts`() { #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com/settings"))) #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://www.ollama.com"))) #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://app.ollama.com/path"))) } @Test - func rejectsNonOllamaHosts() { + func `rejects non ollama hosts`() { #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://example.com"))) #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com.evil.com"))) #expect(!OllamaUsageFetcher.shouldAttachCookie(to: nil)) } @Test - func manualModeWithoutValidHeaderThrowsNoSessionCookie() { + func `rejects non https ollama urls`() { + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://ollama.com/settings"))) + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://www.ollama.com"))) + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ollama.com/path"))) + } + + @Test + func `manual mode without valid header throws no session cookie`() { do { _ = try OllamaUsageFetcher.resolveManualCookieHeader( override: nil, @@ -33,7 +39,7 @@ struct OllamaUsageFetcherTests { } @Test - func autoModeWithoutHeaderDoesNotForceManualError() throws { + func `auto mode without header does not force manual error`() throws { let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( override: nil, manualCookieMode: false) @@ -41,7 +47,7 @@ struct OllamaUsageFetcherTests { } @Test - func manualModeWithoutRecognizedSessionCookieThrowsNoSessionCookie() { + func `manual mode without recognized session cookie throws no session cookie`() { do { _ = try OllamaUsageFetcher.resolveManualCookieHeader( override: "analytics_session_id=noise; theme=dark", @@ -55,7 +61,7 @@ struct OllamaUsageFetcherTests { } @Test - func manualModeWithRecognizedSessionCookieAcceptsHeader() throws { + func `manual mode with recognized session cookie accepts header`() throws { let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( override: "next-auth.session-token.0=abc; theme=dark", manualCookieMode: true) @@ -63,7 +69,15 @@ struct OllamaUsageFetcherTests { } @Test - func retryPolicyRetriesOnlyForAuthErrors() { + func `manual mode accepts secure session cookie header`() throws { + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: "__Secure-session=abc; theme=dark", + manualCookieMode: true) + #expect(resolved?.contains("__Secure-session=abc") == true) + } + + @Test + func `retry policy retries only for auth errors`() { #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.invalidCredentials)) #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.notLoggedIn)) #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate( @@ -77,12 +91,13 @@ struct OllamaUsageFetcherTests { #if os(macOS) @Test - func cookieImporterDefaultsToChromeFirst() { + func `cookie importer defaults to chrome first`() { #expect(OllamaCookieImporter.defaultPreferredBrowsers == [.chrome]) + #expect(OllamaCookieImporter.defaultAllowFallbackBrowsers) } @Test - func cookieSelectorSkipsSessionLikeNoiseAndFindsRecognizedCookie() throws { + func `cookie selector skips session like noise and finds recognized cookie`() throws { let first = OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], sourceLabel: "Profile A") @@ -95,7 +110,7 @@ struct OllamaUsageFetcherTests { } @Test - func cookieSelectorThrowsWhenNoRecognizedSessionCookieExists() { + func `cookie selector throws when no recognized session cookie exists`() { let candidates = [ OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], @@ -116,7 +131,7 @@ struct OllamaUsageFetcherTests { } @Test - func cookieSelectorAcceptsChunkedNextAuthSessionTokenCookie() throws { + func `cookie selector accepts chunked next auth session token cookie`() throws { let candidate = OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "next-auth.session-token.0", value: "chunk0")], sourceLabel: "Profile C") @@ -126,7 +141,17 @@ struct OllamaUsageFetcherTests { } @Test - func cookieSelectorKeepsRecognizedCandidatesInOrder() throws { + func `cookie selector accepts secure session cookie`() throws { + let candidate = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "__Secure-session", value: "auth")], + sourceLabel: "Profile D") + + let selected = try OllamaCookieImporter.selectSessionInfo(from: [candidate]) + #expect(selected.sourceLabel == "Profile D") + } + + @Test + func `cookie selector keeps recognized candidates in order`() throws { let first = OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "session", value: "stale")], sourceLabel: "Chrome Profile A") @@ -142,7 +167,7 @@ struct OllamaUsageFetcherTests { } @Test - func cookieSelectorDoesNotFallbackWhenFallbackDisabled() { + func `cookie selector does not fallback when fallback disabled`() { let preferred = [ OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], @@ -168,7 +193,7 @@ struct OllamaUsageFetcherTests { } @Test - func cookieSelectorFallsBackToNonChromeCandidateWhenFallbackEnabled() throws { + func `cookie selector falls back to non chrome candidate when fallback enabled`() throws { let preferred = [ OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], @@ -187,6 +212,21 @@ struct OllamaUsageFetcherTests { #expect(selected.sourceLabel == "Safari Profile") } + @Test + func `cookie selector can fall back to comet secure session cookie`() throws { + let fallback = [ + OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "__Secure-session", value: "auth")], + sourceLabel: "Comet Profile"), + ] + + let selected = try OllamaCookieImporter.selectSessionInfoWithFallback( + preferredCandidates: [], + allowFallbackBrowsers: true, + loadFallbackCandidates: { fallback }) + #expect(selected.sourceLabel == "Comet Profile") + } + private static func makeCookie( name: String, value: String, diff --git a/Tests/CodexBarTests/OllamaUsageParserTests.swift b/Tests/CodexBarTests/OllamaUsageParserTests.swift index e01d167aee..bdec926067 100644 --- a/Tests/CodexBarTests/OllamaUsageParserTests.swift +++ b/Tests/CodexBarTests/OllamaUsageParserTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct OllamaUsageParserTests { @Test - func parsesCloudUsageFromSettingsHTML() throws { + func `parses cloud usage from settings HTML`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """
@@ -44,10 +43,12 @@ struct OllamaUsageParserTests { let usage = snapshot.toUsageSnapshot() #expect(usage.identity?.loginMethod == "free") #expect(usage.identity?.accountEmail == "user@example.com") + #expect(usage.primary?.windowMinutes == 5 * 60) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) } @Test - func missingUsageThrowsParseFailed() { + func `missing usage throws parse failed`() { let html = "No usage here. login status unknown." #expect { @@ -59,7 +60,7 @@ struct OllamaUsageParserTests { } @Test - func classifiedParseMissingUsageReturnsTypedFailure() { + func `classified parse missing usage returns typed failure`() { let html = "No usage here. login status unknown." let result = OllamaUsageParser.parseClassified(html: html) @@ -72,7 +73,7 @@ struct OllamaUsageParserTests { } @Test - func signedOutThrowsNotLoggedIn() { + func `signed out throws not logged in`() { let html = """ @@ -94,7 +95,7 @@ struct OllamaUsageParserTests { } @Test - func classifiedParseSignedOutReturnsTypedFailure() { + func `classified parse signed out returns typed failure`() { let html = """ @@ -117,7 +118,7 @@ struct OllamaUsageParserTests { } @Test - func genericSignInTextWithoutAuthMarkersThrowsParseFailed() { + func `generic sign in text without auth markers throws parse failed`() { let html = """ @@ -137,7 +138,7 @@ struct OllamaUsageParserTests { } @Test - func parsesHourlyUsageAsPrimaryWindow() throws { + func `parses hourly usage as primary window`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """
@@ -154,10 +155,39 @@ struct OllamaUsageParserTests { #expect(snapshot.sessionUsedPercent == 2.5) #expect(snapshot.weeklyUsedPercent == 4.2) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + } + + @Test + func `weekly usage parser finds reset timestamp in long usage block`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let filler = String(repeating: "", count: 40) + let html = """ +
+ Session usage + 0.1% used + Weekly usage + 0.7% used + \(filler) +
Resets in 2 days
+
+ """ + + let snapshot = try OllamaUsageParser.parse(html: html, now: now) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let expectedWeekly = formatter.date(from: "2026-02-02T00:00:00Z") + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetsAt == nil) + #expect(usage.secondary?.resetsAt == expectedWeekly) } @Test - func parsesUsageWhenUsedIsCapitalized() throws { + func `parses usage when used is capitalized`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """
diff --git a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift new file mode 100644 index 0000000000..88860dd5f6 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift @@ -0,0 +1,217 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenAIAPICreditBalanceTests { + private func makeContext( + apiKey: String = "sk-test", + usesAdminKey: Bool = false, + projectID: String? = nil, + selectedTokenAccountID: UUID? = nil, + historyDays: Int = 30) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + let apiKeyEnvironmentKey = usesAdminKey + ? OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey + : OpenAIAPISettingsReader.apiKeyEnvironmentKey + var env = [apiKeyEnvironmentKey: apiKey] + if let projectID { + env[OpenAIAPISettingsReader.projectIDEnvironmentKey] = projectID + } + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID, + costUsageHistoryDays: historyDays) + } + + @Test + func `prefers admin key environment variable`() { + let token = OpenAIAPISettingsReader.apiKey(environment: [ + "OPENAI_API_KEY": "sk-project", + "OPENAI_ADMIN_KEY": "sk-admin", + ]) + + #expect(token == "sk-admin") + } + + @Test + func `parses credit grants balance`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "object": "credit_summary", + "total_granted": 25.5, + "total_used": 7.25, + "total_available": 18.25, + "grants": { + "object": "list", + "data": [ + { + "grant_amount": 10.0, + "used_amount": 1.0, + "effective_at": 1690000000, + "expires_at": 1800000000 + } + ] + } + } + """ + + let snapshot = try OpenAIAPICreditBalanceFetcher._parseSnapshotForTesting(Data(json.utf8), now: now) + + #expect(snapshot.totalGranted == 25.5) + #expect(snapshot.totalUsed == 7.25) + #expect(snapshot.totalAvailable == 18.25) + #expect(snapshot.nextGrantExpiry == Date(timeIntervalSince1970: 1_800_000_000)) + } + + @Test + func `maps balance to usage snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let balance = OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 40, + totalAvailable: 60, + nextGrantExpiry: Date(timeIntervalSince1970: 1_800_000_000), + updatedAt: now) + + let usage = balance.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 40) + #expect(usage.primary?.resetDescription == "$60.00 available") + #expect(usage.providerCost?.used == 40) + #expect(usage.providerCost?.limit == 100) + #expect(usage.identity?.providerID == .openai) + #expect(usage.identity?.loginMethod == "API balance: $60.00") + } + + @Test + func `falls back to legacy billing when admin usage rejects credentials`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { _, _ in + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { _ in + OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext()) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + } + + @Test + func `legacy API key without project ID falls back to legacy billing`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == nil) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "sk-test") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext()) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + + @Test + func `selected token account uses scrubbed final environment for legacy fallback`() async throws { + let accountID = UUID() + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "account-token") + #expect(credential.projectID == nil) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "account-token") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext( + apiKey: "account-token", + usesAdminKey: true, + selectedTokenAccountID: accountID)) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + + @Test + func `preserves admin usage error when legacy fallback also fails`() async { + let usageFailure = OpenAIAPIUsageError.parseFailed(endpoint: "costs", message: "changed") + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { _, _ in throw usageFailure }, + balanceFetcher: { _ in throw OpenAIAPICreditBalanceError.forbidden }) + + do { + _ = try await strategy.fetch(self.makeContext()) + Issue.record("Expected admin usage failure") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @Test + func `falls back to credit balance when admin usage endpoint is unavailable`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == nil) + #expect(historyDays == 90) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 500) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "sk-test") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext(historyDays: 90)) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.providerCost?.used == 25) + #expect(result.usage.providerCost?.limit == 100) + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift new file mode 100644 index 0000000000..b13eabe477 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift @@ -0,0 +1,166 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct OpenAIAPIMenuCardModelTests { + @Test + func `admin usage model shows summaries and spend without fake quota bars`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 12.5, + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500, + lineItems: [ + OpenAIAPIUsageSnapshot.LineItemBreakdown(name: "Text tokens", costUSD: 12.5), + ], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500), + ]), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: apiUsage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.openAIAPIUsage != nil) + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$12.50") + #expect(model.inlineUsageDashboard?.kpis.last?.title == "Requests") + #expect(model.inlineUsageDashboard?.kpis.last?.value == "40") + #expect(model.inlineUsageDashboard?.points.count == 1) + #expect(model.inlineUsageDashboard?.detailLines.contains("30d requests: 40 requests") == true) + #expect(model.providerCost == nil) + #expect(model.usageNotes.contains { $0.contains("Today: $12.50") }) + #expect(model.usageNotes.contains("Top model: gpt-5.2")) + #expect(model.creditsText == nil) + #expect(model.planText == "Admin API") + } + + @Test + func `admin usage dashboard ignores stale token snapshot after fallback refresh`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let staleTokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 1500, + sessionCostUSD: 12.5, + last30DaysTokens: 1500, + last30DaysCostUSD: 12.5, + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-14", + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + costUSD: 12.5, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: staleTokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.tokenUsage == nil) + } + + @Test + func `admin usage model can show cost card summary`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 12.5, + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500, + lineItems: [], + models: []), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: apiUsage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: apiUsage.toCostUsageTokenSnapshot(), + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(ProviderDescriptorRegistry.descriptor(for: .openai).tokenCost.supportsTokenCost) + #expect(model.tokenUsage?.sessionLine == "Today: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.monthLine == "Last 30 days: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.hintLine == "Reported by OpenAI Admin API organization usage.") + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift b/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift new file mode 100644 index 0000000000..a8a0f3f515 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift @@ -0,0 +1,334 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI +@testable import CodexBarCore + +@Suite(.serialized) +struct OpenAIAPIProjectScopeTests { + @Test + @MainActor + func `token account strips configured project in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "OpenAIAPIProjectScopeTests-app") + settings.openAIAPIKey = "config-token" + settings.openAIAPIProjectID = "proj_config" + settings.addTokenAccount(provider: .openai, label: "Configured account", token: "first-account-token") + settings.addTokenAccount(provider: .openai, label: "Selected account", token: "selected-account-token") + let selectedAccount = settings.tokenAccounts(for: .openai)[1] + + let env = ProviderRegistry.makeEnvironment( + base: [OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env"], + provider: .openai, + settings: settings, + tokenOverride: TokenAccountOverride(provider: .openai, account: selectedAccount)) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "selected-account-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "config-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "first-account-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil) + } + + @Test + func `token account strips configured project in CLI environment builder`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Project account", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + let accounts = ProviderTokenAccountData(version: 1, accounts: [account], activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .openai, + apiKey: "config-token", + workspaceID: "proj_config", + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + + let env = tokenContext.environment( + base: [OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env"], + provider: .openai, + account: account) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "account-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "config-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil) + } + + @Test + @MainActor + func `configured app project scopes admin usage strategy`() async throws { + let settings = Self.makeSettingsStore(suite: "OpenAIAPIProjectScopeTests-configured-project") + settings.openAIAPIKey = "config-token" + settings.openAIAPIProjectID = "proj_config" + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .openai, + settings: settings, + tokenOverride: nil) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "config-token") + #expect(credential.projectID == "proj_config") + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Configured project usage should not fetch legacy organization balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext(env: env)) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_config") + #expect(result.usage.identity?.accountOrganization == "Project: proj_config") + } + + @Test + func `legacy API key environment can scope admin usage by project`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-admin-legacy") + #expect(credential.projectID == "proj_legacy") + #expect(credential.usesAdminKey == false) + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Legacy OPENAI_API_KEY project usage should not fetch unscoped balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-admin-legacy", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_legacy", + ])) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_legacy") + #expect(result.usage.identity?.accountOrganization == "Project: proj_legacy") + } + + @Test + func `ambient project with legacy API key preserves billing fallback`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-ambient") + #expect(credential.projectID == "proj_ambient") + #expect(credential.usesAdminKey == false) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "sk-ambient") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-ambient", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_ambient", + ])) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + + @Test + func `project filtered admin usage does not fall back on service failure`() async { + let usageFailure = OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 500) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + #expect(credential.usesAdminKey == true) + #expect(historyDays == 30) + throw usageFailure + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage must not fall back to organization balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + do { + _ = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + Issue.record("Expected project-filtered admin usage failure.") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @Test + func `project filtered admin usage does not fall back on credential rejection`() async { + let usageFailure = OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + #expect(historyDays == 30) + throw usageFailure + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage must fail closed instead of showing unscoped balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + do { + _ = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + Issue.record("Expected project-filtered admin credential failure.") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @Test + func `project filtered admin usage reports project source label`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, _ in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage should not fetch legacy balance after admin usage succeeds.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_abc") + #expect(result.usage.identity?.accountOrganization == "Project: proj_abc") + } + + @Test + func `project scope follows final environment even when selected account flag is present`() async throws { + let accountID = UUID() + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_env") + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Final project-scoped environments should not fetch legacy balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env", + ], + selectedTokenAccountID: accountID)) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_env") + #expect(result.usage.identity?.accountOrganization == "Project: proj_env") + } + + private static func makeContext( + env: [String: String], + selectedTokenAccountID: UUID? = nil, + historyDays: Int = 30) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID, + costUsageHistoryDays: historyDays) + } + + @MainActor + private static func makeSettingsStore(suite: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift b/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift new file mode 100644 index 0000000000..002d2bd9cb --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift @@ -0,0 +1,53 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `open AI API usage submenu ignores stale token snapshot without current admin usage`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .openai + + let registry = ProviderRegistry.shared + let metadata = try #require(registry.metadata[.openai]) + settings.setProviderEnabled(provider: .openai, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + provider: .openai) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.makeOpenAIAPIUsageSubmenu(provider: .openai) == nil) + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift new file mode 100644 index 0000000000..a9cd8f50ee --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift @@ -0,0 +1,687 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenAIAPIUsageFetcherTests { + @Test + func `parses admin costs and completions usage into daily summaries`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let costs = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 12.50, "currency": "usd" }, + "line_item": "Text tokens" + }, + { + "object": "organization.costs.result", + "amount": { "value": "2.25", "currency": "usd" }, + "line_item": "Web search tool calls" + } + ] + }, + { + "object": "bucket", + "start_time": 1700086400, + "end_time": 1700172800, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 4.00, "currency": "usd" }, + "line_item": "Text tokens" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + let completions = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 1000, + "input_cached_tokens": 250, + "output_tokens": 500, + "num_model_requests": 7, + "model": "gpt-5.2" + }, + { + "object": "organization.usage.completions.result", + "input_tokens": 300, + "output_tokens": 200, + "num_model_requests": 3, + "model": "gpt-5.2-codex" + } + ] + }, + { + "object": "bucket", + "start_time": 1700086400, + "end_time": 1700172800, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 200, + "output_tokens": 100, + "num_model_requests": 2, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + + let snapshot = try OpenAIAPIUsageFetcher._parseSnapshotForTesting( + costs: Data(costs.utf8), + completions: Data(completions.utf8), + now: now, + historyDays: 90) + + #expect(snapshot.historyDays == 90) + #expect(snapshot.historyWindowLabel == "90d") + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[0].costUSD == 14.75) + #expect(snapshot.daily[0].requests == 10) + #expect(snapshot.daily[0].totalTokens == 2000) + #expect(snapshot.daily[0].cachedInputTokens == 250) + #expect(snapshot.daily[0].lineItems.first?.name == "Text tokens") + #expect(snapshot.last30Days.costUSD == 18.75) + #expect(snapshot.last30Days.requests == 12) + #expect(snapshot.last30Days.totalTokens == 2300) + #expect(snapshot.topModels.first?.name == "gpt-5.2") + #expect(snapshot.topModels.first?.totalTokens == 1800) + } + + @Test + func `admin usage fetch pages long history within endpoint bucket limit`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (emptyPage, response) + } + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 90) + + let requests = await transport.requests() + let limits = requests.compactMap { request -> Int? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let raw = components.queryItems?.first(where: { $0.name == "limit" })?.value + else { return nil } + return Int(raw) + } + + #expect(snapshot.historyDays == 90) + #expect(requests.count == 6) + #expect(limits == [31, 31, 28, 31, 31, 28]) + #expect(limits.allSatisfy { $0 <= 31 }) + } + + @Test + func `admin usage filters costs and completions by project`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (emptyPage, response) + } + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + projectID: " proj_abc ", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1) + + let requests = await transport.requests() + let projectIDs = requests.compactMap { request -> String? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + return components.queryItems?.first(where: { $0.name == "project_ids" })?.value + } + let groupBys = requests.compactMap { request -> String? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + return components.queryItems?.first(where: { $0.name == "group_by" })?.value + } + + #expect(snapshot.projectID == "proj_abc") + #expect(snapshot.toUsageSnapshot().identity?.accountOrganization == "Project: proj_abc") + #expect(requests.count == 2) + #expect(projectIDs == ["proj_abc", "proj_abc"]) + #expect(groupBys == ["line_item", "model"]) + } + + @Test + func `admin usage follows costs and completions pagination cursors`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let transport = OpenAIAdminUsagePaginationScript() + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + projectID: "proj_abc", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1) + + let requests = await transport.requests() + let costsRequests = requests.filter { $0.url?.path.contains("/organization/costs") == true } + let completionRequests = requests.filter { $0.url?.path.contains("/usage/completions") == true } + + #expect(snapshot.daily.count == 1) + #expect(snapshot.latestDay.costUSD == 4.0) + #expect(snapshot.latestDay.requests == 3) + #expect(snapshot.latestDay.totalTokens == 45) + #expect(costsRequests.count == 2) + #expect(completionRequests.count == 2) + #expect(Self.queryValue("page", in: costsRequests[0]) == nil) + #expect(Self.queryValue("page", in: costsRequests[1]) == "costs_page_2") + #expect(Self.queryValue("page", in: completionRequests[0]) == nil) + #expect(Self.queryValue("page", in: completionRequests[1]) == "completions_page_2") + #expect(requests.allSatisfy { Self.queryValue("project_ids", in: $0) == "proj_abc" }) + } + + @Test + func `admin usage rejects repeated pagination cursor`() async throws { + let transport = OpenAIAdminUsageRepeatingCursorScript() + + await #expect(throws: OpenAIAPIUsageError.parseFailed( + endpoint: "costs", + message: "Pagination cursor repeated.")) + { + try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: Date(timeIntervalSince1970: 1_700_179_200), + historyDays: 1) + } + } + + @Test + func `admin usage rejects missing pagination cursor`() async throws { + let transport = OpenAIAdminUsageMissingCursorScript() + + await #expect(throws: OpenAIAPIUsageError.parseFailed( + endpoint: "costs", + message: "Pagination cursor missing.")) + { + try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: Date(timeIntervalSince1970: 1_700_179_200), + historyDays: 1) + } + } + + @Test + func `admin usage rejects page without costs data`() async throws { + let transport = OpenAIAdminUsageMalformedPageScript( + costs: #"{"object":"page","has_more":false,"next_page":null}"#, + completions: #"{"object":"page","data":[],"has_more":false,"next_page":null}"#) + + do { + _ = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: Date(timeIntervalSince1970: 1_700_179_200), + historyDays: 1) + Issue.record("Expected costs parse failure.") + } catch let error as OpenAIAPIUsageError { + guard case let .parseFailed(endpoint, message) = error else { + Issue.record("Expected parse failure, got \(error).") + return + } + #expect(endpoint == "costs") + #expect(message.contains("data")) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error).") + } + } + + @Test + func `admin usage rejects page without completions pagination state`() async throws { + let transport = OpenAIAdminUsageMalformedPageScript( + costs: #"{"object":"page","data":[],"has_more":false,"next_page":null}"#, + completions: #"{"object":"page","data":[],"next_page":null}"#) + + do { + _ = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: Date(timeIntervalSince1970: 1_700_179_200), + historyDays: 1) + Issue.record("Expected completions parse failure.") + } catch let error as OpenAIAPIUsageError { + guard case let .parseFailed(endpoint, message) = error else { + Issue.record("Expected parse failure, got \(error).") + return + } + #expect(endpoint == "completions") + #expect(message.contains("missing")) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error).") + } + } + + @Test + func `admin usage retries transient completions failure once`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let completions = Data(""" + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 10, + "output_tokens": 5, + "num_model_requests": 1, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """.utf8) + let transport = OpenAIAdminUsageRetryScript(costs: emptyPage, completions: completions) + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1, + retryPolicy: ProviderHTTPRetryPolicy(maxRetries: 1, baseDelaySeconds: 0, maxDelaySeconds: 0)) + + #expect(snapshot.latestDay.totalTokens == 15) + #expect(snapshot.latestDay.requests == 1) + #expect(await transport.completionsRequestCount() == 2) + } + + @Test + func `maps admin usage to openai usage snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 8.5, + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250, + lineItems: [], + models: []), + ], + updatedAt: now) + + let usage = apiUsage.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.providerCost?.used == 8.5) + #expect(usage.providerCost?.limit == 0) + #expect(usage.providerCost?.period == "Last 30 days") + #expect(usage.openAIAPIUsage?.last30Days.requests == 42) + #expect(usage.identity?.loginMethod == "Admin API") + } + + @Test + func `maps project scoped admin usage to cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-13", + startTime: now.addingTimeInterval(-86400), + endTime: now, + costUSD: 2.25, + requests: 3, + inputTokens: 300, + cachedInputTokens: 100, + outputTokens: 200, + totalTokens: 500, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 3, + inputTokens: 300, + cachedInputTokens: 100, + outputTokens: 200, + totalTokens: 500), + ]), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 8.5, + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2-codex", + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250), + ]), + ], + updatedAt: now, + historyDays: 7, + projectID: " proj_abc ") + + let usage = apiUsage.toUsageSnapshot() + let snapshot = apiUsage.toCostUsageTokenSnapshot() + + #expect(apiUsage.projectID == "proj_abc") + #expect(usage.identity?.loginMethod == "Admin API: proj_abc") + #expect(usage.identity?.accountOrganization == "Project: proj_abc") + #expect(snapshot.historyDays == 7) + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.sessionCostUSD == 8.5) + #expect(snapshot.sessionTokens == 1250) + #expect(snapshot.sessionRequests == 42) + #expect(snapshot.last30DaysCostUSD == 10.75) + #expect(snapshot.last30DaysTokens == 1750) + #expect(snapshot.last30DaysRequests == 45) + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[1].cacheReadTokens == 400) + #expect(snapshot.daily[1].requestCount == 42) + #expect(snapshot.daily[1].modelBreakdowns?.first?.requestCount == 42) + #expect(snapshot.daily[1].modelBreakdowns?.first?.modelName == "gpt-5.2-codex") + } + + private static func queryValue(_ name: String, in request: URLRequest) -> String? { + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + return components.queryItems?.first(where: { $0.name == name })?.value + } +} + +private actor OpenAIAdminUsagePaginationScript: ProviderHTTPTransport { + private var recordedRequests: [URLRequest] = [] + + func requests() -> [URLRequest] { + self.recordedRequests + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.recordedRequests.append(request) + let url = request.url ?? URL(string: "https://api.openai.test")! + let page = Self.queryValue("page", in: url) + let body: String = if url.path.contains("/organization/costs") { + page == "costs_page_2" ? Self.costsPage2 : Self.costsPage1 + } else if url.path.contains("/usage/completions") { + page == "completions_page_2" ? Self.completionsPage2 : Self.completionsPage1 + } else { + #"{"object":"page","data":[],"has_more":false,"next_page":null}"# + } + return (Data(body.utf8), HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + + private static func queryValue(_ name: String, in url: URL) -> String? { + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == name })? + .value + } + + private static let costsPage1 = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 1.25, "currency": "usd" }, + "line_item": "Text tokens" + } + ] + } + ], + "has_more": true, + "next_page": "costs_page_2" + } + """ + + private static let costsPage2 = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 2.75, "currency": "usd" }, + "line_item": "Web search tool calls" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + + private static let completionsPage1 = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 10, + "output_tokens": 5, + "num_model_requests": 1, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": true, + "next_page": "completions_page_2" + } + """ + + private static let completionsPage2 = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 20, + "output_tokens": 10, + "num_model_requests": 2, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ +} + +private actor OpenAIAdminUsageRepeatingCursorScript: ProviderHTTPTransport { + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + let body = """ + { + "object": "page", + "data": [], + "has_more": true, + "next_page": "same_page" + } + """ + return (Data(body.utf8), HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } +} + +private actor OpenAIAdminUsageMissingCursorScript: ProviderHTTPTransport { + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + let body = """ + { + "object": "page", + "data": [], + "has_more": true, + "next_page": null + } + """ + return (Data(body.utf8), HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } +} + +private actor OpenAIAdminUsageMalformedPageScript: ProviderHTTPTransport { + private let costs: String + private let completions: String + + init(costs: String, completions: String) { + self.costs = costs + self.completions = completions + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + let body = url.path.contains("/usage/completions") ? self.completions : self.costs + return (Data(body.utf8), HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } +} + +private actor OpenAIAdminUsageRetryScript: ProviderHTTPTransport { + private let costs: Data + private let completions: Data + private var completionsRequests = 0 + + init(costs: Data, completions: Data) { + self.costs = costs + self.completions = completions + } + + func completionsRequestCount() -> Int { + self.completionsRequests + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + if url.path.contains("/usage/completions") { + self.completionsRequests += 1 + if self.completionsRequests == 1 { + return (Data(), HTTPURLResponse( + url: url, + statusCode: 503, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + return (self.completions, HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + + return (self.costs, HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift index cad9598841..dc505e79cd 100644 --- a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift @@ -1,10 +1,282 @@ -import CodexBarCore +import Foundation import Testing +@testable import CodexBarCore + +private final class CookieCallbackHarness: @unchecked Sendable { + private let lock = NSLock() + private var callback: (@Sendable () -> Void)? + + func capture(_ callback: @escaping @Sendable () -> Void) { + self.lock.withLock { self.callback = callback } + } + + func finish() { + let callback = self.lock.withLock { + let callback = self.callback + self.callback = nil + return callback + } + callback?() + } +} + +private final class CookieCallbackFlag: @unchecked Sendable { + private let lock = NSLock() + private var storedValue = false + + var value: Bool { + self.lock.withLock { self.storedValue } + } + + func set() { + self.lock.withLock { self.storedValue = true } + } +} + +private final class CookieOperationLog: @unchecked Sendable { + private let lock = NSLock() + private var entries: [String] = [] + + var snapshot: [String] { + self.lock.withLock { self.entries } + } + + func append(_ entry: String) { + self.lock.withLock { self.entries.append(entry) } + } +} + +private final class CookieTimeoutProbe: @unchecked Sendable { + private let lock = NSLock() + private var storedFiredAt: Date? + + var firedAt: Date? { + self.lock.withLock { self.storedFiredAt } + } + + func record() { + self.lock.withLock { + if self.storedFiredAt == nil { + self.storedFiredAt = Date() + } + } + } +} -@Suite struct OpenAIDashboardBrowserCookieImporterTests { @Test - func mismatchErrorMentionsSourceLabel() { + func `shared deadline clamps each local timeout to remaining budget`() throws { + let start = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = start.addingTimeInterval(30) + + let remaining = try OpenAIDashboardBrowserCookieImporter.remainingTimeout( + until: deadline, + cappedAt: 10, + now: start.addingTimeInterval(27)) + + #expect(remaining == 3) + } + + @Test + func `shared deadline preserves smaller local timeout`() throws { + let start = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = start.addingTimeInterval(30) + + let remaining = try OpenAIDashboardBrowserCookieImporter.remainingTimeout( + until: deadline, + cappedAt: 10, + now: start.addingTimeInterval(5)) + + #expect(remaining == 10) + } + + @Test + func `expired shared deadline throws structured timeout`() { + let deadline = Date(timeIntervalSinceReferenceDate: 1000) + + do { + _ = try OpenAIDashboardBrowserCookieImporter.remainingTimeout( + until: deadline, + now: deadline) + Issue.record("Expected deadline timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `blocking browser cookie load cannot exceed shared deadline`() async throws { + let start = Date() + let timeoutProbe = CookieTimeoutProbe() + + do { + _ = try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieLoad( + deadline: start.addingTimeInterval(0.05), + timeoutObserver: timeoutProbe.record) + { + Thread.sleep(forTimeInterval: 0.5) + return true + } + Issue.record("Expected cookie load timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + let firedAt = try #require(timeoutProbe.firedAt) + #expect(firedAt.timeIntervalSince(start) < 0.3) + } + + @Test + func `timeout observer stays silent when operation wins`() async throws { + let timeoutProbe = CookieTimeoutProbe() + + let value = try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieLoad( + deadline: Date().addingTimeInterval(0.05), + timeoutObserver: timeoutProbe.record) + { + true + } + try await Task.sleep(for: .milliseconds(100)) + + #expect(value) + #expect(timeoutProbe.firedAt == nil) + } + + @Test + func `timed out cookie cache work stays ordered before retry`() async throws { + let log = CookieOperationLog() + let firstOperationStarted = DispatchSemaphore(value: 0) + let allowFirstOperationToFinish = DispatchSemaphore(value: 0) + + do { + _ = try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieCacheOperation( + deadline: Date().addingTimeInterval(0.05)) + { + log.append("first-start") + firstOperationStarted.signal() + _ = allowFirstOperationToFinish.wait(timeout: .now() + 5) + log.append("first-end") + return true + } + Issue.record("Expected first cache operation timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + let firstOperationStartResult = await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: firstOperationStarted.wait(timeout: .now() + 5)) + } + } + #expect(firstOperationStartResult == .success) + do { + _ = try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieCacheOperation( + deadline: Date().addingTimeInterval(0.05)) + { + log.append("second") + return true + } + Issue.record("Expected retry to wait behind first cache operation") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + allowFirstOperationToFinish.signal() + + _ = try await OpenAIDashboardBrowserCookieImporter.runBoundedCookieCacheOperation( + deadline: Date().addingTimeInterval(1)) { true } + #expect(log.snapshot == ["first-start", "first-end", "second"]) + } + + @Test @MainActor + func `slow callback times out before completion`() async throws { + let start = Date() + let timeoutProbe = CookieTimeoutProbe() + + do { + try await OpenAIDashboardBrowserCookieImporter.runBoundedCallback( + deadline: start.addingTimeInterval(0.05), + timeoutObserver: timeoutProbe.record) + { completion in + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.5) { + completion() + } + } + Issue.record("Expected callback timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + let firedAt = try #require(timeoutProbe.firedAt) + #expect(firedAt.timeIntervalSince(start) < 0.3) + } + + @Test @MainActor + func `slow value callback times out before completion`() async throws { + let start = Date() + let timeoutProbe = CookieTimeoutProbe() + + do { + let _: [String] = try await OpenAIDashboardBrowserCookieImporter.runBoundedValueCallback( + deadline: start.addingTimeInterval(0.05), + timeoutObserver: timeoutProbe.record) + { completion in + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.5) { + completion([]) + } + } + Issue.record("Expected value callback timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + let firedAt = try #require(timeoutProbe.firedAt) + #expect(firedAt.timeIntervalSince(start) < 0.3) + } + + @Test @MainActor + func `retry waits for timed out cookie store mutation`() async throws { + let keyOwner = NSObject() + let key = ObjectIdentifier(keyOwner) + let first = CookieCallbackHarness() + + do { + try await OpenAIDashboardBrowserCookieImporter.runSerializedCallback( + key: key, + deadline: Date().addingTimeInterval(0.05), + start: first.capture) + Issue.record("Expected first mutation timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } + + let secondStarted = CookieCallbackFlag() + let second = Task { @MainActor in + try await OpenAIDashboardBrowserCookieImporter.runSerializedCallback( + key: key, + deadline: Date().addingTimeInterval(1)) + { completion in + secondStarted.set() + completion() + } + } + + try await Task.sleep(for: .milliseconds(50)) + #expect(!secondStarted.value) + first.finish() + try await second.value + #expect(secondStarted.value) + } + + @Test + func `mismatch error mentions source label`() { let err = OpenAIDashboardBrowserCookieImporter.ImportError.noMatchingAccount( found: [ .init(sourceLabel: "Safari", email: "a@example.com"), @@ -14,4 +286,23 @@ struct OpenAIDashboardBrowserCookieImporterTests { #expect(msg.contains("Safari=a@example.com")) #expect(msg.contains("Chrome=b@example.com")) } + + @Test + func `timed out persistent validation keeps verified session`() { + let failure = OpenAIDashboardBrowserCookieImporter.persistentValidationFailure(URLError(.timedOut)) + #expect(OpenAIDashboardBrowserCookieImporter.shouldTrustVerifiedSession( + afterPersistFailure: failure)) + } + + @Test + func `raw cookie mutation timeout is not trusted`() { + #expect(!OpenAIDashboardBrowserCookieImporter.shouldTrustVerifiedSession( + afterPersistFailure: URLError(.timedOut))) + } + + @Test + func `non-timeout persistent validation failures are not trusted`() { + #expect(!OpenAIDashboardBrowserCookieImporter.shouldTrustVerifiedSession( + afterPersistFailure: OpenAIDashboardBrowserCookieImporter.ImportError.dashboardStillRequiresLogin)) + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index d8f0a7f4bc..f501fcf1ba 100644 --- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct OpenAIDashboardFetcherCreditsWaitTests { @Test - func waitsAfterScrollRequest() { + func `waits after scroll request`() { let now = Date() let shouldWait = OpenAIDashboardFetcher.shouldWaitForCreditsHistory(.init( now: now, @@ -18,7 +17,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func waitsBrieflyWhenHeaderVisibleButTableEmpty() { + func `waits briefly when header visible but table empty`() { let now = Date() let visibleAt = now.addingTimeInterval(-1.0) let shouldWait = OpenAIDashboardFetcher.shouldWaitForCreditsHistory(.init( @@ -32,7 +31,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func stopsWaitingAfterHeaderHasBeenVisibleLongEnough() { + func `stops waiting after header has been visible long enough`() { let now = Date() let visibleAt = now.addingTimeInterval(-3.0) let shouldWait = OpenAIDashboardFetcher.shouldWaitForCreditsHistory(.init( @@ -46,7 +45,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func waitsBrieflyAfterFirstDashboardSignalEvenWhenHeaderNotPresentYet() { + func `waits briefly after first dashboard signal even when header not present yet`() { let now = Date() let startedAt = now.addingTimeInterval(-2.0) let shouldWait = OpenAIDashboardFetcher.shouldWaitForCreditsHistory(.init( @@ -60,7 +59,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func stopsWaitingEventuallyWhenHeaderNeverAppears() { + func `stops waiting eventually when header never appears`() { let now = Date() let startedAt = now.addingTimeInterval(-7.0) let shouldWait = OpenAIDashboardFetcher.shouldWaitForCreditsHistory(.init( @@ -74,14 +73,106 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func sanitizedTimeoutPreservesPositiveCallerDeadline() { + func `usage breakdown recovery waits briefly after chart classification error`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForUsageBreakdownRecovery(.init( + now: now, + errorFirstSeenAt: now.addingTimeInterval(-1.0))) + #expect(shouldWait == true) + } + + @Test + func `usage breakdown recovery stops blocking partial snapshots`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForUsageBreakdownRecovery(.init( + now: now, + errorFirstSeenAt: now.addingTimeInterval(-5.0))) + #expect(shouldWait == false) + } + + @Test + func `probe waits briefly after reaching usage route without email or dashboard signals`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now.addingTimeInterval(-1.0), + dashboardSignalSeenAt: nil, + signedInEmail: nil, + hasDashboardSignal: false)) + #expect(shouldWait == true) + } + + @Test + func `probe waits briefly for email after dashboard signals appear`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now.addingTimeInterval(-3.0), + dashboardSignalSeenAt: now.addingTimeInterval(-1.0), + signedInEmail: nil, + hasDashboardSignal: true)) + #expect(shouldWait == true) + } + + @Test + func `probe stops waiting once signed in email is available`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now.addingTimeInterval(-0.2), + dashboardSignalSeenAt: now.addingTimeInterval(-0.2), + signedInEmail: "user@example.com", + hasDashboardSignal: true)) + #expect(shouldWait == false) + } + + @Test + func `probe handoff preserves page only after confirmed signed in email`() { + let result = OpenAIDashboardFetcher.ProbeResult( + href: "https://chatgpt.com/codex/cloud/settings/analytics#usage", + loginRequired: false, + workspacePicker: false, + cloudflareInterstitial: false, + signedInEmail: "user@example.com", + bodyText: "Credits remaining 42") + + #expect(OpenAIDashboardFetcher.shouldPreserveLoadedPageAfterProbe(result)) + } + + @Test + func `probe handoff does not preserve timed out usage page without email`() { + let result = OpenAIDashboardFetcher.ProbeResult( + href: "https://chatgpt.com/codex/cloud/settings/analytics#usage", + loginRequired: false, + workspacePicker: false, + cloudflareInterstitial: false, + signedInEmail: nil, + bodyText: "Codex Analytics") + + #expect(!OpenAIDashboardFetcher.shouldPreserveLoadedPageAfterProbe(result)) + } + + @Test + func `probe grace restarts after route reload resets readiness timestamps`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now, + dashboardSignalSeenAt: nil, + signedInEmail: nil, + hasDashboardSignal: false)) + #expect(shouldWait == true) + } + + @Test + func `sanitized timeout preserves positive caller deadline`() { #expect(OpenAIDashboardFetcher.sanitizedTimeout(60) == 60) #expect(OpenAIDashboardFetcher.sanitizedTimeout(25) == 25) #expect(OpenAIDashboardFetcher.sanitizedTimeout(0.5) == 0.5) } @Test - func sanitizedTimeoutFallsBackForInvalidValues() { + func `sanitized timeout falls back for invalid values`() { #expect(OpenAIDashboardFetcher.sanitizedTimeout(0) == 1) #expect(OpenAIDashboardFetcher.sanitizedTimeout(-5) == 1) #expect(OpenAIDashboardFetcher.sanitizedTimeout(.infinity) == 1) @@ -89,7 +180,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func deadlineStartsAtCallStartAndRemainingTimeoutShrinksFromThere() { + func `deadline starts at call start and remaining timeout shrinks from there`() { let start = Date(timeIntervalSinceReferenceDate: 1000) let deadline = OpenAIDashboardFetcher.deadline(startingAt: start, timeout: 15) @@ -102,11 +193,156 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func remainingTimeoutDoesNotGoNegative() { + func `remaining timeout does not go negative`() { let deadline = Date(timeIntervalSinceReferenceDate: 2000) let remaining = OpenAIDashboardFetcher.remainingTimeout( until: deadline, now: deadline.addingTimeInterval(3)) #expect(remaining == 0) } + + @Test + func `usage route matcher accepts legacy settings route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/settings/usage")) + } + + @Test + func `usage route matcher accepts cloud settings route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage")) + } + + @Test + func `usage route matcher accepts analytics route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics")) + } + + @Test + func `usage route matcher accepts analytics usage hash route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics#usage")) + } + + @Test + func `usage route matcher accepts trailing slash variants`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/settings/usage/")) + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage/")) + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics/")) + } + + @Test + func `usage route matcher rejects unrelated routes`() { + #expect(!OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/")) + #expect(!OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex")) + #expect(!OpenAIDashboardFetcher.isUsageRoute(nil)) + } + + @Test(arguments: [ + ("https://chatgpt.com/#usage", true, false, false, false), + ("https://chatgpt.com/", false, false, true, false), + ("https://chatgpt.com/", false, false, false, true) + ]) + func `usage route reload skips blocking states`( + href: String, + loginRequired: Bool, + workspacePicker: Bool, + cloudflareInterstitial: Bool, + expected: Bool) + { + #expect(OpenAIDashboardFetcher.shouldReloadUsageRoute( + href: href, + loginRequired: loginRequired, + workspacePicker: workspacePicker, + cloudflareInterstitial: cloudflareInterstitial) == expected) + } + + @Test + func `dashboard requests prefer English localization`() throws { + let url = try #require(URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")) + let request = OpenAIDashboardFetcher.usageURLRequest(url: url) + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `usage api request carries cookies and English localization`() { + let request = OpenAIDashboardFetcher.dashboardUsageAPIRequest(cookieHeader: "a=b") + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/wham/usage") + #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `identity api request carries cookies and English localization`() throws { + let url = try #require(URL(string: "https://chatgpt.com/backend-api/me")) + let request = OpenAIDashboardFetcher.dashboardIdentityAPIRequest(url: url, cookieHeader: "a=b") + + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/me") + #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `dashboard api requests accept shared deadline timeout clamps`() throws { + let url = try #require(URL(string: "https://chatgpt.com/backend-api/me")) + let usageRequest = OpenAIDashboardFetcher.dashboardUsageAPIRequest( + cookieHeader: "a=b", + timeout: 1.25) + let identityRequest = OpenAIDashboardFetcher.dashboardIdentityAPIRequest( + url: url, + cookieHeader: "a=b", + timeout: 0.75) + + #expect(usageRequest.timeoutInterval == 1.25) + #expect(identityRequest.timeoutInterval == 0.75) + } + + @Test + func `usage api data maps language independent rate limits and credits`() throws { + let json = """ + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { + "used_percent": 12, + "reset_at": 1700003600, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 34, + "reset_at": 1700604800, + "limit_window_seconds": 604800 + } + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": 42.5 + } + } + """ + let response = try CodexOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + let data = OpenAIDashboardFetcher.dashboardAPIData(from: response) + + #expect(data.primaryLimit?.usedPercent == 12) + #expect(data.primaryLimit?.windowMinutes == 300) + #expect(data.secondaryLimit?.usedPercent == 34) + #expect(data.secondaryLimit?.windowMinutes == 10080) + #expect(data.creditsRemaining == 42.5) + #expect(data.accountPlan == "pro") + #expect(data.hasUsageData) + } + + @Test + func `find first email searches nested api payloads`() { + let json = """ + { + "accounts": [ + { "profile": { "name": "Test" } }, + { "profile": { "email": "nested@example.com" } } + ] + } + """ + + #expect(OpenAIDashboardFetcher.findFirstEmail(inJSONData: Data(json.utf8)) == "nested@example.com") + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift new file mode 100644 index 0000000000..b6547e14fe --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import Foundation +import Testing + +struct OpenAIDashboardModelsTests { + @Test + func `removes skill usage services from usage breakdown`() { + let breakdown = [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 10), + OpenAIDashboardServiceUsage(service: "Skillusage:imagegen", creditsUsed: 7), + OpenAIDashboardServiceUsage(service: " skillusage:github:github ", creditsUsed: 2), + ], + totalCreditsUsed: 19), + OpenAIDashboardDailyBreakdown( + day: "2026-04-29", + services: [ + OpenAIDashboardServiceUsage(service: "Skillusage:deep Research", creditsUsed: 3), + ], + totalCreditsUsed: 3), + ] + + let filtered = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) + + #expect(filtered == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 10), + ], + totalCreditsUsed: 10), + ]) + } + + @Test + func `snapshot initializer sanitizes usage breakdown`() { + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "CLI", creditsUsed: 4), + OpenAIDashboardServiceUsage(service: "Skillusage:pdf Renderer", creditsUsed: 6), + ], + totalCreditsUsed: 10), + ], + creditsPurchaseURL: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.usageBreakdown == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "CLI", creditsUsed: 4), + ], + totalCreditsUsed: 4), + ]) + } + + @Test + func `snapshot decoder drops empty zero usage buckets`() throws { + let json = """ + { + "signedInEmail": "codex@example.com", + "codeReviewRemainingPercent": null, + "creditEvents": [], + "dailyBreakdown": [], + "usageBreakdown": [ + { "day": "2026-04-30", "services": [], "totalCreditsUsed": 0 }, + { "day": "2026-04-29", "services": [], "totalCreditsUsed": 4 } + ], + "creditsPurchaseURL": null, + "updatedAt": "2026-04-30T19:27:07Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) + + #expect(snapshot.usageBreakdown == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-29", + services: [], + totalCreditsUsed: 4), + ]) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index c1045f5dad..37db0483a6 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -3,23 +3,46 @@ import Testing import WebKit @testable import CodexBarCore -@Suite +@Suite(.serialized) struct OpenAIDashboardNavigationDelegateTests { - @Test("ignores NSURLErrorCancelled") - func ignoresCancelledNavigationError() { + final class DelegateBox: @unchecked Sendable { + var delegate: NavigationDelegate? + } + + @MainActor + private func waitForResult( + _ result: @escaping () -> Result?, + timeout: TimeInterval = NavigationDelegate.postCommitSuccessDelay + 10.0) async -> Result? + { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let result = result() { return result } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return result() + } + + @Test + func `ignores NSURLErrorCancelled`() { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) #expect(NavigationDelegate.shouldIgnoreNavigationError(error)) } - @Test("does not ignore non-cancelled URL errors") - func doesNotIgnoreOtherURLErrors() { + @Test + func `ignores WebKit frame load interrupted by policy change`() { + let error = NSError(domain: "WebKitErrorDomain", code: 102) + #expect(NavigationDelegate.shouldIgnoreNavigationError(error)) + } + + @Test + func `does not ignore non-cancelled URL errors`() { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) #expect(!NavigationDelegate.shouldIgnoreNavigationError(error)) } @MainActor - @Test("cancelled failure is ignored until finish") - func cancelledFailureIsIgnoredUntilFinish() { + @Test + func `cancelled failure is ignored until finish`() { let webView = WKWebView() var result: Result? let delegate = NavigationDelegate { result = $0 } @@ -37,8 +60,70 @@ struct OpenAIDashboardNavigationDelegateTests { } @MainActor - @Test("cancelled provisional failure is ignored until real failure") - func cancelledProvisionalFailureIsIgnoredUntilRealFailure() { + @Test + func `explicit cancel completes with cancellation error`() { + var result: Result? + let delegate = NavigationDelegate { result = $0 } + + delegate.cancel() + + switch result { + case let .failure(error)?: + #expect(error is CancellationError) + default: + #expect(Bool(false)) + } + } + + @MainActor + @Test + func `commit completes navigation successfully after grace period`() async { + let webView = WKWebView() + var result: Result? + let box = DelegateBox() + box.delegate = NavigationDelegate { result = $0 } + + box.delegate?.webView(webView, didCommit: nil) + #expect(result == nil) + + let completed = await self.waitForResult { result } + box.delegate = nil + + switch completed { + case .success?: + #expect(Bool(true)) + default: + #expect(Bool(false)) + } + } + + @MainActor + @Test + func `post commit failure wins before delayed success`() async { + let webView = WKWebView() + var result: Result? + let box = DelegateBox() + box.delegate = NavigationDelegate { result = $0 } + + box.delegate?.webView(webView, didCommit: nil) + let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + box.delegate?.webView(webView, didFail: nil, withError: timeout) + + let completed = await self.waitForResult { result } + box.delegate = nil + + switch completed { + case let .failure(error as NSError)?: + #expect(error.domain == NSURLErrorDomain) + #expect(error.code == NSURLErrorTimedOut) + default: + #expect(Bool(false)) + } + } + + @MainActor + @Test + func `cancelled provisional failure is ignored until real failure`() { let webView = WKWebView() var result: Result? let delegate = NavigationDelegate { result = $0 } @@ -62,16 +147,43 @@ struct OpenAIDashboardNavigationDelegateTests { } @MainActor - @Test("navigation timeout fails with timed out error") - func navigationTimeoutFailsWithTimedOutError() async { + @Test + func `frame load interrupted provisional failure is ignored until finish`() { + let webView = WKWebView() var result: Result? let delegate = NavigationDelegate { result = $0 } - delegate.armTimeout(seconds: 0.01) - try? await Task.sleep(for: .milliseconds(30)) + delegate.webView( + webView, + didFailProvisionalNavigation: nil, + withError: NSError(domain: "WebKitErrorDomain", code: 102)) + #expect(result == nil) + + delegate.webView(webView, didFinish: nil) + + switch result { + case .success?: + #expect(Bool(true)) + default: + #expect(Bool(false)) + } + } + + @Test + func `navigation timeout fails with timed out error`() async { + let result = await withCheckedContinuation { (continuation: CheckedContinuation, Never>) in + Task { @MainActor in + let box = DelegateBox() + box.delegate = NavigationDelegate { result in + continuation.resume(returning: result) + box.delegate = nil + } + box.delegate?.armTimeout(seconds: 0.01) + } + } switch result { - case let .failure(error as URLError)?: + case let .failure(error as URLError): #expect(error.code == .timedOut) default: #expect(Bool(false)) diff --git a/Tests/CodexBarTests/OpenAIDashboardOffscreenHostTests.swift b/Tests/CodexBarTests/OpenAIDashboardOffscreenHostTests.swift index 74d1de6a67..b39e5a5716 100644 --- a/Tests/CodexBarTests/OpenAIDashboardOffscreenHostTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardOffscreenHostTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct OpenAIDashboardOffscreenHostTests { @Test - func offscreenHostFrameOnlyIntersectsByASliver() { + func `offscreen host frame only intersects by A sliver`() { let visibleFrame = CGRect(x: 0, y: 0, width: 1000, height: 800) let frame = OpenAIDashboardFetcher.offscreenHostWindowFrame(for: visibleFrame) let intersection = frame.intersection(visibleFrame) @@ -19,7 +18,7 @@ struct OpenAIDashboardOffscreenHostTests { } @Test - func offscreenHostAlphaValueIsNonZeroButTiny() { + func `offscreen host alpha value is non zero but tiny`() { let alpha = OpenAIDashboardFetcher.offscreenHostAlphaValue() #expect(alpha > 0) #expect(alpha <= 0.001) diff --git a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift index c84391dfbf..acb41ae822 100644 --- a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift @@ -3,10 +3,9 @@ import Foundation import Testing @testable import CodexBar -@Suite struct OpenAIDashboardParserTests { @Test - func parsesSignedInEmailFromClientBootstrapHTML() { + func `parses signed in email from client bootstrap HTML`() { let html = """ @@ -22,26 +21,52 @@ struct OpenAIDashboardParserTests { } @Test - func parsesCodeReviewRemainingPercent_inline() { + func `parses code review remaining percent inline`() { let body = "Balance\nCode review 42% remaining\nCredits remaining 291" #expect(OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: body) == 42) } @Test - func parsesCodeReviewRemainingPercent_multiline() { + func `parses code review remaining percent multiline`() { let body = "Balance\nCode review\n100% remaining\nWeekly usage limit\n0% remaining" #expect(OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: body) == 100) } @Test - func parsesCreditsRemaining() { + func `parses code review limit with reset`() { + let body = """ + Balance + Code review + 42% remaining + Resets tomorrow at 2:15 PM + """ + let limit = OpenAIDashboardParser.parseCodeReviewLimit(bodyText: body) + #expect(abs((limit?.usedPercent ?? 0) - 58) < 0.001) + #expect(limit?.resetDescription?.lowercased().contains("resets") == true) + } + + @Test + func `parses core review limit with reset`() { + let body = """ + Balance + Core review + 42% remaining + Resets tomorrow at 2:15 PM + """ + let limit = OpenAIDashboardParser.parseCodeReviewLimit(bodyText: body) + #expect(abs((limit?.usedPercent ?? 0) - 58) < 0.001) + #expect(limit?.resetDescription?.lowercased().contains("resets") == true) + } + + @Test + func `parses credits remaining`() { let body = "Balance\nCredits remaining 1,234.56\nUsage" let value = OpenAIDashboardParser.parseCreditsRemaining(bodyText: body) #expect(abs((value ?? 0) - 1234.56) < 0.001) } @Test - func parsesRateLimits() { + func `parses rate limits`() { let body = """ Usage limits 5h limit @@ -60,7 +85,18 @@ struct OpenAIDashboardParserTests { } @Test - func parsesPlanFromClientBootstrap() { + func `parses spaced five hour limit label`() { + let body = """ + Limite 5 h + 72 % restant + """ + let limits = OpenAIDashboardParser.parseRateLimits(bodyText: body) + #expect(abs((limits.primary?.usedPercent ?? 0) - 28) < 0.001) + #expect(limits.primary?.windowMinutes == 300) + } + + @Test + func `parses plan from client bootstrap`() { let html = """ @@ -74,7 +110,21 @@ struct OpenAIDashboardParserTests { } @Test - func parsesCreditEventsFromTableRows() { + func `parses prolite plan from client bootstrap`() { + let html = """ + + + + + + """ + #expect(OpenAIDashboardParser.parsePlanFromHTML(html: html) == "Pro 5x") + } + + @Test + func `parses credit events from table rows`() { let rows: [[String]] = [ ["Dec 18, 2025", "CLI", "397.205 credits"], ["Dec 17, 2025", "GitHub Code Review", "506.235 credits"], @@ -88,7 +138,27 @@ struct OpenAIDashboardParserTests { } @Test - func buildsDailyBreakdownFromEvents() throws { + func `parses credit event amount with localized credit label`() { + let rows: [[String]] = [ + ["Dec 18, 2025", "CLI", "397,205 crédits"], + ] + let events = OpenAIDashboardParser.parseCreditEvents(rows: rows) + #expect(events.count == 1) + #expect(abs((events.first?.creditsUsed ?? 0) - 397.205) < 0.0001) + } + + @Test + func `parses credit event amount with english comma thousands`() { + let rows: [[String]] = [ + ["Dec 18, 2025", "CLI", "1,234 credits"], + ] + let events = OpenAIDashboardParser.parseCreditEvents(rows: rows) + #expect(events.count == 1) + #expect(events.first?.creditsUsed == 1234) + } + + @Test + func `builds daily breakdown from events`() throws { let calendar = Calendar(identifier: .gregorian) var components = DateComponents() components.calendar = calendar @@ -116,7 +186,7 @@ struct OpenAIDashboardParserTests { } @Test - func decodesSnapshotWithoutUsageBreakdownField() throws { + func `decodes snapshot without usage breakdown field`() throws { let json = """ { "signedInEmail": "user@example.com", @@ -131,4 +201,50 @@ struct OpenAIDashboardParserTests { let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) #expect(snapshot.usageBreakdown.isEmpty) } + + @Test + func `weekly only dashboard usage projects into secondary slot`() { + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 25, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: "pro", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot(provider: .codex) + + #expect(usage?.primary == nil) + #expect(usage?.secondary?.usedPercent == 25) + #expect(usage?.secondary?.windowMinutes == 10080) + #expect(usage?.identity?.providerID == .codex) + #expect(usage?.identity?.accountEmail == "user@example.com") + } + + @Test + func `dashboard usage projection returns nil when all limits are absent`() { + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: "pro", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.toUsageSnapshot(provider: .codex) == nil) + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardScrapeScriptTests.swift b/Tests/CodexBarTests/OpenAIDashboardScrapeScriptTests.swift new file mode 100644 index 0000000000..56901c24b7 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardScrapeScriptTests.swift @@ -0,0 +1,236 @@ +#if os(macOS) +import Foundation +import Testing +import WebKit +@testable import CodexBarCore + +@MainActor +@Suite(.serialized) +struct OpenAIDashboardScrapeScriptTests { + @Test + func `scraper returns structured account fields without full html`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.bootstrapAccountHTML, baseURL: nil) + try await Self.waitForFixture(webView, elementID: "account-fixture") + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + + #expect(dict["bodyHTML"] == nil) + #expect(dict["signedInEmail"] as? String == "user@example.com") + #expect(dict["authStatus"] as? String == "logged_in") + #expect(dict["accountPlan"] as? String == "Pro 5x") + } + + @Test + func `usage breakdown scraper ignores neighboring client charts`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.multiChartHTML, baseURL: nil) + try await Self.waitForFixture(webView) + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + let debug = dict["usageBreakdownDebug"] as? String + let raw = try #require(dict["usageBreakdownJSON"] as? String, "debug: \(debug ?? "nil")") + let decoded = try JSONDecoder().decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8)) + + #expect(decoded.count == 1) + #expect(decoded.first?.day == "2026-05-01") + #expect(decoded.first?.totalCreditsUsed == 30) + #expect((decoded.first?.services.map(\.service) ?? []) == ["Desktop", "CLI"]) + } + + @Test + func `usage breakdown scraper reports wrong chart instead of accepting it`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.clientOnlyChartHTML, baseURL: nil) + try await Self.waitForFixture(webView, elementID: "client-chart") + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + + #expect((dict["usageBreakdownJSON"] as? String) == nil) + #expect((dict["usageBreakdownError"] as? String)?.contains("Threads and turns by client") == true) + } + + @Test + func `usage breakdown scraper rejects non english chart titles`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.localizedUsageChartHTML, baseURL: nil) + try await Self.waitForFixture(webView) + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + + #expect((dict["usageBreakdownJSON"] as? String) == nil) + #expect( + (dict["usageBreakdownError"] as? String)? + .contains("No English usage breakdown chart title found") == true) + } + + private static func shouldSkipOnCI() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" + } + + private static func waitForFixture(_ webView: WKWebView, elementID: String = "usage-chart") async throws { + let deadline = Date().addingTimeInterval(2) + while Date() < deadline { + let loaded = try? await webView.evaluateJavaScript( + "document.getElementById('\(elementID)') !== null") as? Bool + if loaded == true { return } + try await Task.sleep(for: .milliseconds(50)) + } + } + + private static let bootstrapAccountHTML = """ + + +
Usage limits
+ + + + + """ + + private static let multiChartHTML = """ + + +
+

Usage breakdown

+
+

Personal usage

+
+ Daily threads by client + + + + + +
+
+

Product activity

+ + + Daily threads by client + + + + + +
+
+

Tokens by model

+ + + + + +
+ + + + """ + + private static let clientOnlyChartHTML = """ + + +
+

Threads and turns by client

+ + + + + +
+ + + + """ + + private static let localizedUsageChartHTML = """ + + +
+

Desglose de uso

+ + + + + +
+ + + + """ +} +#endif diff --git a/Tests/CodexBarTests/OpenAIDashboardSparkTests.swift b/Tests/CodexBarTests/OpenAIDashboardSparkTests.swift new file mode 100644 index 0000000000..45ad343f8b --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardSparkTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Dashboard-path coverage for Codex `additional_rate_limits` (e.g. GPT-5.3-Codex-Spark): the +/// OpenAI web dashboard usage API decodes the same `wham/usage` JSON as the OAuth path, so Spark +/// limits must survive the `dashboardAPIData -> DashboardSnapshotComponents -> OpenAIDashboardSnapshot +/// -> fromAttachedDashboard -> UsageSnapshot.extraRateWindows` chain without disturbing the +/// existing primary/weekly/credits/plan mapping. +struct OpenAIDashboardSparkTests { + private static func response(from json: String) throws -> CodexUsageResponse { + try JSONDecoder().decode(CodexUsageResponse.self, from: Data(json.utf8)) + } + + @Test + func `dashboard api data maps additional spark limit into extra windows`() throws { + let json = """ + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 }, + "secondary_window": { "used_percent": 43, "reset_at": 1767407914, "limit_window_seconds": 604800 } + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "primary_window": { "used_percent": 30, "reset_at": 1766948068, "limit_window_seconds": 18000 }, + "secondary_window": { "used_percent": 100, "reset_at": 1767407914, "limit_window_seconds": 604800 } + } + } + ] + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + // Primary/weekly/credits/plan continue to map exactly as before. + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.secondaryLimit?.usedPercent == 43) + #expect(apiData.accountPlan == "pro") + // Spark surfaces with stable ids/titles for the additional 5-hour and weekly windows. + #expect(apiData.extraRateWindows.count == 2) + let spark = try #require(apiData.extraRateWindows.first) + #expect(spark.id == "codex-spark") + #expect(spark.title == "Codex Spark 5-hour") + #expect(spark.window.usedPercent == 30) + #expect(spark.window.windowMinutes == 300) + #expect(spark.window.resetsAt != nil) + let weekly = try #require(apiData.extraRateWindows.last) + #expect(weekly.id == "codex-spark-weekly") + #expect(weekly.title == "Codex Spark Weekly") + #expect(weekly.window.usedPercent == 100) + #expect(weekly.window.windowMinutes == 10080) + #expect(weekly.window.resetsAt != nil) + } + + @Test + func `dashboard api data has empty extra windows when additional limits are absent`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 } + } + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.extraRateWindows.isEmpty) + } + + @Test + func `dashboard api data tolerates non array additional limits while keeping primary`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 } + }, + "additional_rate_limits": "unexpected" + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.extraRateWindows.isEmpty) + } + + @Test + func `dashboard api data keeps valid spark when a malformed sibling is present`() throws { + // Lossy per-element decode (shared with the OAuth path via CodexUsageResponse) means a single + // malformed entry cannot discard its valid siblings. + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 } + }, + "additional_rate_limits": [ + "garbage-not-an-object", + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "primary_window": { "used_percent": 30, "reset_at": 1766948068, "limit_window_seconds": 18000 } + } + }, + 42 + ] + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.extraRateWindows.count == 1) + #expect(apiData.extraRateWindows.first?.id == "codex-spark") + #expect(apiData.extraRateWindows.first?.window.usedPercent == 30) + } + + @Test + func `dashboard snapshot exposes extra rate windows via to usage snapshot`() throws { + let now = Date(timeIntervalSince1970: 1_766_948_000) + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondaryLimit: RateWindow( + usedPercent: 43, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "codex-spark", + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil)), + NamedRateWindow( + id: "codex-spark-weekly", + title: "Codex Spark Weekly", + window: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now) + + let usage = try #require(snapshot.toUsageSnapshot(provider: .codex)) + // Primary/weekly behavior preserved. + #expect(usage.primary?.usedPercent == 22) + #expect(usage.secondary?.usedPercent == 43) + // Spark surfaces through UsageSnapshot.extraRateWindows for dashboard-source users. + let extras = try #require(usage.extraRateWindows) + #expect(extras.map(\.id) == ["codex-spark", "codex-spark-weekly"]) + #expect(extras.first?.window.usedPercent == 30) + #expect(extras.last?.window.usedPercent == 100) + } + + @Test + func `dashboard snapshot codable round trips extra rate windows`() throws { + let now = Date(timeIntervalSince1970: 1_766_948_000) + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: nil, + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + extraRateWindows: [ + NamedRateWindow( + id: "codex-spark", + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil)), + NamedRateWindow( + id: "codex-spark-weekly", + title: "Codex Spark Weekly", + window: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let data = try encoder.encode(snapshot) + let decoded = try decoder.decode(OpenAIDashboardSnapshot.self, from: data) + #expect(decoded.extraRateWindows?.map(\.id) == ["codex-spark", "codex-spark-weekly"]) + #expect(decoded.extraRateWindows?.first?.window.usedPercent == 30) + #expect(decoded.extraRateWindows?.last?.window.usedPercent == 100) + } + + @Test + func `dashboard snapshot decoder preserves absence of extra rate windows`() throws { + // Older cached snapshots predate the field; decoding such payloads must yield nil and never + // throw, so existing dashboard caches keep working. + let json = """ + { + "signedInEmail": "codex@example.com", + "codeReviewRemainingPercent": null, + "creditEvents": [], + "dailyBreakdown": [], + "usageBreakdown": [], + "creditsPurchaseURL": null, + "updatedAt": "2026-04-30T19:27:07Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) + #expect(snapshot.extraRateWindows == nil) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index c53cb7394c..dce9826433 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -12,10 +12,44 @@ import WebKit @MainActor @Suite(.serialized) struct OpenAIDashboardWebViewCacheTests { + private func shouldSkipOnCI() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" + } + // MARK: - Data Store Identity Tests - @Test("WKWebsiteDataStore should return same instance for same email") - func dataStoreReturnsSameInstance() { + @Test + func `navigation retry uses only remaining shared deadline`() throws { + let start = Date(timeIntervalSinceReferenceDate: 1000) + let deadline = start.addingTimeInterval(10) + + let remaining = try OpenAIDashboardWebViewCache.remainingNavigationTimeout( + until: deadline, + now: start.addingTimeInterval(9.75)) + + #expect(remaining == 0.25) + } + + @Test + func `navigation retry refuses expired shared deadline`() { + let deadline = Date(timeIntervalSinceReferenceDate: 1000) + + do { + _ = try OpenAIDashboardWebViewCache.remainingNavigationTimeout( + until: deadline, + now: deadline) + Issue.record("Expected deadline timeout") + } catch let error as URLError { + #expect(error.code == .timedOut) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `WKWebsiteDataStore should return same instance for same email`() { + if self.shouldSkipOnCI() { return } OpenAIDashboardWebsiteDataStore.clearCacheForTesting() let store1 = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "test@example.com") @@ -32,10 +66,48 @@ struct OpenAIDashboardWebViewCacheTests { OpenAIDashboardWebsiteDataStore.clearCacheForTesting() } + @Test + func `same email profile homes use distinct website data stores`() { + OpenAIDashboardWebsiteDataStore.clearCacheForTesting() + defer { OpenAIDashboardWebsiteDataStore.clearCacheForTesting() } + + let profileA = CookieHeaderCache.Scope.profileHome("/tmp/codex-profile-a") + let profileB = CookieHeaderCache.Scope.profileHome("/tmp/codex-profile-b") + let storeA = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: "shared@example.com", + scope: profileA) + let storeAAgain = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: "SHARED@example.com", + scope: profileA) + let storeB = OpenAIDashboardWebsiteDataStore.store( + forAccountEmail: "shared@example.com", + scope: profileB) + let liveStore = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "shared@example.com") + + #expect(storeA === storeAAgain) + #expect(storeA !== storeB) + #expect(storeA !== liveStore) + #expect(storeB !== liveStore) + #expect(storeA.identifier != storeB.identifier) + #expect(storeA.identifier != liveStore.identifier) + #expect(storeB.identifier != liveStore.identifier) + } + + @Test + func `live website data store preserves legacy email identifier`() { + OpenAIDashboardWebsiteDataStore.clearCacheForTesting() + defer { OpenAIDashboardWebsiteDataStore.clearCacheForTesting() } + + let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: " SHARED@EXAMPLE.COM ") + + #expect(store.identifier?.uuidString == "CC61BD27-6855-439F-9D11-F470B7977B90") + } + // MARK: - WebView Reuse Tests - @Test("WebView should be cached after release, not destroyed") - func webViewCachedAfterRelease() async throws { + @Test + func `WebView should be cached after release, not destroyed`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -67,8 +139,9 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } - @Test("Different data stores should have separate cached WebViews") - func separateCachesPerDataStore() async throws { + @Test + func `Different data stores should have separate cached WebViews`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store1 = WKWebsiteDataStore.nonPersistent() let store2 = WKWebsiteDataStore.nonPersistent() @@ -98,53 +171,206 @@ struct OpenAIDashboardWebViewCacheTests { // MARK: - Idle Timeout / Pruning Tests - @Test("WebView should be pruned after idle timeout") - func webViewPrunedAfterIdleTimeout() async throws { + @Test + func `WebView should be pruned after idle timeout`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + + #expect(cache.hasCachedEntry(for: store), "Should be cached immediately after release") + + // Simulate time passing beyond the configured idle timeout. + let futureTime = Date().addingTimeInterval(cache.idleTimeoutForTesting + 5) + cache.pruneForTesting(now: futureTime) + + #expect(!cache.hasCachedEntry(for: store), "Should be pruned after idle timeout") + #expect(cache.entryCount == 0, "Should have no cached entries after prune") + } + + @Test + func `Recently used WebView should not be pruned`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + + // Simulate time passing comfortably within the configured idle timeout. + let nearFutureTime = Date().addingTimeInterval(max(1, cache.idleTimeoutForTesting / 2)) + cache.pruneForTesting(now: nearFutureTime) + + #expect(cache.hasCachedEntry(for: store), "Should still be cached within idle timeout") + cache.clearAllForTesting() + } + + @Test + func `Preserved page handoff is consumed only once`() { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting)) + + #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed") + #expect(cache.consumePreservedPageForTesting(websiteDataStore: store), "First acquire should reuse handoff") + #expect( + !cache.consumePreservedPageForTesting(websiteDataStore: store), + "Second acquire should not keep reusing preserved page") + + cache.clearAllForTesting() + } + + @Test + func `Expired preserved page is cleared before idle eviction`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(1)) + + let afterExpiry = Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting + 1) + cache.pruneForTesting(now: afterExpiry) + + #expect(!cache.hasPreservedPageForTesting(for: store), "Expired preserved page should be cleared") + #expect(cache.hasCachedEntry(for: store), "Entry should remain cached after page handoff expires") + + cache.clearAllForTesting() + } + + @Test + func `Preserved page expiry is scheduled without future cache activity`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + let webView = cache.cacheEntryForTesting(websiteDataStore: store) + + _ = webView.loadHTMLString("alive", baseURL: nil) + try? await Task.sleep(for: .milliseconds(150)) + + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(0.2)) + + #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed") + + var bodyText: String? + let deadline = Date().addingTimeInterval(2) + repeat { + try? await Task.sleep(for: .milliseconds(100)) + bodyText = try await webView.evaluateJavaScript( + "document.body ? String(document.body.innerText || '') : ''") as? String + } while (cache.hasPreservedPageForTesting(for: store) || bodyText?.isEmpty != true) && Date() < deadline + + #expect(!cache.hasPreservedPageForTesting(for: store), "Expected scheduled expiry to clear preserved page") + #expect(bodyText?.isEmpty == true, "Expected scheduled expiry to detach the preserved page to about:blank") + + cache.clearAllForTesting() + } + + @Test + func `Idle prune is scheduled without future cache activity`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache(idleTimeout: 0.2) + let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) - // Acquire and release - let lease = try await cache.acquire( + var lease: OpenAIDashboardWebViewLease? = try await cache.acquire( websiteDataStore: store, usageURL: url, logger: nil) - lease.release() + lease?.release() + lease = nil - #expect(cache.hasCachedEntry(for: store), "Should be cached immediately after release") + #expect(cache.hasCachedEntry(for: store), "WebView should remain cached right after release") - // Simulate time passing beyond idle timeout (10 minutes + buffer) - let futureTime = Date().addingTimeInterval(11 * 60) - cache.pruneForTesting(now: futureTime) + let deadline = Date().addingTimeInterval(5) + while cache.hasCachedEntry(for: store), Date() < deadline { + try? await Task.sleep(for: .milliseconds(100)) + } - #expect(!cache.hasCachedEntry(for: store), "Should be pruned after idle timeout") - #expect(cache.entryCount == 0, "Should have no cached entries after prune") + #expect( + !cache.hasCachedEntry(for: store), + "Expected the scheduled idle prune to evict the WebView without any further cache activity") + + cache.clearAllForTesting() } - @Test("Recently used WebView should not be pruned") - func recentlyUsedWebViewNotPruned() async throws { + @Test + func `Later release does not postpone an older idle entry`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache(idleTimeout: 5) + let firstStore = WKWebsiteDataStore.nonPersistent() + let secondStore = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let firstLease = try await cache.acquire( + websiteDataStore: firstStore, + usageURL: url, + logger: nil) + firstLease.release() + let firstDeadline = try #require(cache.idlePruneDeadlineForTesting) + + try await Task.sleep(for: .milliseconds(50)) + + let secondLease = try await cache.acquire( + websiteDataStore: secondStore, + usageURL: url, + logger: nil) + secondLease.release() + let rescheduledDeadline = try #require(cache.idlePruneDeadlineForTesting) + + #expect( + abs(rescheduledDeadline.timeIntervalSince(firstDeadline)) < 0.001, + "A later release should keep the prune scheduled for the oldest idle entry") + #expect(cache.hasCachedEntry(for: firstStore)) + #expect(cache.hasCachedEntry(for: secondStore), "A later release should keep its own idle window") + cache.clearAllForTesting() + } + + @Test + func `Reused page reset clears one shot scraper globals`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) - // Acquire and release let lease = try await cache.acquire( websiteDataStore: store, usageURL: url, logger: nil) - lease.release() - // Simulate time passing within idle timeout (5 minutes) - let nearFutureTime = Date().addingTimeInterval(5 * 60) - cache.pruneForTesting(now: nearFutureTime) + _ = try await lease.webView.evaluateJavaScript( + """ + window.__codexbarDidScrollToCredits = true; + window.__codexbarUsageBreakdownJSON = '[{"day":"2026-04-19"}]'; + window.__codexbarUsageBreakdownDebug = 'debug'; + true; + """) - #expect(cache.hasCachedEntry(for: store), "Should still be cached within idle timeout") + #expect(await cache.resetReusablePageStateForTesting(lease.webView)) + + let reset = try await lease.webView.evaluateJavaScript( + """ + typeof window.__codexbarDidScrollToCredits === 'undefined' && + typeof window.__codexbarUsageBreakdownJSON === 'undefined' && + typeof window.__codexbarUsageBreakdownDebug === 'undefined' + """) as? Bool + + #expect(reset == true, "Expected one-shot scraper globals to be cleared before reuse") + + lease.release() + cache.clearAllForTesting() } // MARK: - Eviction Tests - @Test("Evict should remove specific WebView from cache") - func evictRemovesSpecificWebView() async throws { + @Test + func `Evict should remove specific WebView from cache`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store1 = WKWebsiteDataStore.nonPersistent() let store2 = WKWebsiteDataStore.nonPersistent() @@ -168,10 +394,113 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test + func `Evicted WebView should not be reused on next acquire`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let lease1 = try await cache.acquire(websiteDataStore: store, usageURL: url, logger: nil) + let webView1 = lease1.webView + lease1.release() + + cache.evict(websiteDataStore: store) + + let lease2 = try await cache.acquire(websiteDataStore: store, usageURL: url, logger: nil) + let webView2 = lease2.webView + + #expect(webView1 !== webView2, "Acquire after eviction should create a fresh WebView") + + lease2.release() + cache.clearAllForTesting() + } + + @Test + func `Evict all should remove every cached WebView`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store1 = WKWebsiteDataStore.nonPersistent() + let store2 = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let lease1 = try await cache.acquire(websiteDataStore: store1, usageURL: url, logger: nil) + lease1.release() + let lease2 = try await cache.acquire(websiteDataStore: store2, usageURL: url, logger: nil) + lease2.release() + + #expect(cache.entryCount == 2, "Should have two cached entries") + + cache.evictAll() + + #expect(cache.entryCount == 0, "Evict all should remove every cached entry") + #expect(!cache.hasCachedEntry(for: store1), "First store should be evicted") + #expect(!cache.hasCachedEntry(for: store2), "Second store should be evicted") + } + + @Test + func `Evict idle removes idle WebViews without interrupting busy WebViews`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let idleStore = WKWebsiteDataStore.nonPersistent() + let busyStore = WKWebsiteDataStore.nonPersistent() + + cache.cacheEntryForTesting(websiteDataStore: idleStore) + cache.cacheEntryForTesting(websiteDataStore: busyStore, isBusy: true) + + cache.evictIdle() + + #expect(!cache.hasCachedEntry(for: idleStore), "Idle WebView should be evicted") + #expect(cache.hasCachedEntry(for: busyStore), "Busy WebView should remain cached") + #expect(cache.entryCount == 1, "Only the busy entry should remain") + + cache.clearAllForTesting() + } + + @Test + func `Memory pressure monitor evicts idle shared WebViews without interrupting busy WebViews`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache.shared + cache.clearAllForTesting() + defer { cache.clearAllForTesting() } + + let idleStore = WKWebsiteDataStore.nonPersistent() + let busyStore = WKWebsiteDataStore.nonPersistent() + + cache.cacheEntryForTesting(websiteDataStore: idleStore) + cache.cacheEntryForTesting(websiteDataStore: busyStore, isBusy: true) + + #expect(cache.entryCount == 2, "Should have one idle entry and one busy entry before pressure") + + let monitor = MemoryPressureMonitor() + monitor.handleMemoryPressureForTesting(isWarning: true, isCritical: false) + + #expect(!cache.hasCachedEntry(for: idleStore), "Memory pressure should evict the idle shared WebView") + #expect(cache.hasCachedEntry(for: busyStore), "Memory pressure should not interrupt a busy shared WebView") + #expect(cache.entryCount == 1, "Only the busy shared entry should remain") + } + + @Test + func `Memory pressure malloc relief runs off the main thread`() async { + let probe = MemoryPressureThreadProbe() + let monitor = MemoryPressureMonitor(releaseFreeMallocPages: { + probe.recordCurrentThread() + }) + + monitor.handleMemoryPressureForTesting(isWarning: true, isCritical: false) + + let completed = await Task.detached { + probe.wait(timeout: .now() + 2) + }.value + #expect(completed) + #expect(probe.wasMainThread == false) + } + // MARK: - Busy WebView Tests - @Test("Busy WebView should create temporary WebView for concurrent access") - func busyWebViewCreatesTemporary() async throws { + @Test + func `Busy WebView should create temporary WebView for concurrent access`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -205,8 +534,9 @@ struct OpenAIDashboardWebViewCacheTests { // MARK: - Network Traffic Regression Prevention - @Test("Multiple sequential fetches should reuse same WebView (network optimization)") - func sequentialFetchesReuseWebView() async throws { + @Test + func `Multiple sequential fetches should reuse same WebView (network optimization)`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -239,8 +569,9 @@ struct OpenAIDashboardWebViewCacheTests { // MARK: - Integration Test with Real Data Store Factory - @Test("Sequential fetches with OpenAIDashboardWebsiteDataStore should reuse WebView") - func sequentialFetchesWithRealDataStoreFactory() async throws { + @Test + func `Sequential fetches with OpenAIDashboardWebsiteDataStore should reuse WebView`() async throws { + if self.shouldSkipOnCI() { return } OpenAIDashboardWebsiteDataStore.clearCacheForTesting() let cache = OpenAIDashboardWebViewCache() let url = try #require(URL(string: "about:blank")) @@ -274,3 +605,24 @@ struct OpenAIDashboardWebViewCacheTests { OpenAIDashboardWebsiteDataStore.clearCacheForTesting() } } + +private final class MemoryPressureThreadProbe: @unchecked Sendable { + private let lock = NSLock() + private let semaphore = DispatchSemaphore(value: 0) + private var recordedMainThread: Bool? + + var wasMainThread: Bool? { + self.lock.withLock { self.recordedMainThread } + } + + func recordCurrentThread() { + self.lock.withLock { + self.recordedMainThread = Thread.isMainThread + } + self.semaphore.signal() + } + + func wait(timeout: DispatchTime) -> Bool { + self.semaphore.wait(timeout: timeout) == .success + } +} diff --git a/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift b/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift index 7113583f54..1bab839623 100644 --- a/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift +++ b/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct OpenAIWebAccountSwitchTests { @Test - func clearsDashboardWhenCodexEmailChanges() { + func `clears dashboard when codex email changes`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "OpenAIWebAccountSwitchTests-clears"), zaiTokenStore: NoopZaiTokenStore(), @@ -36,7 +35,7 @@ struct OpenAIWebAccountSwitchTests { } @Test - func keepsDashboardWhenCodexEmailStaysSame() { + func `keeps dashboard when codex email stays same`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "OpenAIWebAccountSwitchTests-keeps"), zaiTokenStore: NoopZaiTokenStore(), @@ -62,4 +61,36 @@ struct OpenAIWebAccountSwitchTests { store.handleOpenAIWebTargetEmailChangeIfNeeded(targetEmail: "a@example.com") #expect(store.openAIDashboard == dash) } + + @Test + func `clears dashboard when profile source changes with the same email`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "OpenAIWebAccountSwitchTests-profile-source"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + + store.handleOpenAIWebTargetEmailChangeIfNeeded( + targetEmail: "shared@example.com", + targetScope: .profileHome("/tmp/codex-profile-a")) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "shared@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: Date()) + + store.handleOpenAIWebTargetEmailChangeIfNeeded( + targetEmail: "shared@example.com", + targetScope: .profileHome("/tmp/codex-profile-b")) + + #expect(store.openAIDashboard == nil) + #expect(store.openAIWebAccountDidChange) + #expect(store.openAIDashboardRequiresLogin) + } } diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift new file mode 100644 index 0000000000..e9a016db5d --- /dev/null +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -0,0 +1,188 @@ +import Foundation +import Testing +@testable import CodexBar + +struct OpenAIWebRefreshGateTests { + @Test + func `Battery saver keeps background OpenAI web refreshes off`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: false, + refreshPhase: .regular)) + + #expect(shouldRun == false) + } + + @Test + func `Disabling battery saver restores normal OpenAI web refreshes`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false, + refreshPhase: .regular)) + + #expect(shouldRun == true) + } + + @Test + func `Manual refresh still forces OpenAI web refreshes with battery saver enabled`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: true, + refreshPhase: .regular)) + + #expect(shouldRun == true) + } + + @Test + func `Startup skips automatic OpenAI web refreshes`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false, + refreshPhase: .startup)) + + #expect(shouldRun == false) + } + + @Test + func `Startup connectivity retry remains startup only for OpenAI web refresh gate`() { + let providerPhase = UsageStore.refreshPhase( + hasCompletedInitialRefresh: true) + let openAIWebPhase = UsageStore.openAIWebRefreshPhase( + providerRefreshPhase: providerPhase, + startupConnectivityRetryAttempt: 1) + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false, + refreshPhase: openAIWebPhase)) + + #expect(providerPhase == .regular) + #expect(openAIWebPhase == .startup) + #expect(shouldRun == false) + } + + @Test + func `Manual startup refresh still forces OpenAI web refreshes`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: true, + refreshPhase: .startup)) + + #expect(shouldRun == true) + } + + @Test + func `Battery saver stale-submenu refresh respects the cooldown`() { + let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: true) + + #expect(shouldForce == false) + } + + @Test + func `Normal stale-submenu refresh still forces when battery saver is off`() { + let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: false) + + #expect(shouldForce == true) + } + + @Test + func `Recent successful dashboard refresh stays throttled`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-60), + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test + func `Recent failed dashboard refresh also stays throttled`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: false, + lastError: "login required", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test + func `Force refresh bypasses throttle after failures`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: true, + accountDidChange: false, + lastError: "login required", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } + + @Test + func `Account switches bypass the prior-attempt cooldown`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: true, + lastError: "mismatch", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } + + @Test + func `Empty dashboard history retry is throttled after a recent attempt`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-120), + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test + func `Empty dashboard history retry runs once for a newer empty snapshot`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-60), + lastAttemptAt: now.addingTimeInterval(-120), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift new file mode 100644 index 0000000000..ca060b0a33 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -0,0 +1,299 @@ +#if os(macOS) + +import Foundation +import SQLite3 +import Testing +@testable import CodexBarCore + +struct OpenCodeGoLocalUsageReaderTests { + @Test + func `reads local OpenCode Go history into usage windows`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-05T12:00:00.000Z"), + cost: 6.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-02-25T07:53:16.000Z"), + cost: 2.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 30) + #expect(snapshot.monthlyUsagePercent == 18.3) + #expect(snapshot.rollingResetInSec == 14400) + #expect(snapshot.weeklyResetInSec == 216_000) + #expect(snapshot.monthlyResetInSec == 1_626_796) + } + + @Test + func `auth without history falls through to web strategy`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.historyUnavailable("database not found")) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + @Test + func `auth with unreadable history falls through to web strategy`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + var db: OpaquePointer? + guard sqlite3_open(env.databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + sqlite3_close(db) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.self) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + @Test + func `monthly window keeps original anchor after shorter month clamp`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-01-31T00:00:00.000Z"), + cost: 1.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-29T10:00:00.000Z"), + cost: 6.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-29T12:00:00.000Z")) / 1000) + let snapshot = try reader.fetch(now: now) + + #expect(snapshot.monthlyUsagePercent == 10) + #expect(snapshot.monthlyResetInSec == 129_600) + } + + @Test + func `reads step finish parts when message only stores metadata`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: nil) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 10) + #expect(snapshot.monthlyUsagePercent == 5) + } + + @Test + func `does not double count step finish parts when message has cost`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 10) + #expect(snapshot.monthlyUsagePercent == 5) + } + + @Test + func `missing auth and history is not detected`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.notDetected) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + private static func makeEnvironment() throws -> (root: URL, authURL: URL, databaseURL: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodeGoLocalUsageReaderTests-\(UUID().uuidString)", isDirectory: true) + let directory = root + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return ( + root, + directory.appendingPathComponent("auth.json", isDirectory: false), + directory.appendingPathComponent("opencode.db", isDirectory: false)) + } + + private static func writeAuth(to url: URL) throws { + let data = Data(#"{"opencode-go":{"type":"api-key","key":"go-key"}}"#.utf8) + try data.write(to: url) + } + + private static func createDatabase(at url: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try Self.exec( + db: db, + sql: """ + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + """) + } + + @discardableResult + private static func insertMessage(databaseURL: URL, createdMs: Int64, cost: Double?) throws -> String { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let messageID = UUID().uuidString + var payload: [String: Any] = [ + "providerID": "opencode-go", + "role": "assistant", + "time": ["created": createdMs], + ] + if let cost { + payload["cost"] = cost + } + let data = try JSONSerialization.data(withJSONObject: payload) + let json = String(data: data, encoding: .utf8) ?? "{}" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, messageID, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_text(stmt, 3, json, -1, transient) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_int64(stmt, 5, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + return messageID + } + + private static func insertStepFinishPart( + databaseURL: URL, + messageID: String, + createdMs: Int64, + cost: Double) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let payload: [String: Any] = [ + "type": "step-finish", + "cost": cost, + "tokens": ["input": 1, "output": 1, "total": 2], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let json = String(data: data, encoding: .utf8) ?? "{}" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO part (id, message_id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient) + sqlite3_bind_text(stmt, 2, messageID, -1, transient) + sqlite3_bind_text(stmt, 3, "session-1", -1, transient) + sqlite3_bind_text(stmt, 4, json, -1, transient) + sqlite3_bind_int64(stmt, 5, createdMs) + sqlite3_bind_int64(stmt, 6, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else { + sqlite3_free(message) + throw SQLiteTestError.exec + } + } + + private static func ms(_ iso: String) -> Int64 { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return Int64((formatter.date(from: iso)?.timeIntervalSince1970 ?? 0) * 1000) + } + + private enum SQLiteTestError: Error { + case open + case prepare + case step + case exec + } +} + +#endif diff --git a/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift b/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift new file mode 100644 index 0000000000..b5cda37f5a --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift @@ -0,0 +1,130 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct OpenCodeGoMenuCardModelTests { + @Test + func `zen balance renders as optional balance`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 98.76, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: now), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Zen balance") + #expect(model.providerCost?.spendLine == "Balance: $98.76") + #expect(model.providerCost?.percentUsed == nil) + #expect(model.providerCost?.percentLine == nil) + } + + @Test + func `required zen balance renders when optional usage is disabled`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 98.76, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: now), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Zen balance") + #expect(model.providerCost?.spendLine == "Balance: $98.76") + } + + @Test + func `subscription zen balance hides when optional usage is disabled`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 98.76, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: now), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost == nil) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift new file mode 100644 index 0000000000..4c4d4d3989 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodeGoProviderStrategyTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext { + let env: [String: String] = [:] + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + @Test + func `auto source prefers web before local fallback`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext()) + + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + } + + @Test + func `web source does not include local fallback`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext(sourceMode: .web)) + + #expect(strategies.map(\.id) == ["opencodego.web"]) + } + + @Test + func `web strategy falls back to local only for auth setup failures in auto mode`() { + let strategy = OpenCodeGoUsageFetchStrategy() + let autoContext = self.makeContext() + let webContext = self.makeContext(sourceMode: .web) + + #expect(strategy.shouldFallback(on: OpenCodeGoSettingsError.missingCookie, context: autoContext)) + #expect(strategy.shouldFallback(on: OpenCodeGoSettingsError.invalidCookie, context: autoContext)) + #expect(strategy.shouldFallback(on: OpenCodeGoUsageError.invalidCredentials, context: autoContext)) + #expect(!strategy.shouldFallback(on: OpenCodeGoUsageError.networkError("timeout"), context: autoContext)) + #expect(!strategy.shouldFallback(on: OpenCodeGoSettingsError.missingCookie, context: webContext)) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift new file mode 100644 index 0000000000..b5e397dd17 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift @@ -0,0 +1,873 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private final class OpenCodeGoRequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Value] = [] + + func append(_ value: Value) { + self.lock.lock() + defer { self.lock.unlock() } + self.storage.append(value) + } + + var values: [Value] { + self.lock.lock() + defer { self.lock.unlock() } + return self.storage + } +} + +private final class OpenCodeGoContinuationBox: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + func wait(onReady: @Sendable () -> Void) async -> Value { + await withCheckedContinuation { continuation in + self.lock.lock() + self.continuation = continuation + self.lock.unlock() + onReady() + } + } + + func resume(returning value: Value) { + self.lock.lock() + let continuation = self.continuation + self.continuation = nil + self.lock.unlock() + continuation?.resume(returning: value) + } +} + +@Suite(.serialized) +struct OpenCodeGoUsageFetcherErrorTests { + @Test + func `dashboard URL uses normalized workspace ID`() { + #expect( + OpenCodeGoUsageFetcher.dashboardURL(workspaceID: "https://opencode.ai/workspace/wrk_abc123/go") + .absoluteString == "https://opencode.ai/workspace/wrk_abc123/go") + #expect( + OpenCodeGoUsageFetcher.dashboardURL(workspaceID: "workspace=wrk_def456") + .absoluteString == "https://opencode.ai/workspace/wrk_def456/go") + #expect( + OpenCodeGoUsageFetcher.dashboardURL(workspaceID: nil) + .absoluteString == "https://opencode.ai") + } + + private struct UsageWindow { + let percent: Double + let resetInSec: Int + } + + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OpenCodeGoStubURLProtocol.self] + return URLSession(configuration: config) + } + + @Test + func `redirect guard allows only same-host https redirects`() { + #expect(OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "https://opencode.ai/workspace/wrk_TEST123/go"))) + + #expect(!OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "https://evil.example/steal"))) + + #expect(!OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "http://opencode.ai/insecure"))) + } + + @Test + func `extracts api error from detail field`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let body = #"{"detail":"Workspace missing"}"# + return Self.makeResponse(url: url, body: body, statusCode: 500, contentType: "application/json") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + Issue.record("Expected OpenCodeGoUsageError.apiError") + } catch let error as OpenCodeGoUsageError { + switch error { + case let .apiError(message): + #expect(message.contains("HTTP 500")) + #expect(message.contains("Workspace missing")) + default: + Issue.record("Expected apiError, got: \(error)") + } + } + } + + @Test + func `workspace get missing ids falls back to post before loading go page`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let requests = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + requests.append("\(request.httpMethod ?? "GET") \(url.path)") + + let workspaceServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f" + if url.query?.contains(workspaceServerID) == true, + request.httpMethod?.uppercased() == "GET" + { + return Self.makeResponse( + url: url, + body: #"{"ok":true}"#, + statusCode: 200, + contentType: "application/json") + } + + if url.path == "/_server", + request.httpMethod?.uppercased() == "POST", + request.value(forHTTPHeaderField: "X-Server-Id") == workspaceServerID + { + return Self.makeResponse( + url: url, + body: #"{"data":[{"id":"wrk_TEST123"}]}"#, + statusCode: 200, + contentType: "application/json") + } + + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 22, resetInSec: 300), + weekly: UsageWindow(percent: 44, resetInSec: 3600), + monthly: UsageWindow(percent: 55, resetInSec: 7200)), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + includeZenBalance: false, + session: self.makeSession()) + + #expect(snapshot.rollingUsagePercent == 22) + #expect(snapshot.weeklyUsagePercent == 44) + #expect(snapshot.monthlyUsagePercent == 55) + #expect(requests.values == [ + "GET /_server", + "POST /_server", + "GET /workspace/wrk_TEST123/go", + ]) + } + + @Test + func `workspace get public actor error is treated as invalid credentials without post retry`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let methods = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + let body = [ + #";0x00000263;((self.$R=self.$R||{})["server-fn:test"]=[],"#, + #"($R=>$R[0]=Object.assign(new Error("actor of type \"public\" is not associated with an account"),"#, + #"{stack:"Error: actor of type \"public\" is not associated with an account"}))"#, + #"($R["server-fn:test"]))"#, + ].joined() + return Self.makeResponse( + url: url, + body: body, + statusCode: 200, + contentType: "text/javascript") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + session: self.makeSession()) + Issue.record("Expected OpenCodeGoUsageError.invalidCredentials") + } catch let error as OpenCodeGoUsageError { + switch error { + case .invalidCredentials: + break + default: + Issue.record("Expected invalidCredentials, got: \(error)") + } + } + + #expect(methods.values == ["GET"]) + } + + @Test + func `go page missing usage fields returns parse failed without post retry`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let methods = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + return Self.makeResponse( + url: url, + body: "opencodeNo usage yet", + statusCode: 200, + contentType: "text/html") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + Issue.record("Expected OpenCodeGoUsageError.parseFailed") + } catch let error as OpenCodeGoUsageError { + switch error { + case let .parseFailed(message): + #expect(message.contains("Missing usage fields")) + default: + Issue.record("Expected parseFailed, got: \(error)") + } + } + + #expect(methods.values == ["GET", "GET", "GET"]) + } + + @Test + func `zen only account waits for balance beyond optional grace`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let observedPaths = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedPaths.append(url.path) + if url.path == "/workspace/wrk_TEST123" { + Thread.sleep(forTimeInterval: 0.4) + return Self.makeResponse( + url: url, + body: #"

現在の残高 $42.50

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: "opencodeNo Go subscription usage", + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.isBalanceOnly) + #expect(snapshot.zenBalanceUSD == 42.5) + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.providerCost?.used == 42.5) + #expect(usage.providerCost?.period == "Zen balance") + #expect(observedPaths.values.count == 2) + #expect(Set(observedPaths.values) == ["/workspace/wrk_TEST123/go", "/workspace/wrk_TEST123"]) + } + + @Test + func `zen only account fetches required balance when optional usage is disabled`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let observedPaths = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedPaths.append(url.path) + if url.path == "/workspace/wrk_TEST123" { + return Self.makeResponse( + url: url, + body: #"

Current balance $23.75

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: "opencodeNo Go subscription usage", + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + includeZenBalance: false, + session: self.makeSession()) + + #expect(snapshot.isBalanceOnly) + #expect(snapshot.zenBalanceUSD == 23.75) + #expect(observedPaths.values == ["/workspace/wrk_TEST123/go", "/workspace/wrk_TEST123"]) + } + + @Test + func `zen only account propagates invalid credentials from required balance fetch`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + return Self.makeResponse( + url: url, + body: "Unauthorized", + statusCode: 401, + contentType: "text/plain") + } + return Self.makeResponse( + url: url, + body: "opencodeNo Go subscription usage", + statusCode: 200, + contentType: "text/html") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=stale", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + Issue.record("Expected invalid credentials to propagate.") + } catch OpenCodeGoUsageError.invalidCredentials { + // Expected. + } catch { + Issue.record("Expected invalidCredentials, got: \(error)") + } + } + + @Test + func `zen only account falls back after final subscription parse failure`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var rootTimeout: TimeInterval? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + rootTimeout = request.timeoutInterval + return Self.makeResponse( + url: url, + body: #"

Current balance $17.25

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: #""#, + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 12, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.isBalanceOnly) + #expect(snapshot.zenBalanceUSD == 17.25) + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.providerCost?.used == 17.25) + #expect(rootTimeout == 12) + } + + @Test + func `zen only fallback promptly cancels when balance task ignores cancellation`() async throws { + let balanceStarted = AsyncStream.makeStream(of: Void.self) + let balanceContinuation = OpenCodeGoContinuationBox() + let balanceTask = Task { + await balanceContinuation.wait { + balanceStarted.continuation.yield(()) + } + } + let fallbackTask = Task { + try await OpenCodeGoUsageFetcher.requiredZenBalanceFallback( + from: balanceTask, + for: .parseFailed("Missing usage fields."), + request: OpenCodeGoUsageFetcher.ZenBalanceRequest( + workspaceID: "wrk_TEST123", + cookieHeader: "auth=test", + timeout: 2, + session: self.makeSession()), + now: Date()) + } + + var iterator = balanceStarted.stream.makeAsyncIterator() + _ = await iterator.next() + let start = ContinuousClock.now + fallbackTask.cancel() + + do { + _ = try await fallbackTask.value + Issue.record("Expected cancellation to propagate.") + } catch is CancellationError { + // Expected. + } catch { + Issue.record("Expected CancellationError, got: \(error)") + } + + #expect(start.duration(to: .now) < .milliseconds(500)) + #expect(balanceTask.isCancelled) + balanceContinuation.resume(returning: 42.5) + #expect(try await balanceTask.value == 42.5) + } + + @Test + func `normalizes workspace override from URL into go page path`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let observedPaths = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedPaths.append(url.path) + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_URL123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "https://opencode.ai/workspace/wrk_URL123/billing", + session: self.makeSession()) + + #expect(observedPaths.values.count == 3) + #expect(Set(observedPaths.values) == [ + "/workspace/wrk_URL123/go", + "/workspace/wrk_URL123", + "/_server", + ]) + } + + @Test + func `fetcher attaches optional zen balance from workspace root`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(snapshot.zenBalanceUSD == 98.76) + #expect(snapshot.toUsageSnapshot().providerCost?.period == "Zen balance") + } + + @Test + func `fetcher falls back to billing server when workspace page omits balance`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let observedRequests = OpenCodeGoRequestRecorder() + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedRequests.append(request) + if url.path == "/workspace/wrk_TEST123" { + return Self.makeResponse( + url: url, + body: "Workspace dashboard without hydrated billing data", + statusCode: 200, + contentType: "text/html") + } + if url.path == "/_server" { + return Self.makeResponse( + url: url, + body: #"$R[0]={customerID:"cus_test",balance:$R[1]=9876000000,reload:!1}"#, + statusCode: 200, + contentType: "text/javascript") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(snapshot.zenBalanceUSD == 98.76) + let billingRequest = try #require(observedRequests.values.first { $0.url?.path == "/_server" }) + let billingURL = try #require(billingRequest.url) + let components = try #require(URLComponents(url: billingURL, resolvingAgainstBaseURL: false)) + let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + #expect(query["id"] == "c83b78a614689c38ebee981f9b39a8b377716db85c1fd7dbab604adc02d3313d") + #expect(query["args"] == #"["wrk_TEST123"]"#) + #expect(billingRequest.value(forHTTPHeaderField: "Cookie") == "auth=test") + #expect(billingRequest.value(forHTTPHeaderField: "Referer") == "https://opencode.ai/workspace/wrk_TEST123") + } + + @Test + func `optional zen balance helper uses normalized cookie and workspace override`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var observedCookie: String? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedCookie = request.value(forHTTPHeaderField: "Cookie") + #expect(url.path == "/workspace/wrk_TEST123") + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + + let balance = try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance( + cookieHeader: "provider=google; auth=test", + timeout: 2, + workspaceIDOverride: "https://opencode.ai/workspace/wrk_TEST123/go", + session: self.makeSession()) + + #expect(balance == 98.76) + #expect(observedCookie == "auth=test") + } + + @Test + func `optional zen balance failure does not fail subscription usage`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var rootTimeout: TimeInterval? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + rootTimeout = request.timeoutInterval + throw URLError(.timedOut) + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.zenBalanceUSD == nil) + #expect(rootTimeout == 60) + } + + @Test + func `optional zen balance does not stall subscription usage`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + Thread.sleep(forTimeInterval: 1) + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let start = ContinuousClock.now + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + let elapsed = start.duration(to: ContinuousClock.now) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.zenBalanceUSD == nil) + #expect(elapsed < .milliseconds(700)) + } + + @Test + func `optional zen balance can be skipped by settings`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var observedPaths: [String] = [] + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedPaths.append(url.path) + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + includeZenBalance: false, + session: self.makeSession()) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.zenBalanceUSD == nil) + #expect(observedPaths == ["/workspace/wrk_TEST123/go"]) + } + + @Test + func `optional zen balance cancellation propagates`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let rootStarted = AsyncStream.makeStream(of: Void.self) + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + rootStarted.continuation.yield(()) + Thread.sleep(forTimeInterval: 0.2) + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let task = Task { + try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + } + + let started = await withTaskGroup(of: Bool.self) { group in + group.addTask { + var iterator = rootStarted.stream.makeAsyncIterator() + return await iterator.next() != nil + } + group.addTask { + try? await Task.sleep(for: .seconds(2)) + return false + } + let result = await group.next() ?? false + group.cancelAll() + return result + } + #expect(started) + task.cancel() + + do { + _ = try await task.value + Issue.record("Expected cancellation to propagate.") + } catch is CancellationError { + // Expected. + } catch { + Issue.record("Expected CancellationError, got: \(error)") + } + } + + @Test + func `fetcher sends only auth cookie to opencode host`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var observedCookie: String? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedCookie = request.value(forHTTPHeaderField: "Cookie") + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "provider=google; auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(observedCookie == "auth=test") + } + + private static func goUsagePageHTML( + workspaceID: String, + rolling: UsageWindow, + weekly: UsageWindow, + monthly: UsageWindow?) -> String + { + let monthlyField: String? = if let monthly { + #"monthlyUsage:{status:"ok",resetInSec:\#(monthly.resetInSec),usagePercent:\#(monthly.percent)}"# + } else { + nil + } + + let usageFields = [ + #"rollingUsage:{status:"ok",resetInSec:\#(rolling.resetInSec),usagePercent:\#(rolling.percent)}"#, + #"weeklyUsage:{status:"ok",resetInSec:\#(weekly.resetInSec),usagePercent:\#(weekly.percent)}"#, + monthlyField, + ] + .compactMap(\.self) + .joined(separator: ",") + + return """ + + + + + + + """ + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int, + contentType: String) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (response, Data(body.utf8)) + } +} + +final class OpenCodeGoStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "opencode.ai" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift new file mode 100644 index 0000000000..4676d80cd0 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift @@ -0,0 +1,470 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodeGoUsageParserTests { + @Test + func `parses workspace ids`() { + let text = ";0x00000089;((self.$R=self.$R||{})[\"codexbar\"]=[]," + + "($R=>$R[0]=[$R[1]={id:\"wrk_01K6AR1ZET89H8NB691FQ2C2VB\",name:\"Default\",slug:null}])" + + "($R[\"codexbar\"]))" + let ids = OpenCodeGoUsageFetcher.parseWorkspaceIDs(text: text) + #expect(ids == ["wrk_01K6AR1ZET89H8NB691FQ2C2VB"]) + } + + @Test + func `parses subscription usage from seroval response`() throws { + let text = + "$R[16]($R[30],$R[41]={rollingUsage:$R[42]={status:\"ok\",resetInSec:5944,usagePercent:17}," + + "weeklyUsage:$R[43]={status:\"ok\",resetInSec:278201,usagePercent:75}," + + "monthlyUsage:$R[44]={status:\"ok\",resetInSec:880201,usagePercent:91}});" + let now = Date(timeIntervalSince1970: 0) + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 91) + #expect(snapshot.rollingResetInSec == 5944) + #expect(snapshot.weeklyResetInSec == 278_201) + #expect(snapshot.monthlyResetInSec == 880_201) + } + + @Test + func `parses zen balance from workspace page text`() { + let text = """ +
+

現在の残高 $1,234.56

+

Claude Opus and GPT-5 models enabled

+
+ """ + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == 1234.56) + } + + @Test + func `parses zen balance from nested JSON`() throws { + let payload: [String: Any] = [ + "data": [ + "billing": [ + "balanceEnabled": true, + "zenBalance": "1,042.75", + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == 1042.75) + } + + @Test + func `parses scaled zen balance from billing server response`() { + let text = + #";0x00000120;((self.$R=self.$R||{})["server-fn:test"]=[],"# + + #"($R=>$R[0]=$R[1]={customerID:"cus_test",balance:$R[2]=2375000000,reload:!1})"# + + #"($R["server-fn:test"]))"# + + #expect(OpenCodeGoZenBalanceParser.parseBillingServerResponse(text: text) == 23.75) + } + + @Test + func `billing server parser ignores unrelated balance metadata`() { + let text = #"$R[0]={balanceEnabled:!0,balanceUpdatedAt:1800000000}"# + + #expect(OpenCodeGoZenBalanceParser.parseBillingServerResponse(text: text) == nil) + } + + @Test + func `billing server parser ignores balance when billing is disabled`() { + let text = #"$R[0]={customerID:null,balance:0,reload:!1}"# + + #expect(OpenCodeGoZenBalanceParser.parseBillingServerResponse(text: text) == nil) + } + + @Test + func `zen balance parser ignores metadata before amount`() throws { + let payload: [String: Any] = [ + "data": [ + "billing": [ + "balanceUpdatedAt": 1_800_000_000, + "balanceRefreshInterval": 60, + "zenBalance": "42.50", + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == 42.50) + } + + @Test + func `parses subscription usage from live go page hydration`() throws { + let rollingResetInSec = 17591 + let weeklyResetInSec = 444_552 + let monthlyResetInSec = 2_591_424 + let text = + "_$HY.r[\"lite.subscription.get[\\\"wrk_LIVE123\\\"]\"]=$R[17]=$R[2]($R[18]={p:0,s:0,f:0});" + + "$R[24]($R[18],$R[27]={mine:!0,useBalance:!1," + + "rollingUsage:$R[28]={status:\"ok\",resetInSec:\(rollingResetInSec),usagePercent:0}," + + "weeklyUsage:$R[29]={status:\"ok\",resetInSec:\(weeklyResetInSec),usagePercent:0}," + + "monthlyUsage:$R[30]={status:\"ok\",resetInSec:\(monthlyResetInSec),usagePercent:0}});" + let now = Date(timeIntervalSince1970: 0) + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 0) + #expect(snapshot.weeklyUsagePercent == 0) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 0) + #expect(snapshot.rollingResetInSec == rollingResetInSec) + #expect(snapshot.weeklyResetInSec == weeklyResetInSec) + #expect(snapshot.monthlyResetInSec == monthlyResetInSec) + } + + @Test + func `parses subscription from JSON with reset at and ratio percentages`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let rollingResetAt = now.addingTimeInterval(3600) + let monthlyResetAt = now.addingTimeInterval(86400) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "usage": [ + "rollingUsage": [ + "usagePercent": 0.25, + "resetAt": formatter.string(from: rollingResetAt), + ], + "weeklyUsage": [ + "usagePercent": 75, + "resetInSec": 7200, + ], + "monthlyUsage": [ + "usagePercent": 0.9, + "resetAt": formatter.string(from: monthlyResetAt), + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 90) + #expect(snapshot.rollingResetInSec == 3600) + #expect(snapshot.weeklyResetInSec == 7200) + #expect(snapshot.monthlyResetInSec == 86400) + } + + @Test + func `computes usage percent from totals and treats monthly as optional`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "rollingUsage": [ + "used": 25, + "limit": 100, + "resetInSec": 600, + ], + "weeklyUsage": [ + "used": 50, + "limit": 200, + "resetInSec": 3600, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 25) + #expect(snapshot.hasMonthlyUsage == false) + #expect(snapshot.monthlyUsagePercent == 0) + #expect(snapshot.monthlyResetInSec == 0) + #expect(usage.tertiary == nil) + } + + @Test + func `snapshot exposes zen balance as provider cost`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: false, + rollingUsagePercent: 10, + weeklyUsagePercent: 20, + monthlyUsagePercent: 0, + rollingResetInSec: 600, + weeklyResetInSec: 3600, + monthlyResetInSec: 0, + zenBalanceUSD: 12.34, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.providerCost?.period == "Zen balance") + #expect(usage.providerCost?.used == 12.34) + #expect(usage.providerCost?.limit == 0) + #expect(usage.providerCost?.currencyCode == "USD") + } + + @Test + func `zen balance parser ignores balance flags without amounts`() throws { + let payload: [String: Any] = [ + "billing": [ + "balanceEnabled": true, + "useBalance": false, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == nil) + } + + @Test + func `parses subscription from nested candidate windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "windows": [ + "primaryWindow": [ + "used": 15, + "limit": 100, + "resetInSec": 600, + ], + "weeklyQuota": [ + "used": 80, + "limit": 200, + "resetInSec": 7200, + ], + "monthlyBucket": [ + "used": 90, + "limit": 300, + "resetInSec": 86400, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 15) + #expect(snapshot.weeklyUsagePercent == 40) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 30) + #expect(snapshot.monthlyResetInSec == 86400) + } + + @Test + func `candidate fallback does not fabricate weekly from non weekly windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "windows": [ + "primaryWindow": [ + "used": 15, + "limit": 100, + "resetInSec": 600, + ], + "monthlyBucket": [ + "used": 90, + "limit": 300, + "resetInSec": 86400, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(throws: OpenCodeGoUsageError.self) { + _ = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + } + } + + @Test + func `clamps invalid percentages`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "rollingUsage": [ + "usagePercent": 150, + "resetInSec": 60, + ], + "weeklyUsage": [ + "usagePercent": -10, + "resetInSec": 120, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 100) + #expect(snapshot.weeklyUsagePercent == 0) + } + + @Test + func `parse subscription throws when required fields are missing`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let text = "{\"monthlyUsage\":{\"usagePercent\":50,\"resetInSec\":123}}" + + #expect(throws: OpenCodeGoUsageError.self) { + _ = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + } + } + + @Test + func `renewsAt parses from ISO8601 renewAt key`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + "renewAt": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt != nil) + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `renewsAt parses from renew_at key`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + "renew_at": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt != nil) + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `renewsAt is nil when absent`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == nil) + } + + @Test + func `top level renewAt is preserved for nested usage object`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renewAt": formatter.string(from: renewAt), + "usage": [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `top level renew_at is preserved for nested usage object`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renew_at": formatter.string(from: renewAt), + "usage": [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `child renewAt overrides parent renewAt`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let parentRenewAt = now.addingTimeInterval(86400 * 30) + let childRenewAt = now.addingTimeInterval(86400 * 45) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renewAt": formatter.string(from: parentRenewAt), + "usage": [ + "renewAt": formatter.string(from: childRenewAt), + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == childRenewAt) + } + + @Test + func `toUsageSnapshot includes renewal NamedRateWindow when renewsAt present`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + "renewAt": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.extraRateWindows != nil) + #expect(usage.extraRateWindows?.count == 1) + #expect(usage.extraRateWindows?[0].id == "renewal") + #expect(usage.extraRateWindows?[0].title == "Renews") + #expect(usage.extraRateWindows?[0].window.resetsAt == renewAt) + } +} diff --git a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift index d64ffb06bf..c934d35205 100644 --- a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift @@ -4,13 +4,15 @@ import Testing @Suite(.serialized) struct OpenCodeUsageFetcherErrorTests { + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OpenCodeStubURLProtocol.self] + return URLSession(configuration: config) + } + @Test - func extractsApiErrorFromUppercaseHTMLTitle() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + func `extracts api error from uppercase HTML title`() async throws { defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -24,7 +26,8 @@ struct OpenCodeUsageFetcherErrorTests { _ = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) Issue.record("Expected OpenCodeUsageError.apiError") } catch let error as OpenCodeUsageError { switch error { @@ -38,12 +41,8 @@ struct OpenCodeUsageFetcherErrorTests { } @Test - func extractsApiErrorFromDetailField() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + func `extracts api error from detail field`() async throws { defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -57,7 +56,8 @@ struct OpenCodeUsageFetcherErrorTests { _ = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) Issue.record("Expected OpenCodeUsageError.apiError") } catch let error as OpenCodeUsageError { switch error { @@ -71,12 +71,8 @@ struct OpenCodeUsageFetcherErrorTests { } @Test - func subscriptionGetNullSkipsPostAndReturnsGracefulError() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + func `subscription get null skips post and returns graceful error`() async throws { defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -103,7 +99,8 @@ struct OpenCodeUsageFetcherErrorTests { _ = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) Issue.record("Expected OpenCodeUsageError.apiError") } catch let error as OpenCodeUsageError { switch error { @@ -123,12 +120,8 @@ struct OpenCodeUsageFetcherErrorTests { } @Test - func subscriptionGetPayloadDoesNotFallbackToPost() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + func `subscription get payload does not fallback to post`() async throws { defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -149,7 +142,8 @@ struct OpenCodeUsageFetcherErrorTests { let snapshot = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) #expect(snapshot.rollingUsagePercent == 17) #expect(snapshot.weeklyUsagePercent == 75) @@ -157,12 +151,49 @@ struct OpenCodeUsageFetcherErrorTests { } @Test - func subscriptionGetMissingFieldsFallsBackToPost() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + func `workspace get public actor error is treated as invalid credentials without post retry`() async throws { defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) + OpenCodeStubURLProtocol.handler = nil + } + + var methods: [String] = [] + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + let body = [ + #";0x00000263;((self.$R=self.$R||{})["server-fn:test"]=[],"#, + #"($R=>$R[0]=Object.assign(new Error("actor of type \"public\" is not associated with an account"),"#, + #"{stack:"Error: actor of type \"public\" is not associated with an account"}))"#, + #"($R["server-fn:test"]))"#, + ].joined() + return Self.makeResponse( + url: url, + body: body, + statusCode: 200, + contentType: "text/javascript") + } + + do { + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + session: self.makeSession()) + Issue.record("Expected OpenCodeUsageError.invalidCredentials") + } catch let error as OpenCodeUsageError { + switch error { + case .invalidCredentials: + break + default: + Issue.record("Expected invalidCredentials, got: \(error)") } + } + + #expect(methods == ["GET"]) + } + + @Test + func `subscription get missing fields falls back to post`() async throws { + defer { OpenCodeStubURLProtocol.handler = nil } @@ -195,13 +226,43 @@ struct OpenCodeUsageFetcherErrorTests { let snapshot = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) #expect(snapshot.rollingUsagePercent == 22) #expect(snapshot.weeklyUsagePercent == 44) #expect(methods == ["GET", "POST"]) } + @Test + func `fetcher sends only auth cookie to opencode host`() async throws { + defer { + OpenCodeStubURLProtocol.handler = nil + } + + var observedCookie: String? + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedCookie = request.value(forHTTPHeaderField: "Cookie") + + let body = """ + { + "rollingUsage": { "usagePercent": 17, "resetInSec": 600 }, + "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 } + } + """ + return Self.makeResponse(url: url, body: body, statusCode: 200, contentType: "application/json") + } + + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "provider=google; auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(observedCookie == "auth=test") + } + private static func makeResponse( url: URL, body: String, diff --git a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift index fe7e1a345a..ab7359cb19 100644 --- a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct OpenCodeUsageParserTests { @Test - func parsesWorkspaceIDs() { + func `parses workspace I ds`() { let text = ";0x00000089;((self.$R=self.$R||{})[\"codexbar\"]=[]," + "($R=>$R[0]=[$R[1]={id:\"wrk_01K6AR1ZET89H8NB691FQ2C2VB\",name:\"Default\",slug:null}])" + "($R[\"codexbar\"]))" @@ -14,7 +13,7 @@ struct OpenCodeUsageParserTests { } @Test - func parsesSubscriptionUsage() throws { + func `parses subscription usage`() throws { let text = "$R[16]($R[30],$R[41]={rollingUsage:$R[42]={status:\"ok\",resetInSec:5944,usagePercent:17}," + "weeklyUsage:$R[43]={status:\"ok\",resetInSec:278201,usagePercent:75}});" let now = Date(timeIntervalSince1970: 0) @@ -26,7 +25,7 @@ struct OpenCodeUsageParserTests { } @Test - func parsesSubscriptionFromJSONWithResetAt() throws { + func `parses subscription from JSON with reset at`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let resetAt = now.addingTimeInterval(3600) let formatter = ISO8601DateFormatter() @@ -55,7 +54,7 @@ struct OpenCodeUsageParserTests { } @Test - func parsesSubscriptionFromCandidateWindows() throws { + func `parses subscription from candidate windows`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let payload: [String: Any] = [ "windows": [ @@ -81,7 +80,7 @@ struct OpenCodeUsageParserTests { } @Test - func computesUsagePercentFromTotals() throws { + func `computes usage percent from totals`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let payload: [String: Any] = [ "rollingUsage": [ @@ -105,7 +104,7 @@ struct OpenCodeUsageParserTests { } @Test - func parseSubscriptionThrowsWhenFieldsMissing() { + func `parse subscription throws when fields missing`() { let now = Date(timeIntervalSince1970: 1_700_000_000) let text = "{\"ok\":true}" @@ -113,4 +112,148 @@ struct OpenCodeUsageParserTests { _ = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) } } + + @Test + func `renewsAt parses from ISO8601 renewAt key`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "renewAt": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt != nil) + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `renewsAt parses from renew_at key`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "renew_at": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt != nil) + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `renewsAt is nil when absent`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == nil) + } + + @Test + func `top level renewAt is preserved for nested usage object`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renewAt": formatter.string(from: renewAt), + "usage": [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `top level renew_at is preserved for nested usage object`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renew_at": formatter.string(from: renewAt), + "usage": [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `child renewAt overrides parent renewAt`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let parentRenewAt = now.addingTimeInterval(86400 * 30) + let childRenewAt = now.addingTimeInterval(86400 * 45) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renewAt": formatter.string(from: parentRenewAt), + "usage": [ + "renewAt": formatter.string(from: childRenewAt), + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == childRenewAt) + } + + @Test + func `toUsageSnapshot includes renewal NamedRateWindow when renewsAt present`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "renewAt": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.extraRateWindows != nil) + #expect(usage.extraRateWindows?.count == 1) + #expect(usage.extraRateWindows?[0].id == "renewal") + #expect(usage.extraRateWindows?[0].title == "Renews") + #expect(usage.extraRateWindows?[0].window.resetsAt == renewAt) + } } diff --git a/Tests/CodexBarTests/OpenCodeWebCookieSupportTests.swift b/Tests/CodexBarTests/OpenCodeWebCookieSupportTests.swift new file mode 100644 index 0000000000..be73d4e50c --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeWebCookieSupportTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import CodexBarCore + +struct OpenCodeWebCookieSupportTests { + @Test + func `request cookie header keeps only opencode auth cookies`() { + let header = OpenCodeWebCookieSupport.requestCookieHeader( + from: "provider=google; auth=session123; theme=dark; __Host-auth=host456") + + #expect(header == "auth=session123; __Host-auth=host456") + } + + @Test + func `request cookie header returns nil when auth cookie is missing`() { + let header = OpenCodeWebCookieSupport.requestCookieHeader(from: "provider=google; theme=dark") + + #expect(header == nil) + } +} diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift index 25ec01bcf1..b06015e6e4 100644 --- a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift +++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) struct OpenRouterUsageStatsTests { @Test - func toUsageSnapshot_usesKeyQuotaForPrimaryWindow() { + func `to usage snapshot uses key quota for primary window`() { let snapshot = OpenRouterUsageSnapshot( totalCredits: 50, totalUsage: 45.3895596325, @@ -25,7 +25,7 @@ struct OpenRouterUsageStatsTests { } @Test - func toUsageSnapshot_withoutValidKeyLimitOmitsPrimaryWindow() { + func `to usage snapshot without valid key limit omits primary window`() { let snapshot = OpenRouterUsageSnapshot( totalCredits: 50, totalUsage: 45.3895596325, @@ -43,7 +43,7 @@ struct OpenRouterUsageStatsTests { } @Test - func toUsageSnapshot_whenNoLimitConfiguredOmitsPrimaryAndMarksNoLimit() { + func `to usage snapshot when no limit configured omits primary and marks no limit`() { let snapshot = OpenRouterUsageSnapshot( totalCredits: 50, totalUsage: 45.3895596325, @@ -62,7 +62,7 @@ struct OpenRouterUsageStatsTests { } @Test - func sanitizers_redactSensitiveTokenShapes() { + func `sanitizers redact sensitive token shapes`() { let body = """ {"error":"bad token sk-or-v1-abc123","token":"secret-token","authorization":"Bearer sk-or-v1-xyz789"} """ @@ -82,7 +82,7 @@ struct OpenRouterUsageStatsTests { } @Test - func non200FetchThrowsGenericHTTPErrorWithoutBodyDetails() async throws { + func `non200 fetch throws generic HTTP error without body details`() async throws { let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self) defer { if registered { @@ -114,7 +114,7 @@ struct OpenRouterUsageStatsTests { } @Test - func fetchUsage_setsCreditsTimeoutAndClientHeaders() async throws { + func `fetch usage sets credits timeout and client headers`() async throws { let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self) defer { if registered { @@ -133,7 +133,16 @@ struct OpenRouterUsageStatsTests { let body = #"{"data":{"total_credits":100,"total_usage":40}}"# return Self.makeResponse(url: url, body: body, statusCode: 200) case "/api/v1/key": - let body = #"{"data":{"limit":20,"usage":0.5,"rate_limit":{"requests":120,"interval":"10s"}}}"# + let body = #""" + {"data":{ + "limit":20, + "usage":0.5, + "usage_daily":0.12, + "usage_weekly":0.74, + "usage_monthly":4.56, + "rate_limit":{"requests":120,"interval":"10s"} + }} + """# return Self.makeResponse(url: url, body: body, statusCode: 200) default: return Self.makeResponse(url: url, body: "{}", statusCode: 404) @@ -153,13 +162,16 @@ struct OpenRouterUsageStatsTests { #expect(usage.keyDataFetched) #expect(usage.keyLimit == 20) #expect(usage.keyUsage == 0.5) + #expect(usage.keyUsageDaily == 0.12) + #expect(usage.keyUsageWeekly == 0.74) + #expect(usage.keyUsageMonthly == 4.56) #expect(usage.keyRemaining == 19.5) #expect(usage.keyUsedPercent == 2.5) #expect(usage.keyQuotaStatus == .available) } @Test - func fetchUsage_whenKeyEndpointFailsMarksQuotaUnavailable() async throws { + func `fetch usage when key endpoint fails marks quota unavailable`() async throws { let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self) defer { if registered { @@ -190,7 +202,28 @@ struct OpenRouterUsageStatsTests { } @Test - func usageSnapshot_roundTripPersistsOpenRouterUsageMetadata() throws { + func `key enrichment timeout does not wait for operation that ignores cancellation`() async throws { + let startedAt = ContinuousClock.now + + let fetched = try await OpenRouterUsageFetcher._boundedKeyFetchForTesting( + timeout: .milliseconds(20)) + { + await withCheckedContinuation { continuation in + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + continuation.resume() + } + } + } + + let elapsed = startedAt.duration(to: .now) + #expect(!fetched) + #expect(elapsed < .milliseconds(300)) + + try await Task.sleep(for: .milliseconds(550)) + } + + @Test + func `usage snapshot round trip persists open router usage metadata`() throws { let openRouter = OpenRouterUsageSnapshot( totalCredits: 50, totalUsage: 45.3895596325, @@ -199,6 +232,9 @@ struct OpenRouterUsageStatsTests { keyDataFetched: true, keyLimit: nil, keyUsage: nil, + keyUsageDaily: 0.12, + keyUsageWeekly: 0.74, + keyUsageMonthly: 4.56, rateLimit: nil, updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) let snapshot = openRouter.toUsageSnapshot() @@ -209,6 +245,9 @@ struct OpenRouterUsageStatsTests { #expect(decoded.openRouterUsage?.keyDataFetched == true) #expect(decoded.openRouterUsage?.keyQuotaStatus == .noLimitConfigured) + #expect(decoded.openRouterUsage?.keyUsageDaily == 0.12) + #expect(decoded.openRouterUsage?.keyUsageWeekly == 0.74) + #expect(decoded.openRouterUsage?.keyUsageMonthly == 4.56) } private static func makeResponse( diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index 014dae6e00..a8172cbb34 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -1,12 +1,11 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore -@Suite struct PathBuilderTests { @Test - func mergesLoginShellPathWhenAvailable() { + func `merges login shell path when available`() { let seeded = PathBuilder.effectivePATH( purposes: [.rpc], env: ["PATH": "/custom/bin:/usr/bin"], @@ -15,7 +14,7 @@ struct PathBuilderTests { } @Test - func fallsBackToExistingPathWhenNoLoginPath() { + func `falls back to existing path when no login path`() { let seeded = PathBuilder.effectivePATH( purposes: [.tty], env: ["PATH": "/custom/bin:/usr/bin"], @@ -24,7 +23,7 @@ struct PathBuilderTests { } @Test - func usesFallbackWhenNoPathAvailable() { + func `uses fallback when no path available`() { let seeded = PathBuilder.effectivePATH( purposes: [.tty], env: [:], @@ -33,7 +32,7 @@ struct PathBuilderTests { } @Test - func debugSnapshotAsyncMatchesSync() async { + func `debug snapshot async matches sync`() async { let env = [ "CODEX_CLI_PATH": "/usr/bin/true", "CLAUDE_CLI_PATH": "/usr/bin/true", @@ -46,7 +45,85 @@ struct PathBuilderTests { } @Test - func resolvesCodexFromEnvOverride() { + func `login shell cache retries after timed out nil capture`() async { + let capture = LoginShellPathCaptureStub([ + nil, + ["/login/bin", "/usr/bin"], + ]) + + let cache = LoginShellPathCache { _, _ in capture.next() } + let firstResult: [String]? = await withCheckedContinuation { continuation in + cache.captureOnce(shell: "/unused", timeout: 0.01) { result in + continuation.resume(returning: result) + } + } + + #expect(firstResult == nil) + #expect(cache.current == nil) + + let recovered = cache.currentOrCapture(shell: "/unused", timeout: 2.0) + #expect(recovered == ["/login/bin", "/usr/bin"]) + #expect(cache.current == ["/login/bin", "/usr/bin"]) + #expect(capture.callCount == 2) + } + + @Test + func `shell runner drains noisy stdout and stderr`() throws { + let script = """ + i=0 + while [ "$i" -lt 4000 ]; do + printf 'out-%04d\\n' "$i" + printf 'err-%04d\\n' "$i" >&2 + i=$((i + 1)) + done + printf '__CODEXBAR_DONE__\\n' + """ + let data = try #require(ShellCommandLocator.test_runShellCommand( + shell: "/bin/sh", + arguments: ["-c", script], + timeout: 4.0)) + let output = try #require(String(data: data, encoding: .utf8)) + + #expect(output.contains("out-3999")) + #expect(output.contains("__CODEXBAR_DONE__")) + } + + @Test + func `shell runner terminates background children after normal exit`() throws { + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-shell-runner-\(UUID().uuidString)") + .path + let escapedMarker = Self.shellSingleQuoted(marker) + let script = """ + ( + trap '' TERM + touch \(escapedMarker) + while :; do sleep 1; done + ) & + printf '%s\\n' "$!" + """ + let data = try #require(ShellCommandLocator.test_runShellCommand( + shell: "/bin/sh", + arguments: ["-c", script], + timeout: 2.0)) + let pidText = try #require(String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)) + let pid = try #require(pid_t(pidText)) + + defer { + kill(pid, SIGKILL) + try? FileManager.default.removeItem(atPath: marker) + } + + let deadline = Date().addingTimeInterval(2.0) + while kill(pid, 0) == 0, Date() < deadline { + usleep(50000 as useconds_t) + } + + #expect(kill(pid, 0) != 0) + } + + @Test + func `resolves codex from env override`() { let overridePath = "/custom/bin/codex" let fm = MockFileManager(executables: [overridePath]) @@ -59,7 +136,7 @@ struct PathBuilderTests { } @Test - func resolvesCodexFromLoginPath() { + func `resolves codex from login path`() { let fm = MockFileManager(executables: ["/login/bin/codex"]) let resolved = BinaryLocator.resolveCodexBinary( env: ["PATH": "/env/bin"], @@ -70,7 +147,7 @@ struct PathBuilderTests { } @Test - func resolvesCodexFromEnvPath() { + func `resolves codex from env path`() { let fm = MockFileManager(executables: ["/env/bin/codex"]) let resolved = BinaryLocator.resolveCodexBinary( env: ["PATH": "/env/bin:/usr/bin"], @@ -81,7 +158,213 @@ struct PathBuilderTests { } @Test - func resolvesCodexFromInteractiveShell() { + func `skips blocked codex path and falls back to signed app binary`() { + let blockedPath = "/usr/local/bin/codex" + let appPath = "/Applications/Codex.app/Contents/Resources/codex" + let fm = MockFileManager(executables: [blockedPath, appPath]) + var checked: [String] = [] + + let resolved = BinaryLocator.resolveCodexBinary( + env: ["PATH": "/usr/local/bin"], + loginPATH: nil, + commandV: { _, _, _, _ in nil }, + aliasResolver: { _, _, _, _, _ in nil }, + launchCandidateFilter: { path, _ in + checked.append(path) + return path != blockedPath + }, + fileManager: fm, + home: "/Users/test") + + #expect(resolved == appPath) + #expect(checked == [blockedPath, appPath]) + } + + @Test + func `explicit codex override bypasses launch candidate fallback`() { + let overridePath = "/custom/bin/codex" + let appPath = "/Applications/Codex.app/Contents/Resources/codex" + let fm = MockFileManager(executables: [overridePath, appPath]) + var checked: [String] = [] + + let resolved = BinaryLocator.resolveCodexBinary( + env: ["CODEX_CLI_PATH": overridePath], + loginPATH: nil, + launchCandidateFilter: { path, _ in + checked.append(path) + return false + }, + fileManager: fm, + home: "/Users/test") + + #expect(resolved == overridePath) + #expect(checked.isEmpty) + } + + @Test + func `Codex CLI strategy availability uses filtered binary resolution`() { + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let unavailable = CodexCLIUsageStrategy.resolvedBinary( + env: ["PATH": "/missing/bin", "SHELL": "/bin/sh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: MockFileManager(executables: []), + home: "/home/test") + #expect(unavailable == nil) + + let available = CodexCLIUsageStrategy.resolvedBinary( + env: ["PATH": "/tools/bin", "SHELL": "/bin/sh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: MockFileManager(executables: ["/tools/bin/codex"]), + home: "/home/test") + #expect(available == "/tools/bin/codex") + } + + #if os(macOS) + @Test + func `Codex launch preflight allows quarantined notarized native binary`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/Applications/Codex.app/Contents/Resources/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { _ in "accepted\nsource=Notarized Developer ID" }, + isMachOExecutable: { _ in true }) + + #expect(allowed) + } + + @Test + func `Codex launch preflight blocks malware attribute before assessment`() { + var assessed = false + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/Applications/Codex.app/Contents/Resources/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.malware" }, + spctlAssessment: { _ in + assessed = true + return "accepted\nsource=Notarized Developer ID" + }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + #expect(!assessed) + } + + @Test + func `Codex launch preflight blocks quarantined script without native assessment`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { _ in nil }, + isMachOExecutable: { _ in false }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight blocks revoked assessment`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/Applications/Codex.app/Contents/Resources/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { _ in "rejected\nCSSMERR_TP_CERT_REVOKED" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight blocks generic Gatekeeper rejection`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { _ in "rejected\nsource=no usable signature" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight allows valid signed command line binary assessment`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected (the code is valid but does not seem to be an app)" }, + isMachOExecutable: { _ in true }) + + #expect(allowed) + } + + @Test + func `Codex launch preflight blocks revoked assessment even with non app rejection text`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { _ in + """ + rejected (the code is valid but does not seem to be an app) + CSSMERR_TP_CERT_REVOKED + """ + }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores benign text in path when verdict is generic rejection`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/code is valid but does not seem to be an app/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected\nsource=no usable signature" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores benign text before verdict separator`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/x: code is valid but does not seem to be an app/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected\nsource=no usable signature" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores blocked words in accepted path and source fields`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/rejected/quarantine/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in + """ + \(path): accepted + source=revoked quarantine marker + origin=malware test fixture + """ + }, + isMachOExecutable: { _ in true }) + + #expect(allowed) + } + #endif + + @Test + func `resolves codex from interactive shell`() { let fm = MockFileManager(executables: ["/shell/bin/codex"]) let commandV: (String, String?, TimeInterval, FileManager) -> String? = { tool, shell, timeout, fileManager in #expect(tool == "codex") @@ -101,7 +384,7 @@ struct PathBuilderTests { } @Test - func resolvesClaudeFromInteractiveShell() { + func `resolves claude from interactive shell`() { let fm = MockFileManager(executables: ["/shell/bin/claude"]) let commandV: (String, String?, TimeInterval, FileManager) -> String? = { tool, shell, timeout, fileManager in #expect(tool == "claude") @@ -121,7 +404,7 @@ struct PathBuilderTests { } @Test - func resolvesGeminiFromInteractiveShell() { + func `resolves gemini from interactive shell`() { let fm = MockFileManager(executables: ["/shell/bin/gemini"]) let commandV: (String, String?, TimeInterval, FileManager) -> String? = { tool, shell, timeout, fileManager in #expect(tool == "gemini") @@ -141,7 +424,7 @@ struct PathBuilderTests { } @Test - func resolvesClaudeFromLoginPath() { + func `resolves claude from login path`() { let fm = MockFileManager(executables: ["/login/bin/claude"]) let resolved = BinaryLocator.resolveClaudeBinary( env: ["PATH": "/env/bin"], @@ -152,7 +435,7 @@ struct PathBuilderTests { } @Test - func resolvesClaudeFromAliasWhenOtherLookupsFail() { + func `resolves claude from alias when other lookups fail`() { let aliasPath = "/home/test/.claude/local/bin/claude" let fm = MockFileManager(executables: [aliasPath]) var aliasCalled = false @@ -182,7 +465,7 @@ struct PathBuilderTests { } @Test - func resolvesCodexFromAliasWhenOtherLookupsFail() { + func `resolves codex from alias when other lookups fail`() { let aliasPath = "/home/test/.codex/bin/codex" let fm = MockFileManager(executables: [aliasPath]) var aliasCalled = false @@ -212,7 +495,134 @@ struct PathBuilderTests { } @Test - func skipsAliasWhenCommandVResolves() { + func `resolves claude from well-known cmux path when shell lookups fail`() { + let cmuxPath = "/Applications/cmux.app/Contents/Resources/bin/claude" + let fm = MockFileManager(executables: [cmuxPath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == cmuxPath) + } + + @Test + func `resolves claude from well-known home dir path`() { + let homePath = "/Users/test/.claude/bin/claude" + let fm = MockFileManager(executables: [homePath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == homePath) + } + + @Test + func `resolves claude from native installer path`() { + let nativePath = "/Users/test/.local/bin/claude" + let fm = MockFileManager(executables: [nativePath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == nativePath) + } + + @Test + func `prefers migrated local claude path over legacy home dir path`() { + let migratedPath = "/Users/test/.claude/local/claude" + let legacyPath = "/Users/test/.claude/bin/claude" + let fm = MockFileManager(executables: [migratedPath, legacyPath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == migratedPath) + } + + @Test + func `prefers user managed well-known path over cmux path`() { + let homePath = "/Users/test/.claude/bin/claude" + let cmuxPath = "/Applications/cmux.app/Contents/Resources/bin/claude" + let fm = MockFileManager(executables: [homePath, cmuxPath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == homePath) + } + + @Test + func `prefers homebrew arm path over usr local fallback`() { + let fm = MockFileManager(executables: [ + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + ]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == "/opt/homebrew/bin/claude") + } + + @Test + func `prefers well-known paths over interactive shell lookup`() { + let shellPath = "/custom/bin/claude" + let cmuxPath = "/Applications/cmux.app/Contents/Resources/bin/claude" + let fm = MockFileManager(executables: [shellPath, cmuxPath]) + var shellLookupCalled = false + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in + shellLookupCalled = true + return shellPath + } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + fileManager: fm, + home: "/Users/test") + #expect(!shellLookupCalled) + #expect(resolved == cmuxPath) + } + + @Test + func `skips alias when command V resolves`() { let path = "/shell/bin/claude" let fm = MockFileManager(executables: [path]) var aliasCalled = false @@ -235,6 +645,33 @@ struct PathBuilderTests { #expect(!aliasCalled) #expect(resolved == path) } + + private static func shellSingleQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } +} + +private final class LoginShellPathCaptureStub: @unchecked Sendable { + private let lock = NSLock() + private var results: [[String]?] + private var callCountStorage = 0 + + var callCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.callCountStorage + } + + init(_ results: [[String]?]) { + self.results = results + } + + func next() -> [String]? { + self.lock.lock() + defer { self.lock.unlock() } + self.callCountStorage += 1 + return self.results.isEmpty ? nil : self.results.removeFirst() + } } private final class MockFileManager: FileManager { diff --git a/Tests/CodexBarTests/PerplexityCookieCacheTests.swift b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift new file mode 100644 index 0000000000..a734d9edf6 --- /dev/null +++ b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift @@ -0,0 +1,203 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PerplexityCookieCacheTests { + private static let testToken = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.fake-test-token" + private static let testCookieName = PerplexityCookieHeader.defaultSessionCookieName + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + // MARK: - Cache round-trip + + @Test + func `cache round trip produces valid cookie override`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + let cached = CookieHeaderCache.load(provider: .perplexity) + #expect(cached != nil) + #expect(cached?.sourceLabel == "web") + + let override = PerplexityCookieHeader.override(from: cached?.cookieHeader) + #expect(override?.name == Self.testCookieName) + #expect(override?.token == Self.testToken) + } + + // MARK: - isAvailable returns true when cache has entry + + @Test + func `is available returns true when cache populated`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + // With no cache and no other sources, load should return nil + let beforeStore = CookieHeaderCache.load(provider: .perplexity) + #expect(beforeStore == nil) + + // After storing, cache should be available + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + let afterStore = CookieHeaderCache.load(provider: .perplexity) + #expect(afterStore != nil) + } + + // MARK: - Cache cleared on invalidToken + + @Test + func `cache cleared on invalid token`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + // Verify it's cached + #expect(CookieHeaderCache.load(provider: .perplexity) != nil) + + // Simulate what fetch() does on invalidToken: clear the cache + CookieHeaderCache.clear(provider: .perplexity) + + #expect(CookieHeaderCache.load(provider: .perplexity) == nil) + } + + // MARK: - Cache NOT cleared on non-auth errors + + @Test + func `cache not cleared on network error`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + // Simulate a networkError — cache should NOT be cleared + let error = PerplexityAPIError.networkError("timeout") + switch error { + case .invalidToken: + CookieHeaderCache.clear(provider: .perplexity) + default: + break // non-auth errors do not clear cache + } + + #expect(CookieHeaderCache.load(provider: .perplexity) != nil) + } + + @Test + func `cache not cleared on API error`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + // Simulate an apiError (e.g. HTTP 500) — cache should NOT be cleared + let error = PerplexityAPIError.apiError("HTTP 500") + switch error { + case .invalidToken: + CookieHeaderCache.clear(provider: .perplexity) + default: + break // non-auth errors do not clear cache + } + + #expect(CookieHeaderCache.load(provider: .perplexity) != nil) + } + + // MARK: - Bare token stored as default cookie name + + @Test + func `bare token round trips with default cookie name`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + // Store with default cookie name format + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + let cached = CookieHeaderCache.load(provider: .perplexity) + let override = PerplexityCookieHeader.override(from: cached?.cookieHeader) + #expect(override?.name == Self.testCookieName) + #expect(override?.token == Self.testToken) + } + + @Test + func `off mode ignores cached session cookie`() async { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=cached-token", + sourceLabel: "web") + + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + #expect(await strategy.isAvailable(context) == false) + } +} diff --git a/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift new file mode 100644 index 0000000000..39b7d2a52b --- /dev/null +++ b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PerplexityCookieHeaderTests { + @Test + func `bare token uses default session cookie name`() { + let override = PerplexityCookieHeader.override(from: "abc123") + #expect(override?.name == PerplexityCookieHeader.defaultSessionCookieName) + #expect(override?.token == "abc123") + #expect(override?.requestCookieNames == PerplexityCookieHeader.supportedSessionCookieNames) + } + + @Test + func `extracts secure next auth session cookie from header`() { + let header = "foo=bar; __Secure-next-auth.session-token=token-a; baz=qux" + let override = PerplexityCookieHeader.override(from: header) + #expect(override?.name == "__Secure-next-auth.session-token") + #expect(override?.token == "token-a") + } + + @Test + func `extracts auth JS session cookie from header`() { + let header = "foo=bar; __Secure-authjs.session-token=token-b; baz=qux" + let override = PerplexityCookieHeader.override(from: header) + #expect(override?.name == "__Secure-authjs.session-token") + #expect(override?.token == "token-b") + } + + @Test + func `prefers auth JS session cookie when both names exist`() { + let header = """ + __Secure-next-auth.session-token=legacy-token; __Secure-authjs.session-token=live-token + """ + let override = PerplexityCookieHeader.override(from: header) + #expect(override?.name == "__Secure-authjs.session-token") + #expect(override?.token == "live-token") + } + + @Test + func `reassembles chunked next auth session cookie from header`() { + let header = """ + foo=bar; __Secure-next-auth.session-token.1=chunk-b; __Secure-next-auth.session-token.0=chunk-a + """ + let override = PerplexityCookieHeader.override(from: header) + #expect(override?.name == "__Secure-next-auth.session-token") + #expect(override?.token == "chunk-achunk-b") + } + + @Test + func `reassembles chunked auth JS session cookie from header`() { + let header = "foo=bar; authjs.session-token.0=chunk-a; authjs.session-token.1=chunk-b" + let override = PerplexityCookieHeader.override(from: header) + #expect(override?.name == "authjs.session-token") + #expect(override?.token == "chunk-achunk-b") + } + + @Test + func `unsupported cookie header returns nil`() { + let override = PerplexityCookieHeader.override(from: "foo=bar; hello=world") + #expect(override == nil) + } + + #if os(macOS) + @Test + func `importer session info reassembles chunked session cookies`() throws { + let cookies = try [ + #require(self.makeCookie(name: "__Secure-authjs.session-token.0", value: "chunk-a")), + #require(self.makeCookie(name: "__Secure-authjs.session-token.1", value: "chunk-b")), + ] + let session = PerplexityCookieImporter.SessionInfo(cookies: cookies, sourceLabel: "Chrome") + + #expect(session.sessionCookie?.name == "__Secure-authjs.session-token") + #expect(session.sessionCookie?.token == "chunk-achunk-b") + } + #endif + + #if os(macOS) + private func makeCookie(name: String, value: String) -> HTTPCookie? { + HTTPCookie(properties: [ + .domain: "www.perplexity.ai", + .path: "/", + .name: name, + .value: value, + .secure: "TRUE", + ]) + } + #endif +} diff --git a/Tests/CodexBarTests/PerplexityProviderTests.swift b/Tests/CodexBarTests/PerplexityProviderTests.swift new file mode 100644 index 0000000000..b81a468447 --- /dev/null +++ b/Tests/CodexBarTests/PerplexityProviderTests.swift @@ -0,0 +1,397 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PerplexityProviderTests { + private static let now = Date(timeIntervalSince1970: 1_740_000_000) + + private final class LockedArray: @unchecked Sendable { + private let lock = NSLock() + private var values: [Element] = [] + + func append(_ value: Element) { + self.lock.lock() + defer { self.lock.unlock() } + self.values.append(value) + } + + func snapshot() -> [Element] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } + } + + private final class LockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var value: Int = 0 + + func increment() { + self.lock.lock() + defer { self.lock.unlock() } + self.value += 1 + } + + func snapshot() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext( + settings: ProviderSettingsSnapshot?, + env: [String: String] = [:]) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + private func stubSnapshot(now: Date = Self.now) -> PerplexityUsageSnapshot { + PerplexityUsageSnapshot( + response: PerplexityCreditsResponse( + balanceCents: 500, + renewalDateTs: now.addingTimeInterval(3600).timeIntervalSince1970, + currentPeriodPurchasedCents: 0, + creditGrants: [ + PerplexityCreditGrant(type: "recurring", amountCents: 1000, expiresAtTs: nil), + ], + totalUsageCents: 500), + now: now) + } + + private func withIsolatedCacheStore(operation: () async throws -> T) async rethrows -> T { + let service = "perplexity-provider-tests-\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await operation() + } + } + + @Test + func `off mode ignores environment session cookie`() async { + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) + + #expect(await strategy.isAvailable(context) == false) + } + + @Test + func `manual mode invalid cookie does not fall back to cache or environment`() async { + await self.withIsolatedCacheStore { + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(PerplexityCookieHeader.defaultSessionCookieName)=cached-token", + sourceLabel: "web") + + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .manual, + manualCookieHeader: "foo=bar")) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in + self.stubSnapshot() + } + + do { + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + Issue.record("Expected invalid manual-cookie error instead of falling back to cache/environment") + } catch let error as PerplexityAPIError { + #expect(error == .invalidCookie) + } catch { + Issue.record("Expected PerplexityAPIError.invalidCookie, got \(error)") + } + } + } + + @Test + func `environment token does not populate browser cookie cache`() async throws { + try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + throw PerplexityCookieImportError.noCookies + } + defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in + self.stubSnapshot() + } + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(CookieHeaderCache.load(provider: .perplexity) == nil) + } + } + + @Test + func `manual token does not populate browser cookie cache`() async throws { + try await self.withIsolatedCacheStore { + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .manual, + manualCookieHeader: "authjs.session-token=manual-token")) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in + self.stubSnapshot() + } + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(CookieHeaderCache.load(provider: .perplexity) == nil) + } + } + + @Test + func `bare environment token falls back to auth JS cookie name`() async throws { + try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + throw PerplexityCookieImportError.noCookies + } + defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let attemptedCookieNames = LockedArray() + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, cookieName, _ in + #expect(token == "env-token") + attemptedCookieNames.append(cookieName) + if cookieName == "authjs.session-token" { + return self.stubSnapshot() + } + throw PerplexityAPIError.invalidToken + } + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(attemptedCookieNames.snapshot() == [ + "__Secure-authjs.session-token", + "authjs.session-token", + ]) + } + } + + @Test + func `valid environment cookie wins after invalid browser session`() async throws { + try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + let cookie = try #require(HTTPCookie(properties: [ + .domain: "www.perplexity.ai", + .path: "/", + .name: PerplexityCookieHeader.defaultSessionCookieName, + .value: "browser-token", + .secure: "TRUE", + ])) + return PerplexityCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome") + } + defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let attemptedTokens = LockedArray() + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, _, _ in + attemptedTokens.append(token) + if token == "browser-token" { + throw PerplexityAPIError.invalidToken + } + if token == "env-token" { + return self.stubSnapshot() + } + Issue.record("Unexpected token \(token)") + throw PerplexityAPIError.invalidToken + } + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(attemptedTokens.snapshot() == ["browser-token", "env-token"]) + } + } + + @Test + func `later browser session wins after earlier imported session fails auth`() async throws { + try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.importSessionsOverrideForTesting = { _, _ in + let staleCookie = try #require(HTTPCookie(properties: [ + .domain: "www.perplexity.ai", + .path: "/", + .name: "__Secure-authjs.session-token", + .value: "stale-browser-token", + .secure: "TRUE", + ])) + let liveCookie = try #require(HTTPCookie(properties: [ + .domain: "www.perplexity.ai", + .path: "/", + .name: "__Secure-authjs.session-token", + .value: "live-browser-token", + .secure: "TRUE", + ])) + return [ + PerplexityCookieImporter.SessionInfo(cookies: [staleCookie], sourceLabel: "Chrome"), + PerplexityCookieImporter.SessionInfo(cookies: [liveCookie], sourceLabel: "Safari"), + ] + } + defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let attemptedTokens = LockedArray() + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, _, _ in + attemptedTokens.append(token) + if token == "stale-browser-token" { + throw PerplexityAPIError.invalidToken + } + if token == "live-browser-token" { + return self.stubSnapshot() + } + Issue.record("Unexpected token \(token)") + throw PerplexityAPIError.invalidToken + } + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(attemptedTokens.snapshot() == ["stale-browser-token", "live-browser-token"]) + } + } + + @Test + func `auto mode reuses browser import between availability and fetch`() async throws { + try await self.withIsolatedCacheStore { + let importCount = LockedCounter() + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + importCount.increment() + let cookie = try #require(HTTPCookie(properties: [ + .domain: "www.perplexity.ai", + .path: "/", + .name: PerplexityCookieHeader.defaultSessionCookieName, + .value: "browser-token", + .secure: "TRUE", + ])) + return PerplexityCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome") + } + defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, _, _ in + #expect(token == "browser-token") + return self.stubSnapshot() + } + + #expect(await strategy.isAvailable(context)) + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(importCount.snapshot() == 1) + } + } +} diff --git a/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift b/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift new file mode 100644 index 0000000000..2f7b1ff7ad --- /dev/null +++ b/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift @@ -0,0 +1,38 @@ +import Testing +@testable import CodexBarCore + +struct PerplexitySettingsReaderTests { + @Test + func `PERPLEXITY_COOKIE preserves the original supported cookie name`() { + let override = PerplexitySettingsReader.sessionCookieOverride(environment: [ + "PERPLEXITY_COOKIE": "authjs.session-token=env-token", + ]) + + #expect(override?.name == "authjs.session-token") + #expect(override?.token == "env-token") + #expect(PerplexitySettingsReader.sessionToken(environment: [ + "PERPLEXITY_COOKIE": "authjs.session-token=env-token", + ]) == "env-token") + } + + @Test + func `PERPLEXITY_COOKIE reassembles chunked session cookies`() { + let override = PerplexitySettingsReader.sessionCookieOverride(environment: [ + "PERPLEXITY_COOKIE": "authjs.session-token.0=chunk-a; authjs.session-token.1=chunk-b", + ]) + + #expect(override?.name == "authjs.session-token") + #expect(override?.token == "chunk-achunk-b") + } + + @Test + func `PERPLEXITY_SESSION_TOKEN tries all supported cookie names`() { + let override = PerplexitySettingsReader.sessionCookieOverride(environment: [ + "PERPLEXITY_SESSION_TOKEN": "env-token", + ]) + + #expect(override?.name == PerplexityCookieHeader.defaultSessionCookieName) + #expect(override?.token == "env-token") + #expect(override?.requestCookieNames == PerplexityCookieHeader.supportedSessionCookieNames) + } +} diff --git a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift new file mode 100644 index 0000000000..0afd0cbcc2 --- /dev/null +++ b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift @@ -0,0 +1,362 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PerplexityUsageFetcherTests { + // Fixed "now" so expiry comparisons are deterministic + private static let now = Date(timeIntervalSince1970: 1_740_000_000) // Feb 20, 2026 + private static let futureTs: TimeInterval = 1_750_000_000 // ~Jun 2025, after now + private static let pastTs: TimeInterval = 1_700_000_000 // ~Nov 2023, before now + private static let renewalTs: TimeInterval = 1_743_000_000 // ~Mar 26, 2026 + + // MARK: - JSON Parsing + + @Test + func `parses full response with recurring and promotional credits`() throws { + let json = """ + { + "balance_cents": 7250, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }, + { "type": "promotional", "amount_cents": 20000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 2750 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + #expect(snapshot.recurringTotal == 10000) + #expect(snapshot.recurringUsed == 2750) + #expect(snapshot.promoTotal == 20000) + #expect(snapshot.promoUsed == 0) + #expect(snapshot.purchasedTotal == 0) + #expect(snapshot.purchasedUsed == 0) + #expect(snapshot.balanceCents == 7250) + #expect(snapshot.totalUsageCents == 2750) + #expect(abs(snapshot.renewalDate.timeIntervalSince1970 - Self.renewalTs) < 1) + } + + @Test + func `waterfall attribution recurring then purchased then promo`() throws { + // Usage exceeds recurring, spills into purchased, then promo + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 3000, + "credit_grants": [ + { "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) }, + { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 9000 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + #expect(snapshot.recurringUsed == 5000) // recurring fully consumed + #expect(snapshot.purchasedUsed == 3000) // purchased fully consumed + #expect(snapshot.promoUsed == 1000) // 9000 - 5000 - 3000 = 1000 from promo + } + + @Test + func `expired promotional grants are excluded`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }, + { "type": "promotional", "amount_cents": 5000, "expires_at_ts": \(Self.pastTs) } + ], + "total_usage_cents": 1000 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + #expect(snapshot.promoTotal == 0) // expired grant excluded + #expect(snapshot.promoUsed == 0) + #expect(snapshot.promoExpiration == nil) + } + + @Test + func `empty credit grants produces zero recurring`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [], + "total_usage_cents": 0 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + #expect(snapshot.recurringTotal == 0) + #expect(snapshot.promoTotal == 0) + #expect(snapshot.purchasedTotal == 0) + #expect(snapshot.planName == nil) + } + + @Test + func `malformed JSON throws parse failed`() { + let json = """ + { "balance_cents": "not a number", "credit_grants": null } + """ + #expect { + _ = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + } throws: { error in + guard case PerplexityAPIError.parseFailed = error else { return false } + return true + } + } + + // MARK: - Plan Name Inference + + @Test + func `plan name inference`() throws { + func makeSnapshot(recurringCents: Double) throws -> PerplexityUsageSnapshot { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": \(recurringCents), "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 0 + } + """ + return try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + } + + #expect(try makeSnapshot(recurringCents: 0).planName == nil) + #expect(try makeSnapshot(recurringCents: 500).planName == "Pro") + #expect(try makeSnapshot(recurringCents: 1000).planName == "Pro") + #expect(try makeSnapshot(recurringCents: 10000).planName == "Max") + } + + // MARK: - toUsageSnapshot + + @Test + func `to usage snapshot always has secondary and tertiary`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 0 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + .toUsageSnapshot() + + // secondary and tertiary always present even when no promo/purchased credits + #expect(snapshot.secondary != nil) + #expect(snapshot.tertiary != nil) + } + + @Test + func `to usage snapshot zero recurring bar is fully depleted`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [], + "total_usage_cents": 0 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + .toUsageSnapshot() + let primary = try #require(snapshot.primary) + + // No recurring credits → bar renders as empty (100% used), not full (0% used) + #expect(primary.usedPercent == 100.0) + } + + @Test + func `to usage snapshot omits primary when only fallback credits remain`() throws { + let json = """ + { + "balance_cents": 6000, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 2000, + "credit_grants": [ + { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 0 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + .toUsageSnapshot() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary?.usedPercent == 0.0) + #expect(snapshot.tertiary?.usedPercent == 0.0) + } + + @Test + func `to usage snapshot empty pools bars are fully depleted`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 0 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + .toUsageSnapshot() + let secondary = try #require(snapshot.secondary) + let tertiary = try #require(snapshot.tertiary) + + // Empty pools render as 100% used (empty bar) not 0% used (full bar) + #expect(secondary.usedPercent == 100.0) + #expect(tertiary.usedPercent == 100.0) + } + + // MARK: - Purchased credits from credit_grants + + @Test + func `purchased credits from credit grants array`() throws { + // Purchased credits appear as credit_grant type="purchased" instead of + // current_period_purchased_cents. The snapshot should pick them up. + let json = """ + { + "balance_cents": 23065, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }, + { "type": "purchased", "amount_cents": 40000 }, + { "type": "promotional", "amount_cents": 55000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 81935 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + #expect(snapshot.recurringTotal == 10000) + #expect(snapshot.purchasedTotal == 40000) + #expect(snapshot.promoTotal == 55000) + + // Waterfall: recurring eats 10000, purchased eats 40000, promo eats 31935 + #expect(snapshot.recurringUsed == 10000) + #expect(snapshot.purchasedUsed == 40000) + #expect(snapshot.promoUsed == 31935) + } + + @Test + func `purchased credits prefer grants over field when both present`() throws { + // When both current_period_purchased_cents AND credit_grants type="purchased" + // are provided, the larger value wins. + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 3000, + "credit_grants": [ + { "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) }, + { "type": "purchased", "amount_cents": 8000 }, + { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 14000 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + // Purchased should use max(8000, 3000) = 8000 + #expect(snapshot.purchasedTotal == 8000) + // Waterfall: 5000 recurring + 8000 purchased + 1000 promo = 14000 + #expect(snapshot.recurringUsed == 5000) + #expect(snapshot.purchasedUsed == 8000) + #expect(snapshot.promoUsed == 1000) + } + + @Test + func `purchased credits from field when no grant type`() throws { + // Legacy path: current_period_purchased_cents is set but no "purchased" grant + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 3000, + "credit_grants": [ + { "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) }, + { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 9000 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + // Still picks up purchased from the top-level field + #expect(snapshot.purchasedTotal == 3000) + #expect(snapshot.recurringUsed == 5000) + #expect(snapshot.purchasedUsed == 3000) + #expect(snapshot.promoUsed == 1000) + } + + @Test + func `real world max plan with all three pools`() throws { + // Real-world scenario: Max plan, 10k recurring + 40k purchased + 55k bonus + // Total 105,000 available, 23,065 remaining → 81,935 used + let json = """ + { + "balance_cents": 23065, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }, + { "type": "purchased", "amount_cents": 40000 }, + { "type": "promotional", "amount_cents": 55000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 81935 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + let usage = snapshot.toUsageSnapshot() + + // Primary (recurring): fully consumed → 100% + let primary = try #require(usage.primary) + #expect(primary.usedPercent == 100.0) + + // Tertiary (purchased): fully consumed → 100% + let tertiary = try #require(usage.tertiary) + #expect(tertiary.usedPercent == 100.0) + + // Secondary (bonus): 31935/55000 ≈ 58.06% used → ~42% remaining + let secondary = try #require(usage.secondary) + let expectedPromoPercent = 31935.0 / 55000.0 * 100.0 + #expect(abs(secondary.usedPercent - expectedPromoPercent) < 0.1) + } + + @Test + func `to usage snapshot primary percent matches usage`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 2500 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + .toUsageSnapshot() + let primary = try #require(snapshot.primary) + + #expect(primary.usedPercent == 25.0) + } +} diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift new file mode 100644 index 0000000000..14a6bfe3a2 --- /dev/null +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -0,0 +1,731 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiSessionCostScannerTests { + @Test + func `pi scanner maps assistant usage to codex and claude reports`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let codexDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let claudeDay = try env.makeLocalNoon(year: 2026, month: 4, day: 3) + + let codexEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: codexDay), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(codexDay.timeIntervalSince1970 * 1000), + "usage": [ + "input": 120, + "output": 30, + "cacheRead": 10, + "cacheWrite": 5, + "totalTokens": 165, + ], + ], + ] + let claudeEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: claudeDay), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "anthropic.foo.claude-sonnet-4-6-v1:0", + "timestamp": Int(claudeDay.timeIntervalSince1970 * 1000), + "usage": [ + "input": 80, + "output": 20, + "cacheRead": 4, + "cacheWrite": 6, + "totalTokens": 110, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "nested/run-0/2026-04-02T10-00-00-000Z_test.jsonl", + contents: env.jsonl([codexEntry, claudeEntry])) + + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + + let codexReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: codexDay, + until: claudeDay, + now: claudeDay, + options: options) + let expectedCodexCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 135, + cachedInputTokens: 10, + outputTokens: 30) + #expect(codexReport.data.count == 1) + #expect(codexReport.data.first?.date == "2026-04-02") + #expect(codexReport.data.first?.totalTokens == 165) + #expect(abs((codexReport.data.first?.costUSD ?? 0) - (expectedCodexCost ?? 0)) < 0.000001) + #expect(codexReport.data.first?.modelBreakdowns?.map(\.modelName) == ["gpt-5.4"]) + + let claudeReport = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: codexDay, + until: claudeDay, + now: claudeDay, + options: options) + let expectedClaudeCost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 80, + cacheReadInputTokens: 4, + cacheCreationInputTokens: 6, + outputTokens: 20) + #expect(claudeReport.data.count == 1) + #expect(claudeReport.data.first?.date == "2026-04-03") + #expect(claudeReport.data.first?.totalTokens == 110) + #expect(abs((claudeReport.data.first?.costUSD ?? 0) - (expectedClaudeCost ?? 0)) < 0.000001) + #expect(claudeReport.data.first?.modelBreakdowns?.map(\.modelName) == ["claude-sonnet-4-6"]) + } + + @Test + func `pi scanner keeps ambiguous claude errors priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 9) + let claudeEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-fable-5", + "stopReason": "error", + "errorMessage": "An unknown error occurred", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 100, + "output": 0, + "cacheRead": 20, + "cacheWrite": 10, + "totalTokens": 130, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-06-09T10-00-00-000Z_refusal.jsonl", + contents: env.jsonl([claudeEntry])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 130) + let expectedCost = CostUsagePricing.claudeCostUSD( + model: "claude-fable-5", + inputTokens: 100, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 10, + outputTokens: 0) + + #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `pi scanner uses model change fallback and assistant timestamp day`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let sessionStart = try env.makeLocalNoon(year: 2026, month: 4, day: 1) + let assistantDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + + let modelChange: [String: Any] = [ + "type": "model_change", + "timestamp": env.isoString(for: sessionStart), + "provider": "openai-codex", + "modelId": "openai/gpt-5.3-codex", + ] + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: sessionStart), + "message": [ + "role": "assistant", + "timestamp": Int(assistantDay.timeIntervalSince1970 * 1000), + "usage": [ + "input": 20, + "cacheRead": 2, + "output": 20, + "totalTokens": 42, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-04-01T09-00-00-000Z_test.jsonl", + contents: env.jsonl([modelChange, assistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: sessionStart, + until: assistantDay, + now: assistantDay, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + let expectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.3-codex", + inputTokens: 22, + cachedInputTokens: 2, + outputTokens: 20) + #expect(report.data.count == 1) + #expect(report.data.first?.date == "2026-04-02") + #expect(report.data.first?.totalTokens == 42) + #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["gpt-5.3-codex"]) + } + + @Test + func `pi scanner refreshes appended file without duplicating existing usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 4) + let firstTimestamp = Int(day.timeIntervalSince1970 * 1000) + let secondTimestamp = Int(day.addingTimeInterval(60).timeIntervalSince1970 * 1000) + + let firstAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": firstTimestamp, + "usage": [ + "input": 10, + "output": 5, + "totalTokens": 15, + ], + ], + ] + let secondAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": secondTimestamp, + "usage": [ + "input": 20, + "output": 10, + "totalTokens": 30, + ], + ], + ] + + let url = try env.writePiSessionFile( + relativePath: "2026-04-04T10-00-00-000Z_test.jsonl", + contents: env.jsonl([firstAssistant])) + + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let firstReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let firstExpectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 10, + cachedInputTokens: 0, + outputTokens: 5) + #expect(firstReport.data.count == 1) + #expect(firstReport.data.first?.totalTokens == 15) + #expect(abs((firstReport.data.first?.costUSD ?? 0) - (firstExpectedCost ?? 0)) < 0.000001) + + try env.jsonl([firstAssistant, secondAssistant]).write(to: url, atomically: true, encoding: .utf8) + + let secondReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let secondExpectedCost = (firstExpectedCost ?? 0) + (CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 20, + cachedInputTokens: 0, + outputTokens: 10) ?? 0) + #expect(secondReport.data.count == 1) + #expect(secondReport.data.first?.totalTokens == 45) + #expect(abs((secondReport.data.first?.costUSD ?? 0) - secondExpectedCost) < 0.000001) + } + + @Test + func `pi scanner ignores explicit unsupported provider even with fallback context`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 5) + let modelChange: [String: Any] = [ + "type": "model_change", + "timestamp": env.isoString(for: day), + "provider": "openai-codex", + "modelId": "gpt-5.4", + ] + let unsupportedAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openrouter", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 99, + "output": 1, + "totalTokens": 100, + ], + ], + ] + let fallbackAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day.addingTimeInterval(60)), + "message": [ + "role": "assistant", + "timestamp": Int(day.addingTimeInterval(60).timeIntervalSince1970 * 1000), + "usage": [ + "input": 10, + "output": 5, + "totalTokens": 15, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-04-05T10-00-00-000Z_test.jsonl", + contents: env.jsonl([modelChange, unsupportedAssistant, fallbackAssistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 15) + #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["gpt-5.4"]) + } + + @Test + func `pi scanner force rescan bypasses stale same size metadata cache`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 6) + let assistantOne: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 10, + "output": 5, + "totalTokens": 15, + ], + ], + ] + let assistantTwo: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 20, + "output": 5, + "totalTokens": 25, + ], + ], + ] + + let firstContents = try env.jsonl([assistantOne]) + let secondContents = try env.jsonl([assistantTwo]) + #expect(firstContents.utf8.count == secondContents.utf8.count) + + let url = try env.writePiSessionFile( + relativePath: "2026-04-06T10-00-00-000Z_test.jsonl", + contents: firstContents) + let originalModifiedAt = try #require( + FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) + + let cachedOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let firstReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: cachedOptions) + #expect(firstReport.data.first?.totalTokens == 15) + + try secondContents.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: originalModifiedAt], ofItemAtPath: url.path) + + let staleReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: cachedOptions) + #expect(staleReport.data.first?.totalTokens == 15) + + let refreshedReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + forceRescan: true)) + #expect(refreshedReport.data.first?.totalTokens == 25) + } + + @Test + func `pi scanner derives cost when explicit cost is absent`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 7) + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 70, + "cacheRead": 4, + "cacheWrite": 6, + "output": 19, + "totalTokens": 99, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-04-07T10-00-00-000Z_test.jsonl", + contents: env.jsonl([assistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + let expectedCost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 70, + cacheReadInputTokens: 4, + cacheCreationInputTokens: 6, + outputTokens: 19) + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 99) + #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `pi scanner preserves per-message threshold pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) + let model = "claude-sonnet-4-5" + let firstAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + let secondAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.addingTimeInterval(1).timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-05-09T10-00-00-000Z_threshold.jsonl", + contents: env.jsonl([firstAssistant, secondAssistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + let expectedRequestCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 150_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let aggregateCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 300_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let expectedCost = expectedRequestCost * 2 + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 300_000) + #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) + #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) + #expect(abs((report.data.first?.modelBreakdowns?.first?.costUSD ?? 0) - expectedCost) < 0.000001) + } + + @Test + func `pi scanner ignores v2 cache with stale claude pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let model = "claude-sonnet-4-5" + let firstAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + let secondAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.addingTimeInterval(1).timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + + let fileURL = try env.writePiSessionFile( + relativePath: "2026-05-10T10-00-00-000Z_threshold.jsonl", + contents: env.jsonl([firstAssistant, secondAssistant])) + let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let mtime = try #require(attrs[.modificationDate] as? Date) + let size = try #require((attrs[.size] as? NSNumber)?.int64Value) + + let requestCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 150_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let aggregateCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 300_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let aggregatePacked = PiPackedUsage( + inputTokens: 300_000, + totalTokens: 300_000, + costNanos: Int64((aggregateCost * 1_000_000_000).rounded()), + costSampleCount: 2, + usageSampleCount: 2) + let dayKey = "2026-05-10" + let contributions = [ + UsageProvider.claude.rawValue: [ + dayKey: [ + model: aggregatePacked, + ], + ], + ] + let oldFileUsage = PiSessionFileUsage( + mtimeUnixMs: Int64(mtime.timeIntervalSince1970 * 1000), + size: size, + parsedBytes: size, + lastModelContext: nil, + contributions: contributions) + var oldCache = PiSessionCostCache(version: 2) + oldCache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) + oldCache.scanSinceKey = dayKey + oldCache.scanUntilKey = dayKey + oldCache.daysByProvider = contributions + oldCache.files = [fileURL.path: oldFileUsage] + let oldCacheURL = env.cacheRoot + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("pi-sessions-v2.json", isDirectory: false) + try FileManager.default.createDirectory( + at: oldCacheURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try JSONEncoder().encode(oldCache).write(to: oldCacheURL) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600)) + + let expectedCost = requestCost * 2 + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 300_000) + #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) + #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) + + let newCacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) + #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) + let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + let rebuilt = newCache.daysByProvider[UsageProvider.claude.rawValue]?[dayKey]?[model] + #expect(newCacheURL.lastPathComponent == "pi-sessions-v3.json") + #expect(newCache.version == 3) + #expect(rebuilt?.usageSampleCount == 2) + #expect(rebuilt?.costSampleCount == 2) + } + + @Test + func `pi scanner reparses unchanged cached file when scan window expands`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let oldDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let newDay = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let oldAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: oldDay), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(oldDay.timeIntervalSince1970 * 1000), + "usage": [ + "input": 10, + "output": 5, + "totalTokens": 15, + ], + ], + ] + let newAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: newDay), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(newDay.timeIntervalSince1970 * 1000), + "usage": [ + "input": 20, + "output": 10, + "totalTokens": 30, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-04-08T10-00-00-000Z_test.jsonl", + contents: env.jsonl([oldAssistant, newAssistant])) + + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600) + let narrowReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: newDay, + until: newDay, + now: newDay, + options: options) + #expect(narrowReport.data.map(\.date) == ["2026-04-08"]) + #expect(narrowReport.data.first?.totalTokens == 30) + + let expandedReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: oldDay, + until: newDay, + now: newDay.addingTimeInterval(1), + options: options) + #expect(expandedReport.data.map(\.date) == ["2026-04-02", "2026-04-08"]) + #expect(expandedReport.summary?.totalTokens == 45) + } +} diff --git a/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift new file mode 100644 index 0000000000..dd86189a39 --- /dev/null +++ b/Tests/CodexBarTests/PlanUtilizationHistoryChartMenuViewTests.swift @@ -0,0 +1,88 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct PlanUtilizationHistoryChartMenuViewTests { + @Test + func `merged entries preserve first occurrence order while removing duplicates`() { + let first = PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 100), + usedPercent: 10, + resetsAt: Date(timeIntervalSince1970: 200)) + let second = PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 300), + usedPercent: 20, + resetsAt: nil) + + let merged = PlanUtilizationHistoryChartMenuView.mergedEntries([ + first, + second, + first, + second, + ]) + + #expect(merged == [first, second]) + } + + @Test + func `generic primary weekly window keeps weekly history visible`() { + let history = PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: 42, + resetsAt: nil), + ]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: [history], + provider: .zai, + snapshot: snapshot) + + #expect(model.visibleSeries == ["weekly:10080"]) + #expect(model.selectedSeries == "weekly:10080") + } + + @Test + func `generic unknown weekly extra window does not filter saved history`() { + let history = PlanUtilizationSeriesHistory( + name: .weekly, + windowMinutes: 10080, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: 42, + resetsAt: nil), + ]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "weekly-reset-only", + title: "Weekly reset", + window: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_700_003_600), + resetDescription: nil), + usageKnown: false), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: [history], + provider: .zed, + snapshot: snapshot) + + #expect(model.visibleSeries == ["weekly:10080"]) + #expect(model.selectedSeries == "weekly:10080") + } +} diff --git a/Tests/CodexBarTests/PoeSettingsReaderTests.swift b/Tests/CodexBarTests/PoeSettingsReaderTests.swift new file mode 100644 index 0000000000..efc3f8095e --- /dev/null +++ b/Tests/CodexBarTests/PoeSettingsReaderTests.swift @@ -0,0 +1,11 @@ +import CodexBarCore +import Foundation +import Testing + +struct PoeSettingsReaderTests { + @Test + func `api key trims quotes`() { + let env = [PoeSettingsReader.apiKeyEnvironmentKey: " 'poe-key' "] + #expect(PoeSettingsReader.apiKey(environment: env) == "poe-key") + } +} diff --git a/Tests/CodexBarTests/PoeUsageFetcherTests.swift b/Tests/CodexBarTests/PoeUsageFetcherTests.swift new file mode 100644 index 0000000000..840541d95b --- /dev/null +++ b/Tests/CodexBarTests/PoeUsageFetcherTests.swift @@ -0,0 +1,251 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PoeUsageFetcherTests { + @Test + func `parse snapshot extracts current point balance`() throws { + let json = #"{"current_point_balance": 1500}"# + let data = Data(json.utf8) + let snapshot = try PoeUsageFetcher._parseSnapshotForTesting(data) + #expect(snapshot.currentPointBalance == 1500) + } + + @Test + func `parse snapshot accepts string-encoded balance`() throws { + let json = #"{"current_point_balance": "2500"}"# + let snapshot = try PoeUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.currentPointBalance == 2500) + } + + @Test + func `parse snapshot returns nil balance when absent`() throws { + let json = #"{}"# + let snapshot = try PoeUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.currentPointBalance == nil) + } + + @Test + func `parse snapshot throws on malformed JSON`() { + #expect { + _ = try PoeUsageFetcher._parseSnapshotForTesting(Data("not-json".utf8)) + } throws: { error in + guard case PoeUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `snapshot maps balance to identity loginMethod only, not RateWindow`() { + let snapshot = PoeUsageSnapshot( + currentPointBalance: 500, + updatedAt: Date()) + + let unified = snapshot.toUsageSnapshot() + // No rate windows — balance is not a usage percentage + #expect(unified.primary == nil) + #expect(unified.secondary == nil) + #expect(unified.tertiary == nil) + // Balance lives in identity.loginMethod as "Balance: X points" + #expect(unified.identity?.providerID == .poe) + #expect(unified.identity?.loginMethod == "Balance: 500 points") + } + + @Test + func `snapshot hides balance when balance is absent`() { + let snapshot = PoeUsageSnapshot( + currentPointBalance: nil, + updatedAt: Date()) + + let unified = snapshot.toUsageSnapshot() + #expect(unified.primary == nil) + #expect(unified.identity?.loginMethod == nil) + } + + @Test + func `missing credentials fetch call throws missing credentials`() async { + do { + _ = try await PoeUsageFetcher.fetchUsage(apiKey: " ") + Issue.record("Expected missingCredentials error") + } catch let error as PoeUsageError { + guard case .missingCredentials = error else { + Issue.record("Expected .missingCredentials but got \(error)") + return + } + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func `compact number formats thousands with no decimals`() { + #expect(PoeUsageSnapshot.compactNumber(1500) == "1,500") + #expect(PoeUsageSnapshot.compactNumber(999) == "999") + #expect(PoeUsageSnapshot.compactNumber(10000) == "10,000") + } + + @Test + func `history page parser extracts entries and cursor`() throws { + let json = """ + { + "data": [ + { + "query_id": "a1", + "creation_time": 1717000000000000, + "bot_name": "GPT-4o", + "usage_type": "API", + "cost_points": 12.5, + "cost_usd": "0.03" + }, + { + "query_id": "a2", + "creation_time": 1717003600, + "bot_name": "Claude Sonnet", + "usage_type": "Chat", + "cost_points": "8", + "usd": "0.02" + } + ], + "next_cursor": "cursor-2" + } + """ + + let parsed = try PoeUsageFetcher._parseHistoryPageForTesting(Data(json.utf8)) + #expect(parsed.entries.count == 2) + #expect(parsed.entries[0].model == "GPT-4o") + #expect(parsed.entries[0].points == 12.5) + #expect(parsed.entries[0].id == "a1") + #expect(parsed.entries[1].costUSD == 0.02) + #expect(parsed.nextCursor == "cursor-2") + } + + @Test + func `history parser derives cursor from has_more and last query id`() throws { + let json = """ + { + "has_more": true, + "data": [ + { + "query_id": "q-1", + "creation_time": 1717000000, + "bot_name": "GPT-4o", + "usage_type": "API", + "cost_points": 3 + }, + { + "query_id": "q-2", + "creation_time": 1717003600, + "bot_name": "Claude Sonnet", + "usage_type": "Chat", + "cost_points": 9 + } + ] + } + """ + + let parsed = try PoeUsageFetcher._parseHistoryPageForTesting(Data(json.utf8)) + #expect(parsed.entries.count == 2) + #expect(parsed.nextCursor == "q-2") + } + + @Test + func `history daily aggregation groups by utc day`() { + let entries = [ + PoeUsageHistorySnapshot.Entry( + id: "1", + createdAt: Date(timeIntervalSince1970: 1_717_000_000), + model: "GPT-4o", + usageType: "inference", + points: 10, + costUSD: 0.02), + PoeUsageHistorySnapshot.Entry( + id: "2", + createdAt: Date(timeIntervalSince1970: 1_717_000_100), + model: "GPT-4o", + usageType: "inference", + points: 5, + costUSD: 0.01), + ] + + let daily = PoeUsageFetcher._buildDailyBucketsForTesting(entries: entries) + #expect(daily.count == 1) + #expect(daily[0].requests == 2) + #expect(daily[0].points == 15) + #expect(daily[0].costUSD == 0.03) + } + + @Test + func `fetch usage returns balance when points history endpoint fails`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let path = url.path + let response: (Data, Int) + if path.contains("current_balance") { + response = (Data(#"{"current_point_balance": 1500}"#.utf8), 200) + } else if path.contains("points_history") { + // Simulate a 500 from the optional history endpoint. + response = (Data("server error".utf8), 500) + } else { + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.httpResponse(url: nil, status: 0) + } + return Self.httpResponse(url: nil, status: response.1, body: response.0) + } + + let snapshot = try await PoeUsageFetcher._fetchUsage( + apiKey: "test-key", + transport: transport) + + #expect(snapshot.currentPointBalance == 1500) + // History should be nil, not propagate the failure. + #expect(snapshot.history == nil) + } + + @Test + func `fetch usage surfaces history snapshot when history endpoint succeeds`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let path = url.path + if path.contains("current_balance") { + return Self.httpResponse( + url: nil, + status: 200, + body: Data(#"{"current_point_balance": 2500}"#.utf8)) + } + if path.contains("points_history") { + return Self.httpResponse( + url: nil, + status: 200, + body: Data(""" + {"data":[],"next_cursor":null} + """.utf8)) + } + Issue.record("Unexpected request: \(url.absoluteString)") + return Self.httpResponse(url: nil, status: 0) + } + + let snapshot = try await PoeUsageFetcher._fetchUsage( + apiKey: "test-key", + transport: transport) + + #expect(snapshot.currentPointBalance == 2500) + // Empty history page still produces a non-nil snapshot with empty buckets. + #expect(snapshot.history == nil) + } +} + +extension PoeUsageFetcherTests { + fileprivate static func httpResponse( + url: URL?, + status: Int, + body: Data = Data()) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url ?? URL(string: "https://example.invalid")!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: nil) ?? HTTPURLResponse() + return (body, response) + } +} diff --git a/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift b/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift new file mode 100644 index 0000000000..c24fc82f5b --- /dev/null +++ b/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift @@ -0,0 +1,416 @@ +import CodexBarCore +import Foundation +import Testing + +struct PoeUsageHistorySnapshotTests { + // MARK: - summary(days:) + + @Test + func `summary over empty daily returns zeroed summary with nil cost`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [], + updatedAt: Date()) + + let summary = snapshot.summary(days: 7) + + #expect(summary.points == 0) + #expect(summary.requests == 0) + #expect(summary.costUSD == nil) + } + + @Test + func `summary over single day reports that day's points and requests`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [PoeUsageHistorySnapshot.DailyBucket( + day: "2026-05-31", + points: 250, + requests: 3, + costUSD: 0.05)], + updatedAt: Date()) + + let summary = snapshot.summary(days: 1) + + #expect(summary.points == 250) + #expect(summary.requests == 3) + #expect(summary.costUSD == 0.05) + } + + @Test + func `summary over seven days uses the last seven daily buckets`() { + let daily: [PoeUsageHistorySnapshot.DailyBucket] = (1...10).map { offset in + PoeUsageHistorySnapshot.DailyBucket( + day: String(format: "2026-05-%02d", offset), + points: Double(offset * 10), + requests: offset, + costUSD: nil) + } + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: daily, + updatedAt: Date()) + + // Last 7 buckets: offsets 4..10 → 40+50+60+70+80+90+100 = 490 + let summary = snapshot.summary(days: 7) + + #expect(summary.points == 490) + #expect(summary.requests == 4 + 5 + 6 + 7 + 8 + 9 + 10) + #expect(summary.costUSD == nil) + } + + @Test + func `summary clamps zero and negative day counts up to one`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [ + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-29", points: 100, requests: 1, costUSD: nil), + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-30", points: 200, requests: 2, costUSD: nil), + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-31", points: 300, requests: 3, costUSD: nil), + ], + updatedAt: Date()) + + #expect(snapshot.summary(days: 0).points == 300) // last bucket only + #expect(snapshot.summary(days: 0).requests == 3) + #expect(snapshot.summary(days: -5).points == 300) // clamped up to 1 + } + + @Test + func `summary ignores daily buckets beyond the requested window`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: (1...30).map { offset in + PoeUsageHistorySnapshot.DailyBucket( + day: String(format: "2026-04-%02d", offset), + points: 1, + requests: 1, + costUSD: nil) + }, + updatedAt: Date()) + + let last30 = snapshot.summary(days: 30) + let last7 = snapshot.summary(days: 7) + + #expect(last30.points == 30) + #expect(last7.points == 7) + } + + @Test + func `summary reports nil cost when every daily bucket has nil cost`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: (1...3).map { offset in + PoeUsageHistorySnapshot.DailyBucket( + day: "2026-05-\(28 + offset)", + points: 50, + requests: 1, + costUSD: nil) + }, + updatedAt: Date()) + + #expect(snapshot.summary(days: 7).costUSD == nil) + } + + @Test + func `summary sums only the non-nil cost buckets and keeps the rest invisible`() throws { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [ + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-29", points: 100, requests: 1, costUSD: nil), + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-30", points: 200, requests: 1, costUSD: 0.10), + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-31", points: 300, requests: 1, costUSD: 0.20), + ], + updatedAt: Date()) + + // Skips the nil bucket, sums 0.10 + 0.20 (allow IEEE-754 round-trip) + let cost = try #require(snapshot.summary(days: 7).costUSD) + #expect(abs(cost - 0.30) < 1e-9) + } + + // MARK: - latestDay / last7Days / last30Days shortcuts + + @Test + func `latest day, last 7 and last 30 days agree with summary by day count`() { + let daily: [PoeUsageHistorySnapshot.DailyBucket] = (1...40).map { offset in + PoeUsageHistorySnapshot.DailyBucket( + day: String(format: "2026-04-%02d", offset), + points: Double(offset), + requests: 1, + costUSD: nil) + } + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: daily, + updatedAt: Date()) + + #expect(snapshot.latestDay == snapshot.summary(days: 1)) + #expect(snapshot.last7Days == snapshot.summary(days: 7)) + #expect(snapshot.last30Days == snapshot.summary(days: 30)) + } + + // MARK: - topModels / topModel + + @Test + func `top models is empty when entries is empty`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [], + updatedAt: Date()) + + #expect(snapshot.topModels.isEmpty) + #expect(snapshot.topModel == nil) + } + + @Test + func `top models groups by model and sums points and requests`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 10, costUSD: 0.01), + self.makeEntry(id: "2", model: "GPT-4o", usageType: "chat", points: 5, costUSD: 0.01), + self.makeEntry(id: "3", model: "Claude-3.7", usageType: "chat", points: 20, costUSD: 0.02), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + let top = snapshot.topModels + + #expect(top.count == 2) + #expect(top[0].name == "Claude-3.7") + #expect(top[0].points == 20) + #expect(top[0].requests == 1) + #expect(top[1].name == "GPT-4o") + #expect(top[1].points == 15) + #expect(top[1].requests == 2) + } + + @Test + func `top models breaks ties by name ascending`() { + let entries = [ + self.makeEntry(id: "1", model: "Z-Model", usageType: "chat", points: 10, costUSD: nil), + self.makeEntry(id: "2", model: "A-Model", usageType: "chat", points: 10, costUSD: nil), + self.makeEntry(id: "3", model: "M-Model", usageType: "chat", points: 10, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + let top = snapshot.topModels + #expect(top.map(\.name) == ["A-Model", "M-Model", "Z-Model"]) + } + + @Test + func `top models falls back to unknown for empty or whitespace model strings`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 5, costUSD: nil), + self.makeEntry(id: "2", model: "", usageType: "chat", points: 5, costUSD: nil), + self.makeEntry(id: "3", model: " ", usageType: "chat", points: 5, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + let top = snapshot.topModels + let names = top.map(\.name) + #expect(names.contains("unknown")) + #expect(names.contains("GPT-4o")) + } + + @Test + func `top models omits cost when no entry reported cost`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 5, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + #expect(snapshot.topModels.first?.costUSD == nil) + } + + @Test + func `top models sums cost across entries for the same model`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 5, costUSD: 0.01), + self.makeEntry(id: "2", model: "GPT-4o", usageType: "chat", points: 5, costUSD: 0.02), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + #expect(snapshot.topModels.first?.costUSD == 0.03) + } + + // MARK: - topUsageTypes / topUsageType + + @Test + func `top usage types groups by usage type independent of model`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 5, costUSD: nil), + self.makeEntry(id: "2", model: "Claude", usageType: "chat", points: 10, costUSD: nil), + self.makeEntry(id: "3", model: "GPT-4o", usageType: "api", points: 8, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + let top = snapshot.topUsageTypes + #expect(top.map(\.name) == ["chat", "api"]) + #expect(top[0].points == 15) + #expect(top[0].requests == 2) + } + + @Test + func `top usage type is the first entry in top usage types`() { + let entries = [ + self.makeEntry(id: "1", model: "GPT-4o", usageType: "chat", points: 5, costUSD: nil), + self.makeEntry(id: "2", model: "GPT-4o", usageType: "api", points: 10, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: Date()) + + #expect(snapshot.topUsageType == "api") + #expect(snapshot.topUsageType == snapshot.topUsageTypes.first?.name) + } + + @Test + func `top usage type is nil for empty entries`() { + let snapshot = PoeUsageHistorySnapshot( + entries: [], + daily: [], + updatedAt: Date()) + + #expect(snapshot.topUsageType == nil) + } + + // MARK: - recentEntries(limit:) + + @Test + func `recent entries returns up to the requested limit, newest first`() { + let now = Date(timeIntervalSince1970: 1_717_000_000) + let entries = (0..<5).map { offset in + self.makeEntry( + id: "\(offset)", + createdAt: now.addingTimeInterval(TimeInterval(offset * 60)), + model: "GPT-4o", + usageType: "chat", + points: 1, + costUSD: nil) + } + // entries are passed in order they came back; init should sort + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: now) + + let recent = snapshot.recentEntries(limit: 3) + + #expect(recent.count == 3) + // Newest three (offsets 4, 3, 2) should be first + #expect(recent[0].id == "4") + #expect(recent[1].id == "3") + #expect(recent[2].id == "2") + } + + @Test + func `recent entries clamps non-positive limit up to one`() { + let now = Date(timeIntervalSince1970: 1_717_000_000) + let entries = (0..<3).map { offset in + self.makeEntry( + id: "\(offset)", + createdAt: now.addingTimeInterval(TimeInterval(offset * 60)), + model: "GPT-4o", + usageType: "chat", + points: 1, + costUSD: nil) + } + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: now) + + #expect(snapshot.recentEntries(limit: 0).count == 1) + #expect(snapshot.recentEntries(limit: -3).count == 1) + #expect(snapshot.recentEntries(limit: 0).first?.id == "2") + } + + @Test + func `recent entries returns everything when limit exceeds entries count`() { + let now = Date(timeIntervalSince1970: 1_717_000_000) + let entries = [ + self.makeEntry(id: "1", createdAt: now, model: "A", usageType: "t", points: 1, costUSD: nil), + self.makeEntry( + id: "2", + createdAt: now.addingTimeInterval(60), + model: "A", + usageType: "t", + points: 1, + costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: [], + updatedAt: now) + + let recent = snapshot.recentEntries(limit: 10) + #expect(recent.count == 2) + } + + // MARK: - Init sorting invariants + + @Test + func `init sorts entries ascending by created at and daily ascending by day string`() { + let now = Date(timeIntervalSince1970: 1_717_000_000) + let entries = [ + self.makeEntry( + id: "newer", + createdAt: now.addingTimeInterval(120), + model: "A", + usageType: "t", + points: 1, + costUSD: nil), + self.makeEntry(id: "older", createdAt: now, model: "A", usageType: "t", points: 1, costUSD: nil), + ] + let daily = [ + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-31", points: 1, requests: 1, costUSD: nil), + PoeUsageHistorySnapshot.DailyBucket(day: "2026-05-30", points: 1, requests: 1, costUSD: nil), + ] + let snapshot = PoeUsageHistorySnapshot( + entries: entries, + daily: daily, + updatedAt: now) + + // Public init sorts entries ASC by createdAt, daily ASC by day + // (consumers wanting newest-first should use recentEntries(limit:)) + #expect(snapshot.entries.first?.id == "older") + #expect(snapshot.entries.last?.id == "newer") + #expect(snapshot.daily.first?.day == "2026-05-30") + #expect(snapshot.daily.last?.day == "2026-05-31") + } + + // MARK: - Helpers + + private func makeEntry( + id: String, + createdAt: Date = Date(timeIntervalSince1970: 1_717_000_000), + model: String, + usageType: String, + points: Double, + costUSD: Double?) -> PoeUsageHistorySnapshot.Entry + { + PoeUsageHistorySnapshot.Entry( + id: id, + createdAt: createdAt, + model: model, + usageType: usageType, + points: points, + costUSD: costUSD) + } +} diff --git a/Tests/CodexBarTests/PopupLocalizationTests.swift b/Tests/CodexBarTests/PopupLocalizationTests.swift new file mode 100644 index 0000000000..8389645f91 --- /dev/null +++ b/Tests/CodexBarTests/PopupLocalizationTests.swift @@ -0,0 +1,183 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct PopupLocalizationTests { + @Test + func `descriptor account labels use selected localization`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let suite = "PopupLocalizationTests-descriptor" + let settings = try Self.makeSettingsStore(suite: suite) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "free")), + provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = Self.textLines(from: descriptor) + + #expect(lines.contains("帳號: codex@example.com")) + #expect(lines.contains("方案: Free")) + #expect(!lines.contains("Account: codex@example.com")) + #expect(!lines.contains("Plan: Free")) + } + } + + @Test + func `inline dashboard labels use selected localization`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let usage = OpenRouterUsageSnapshot( + totalCredits: 100, + totalUsage: 40, + balance: 60, + usedPercent: 40, + keyDataFetched: true, + keyLimit: 25, + keyUsage: 10, + keyUsageDaily: 1.25, + keyUsageWeekly: 7.5, + keyUsageMonthly: 18.75, + rateLimit: OpenRouterRateLimit(requests: 100, interval: "10s"), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let dashboard = try #require(model.inlineUsageDashboard) + + #expect(dashboard.kpis.map(\.title) == ["餘額", "今天", "週", "月"]) + #expect(dashboard.points.map(\.label) == ["今天", "週", "月"]) + #expect(dashboard.detailLines.contains("速率限制: 100 / 10s")) + } + } + + @Test + func `cookie source dynamic subtitles use selected localization`() { + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let subtitle = ProviderCookieSourceUI.subtitle( + source: .manual, + keychainDisabled: false, + auto: "Automatically imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from T3 Chat settings.", + off: "T3 Chat cookies are disabled.") + let disabledSubtitle = ProviderCookieSourceUI.subtitle( + source: .manual, + keychainDisabled: true, + auto: "Automatically imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from T3 Chat settings.", + off: "T3 Chat cookies are disabled.") + let jsonBundleSubtitle = ProviderCookieSourceUI.subtitle( + source: .manual, + keychainDisabled: false, + auto: "Automatically imports browser cookies.", + manual: "Paste the localStorage JSON bundle from Windsurf session.", + off: "Windsurf cookies are disabled.") + + #expect(subtitle.contains("貼上")) + #expect(!subtitle.contains("Paste a Cookie")) + #expect(disabledSubtitle.contains("鑰匙圈")) + #expect(!disabledSubtitle.contains("Keychain access")) + #expect(jsonBundleSubtitle.contains("來自 Windsurf session 的 localStorage JSON")) + } + } + + @Test + func `provider organization entries preserve provider supplied text`() throws { + let settings = try Self.makeSettingsStore(suite: "PopupLocalizationTests-organizations") + settings.kiloKnownOrganizations = [ + KiloOrganization(id: "org_cost", name: "Cost", role: "Today"), + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let context = ProviderSettingsContext( + provider: .kilo, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + let descriptor = try #require(KiloProviderImplementation().settingsOrganizations(context: context)) + let orgEntry = try #require(descriptor.entries().first { $0.id == "org_cost" }) + + #expect(orgEntry.title == "Cost") + #expect(orgEntry.localizesTitle == false) + #expect(orgEntry.subtitle == "Today") + #expect(orgEntry.localizesSubtitle == false) + } + + private static func makeSettingsStore(suite: String) throws -> SettingsStore { + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + return settings + } + + private static func textLines(from descriptor: MenuDescriptor) -> [String] { + descriptor.sections.flatMap(\.entries).compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + } +} diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index bb6d8f43a4..3245c08b58 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -4,10 +4,10 @@ import Testing @testable import CodexBar @MainActor -@Suite +@Suite(.serialized) struct PreferencesPaneSmokeTests { @Test - func buildsPreferencePanesWithDefaultSettings() { + func `builds preference panes with default settings`() { let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-default") let store = Self.makeUsageStore(settings: settings) @@ -22,11 +22,11 @@ struct PreferencesPaneSmokeTests { } @Test - func buildsPreferencePanesWithToggledSettings() { + func `builds preference panes with toggled settings`() { let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-toggled") settings.menuBarShowsBrandIconWithPercent = true settings.menuBarShowsHighestUsage = true - settings.showAllTokenAccountsInMenu = true + settings.multiAccountMenuLayout = .stacked settings.hidePersonalInfo = true settings.resetTimesShowAbsolute = true settings.debugDisableKeychainAccess = true @@ -44,6 +44,106 @@ struct PreferencesPaneSmokeTests { _ = AboutPane(updater: DisabledUpdaterController()).body } + @Test + func `overview provider limit text formats numeric limit as object argument`() { + let text = DisplayPane.overviewProviderLimitText(limit: 3) + + #expect(text.contains("3")) + #expect(!text.contains("%@")) + } + + @Test + func `cost history days editor builds with clamped settings binding`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-cost-history-days") + + settings.costUsageHistoryDays = 999 + #expect(settings.costUsageHistoryDays == 365) + #expect(CostHistoryDaysEditor.title(days: 365).contains("365")) + #expect(!CostHistoryDaysEditor.title(days: 365).contains("%d")) + + _ = CostHistoryDaysEditor(settings: settings).body + } + + @Test + func `language preference updates global localization resolver`() { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + if let previousAppleLanguages { + UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages") + } else { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } + } + + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language") + + settings.appLanguage = "zh-Hans" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "zh-Hans") + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect(L("tab_general") == "通用") + #expect(L("quota_warning_notifications_title") == "配额预警通知") + #expect(L("show_provider_storage_usage_title") == "显示提供商存储用量") + } + + settings.appLanguage = "ja" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "ja") + CodexBarLocalizationOverride.$appLanguage.withValue("ja") { + #expect(L("language_title") == "言語") + #expect(L("start_at_login_title") == "ログイン時に起動") + #expect(L("quit_app") == "CodexBar を終了") + } + + settings.appLanguage = "id" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "id") + CodexBarLocalizationOverride.$appLanguage.withValue("id") { + #expect(L("language_title") == "Bahasa") + #expect(L("start_at_login_title") == "Mulai saat Login") + #expect(L("quit_app") == "Keluar CodexBar") + } + } + + @Test + func `german app language resolves localized labels`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language-de") + settings.appLanguage = "de" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "de") + CodexBarLocalizationOverride.$appLanguage.withValue("de") { + #expect(L("tab_general") == "Allgemein") + #expect(L("language_title") == "Sprache") + #expect(L("quit_app") == "CodexBar beenden") + #expect(L("display_mode_reset_time") == "Zurücksetzungszeit") + #expect(L("display_mode_reset_time_desc").contains("↻ 15:56")) + #expect(L("vertex_ai_login_instructions").contains("\n\n1. Öffnen Sie Terminal")) + #expect(!L("vertex_ai_login_instructions").contains("\\n")) + } + } + + @Test + func `italian language preference resolves italian strings`() { + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language-italian") + settings.appLanguage = "it" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "it") + CodexBarLocalizationOverride.$appLanguage.withValue("it") { + #expect(L("language_title") == "Lingua") + #expect(L("section_system") == "Sistema") + #expect(L("language_italian") == "Italiano") + #expect(L("tab_display") == "Aspetto") + #expect(L("tab_advanced") == "Avanzate") + #expect(L("quit_app") == "Esci da CodexBar") + } + } + private static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/PreferencesTabLayoutCoordinatorTests.swift b/Tests/CodexBarTests/PreferencesTabLayoutCoordinatorTests.swift new file mode 100644 index 0000000000..7471028f6d --- /dev/null +++ b/Tests/CodexBarTests/PreferencesTabLayoutCoordinatorTests.swift @@ -0,0 +1,64 @@ +import Testing +@testable import CodexBar + +struct PreferencesTabLayoutCoordinatorTests { + @Test + func `binding selection defers one layout and suppresses observed duplicate`() { + var coordinator = PreferencesTabLayoutCoordinator() + + let beganSelection = coordinator.beginBindingSelection(from: .general, to: .providers) + #expect(beganSelection) + #expect(coordinator.bindingHandledTab == .providers) + let observedLayout = coordinator.layoutForObservedSelection(.providers, current: .providers) + #expect(observedLayout == nil) + #expect(coordinator.bindingHandledTab == nil) + #expect(coordinator.layoutForDeferredSelection(.providers, current: .providers) == .providers) + } + + @Test + func `same tab selection schedules no layout`() { + var coordinator = PreferencesTabLayoutCoordinator() + + let beganSelection = coordinator.beginBindingSelection(from: .general, to: .general) + #expect(!beganSelection) + #expect(coordinator.bindingHandledTab == nil) + } + + @Test + func `programmatic selection performs observed layout`() { + var coordinator = PreferencesTabLayoutCoordinator() + + let observedLayout = coordinator.layoutForObservedSelection(.about, current: .about) + #expect(observedLayout == .about) + #expect(coordinator.bindingHandledTab == nil) + } + + @Test + func `rapid tab clicks ignore stale observation and deferred layout`() { + var coordinator = PreferencesTabLayoutCoordinator() + + let beganProviders = coordinator.beginBindingSelection(from: .general, to: .providers) + let beganAbout = coordinator.beginBindingSelection(from: .providers, to: .about) + let staleObservedLayout = coordinator.layoutForObservedSelection(.providers, current: .about) + #expect(beganProviders) + #expect(beganAbout) + #expect(staleObservedLayout == nil) + #expect(coordinator.bindingHandledTab == .about) + #expect(coordinator.layoutForDeferredSelection(.providers, current: .about) == nil) + let currentObservedLayout = coordinator.layoutForObservedSelection(.about, current: .about) + #expect(currentObservedLayout == nil) + #expect(coordinator.layoutForDeferredSelection(.about, current: .about) == .about) + } + + @Test + func `programmatic selection replaces pending binding layout`() { + var coordinator = PreferencesTabLayoutCoordinator() + + let beganSelection = coordinator.beginBindingSelection(from: .general, to: .providers) + let programmaticLayout = coordinator.layoutForObservedSelection(.advanced, current: .advanced) + #expect(beganSelection) + #expect(programmaticLayout == .advanced) + #expect(coordinator.bindingHandledTab == nil) + #expect(coordinator.layoutForDeferredSelection(.providers, current: .advanced) == nil) + } +} diff --git a/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift b/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift index 4632d1b95f..efe66f1c80 100644 --- a/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift +++ b/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift @@ -1,7 +1,6 @@ import Testing @testable import CodexBarCore -@Suite struct ProviderCandidateRetryRunnerTests { private enum TestError: Error, Equatable { case retryable(Int) @@ -9,7 +8,7 @@ struct ProviderCandidateRetryRunnerTests { } @Test - func retriesThenSucceeds() async throws { + func `retries then succeeds`() async throws { let candidates = [1, 2, 3] var attempted: [Int] = [] var retried: [Int] = [] @@ -39,7 +38,7 @@ struct ProviderCandidateRetryRunnerTests { } @Test - func nonRetryableFailsImmediately() async { + func `non retryable fails immediately`() async { let candidates = [1, 2, 3] var attempted: [Int] = [] var retried: [Int] = [] @@ -71,7 +70,7 @@ struct ProviderCandidateRetryRunnerTests { } @Test - func exhaustedRetryableThrowsLastError() async { + func `exhausted retryable throws last error`() async { let candidates = [1, 2] var attempted: [Int] = [] var retried: [Int] = [] @@ -103,7 +102,7 @@ struct ProviderCandidateRetryRunnerTests { } @Test - func emptyCandidatesThrowsNoCandidates() async { + func `empty candidates throws no candidates`() async { do { let candidates: [Int] = [] _ = try await ProviderCandidateRetryRunner.run( diff --git a/Tests/CodexBarTests/ProviderChangelogLinkTests.swift b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift new file mode 100644 index 0000000000..a1923a4abf --- /dev/null +++ b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct ProviderChangelogLinkTests { + @Test + func `known CLI providers declare changelog URLs`() { + let metadata = ProviderDefaults.metadata + + #expect(metadata[.codex]?.changelogURL == "https://github.com/openai/codex/releases") + #expect(metadata[.claude]?.changelogURL == "https://github.com/anthropics/claude-code/releases") + #expect(metadata[.gemini]?.changelogURL == "https://github.com/google-gemini/gemini-cli/releases") + } + + @Test + func `provider menu hides changelog action until enabled`() { + let codexDescriptor = self.makeDescriptor( + provider: .codex, + suite: "ProviderChangelogLinkTests-codex-default") + #expect(!self.actionTitles(from: codexDescriptor).contains("Changelog")) + } + + @Test + func `provider menu shows changelog action only when setting and URL are present`() { + let codexDescriptor = self.makeDescriptor( + provider: .codex, + suite: "ProviderChangelogLinkTests-codex", + changelogLinksEnabled: true) + #expect(self.actionTitles(from: codexDescriptor).contains("Changelog")) + + let openRouterDescriptor = self.makeDescriptor( + provider: .openrouter, + suite: "ProviderChangelogLinkTests-openrouter", + changelogLinksEnabled: true) + #expect(!self.actionTitles(from: openRouterDescriptor).contains("Changelog")) + } + + private func makeDescriptor( + provider: UsageProvider, + suite: String, + changelogLinksEnabled: Bool = false) -> MenuDescriptor + { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.providerChangelogLinksEnabled = changelogLinksEnabled + + let fetcher = UsageFetcher(environment: [:]) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + + return MenuDescriptor.build( + provider: provider, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: true) + } + + private func actionTitles(from descriptor: MenuDescriptor) -> [String] { + descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .action(title, _) = entry else { return nil } + return title + } + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 702a508436..084a591643 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -1,10 +1,21 @@ import CodexBarCore import Testing -@Suite struct ProviderConfigEnvironmentTests { @Test - func appliesAPIKeyOverrideForZai() { + func `applies API key override for amp`() { + let config = ProviderConfig(id: .amp, apiKey: "sgamp-config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .amp, + config: config) + + #expect(env[AmpSettingsReader.apiTokenKey] == "sgamp-config") + #expect(ProviderTokenResolver.ampToken(environment: env) == "sgamp-config") + } + + @Test + func `applies API key override for zai`() { let config = ProviderConfig(id: .zai, apiKey: "z-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], @@ -15,7 +26,7 @@ struct ProviderConfigEnvironmentTests { } @Test - func appliesAPIKeyOverrideForWarp() { + func `applies API key override for warp`() { let config = ProviderConfig(id: .warp, apiKey: "w-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], @@ -30,7 +41,7 @@ struct ProviderConfigEnvironmentTests { } @Test - func appliesAPIKeyOverrideForOpenRouter() { + func `applies API key override for open router`() { let config = ProviderConfig(id: .openrouter, apiKey: "or-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], @@ -41,7 +52,340 @@ struct ProviderConfigEnvironmentTests { } @Test - func appliesAPIKeyOverrideForKilo() { + func `applies API key override for doubao`() { + let config = ProviderConfig(id: .doubao, apiKey: "db-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "db-token") + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "db-token") + } + + @Test + func `applies API key override for moonshot`() { + let config = ProviderConfig(id: .moonshot, apiKey: "moon-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .moonshot, + config: config) + + let key = MoonshotSettingsReader.apiKeyEnvironmentKeys.first + #expect(key != nil) + guard let key else { return } + + #expect(env[key] == "moon-token") + } + + @Test + func `applies Kimi API key and base URL config overrides`() throws { + let config = ProviderConfig( + id: .kimi, + apiKey: "kimi-api-token", + enterpriseHost: "https://proxy.example.com/kimi") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .kimi, + config: config) + + #expect(env["KIMI_CODE_API_KEY"] == "kimi-api-token") + #expect(env["KIMI_API_KEY"] == nil) + #expect(env[KimiSettingsReader.codeAPIBaseURLEnvironmentKey] == "https://proxy.example.com/kimi") + #expect(ProviderTokenResolver.kimiAPIToken(environment: env) == "kimi-api-token") + #expect(try KimiSettingsReader.codeAPIBaseURL(environment: env).absoluteString == + "https://proxy.example.com/kimi") + } + + @Test + func `applies API key override for elevenlabs`() { + let config = ProviderConfig(id: .elevenlabs, apiKey: "xi-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .elevenlabs, + config: config) + + #expect(env[ElevenLabsSettingsReader.apiKeyEnvironmentKey] == "xi-token") + #expect(ProviderTokenResolver.elevenLabsToken(environment: env) == "xi-token") + } + + @Test + func `applies API key override for groq`() { + let config = ProviderConfig(id: .groq, apiKey: "gsk-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .groq, + config: config) + + #expect(env[GroqSettingsReader.apiKeyEnvironmentKey] == "gsk-token") + #expect(ProviderTokenResolver.groqToken(environment: env) == "gsk-token") + } + + @Test + func `applies LLM Proxy config overrides`() { + let config = ProviderConfig( + id: .llmproxy, + apiKey: "proxy-token", + enterpriseHost: "https://proxy.example.com") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .llmproxy, + config: config) + + #expect(env[LLMProxySettingsReader.apiKeyEnvironmentKey] == "proxy-token") + #expect(env[LLMProxySettingsReader.baseURLEnvironmentKey] == "https://proxy.example.com") + #expect(ProviderTokenResolver.llmProxyToken(environment: env) == "proxy-token") + } + + @Test + func `applies LiteLLM config overrides`() { + let config = ProviderConfig( + id: .litellm, + apiKey: "litellm-token", + enterpriseHost: "https://litellm.example.com/v1") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .litellm, + config: config) + + #expect(env[LiteLLMSettingsReader.apiKeyEnvironmentKey] == "litellm-token") + #expect(env[LiteLLMSettingsReader.baseURLEnvironmentKey] == "https://litellm.example.com/v1") + #expect(ProviderTokenResolver.liteLLMToken(environment: env) == "litellm-token") + } + + @Test + func `openai config override uses preferred admin key environment`() { + let config = ProviderConfig(id: .openai, apiKey: "config-openai-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "env-admin-token", + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "env-api-token", + ], + provider: .openai, + config: config) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "config-openai-token") + #expect(env[OpenAIAPISettingsReader.apiKeyEnvironmentKey] == "env-api-token") + #expect(ProviderTokenResolver.openAIAPIToken(environment: env) == "config-openai-token") + } + + @Test + func `openai config override applies project ID without replacing environment key`() { + let config = ProviderConfig(id: .openai, workspaceID: "proj_config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "env-admin-token", + ], + provider: .openai, + config: config) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "env-admin-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == "proj_config") + #expect(OpenAIAPISettingsReader.projectID(environment: env) == "proj_config") + } + + @Test + func `applies Azure OpenAI config overrides`() { + let config = ProviderConfig( + id: .azureopenai, + apiKey: "config-azure-token", + workspaceID: "chat-prod", + enterpriseHost: "https://example-resource.openai.azure.com") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [ + AzureOpenAISettingsReader.apiKeyEnvironmentKey: "env-azure-token", + AzureOpenAISettingsReader.endpointEnvironmentKey: "https://env-resource.openai.azure.com", + AzureOpenAISettingsReader.deploymentNameEnvironmentKey: "env-deployment", + ], + provider: .azureopenai, + config: config) + + #expect(env[AzureOpenAISettingsReader.apiKeyEnvironmentKey] == "config-azure-token") + #expect(env[AzureOpenAISettingsReader.endpointEnvironmentKey] == "https://example-resource.openai.azure.com") + #expect(env[AzureOpenAISettingsReader.deploymentNameEnvironmentKey] == "chat-prod") + #expect(ProviderTokenResolver.azureOpenAIToken(environment: env) == "config-azure-token") + #expect(AzureOpenAISettingsReader.deploymentName(environment: env) == "chat-prod") + } + + @Test + func `bedrock config maps AWS credential fields`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + cookieHeader: "legacy-cookie-secret", + region: "us-west-2") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "secret") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "us-west-2") + #expect(!env.values.contains("legacy-cookie-secret")) + } + + @Test + func `bedrock config merges secret and region without replacing environment access key`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: nil, + secretKey: "config-secret", + region: "eu-central-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [BedrockSettingsReader.accessKeyIDKey: "env-access"], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "env-access") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "config-secret") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "eu-central-1") + #expect(BedrockSettingsReader.hasCredentials(environment: env)) + } + + @Test + func `bedrock merged static credentials win over inherited AWS_PROFILE`() { + let config = ProviderConfig( + id: .bedrock, + secretKey: "config-secret", + region: "eu-central-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.profileKey: "work", + BedrockSettingsReader.accessKeyIDKey: "env-access", + ], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "env-access") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "config-secret") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "eu-central-1") + #expect(BedrockSettingsReader.authMode(environment: env) == .keys) + } + + @Test + func `bedrock profile mode projects AWS_PROFILE without saved static keys`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + region: "eu-west-1", + awsProfile: "work", + awsAuthMode: "profile") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == "profile") + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "eu-west-1") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == nil) + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == nil) + } + + @Test + func `bedrock config without explicit mode preserves env profile inference`() { + let config = ProviderConfig(id: .bedrock, region: "us-east-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [BedrockSettingsReader.profileKey: "work"], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == nil) + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(BedrockSettingsReader.authMode(environment: env) == .profile) + } + + @Test + func `bedrock saved static keys survive base AWS_PROFILE when auth mode is unset`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIASAVED", + secretKey: "saved-secret", + region: "us-east-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [BedrockSettingsReader.profileKey: "work"], + provider: .bedrock, + config: config) + // Upgrade path: saved keys win over an inherited AWS_PROFILE, no silent switch. + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIASAVED") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "saved-secret") + #expect(BedrockSettingsReader.authMode(environment: env) == .keys) + } + + @Test + func `bedrock profile mode preserves inherited static credentials for environment source profiles`() { + let config = ProviderConfig(id: .bedrock, awsProfile: "work", awsAuthMode: "profile") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.accessKeyIDKey: "AKIAINHERITED", + BedrockSettingsReader.secretAccessKeyKey: "inherited-secret", + BedrockSettingsReader.sessionTokenKey: "inherited-token", + ], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIAINHERITED") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "inherited-secret") + #expect(env[BedrockSettingsReader.sessionTokenKey] == "inherited-token") + #expect(env[BedrockSettingsReader.profileKey] == "work") + } + + @Test + func `bedrock env profile mode does not project saved static credentials`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIASAVED", + secretKey: "saved-secret") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + ], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.authModeKey] == "profile") + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == nil) + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == nil) + } + + @Test + func `bedrock keys mode still projects static credentials`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + region: "us-west-2", + awsAuthMode: "keys") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == "keys") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "secret") + #expect(env[BedrockSettingsReader.profileKey] == nil) + } + + @Test + func `ignores legacy API key override for deepseek`() { + let config = ProviderConfig(id: .deepseek, apiKey: "ds-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .deepseek, + config: config) + + let key = DeepSeekSettingsReader.apiKeyEnvironmentKeys.first + #expect(key != nil) + guard let key else { return } + + #expect(env[key] == nil) + #expect(ProviderTokenResolver.deepseekToken(environment: env) == nil) + } + + @Test + func `applies API key override for kilo`() { let config = ProviderConfig(id: .kilo, apiKey: "kilo-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], @@ -53,7 +397,7 @@ struct ProviderConfigEnvironmentTests { } @Test - func openRouterConfigOverrideWinsOverEnvironmentToken() { + func `open router config override wins over environment token`() { let config = ProviderConfig(id: .openrouter, apiKey: "config-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [OpenRouterSettingsReader.envKey: "env-token"], @@ -65,7 +409,85 @@ struct ProviderConfigEnvironmentTests { } @Test - func leavesEnvironmentWhenAPIKeyMissing() { + func `deepseek config override leaves environment token alone`() { + let config = ProviderConfig(id: .deepseek, apiKey: "config-token") + let envKey = DeepSeekSettingsReader.apiKeyEnvironmentKeys[0] + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [envKey: "env-token"], + provider: .deepseek, + config: config) + + #expect(env[envKey] == "env-token") + #expect(ProviderTokenResolver.deepseekToken(environment: env) == "env-token") + } + + @Test + func `applies API key override for codebuff`() { + let config = ProviderConfig(id: .codebuff, apiKey: "cb-config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .codebuff, + config: config) + + #expect(env[CodebuffSettingsReader.apiTokenKey] == "cb-config-token") + #expect( + ProviderTokenResolver.codebuffToken(environment: env, authFileURL: nil) + == "cb-config-token") + } + + @Test + func `applies API key override for deepgram`() { + let config = ProviderConfig(id: .deepgram, apiKey: "dg-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .deepgram, + config: config) + + #expect(env[DeepgramSettingsReader.apiKeyEnvironmentKey] == "dg-token") + #expect(ProviderTokenResolver.deepgramResolution( + type: .apiKey, + environment: env) + == "dg-token") + } + + @Test + func `applies Deepgram project ID override from provider config`() { + let config = ProviderConfig(id: .deepgram, workspaceID: "proj-123") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .deepgram, + config: config) + + #expect(env[DeepgramSettingsReader.projectIDEnvironmentKey] == "proj-123") + } + + @Test + func `Deepgram project ID config overrides environment`() { + let config = ProviderConfig(id: .deepgram, workspaceID: "config-project") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [DeepgramSettingsReader.projectIDEnvironmentKey: "env-project"], + provider: .deepgram, + config: config) + + #expect(env[DeepgramSettingsReader.projectIDEnvironmentKey] == "config-project") + } + + @Test + func `codebuff config override leaves environment token alone`() { + let config = ProviderConfig(id: .codebuff, apiKey: "config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [CodebuffSettingsReader.apiTokenKey: "env-token"], + provider: .codebuff, + config: config) + + #expect(env[CodebuffSettingsReader.apiTokenKey] == "env-token") + #expect( + ProviderTokenResolver.codebuffToken(environment: env, authFileURL: nil) + == "env-token") + } + + @Test + func `leaves environment when API key missing`() { let config = ProviderConfig(id: .zai, apiKey: nil) let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [ZaiSettingsReader.apiTokenKey: "existing"], @@ -74,4 +496,21 @@ struct ProviderConfigEnvironmentTests { #expect(env[ZaiSettingsReader.apiTokenKey] == "existing") } + + @Test + func `applies API key override for poe`() { + let config = ProviderConfig(id: .poe, apiKey: "poe-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .poe, + config: config) + + #expect(env[PoeSettingsReader.apiKeyEnvironmentKey] == "poe-token") + #expect(ProviderTokenResolver.poeToken(environment: env) == "poe-token") + } + + @Test + func `poe supports API key override`() { + #expect(ProviderConfigEnvironment.supportsAPIKeyOverride(for: .poe) == true) + } } diff --git a/Tests/CodexBarTests/ProviderCookieSettingsResolverTests.swift b/Tests/CodexBarTests/ProviderCookieSettingsResolverTests.swift new file mode 100644 index 0000000000..5d88ebf17c --- /dev/null +++ b/Tests/CodexBarTests/ProviderCookieSettingsResolverTests.swift @@ -0,0 +1,91 @@ +import CodexBarCore +import Foundation +import Testing + +struct ProviderCookieSettingsResolverTests { + @Test + func `shared cookie settings preserve Alibaba token plan defaults`() { + let settings = ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings() + + #expect(settings.cookieSource == .auto) + #expect(settings.manualCookieHeader == nil) + } + + @Test + func `provider cookie settings remain distinct nominal types`() { + let cursor = ProviderSettingsSnapshot.CursorProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil) + let factory = ProviderSettingsSnapshot.FactoryProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil) + + #expect(Self.providerName(cursor) == "cursor") + #expect(Self.providerName(factory) == "factory") + } + + @Test + func `selected cookie account overrides configured credentials`() { + let settings = ProviderCookieSettingsResolver.resolve( + provider: .manus, + configuredSource: .auto, + configuredHeader: "session_id=config", + selectedAccount: Self.account(token: "account")) + + #expect(settings.cookieSource == .manual) + #expect(settings.manualCookieHeader == "session_id=account") + } + + @Test + func `configured credentials remain when no account is selected`() { + let settings = ProviderCookieSettingsResolver.resolve( + provider: .cursor, + configuredSource: .manual, + configuredHeader: "Cookie: session=config", + selectedAccount: nil) + + #expect(settings.cookieSource == .manual) + #expect(settings.manualCookieHeader == "Cookie: session=config") + } + + @Test + func `environment token accounts do not become cookie credentials`() { + let settings = ProviderCookieSettingsResolver.resolve( + provider: .zai, + configuredSource: .off, + configuredHeader: nil, + selectedAccount: Self.account(token: "api-token")) + + #expect(settings.cookieSource == .off) + #expect(settings.manualCookieHeader == nil) + } + + @Test + func `providers without token account support ignore selected account`() { + let settings = ProviderCookieSettingsResolver.resolve( + provider: .mimo, + configuredSource: .auto, + configuredHeader: "configured=true", + selectedAccount: Self.account(token: "account=true")) + + #expect(settings.cookieSource == .auto) + #expect(settings.manualCookieHeader == "configured=true") + } + + private static func account(token: String) -> ProviderTokenAccount { + ProviderTokenAccount( + id: UUID(), + label: "Test", + token: token, + addedAt: 0, + lastUsed: nil) + } + + private static func providerName(_: ProviderSettingsSnapshot.CursorProviderSettings) -> String { + "cursor" + } + + private static func providerName(_: ProviderSettingsSnapshot.FactoryProviderSettings) -> String { + "factory" + } +} diff --git a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift new file mode 100644 index 0000000000..5209e3596f --- /dev/null +++ b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift @@ -0,0 +1,594 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ProviderDiagnosticExportTests { + @Test + func `generic diagnostic export encodes safe provider envelope`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let export = ProviderDiagnosticExport( + timestamp: now, + provider: "openai", + displayName: "OpenAI", + source: "api", + sourceMode: "auto", + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["api"]), + usage: ProviderDiagnosticUsageSummary(from: UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: "raw local text"), + secondary: nil, + updatedAt: now)), + fetchAttempts: [ + ProviderDiagnosticFetchAttempt( + kind: "api", + wasAvailable: true, + errorCategory: nil), + ], + error: nil, + settings: ProviderDiagnosticSettingsSummary(sourceMode: .auto), + details: nil) + + let json = try self.json(export) + + #expect(json.contains("\"provider\"")) + #expect(json.contains("\"openai\"")) + #expect(json.contains("\"platform\"")) + #expect(json.contains("\"auth\"")) + #expect(json.contains("\"dataConfidence\"")) + #expect(json.contains("\"unknown\"")) + #expect(json.contains("\"hasResetDescription\"")) + #expect(!json.contains("sk-cp-")) + #expect(!json.contains("sk-api-")) + #expect(!json.contains("Bearer")) + #expect(!json.contains("raw local text")) + #expect(!json.contains("errorMessage")) + #expect(!json.contains("localizedDescription")) + } + + @Test + func `diagnostic export decodes legacy schema without platform metadata`() throws { + let export = ProviderDiagnosticExport( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + provider: "openai", + displayName: "OpenAI", + source: "api", + sourceMode: "auto", + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["api"]), + usage: nil, + fetchAttempts: [], + error: nil, + settings: ProviderDiagnosticSettingsSummary(sourceMode: .auto), + details: nil) + var object = try #require( + try JSONSerialization.jsonObject(with: Data(self.json(export).utf8)) as? [String: Any]) + object.removeValue(forKey: "platform") + object.removeValue(forKey: "appVersion") + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode( + ProviderDiagnosticExport.self, + from: JSONSerialization.data(withJSONObject: object)) + + #expect(decoded.platform == ProviderDiagnosticPlatform.current) + #expect(decoded.appVersion == nil) + } + + @Test + func `usage snapshot defaults legacy payloads to unknown confidence without reencoding unknown`() throws { + let json = """ + { + "primary": { + "usedPercent": 42, + "windowMinutes": 300, + "hasResetDescription": false + }, + "secondary": null, + "tertiary": null, + "updatedAt": "2023-11-14T22:13:20Z" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(UsageSnapshot.self, from: Data(json.utf8)) + #expect(snapshot.dataConfidence == .unknown) + + let encoded = try self.json(snapshot) + #expect(!encoded.contains("dataConfidence")) + } + + @Test + func `usage snapshot preserves explicit confidence through Codable`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: nil), + secondary: nil, + updatedAt: now, + dataConfidence: .exact) + + let encoded = try self.json(snapshot) + #expect(encoded.contains("\"dataConfidence\" : \"exact\"")) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(UsageSnapshot.self, from: Data(encoded.utf8)) + #expect(decoded.dataConfidence == .exact) + } + + @Test + func `usage snapshot treats future confidence values as unknown`() throws { + let json = """ + { + "primary": null, + "secondary": null, + "tertiary": null, + "updatedAt": "2023-11-14T22:13:20Z", + "dataConfidence": "future" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(UsageSnapshot.self, from: Data(json.utf8)) + + #expect(snapshot.dataConfidence == .unknown) + #expect(try !self.json(snapshot).contains("dataConfidence")) + } + + @Test + func `diagnostic usage summary includes confidence`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let summary = ProviderDiagnosticUsageSummary(from: UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: nil), + secondary: nil, + updatedAt: now, + dataConfidence: .exact)) + + #expect(summary.dataConfidence == "exact") + } + + @Test + func `diagnostic usage summary defaults legacy payloads to unknown confidence`() throws { + let json = """ + { + "updatedAt": "2023-11-14T22:13:20Z", + "windows": [], + "extraWindowCount": 0, + "providerCostPresent": false, + "providerSpecificData": [] + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let summary = try decoder.decode( + ProviderDiagnosticUsageSummary.self, + from: Data(json.utf8)) + + #expect(summary.dataConfidence == "unknown") + #expect(try self.json(summary).contains("\"dataConfidence\" : \"unknown\"")) + } + + @Test + func `unwired provider diagnostics remain unknown confidence`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + let summary = ProviderDiagnosticUsageSummary(from: usage) + + #expect(usage.dataConfidence == .unknown) + #expect(summary.dataConfidence == "unknown") + #expect(summary.windows.first?.usedPercent == 25) + } + + @Test + func `diagnostic export marks named windows with unknown usage`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let summary = ProviderDiagnosticUsageSummary(from: UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "nebula-window", + title: "Nebula Window", + window: RateWindow( + usedPercent: 100, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + usageKnown: false), + ], + updatedAt: now)) + + let json = try self.json(summary) + let object = try #require( + JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + let windows = try #require(object["windows"] as? [[String: Any]]) + + #expect(windows.first?["usageKnown"] as? Bool == false) + } + + @Test + func `diagnostic rate window defaults legacy payloads to known usage`() throws { + let json = """ + { + "label": "Legacy Window", + "usedPercent": 42, + "hasResetDescription": false + } + """ + + let window = try JSONDecoder().decode( + ProviderDiagnosticRateWindow.self, + from: Data(json.utf8)) + + #expect(window.usageKnown) + } + + @Test + func `raw error text never appears in encoded JSON`() throws { + let export = ProviderDiagnosticExport( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + provider: "minimax", + displayName: "MiniMax", + source: "failed", + sourceMode: "auto", + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["api"]), + usage: nil, + fetchAttempts: [ + ProviderDiagnosticFetchAttempt( + kind: "api", + wasAvailable: true, + errorCategory: "network"), + ], + error: ProviderDiagnosticError( + category: "network", + safeDescription: "Network error - check your connection"), + settings: ProviderDiagnosticSettingsSummary(sourceMode: .auto, apiRegion: "global"), + details: nil) + + let json = try self.json(export) + + #expect(!json.contains("connection refused")) + #expect(!json.contains("network probe")) + #expect(!json.contains("not safe to expose")) + #expect(!json.contains("localizedDescription")) + #expect(!json.contains("raw")) + #expect(!json.contains("errorMessage")) + #expect(json.contains("errorCategory")) + #expect(json.contains("\"network\"")) + } + + @Test + func `diagnostic error maps MiniMaxUsageError categories safely`() { + let networkError = MiniMaxUsageError.networkError("connection refused") + let invalidCreds = MiniMaxUsageError.invalidCredentials + let apiError = MiniMaxUsageError.apiError("HTTP 404") + let parseError = MiniMaxUsageError.parseFailed("unexpected") + + let diagNetwork = ProviderDiagnosticError(from: networkError, authConfigured: true) + #expect(diagNetwork.category == "network") + #expect(!diagNetwork.safeDescription.contains("connection refused")) + + let diagCreds = ProviderDiagnosticError(from: invalidCreds, authConfigured: true) + #expect(diagCreds.category == "auth") + + let diagAPI = ProviderDiagnosticError(from: apiError, authConfigured: true) + #expect(diagAPI.category == "api") + + let diagParse = ProviderDiagnosticError(from: parseError, authConfigured: true) + #expect(diagParse.category == "parse") + } + + @Test + func `diagnostic error maps Alibaba invalid endpoint override to configuration`() { + let error = ProviderEndpointOverrideError.alibabaCodingPlan("ALIBABA_CODING_PLAN_QUOTA_URL") + let diag = ProviderDiagnosticError(from: error, authConfigured: true) + + #expect(diag.category == "configuration") + #expect(diag.safeDescription == "Configuration issue - check provider source and settings") + } + + @Test + func `endpoint override fetch attempt stays in configuration category`() { + let error = ProviderEndpointOverrideError.minimax("MINIMAX_HOST") + let attempt = ProviderFetchAttempt( + strategyID: "minimax.web", + kind: .web, + wasAvailable: true, + errorDescription: error.localizedDescription) + + let diagError = ProviderDiagnosticError(from: error, authConfigured: true) + let diagAttempt = ProviderDiagnosticFetchAttempt(from: attempt) + + #expect(diagError.category == "configuration") + #expect(diagAttempt.errorCategory == "configuration") + } + + @Test + func `no available strategy maps missing auth to auth category`() { + let error = ProviderFetchError.noAvailableStrategy(.minimax) + let diag = ProviderDiagnosticError(from: error, authConfigured: false) + + #expect(diag.category == "auth") + #expect(diag.safeDescription.contains("Authentication")) + } + + @Test + func `available failed strategy does not imply auth is configured`() { + let outcome = ProviderFetchOutcome( + result: .failure(ProviderFetchError.noAvailableStrategy(.antigravity)), + attempts: [ + ProviderFetchAttempt( + strategyID: "antigravity.ide-local", + kind: .localProbe, + wasAvailable: true, + errorDescription: "unauthenticated local probe"), + ]) + + let summary = ProviderDiagnosticAuthSummary(configured: false, modes: []).resolved(with: outcome) + + #expect(!summary.configured) + #expect(summary.modes.isEmpty) + } + + @Test + func `fetch attempt error maps to safe category, never raw text`() { + let attemptWithRawError = ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: "MiniMax API timeout after 30 seconds - connection refused for host platform.minimax.io") + let diagAttempt = ProviderDiagnosticFetchAttempt(from: attemptWithRawError) + #expect(diagAttempt.kind == "api") + #expect(diagAttempt.wasAvailable == true) + let errorCategoryOne = diagAttempt.errorCategory + #expect(errorCategoryOne == "network") + let cat1 = errorCategoryOne ?? "" + #expect(!cat1.contains("timeout")) + #expect(!cat1.contains("connection refused")) + #expect(!cat1.contains("platform.minimax.io")) + + let attemptWithAuthError = ProviderFetchAttempt( + strategyID: "minimax.web", + kind: .web, + wasAvailable: false, + errorDescription: "invalid auth token cookie HERTZ-SESSION=abc123") + let diagAuthAttempt = ProviderDiagnosticFetchAttempt(from: attemptWithAuthError) + #expect(diagAuthAttempt.wasAvailable == false) + let errorCategoryTwo = diagAuthAttempt.errorCategory + #expect(errorCategoryTwo == "auth") + let cat2 = errorCategoryTwo ?? "" + #expect(!cat2.contains("HERTZ-SESSION")) + } + + @Test + func `missing api key setup errors map to auth before api`() { + let category = ProviderDiagnosticFetchAttempt.errorCategoryLabel( + "Azure OpenAI API key not configured. Set AZURE_OPENAI_API_KEY.") + + #expect(category == "auth") + } + + @Test + func `MiniMax details map from MiniMaxUsageSnapshot correctly`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now, + services: nil) + + let details = MiniMaxDiagnosticDetails(from: snapshot) + #expect(details.planName == "Max") + #expect(details.availablePrompts == 1000) + #expect(details.currentPrompts == 250) + #expect(details.remainingPrompts == 750) + #expect(details.windowMinutes == 300) + #expect(details.usedPercent == 25) + } + + @Test + func `service usage maps from MiniMaxServiceUsage correctly`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let service = MiniMaxServiceUsage( + serviceType: "General", + windowType: "Weekly", + timeRange: "Jun 15-Jun 22", + usage: 6, + limit: 150, + percent: 4, + resetsAt: now.addingTimeInterval(18000), + resetDescription: "Weekly") + + let diagService = MiniMaxDiagnosticServiceUsage(from: service) + #expect(diagService.displayName == "General") + #expect(diagService.percent == 4) + #expect(diagService.usage == 6) + #expect(diagService.limit == 150) + #expect(diagService.remaining == 144) + #expect(diagService.isUnlimited == false) + #expect(diagService.windowType == "Weekly") + #expect(diagService.hasResetDescription == true) + + let json = try self.json(diagService) + #expect(json.contains("hasResetDescription")) + #expect(json.contains(#""usage" : 6"#)) + #expect(json.contains(#""limit" : 150"#)) + #expect(json.contains(#""remaining" : 144"#)) + #expect(!json.contains("resetDescription")) + } + + @Test + func `unlimited MiniMax diagnostic omits remaining quota`() throws { + let service = MiniMaxServiceUsage( + serviceType: "General", + windowType: "Weekly", + timeRange: "", + usage: 0, + limit: 0, + percent: 0, + isUnlimited: true, + resetsAt: nil, + resetDescription: "Unlimited") + + let diagnostic = MiniMaxDiagnosticServiceUsage(from: service) + #expect(diagnostic.isUnlimited) + #expect(diagnostic.remaining == nil) + + let json = try self.json(diagnostic) + #expect(!json.contains("remaining")) + } + + @Test + func `legacy MiniMax service diagnostic decodes without quota values`() throws { + let data = Data(#""" + { + "displayName": "General", + "percent": 4, + "windowType": "Weekly", + "resetsAt": null, + "hasResetDescription": true + } + """#.utf8) + + let diagnostic = try JSONDecoder().decode(MiniMaxDiagnosticServiceUsage.self, from: data) + + #expect(diagnostic.displayName == "General") + #expect(diagnostic.percent == 4) + #expect(diagnostic.usage == 0) + #expect(diagnostic.limit == 0) + #expect(diagnostic.remaining == nil) + #expect(!diagnostic.isUnlimited) + #expect(diagnostic.windowType == "Weekly") + #expect(diagnostic.hasResetDescription) + } + + @Test + func `builder creates generic safe diagnostic with error on failure`() { + let outcome = ProviderFetchOutcome( + result: .failure(MiniMaxUsageError.networkError("timeout")), + attempts: [ + ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: "timeout"), + ]) + + let diag = ProviderDiagnosticExportBuilder.build(.init( + provider: .minimax, + descriptor: ProviderDescriptorRegistry.descriptor(for: .minimax), + outcome: outcome, + sourceMode: .auto, + settings: nil, + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["apiToken"]), + appVersion: "9.8.7")) + + #expect(diag.provider == "minimax") + #expect(diag.platform == ProviderDiagnosticPlatform.current) + #expect(diag.appVersion == "9.8.7") + #expect(diag.source == "failed") + #expect(diag.auth.configured == true) + #expect(diag.usage == nil) + #expect(diag.error != nil) + #expect(diag.error?.category == "network") + #expect(diag.fetchAttempts.count == 1) + #expect(diag.fetchAttempts[0].errorCategory == "network") + } + + @Test + func `builder creates generic safe diagnostic with MiniMax details on success`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now) + + let result = ProviderFetchResult( + usage: UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + minimaxUsage: snapshot, + updatedAt: now), + credits: nil, + dashboard: nil, + sourceLabel: "api", + strategyID: "minimax.api", + strategyKind: .apiToken) + + let outcome = ProviderFetchOutcome( + result: .success(result), + attempts: [ + ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: nil), + ]) + + let diag = ProviderDiagnosticExportBuilder.build(.init( + provider: .minimax, + descriptor: ProviderDescriptorRegistry.descriptor(for: .minimax), + outcome: outcome, + sourceMode: .auto, + settings: nil, + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["apiToken"]))) + + #expect(diag.provider == "minimax") + #expect(diag.source == "api") + #expect(diag.auth.configured == true) + #expect(diag.usage != nil) + #expect(diag.error == nil) + + guard case let .minimax(details) = diag.details else { + Issue.record("Expected MiniMax diagnostic details") + return + } + #expect(details.planName == "Max") + } + + private func json(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(value) + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/Tests/CodexBarTests/ProviderEndpointOverrideSecurityTests.swift b/Tests/CodexBarTests/ProviderEndpointOverrideSecurityTests.swift new file mode 100644 index 0000000000..aec2741f4c --- /dev/null +++ b/Tests/CodexBarTests/ProviderEndpointOverrideSecurityTests.swift @@ -0,0 +1,232 @@ +import CodexBarCore +import Foundation +import Testing + +struct ProviderEndpointOverrideSecurityTests { + @Test + func `sibling endpoint overrides allow bracketed IPv6 literals`() throws { + let endpoint = "https://[::1]:8443/v1" + + try OpenRouterSettingsReader.validateEndpointOverrides( + environment: ["OPENROUTER_API_URL": endpoint]) + #expect(OpenRouterSettingsReader.apiURL( + environment: ["OPENROUTER_API_URL": endpoint]).absoluteString == endpoint) + + try CodebuffSettingsReader.validateEndpointOverrides( + environment: ["CODEBUFF_API_URL": endpoint]) + #expect(CodebuffSettingsReader.apiURL( + environment: ["CODEBUFF_API_URL": endpoint]).absoluteString == endpoint) + + try GroqSettingsReader.validateEndpointOverrides( + environment: [GroqSettingsReader.apiURLEnvironmentKey: endpoint]) + #expect(GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: endpoint]).absoluteString == endpoint) + + try ElevenLabsSettingsReader.validateEndpointOverrides( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: endpoint]) + #expect(ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: endpoint]).absoluteString == endpoint) + } + + @Test + func `sibling endpoint overrides reject userinfo and encoded host delimiters`() { + let userInfoURL = "https://user:pass@proxy.test/v1" + let malformedHostURLs = [ + "https://proxy.test%2f.attacker.test/v1", + "https://bad host/v1", + "https://bad%20host/v1", + "https://bad%09host/v1", + ] + + #expect(OpenRouterSettingsReader.apiURL( + environment: ["OPENROUTER_API_URL": userInfoURL]).host == "openrouter.ai") + for malformedHostURL in malformedHostURLs { + #expect(throws: OpenRouterSettingsError.invalidEndpointOverride("OPENROUTER_API_URL")) { + try OpenRouterSettingsReader.validateEndpointOverrides( + environment: ["OPENROUTER_API_URL": malformedHostURL]) + } + } + + #expect(CodebuffSettingsReader.apiURL( + environment: ["CODEBUFF_API_URL": userInfoURL]).host == "www.codebuff.com") + for malformedHostURL in malformedHostURLs { + #expect(throws: CodebuffSettingsError.invalidEndpointOverride("CODEBUFF_API_URL")) { + try CodebuffSettingsReader.validateEndpointOverrides( + environment: ["CODEBUFF_API_URL": malformedHostURL]) + } + } + + #expect(GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: userInfoURL]).host == "api.groq.com") + for malformedHostURL in malformedHostURLs { + #expect(throws: GroqSettingsError.invalidEndpointOverride(GroqSettingsReader.apiURLEnvironmentKey)) { + try GroqSettingsReader.validateEndpointOverrides( + environment: [GroqSettingsReader.apiURLEnvironmentKey: malformedHostURL]) + } + } + + #expect(ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: userInfoURL]).host == "api.elevenlabs.io") + for malformedHostURL in malformedHostURLs { + #expect(throws: ElevenLabsSettingsError.invalidEndpointOverride( + ElevenLabsSettingsReader.apiURLEnvironmentKey)) + { + try ElevenLabsSettingsReader.validateEndpointOverrides( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: malformedHostURL]) + } + } + } + + @Test + func `credentialed fetchers reject insecure overrides before sending requests`() async { + let insecureURL = "http://attacker.test/v1" + + do { + _ = try await OpenRouterUsageFetcher.fetchUsage( + apiKey: "openrouter-test", + environment: ["OPENROUTER_API_URL": insecureURL]) + Issue.record("Expected OpenRouterSettingsError.invalidEndpointOverride") + } catch { + #expect(error as? OpenRouterSettingsError == .invalidEndpointOverride("OPENROUTER_API_URL")) + } + + do { + _ = try await CodebuffUsageFetcher.fetchUsage( + apiKey: "codebuff-test", + environment: ["CODEBUFF_API_URL": insecureURL]) + Issue.record("Expected CodebuffSettingsError.invalidEndpointOverride") + } catch { + #expect(error as? CodebuffSettingsError == .invalidEndpointOverride("CODEBUFF_API_URL")) + } + + do { + _ = try await GroqUsageFetcher.fetchUsage( + apiKey: "groq-test", + environment: [GroqSettingsReader.apiURLEnvironmentKey: insecureURL]) + Issue.record("Expected GroqSettingsError.invalidEndpointOverride") + } catch { + #expect(error as? GroqSettingsError == .invalidEndpointOverride(GroqSettingsReader.apiURLEnvironmentKey)) + } + + do { + _ = try await ElevenLabsUsageFetcher.fetchUsage( + apiKey: "elevenlabs-test", + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: insecureURL]) + Issue.record("Expected ElevenLabsSettingsError.invalidEndpointOverride") + } catch { + #expect(error as? ElevenLabsSettingsError == .invalidEndpointOverride( + ElevenLabsSettingsReader.apiURLEnvironmentKey)) + } + } + + @Test + func `OpenRouter endpoint override must be HTTPS or a bare host`() throws { + let httpsURL = OpenRouterSettingsReader.apiURL( + environment: ["OPENROUTER_API_URL": "https://router.test/v1"]) + #expect(httpsURL.absoluteString == "https://router.test/v1") + + let bareURL = OpenRouterSettingsReader.apiURL(environment: ["OPENROUTER_API_URL": "router.test/v1"]) + #expect(bareURL.absoluteString == "https://router.test/v1") + + let hostPortURL = OpenRouterSettingsReader.apiURL( + environment: ["OPENROUTER_API_URL": "localhost:8080/v1"]) + #expect(hostPortURL.absoluteString == "https://localhost:8080/v1") + + let httpURL = OpenRouterSettingsReader.apiURL( + environment: ["OPENROUTER_API_URL": "http://attacker.test/v1"]) + #expect(httpURL.absoluteString == "https://openrouter.ai/api/v1") + + do { + try OpenRouterSettingsReader.validateEndpointOverrides( + environment: ["OPENROUTER_API_URL": "http://attacker.test/v1"]) + Issue.record("Expected OpenRouterSettingsError.invalidEndpointOverride") + } catch OpenRouterSettingsError.invalidEndpointOverride("OPENROUTER_API_URL") { + // Expected. + } catch { + Issue.record("Expected OpenRouterSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func `Codebuff endpoint override must be HTTPS or a bare host`() throws { + let httpsURL = CodebuffSettingsReader.apiURL(environment: ["CODEBUFF_API_URL": "https://codebuff.test"]) + #expect(httpsURL.absoluteString == "https://codebuff.test") + + let bareURL = CodebuffSettingsReader.apiURL(environment: ["CODEBUFF_API_URL": "codebuff.test"]) + #expect(bareURL.absoluteString == "https://codebuff.test") + + let hostPortURL = CodebuffSettingsReader.apiURL(environment: ["CODEBUFF_API_URL": "localhost:8080"]) + #expect(hostPortURL.absoluteString == "https://localhost:8080") + + let httpURL = CodebuffSettingsReader.apiURL(environment: ["CODEBUFF_API_URL": "http://attacker.test"]) + #expect(httpURL.absoluteString == "https://www.codebuff.com") + + do { + try CodebuffSettingsReader.validateEndpointOverrides( + environment: ["CODEBUFF_API_URL": "http://attacker.test"]) + Issue.record("Expected CodebuffSettingsError.invalidEndpointOverride") + } catch CodebuffSettingsError.invalidEndpointOverride("CODEBUFF_API_URL") { + // Expected. + } catch { + Issue.record("Expected CodebuffSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func `Groq endpoint override must be HTTPS or a bare host`() throws { + let httpsURL = GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: "https://groq.test/v1"]) + #expect(httpsURL.absoluteString == "https://groq.test/v1") + + let bareURL = GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: "groq.test/v1"]) + #expect(bareURL.absoluteString == "https://groq.test/v1") + + let hostPortURL = GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: "localhost:8080/v1"]) + #expect(hostPortURL.absoluteString == "https://localhost:8080/v1") + + let httpURL = GroqSettingsReader.apiURL( + environment: [GroqSettingsReader.apiURLEnvironmentKey: "http://attacker.test/v1"]) + #expect(httpURL.absoluteString == "https://api.groq.com/v1") + + do { + try GroqSettingsReader.validateEndpointOverrides( + environment: [GroqSettingsReader.apiURLEnvironmentKey: "http://attacker.test/v1"]) + Issue.record("Expected GroqSettingsError.invalidEndpointOverride") + } catch GroqSettingsError.invalidEndpointOverride(GroqSettingsReader.apiURLEnvironmentKey) { + // Expected. + } catch { + Issue.record("Expected GroqSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func `ElevenLabs endpoint override must be HTTPS or a bare host`() throws { + let httpsURL = ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "https://eleven.test"]) + #expect(httpsURL.absoluteString == "https://eleven.test") + + let bareURL = ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "eleven.test"]) + #expect(bareURL.absoluteString == "https://eleven.test") + + let hostPortURL = ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "localhost:8080"]) + #expect(hostPortURL.absoluteString == "https://localhost:8080") + + let httpURL = ElevenLabsSettingsReader.apiURL( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "http://attacker.test"]) + #expect(httpURL.absoluteString == "https://api.elevenlabs.io") + + do { + try ElevenLabsSettingsReader.validateEndpointOverrides( + environment: [ElevenLabsSettingsReader.apiURLEnvironmentKey: "http://attacker.test"]) + Issue.record("Expected ElevenLabsSettingsError.invalidEndpointOverride") + } catch ElevenLabsSettingsError.invalidEndpointOverride(ElevenLabsSettingsReader.apiURLEnvironmentKey) { + // Expected. + } catch { + Issue.record("Expected ElevenLabsSettingsError.invalidEndpointOverride, got \(error)") + } + } +} diff --git a/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift b/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift new file mode 100644 index 0000000000..52bb2fd5cd --- /dev/null +++ b/Tests/CodexBarTests/ProviderEnvironmentResolverTests.swift @@ -0,0 +1,91 @@ +import CodexBarCore +import Foundation +import Testing + +struct ProviderEnvironmentResolverTests { + @Test + func `selected API account overrides saved and ambient credentials`() { + let account = Self.account(token: "account-token") + let environment = ProviderEnvironmentResolver.resolve( + base: [ZaiSettingsReader.apiTokenKey: "ambient-token"], + provider: .zai, + config: ProviderConfig(id: .zai, apiKey: "saved-token"), + selectedAccount: account) + + #expect(environment[ZaiSettingsReader.apiTokenKey] == "account-token") + } + + @Test + func `OpenAI account removes project scoping from saved config`() { + let account = Self.account(token: "sk-admin-account") + let environment = ProviderEnvironmentResolver.resolve( + base: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "ambient-token", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "ambient-project", + ], + provider: .openai, + config: ProviderConfig( + id: .openai, + apiKey: "saved-token", + workspaceID: "saved-project"), + selectedAccount: account) + + #expect(environment[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "sk-admin-account") + #expect(environment[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil) + } + + @Test + func `Claude session account removes API and OAuth credentials`() { + let environment = ProviderEnvironmentResolver.resolve( + base: [ + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "ambient-admin", + ClaudeOAuthCredentialsStore.environmentTokenKey: "ambient-oauth", + ], + provider: .claude, + config: ProviderConfig(id: .claude, apiKey: "saved-admin"), + selectedAccount: Self.account(token: "sk-ant-session-account")) + + for key in ClaudeAdminAPISettingsReader.apiKeyEnvironmentKeys { + #expect(environment[key] == nil) + } + #expect(environment[ClaudeOAuthCredentialsStore.environmentTokenKey] == nil) + } + + @Test + func `Claude OAuth account replaces incompatible credentials`() { + let environment = ProviderEnvironmentResolver.resolve( + base: [ + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "ambient-admin", + ClaudeOAuthCredentialsStore.environmentTokenKey: "ambient-oauth", + ], + provider: .claude, + config: ProviderConfig(id: .claude, apiKey: "saved-admin"), + selectedAccount: Self.account(token: "Bearer sk-ant-oat-account")) + + for key in ClaudeAdminAPISettingsReader.apiKeyEnvironmentKeys { + #expect(environment[key] == nil) + } + #expect(environment[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account") + } + + @Test + func `cookie account leaves unrelated provider environment intact`() { + let base = ["FOO": "bar"] + let environment = ProviderEnvironmentResolver.resolve( + base: base, + provider: .cursor, + config: ProviderConfig(id: .cursor), + selectedAccount: Self.account(token: "session=account")) + + #expect(environment == base) + } + + private static func account(token: String) -> ProviderTokenAccount { + ProviderTokenAccount( + id: UUID(), + label: "Test", + token: token, + addedAt: 0, + lastUsed: nil) + } +} diff --git a/Tests/CodexBarTests/ProviderHTTPClientTests.swift b/Tests/CodexBarTests/ProviderHTTPClientTests.swift new file mode 100644 index 0000000000..c2fa5fc063 --- /dev/null +++ b/Tests/CodexBarTests/ProviderHTTPClientTests.swift @@ -0,0 +1,273 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ProviderHTTPClientTests { + @Test + func `default client configuration fails blocked connections promptly`() { + let configuration = ProviderHTTPClient.defaultConfiguration() + + #expect(configuration.timeoutIntervalForRequest == 30) + #expect(configuration.timeoutIntervalForResource == 90) + #if !os(Linux) + #expect(configuration.waitsForConnectivity == false) + #endif + } + + @Test + func `client loads requests through an injected session`() async throws { + StubURLProtocol.requests = [] + StubURLProtocol.handler = { request in + StubURLProtocol.requests.append(request) + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(#"{"ok":true}"#.utf8), response) + } + defer { + StubURLProtocol.handler = nil + StubURLProtocol.requests = [] + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + let client = ProviderHTTPClient(session: URLSession(configuration: configuration)) + let request = try URLRequest(url: #require(URL(string: "https://example.com/status"))) + + let (data, response) = try await client.data(for: request) + + let body = try #require(String(data: data, encoding: .utf8)) + #expect(body == #"{"ok":true}"#) + #expect((response as? HTTPURLResponse)?.statusCode == 200) + #expect(StubURLProtocol.requests.count == 1) + #expect(StubURLProtocol.requests.first?.url?.host == "example.com") + } + + @Test + func `response helper unwraps HTTP responses`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 204, + httpVersion: "HTTP/1.1", + headerFields: ["X-Test": "ok"])! + return (Data("done".utf8), response) + } + let request = try URLRequest(url: #require(URL(string: "https://example.com/ok"))) + + let response = try await transport.response(for: request) + + #expect(response.statusCode == 204) + #expect(response.response.value(forHTTPHeaderField: "X-Test") == "ok") + #expect(String(data: response.data, encoding: .utf8) == "done") + } + + @Test + func `response helper rejects non HTTP responses`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let response = URLResponse( + url: request.url ?? URL(string: "https://example.com/not-http")!, + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil) + return (Data(), response) + } + let request = try URLRequest(url: #require(URL(string: "https://example.com/not-http"))) + + await #expect(throws: URLError.self) { + _ = try await transport.response(for: request) + } + } + + @Test + func `response helper retries transient HTTP status once`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [503, 200]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/retry"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 200) + #expect(await script.requestCount() == 2) + } + + @Test + func `response helper retries transient URL error once`() async throws { + let script = ScriptedHTTPTransport(results: [ + .failure(URLError(.timedOut)), + .success(200), + ]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/retry-error"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 200) + #expect(await script.requestCount() == 2) + } + + @Test + func `response helper does not retry non idempotent methods`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [503, 200]) + var request = try URLRequest(url: #require(URL(string: "https://example.com/post"))) + request.httpMethod = "POST" + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 503) + #expect(await script.requestCount() == 1) + } + + @Test + func `response helper does not retry auth failures`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [403, 200]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/forbidden"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 403) + #expect(await script.requestCount() == 1) + } + + @Test + func `redirect guard blocks cross origin redirects`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "https://attacker.example/capture"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "x-api-key") + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks non HTTPS redirects`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "http://provider.example/capture"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks redirects without an original URL`() throws { + let redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example/usage/next"))) + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: nil, + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard blocks port changes`() throws { + let redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example:8443/usage"))) + + let guarded = ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest) + + #expect(guarded == nil) + } + + @Test + func `redirect guard preserves same origin HTTPS requests`() throws { + var redirectRequest = try URLRequest(url: #require(URL(string: "https://provider.example/usage/next"))) + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Cookie") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "Authorization") + redirectRequest.setValue("[REDACTED]", forHTTPHeaderField: "x-api-key") + redirectRequest.setValue("application/json", forHTTPHeaderField: "Accept") + + let guarded = try #require(ProviderHTTPRedirectGuardDelegate.guardedRedirectRequest( + originalURL: URL(string: "https://provider.example/usage"), + redirectRequest: redirectRequest)) + + #expect(guarded.value(forHTTPHeaderField: "Cookie") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "Authorization") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "x-api-key") == "[REDACTED]") + #expect(guarded.value(forHTTPHeaderField: "Accept") == "application/json") + } +} + +extension ProviderHTTPRetryPolicy { + fileprivate static let testOneRetry = ProviderHTTPRetryPolicy( + maxRetries: 1, + baseDelaySeconds: 0, + maxDelaySeconds: 0) +} + +private actor ScriptedHTTPTransport: ProviderHTTPTransport { + enum Result { + case success(Int) + case failure(URLError) + } + + private var results: [Result] + private var requests: [URLRequest] = [] + + init(statusCodes: [Int]) { + self.results = statusCodes.map(Result.success) + } + + init(results: [Result]) { + self.results = results + } + + func requestCount() -> Int { + self.requests.count + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.requests.append(request) + let next = self.results.isEmpty ? .success(200) : self.results.removeFirst() + switch next { + case let .success(statusCode): + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (Data(#"{"ok":true}"#.utf8), response) + case let .failure(error): + throw error + } + } +} + +final class StubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (Data, URLResponse))? + nonisolated(unsafe) static var requests: [URLRequest] = [] + + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.cannotLoadFromNetwork)) + return + } + + do { + let (data, response) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/ProviderHTTPTransportStub.swift b/Tests/CodexBarTests/ProviderHTTPTransportStub.swift new file mode 100644 index 0000000000..1c01e75cc6 --- /dev/null +++ b/Tests/CodexBarTests/ProviderHTTPTransportStub.swift @@ -0,0 +1,20 @@ +import Foundation +@testable import CodexBarCore + +actor ProviderHTTPTransportStub: ProviderHTTPTransport { + private let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) + private var recordedRequests: [URLRequest] = [] + + init(handler: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) { + self.handler = handler + } + + func requests() -> [URLRequest] { + self.recordedRequests + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.recordedRequests.append(request) + return try await self.handler(request) + } +} diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 7ecbe7b260..2d00a9ca44 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -1,12 +1,13 @@ import AppKit +import CodexBarCore import Foundation import Testing +@testable import CodexBar @MainActor -@Suite struct ProviderIconResourcesTests { @Test - func providerIconSVGsExist() throws { + func `provider icon SV gs exist`() throws { let root = try Self.repoRoot() let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) @@ -17,10 +18,24 @@ struct ProviderIconResourcesTests { "minimax", "cursor", "opencode", + "opencodego", + "alibaba", "gemini", "antigravity", "factory", "copilot", + "devin", + "crof", + "commandcode", + "t3chat", + "kimi", + "bedrock", + "elevenlabs", + "groq", + "llmproxy", + "litellm", + "deepgram", + "ollama", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") @@ -33,6 +48,82 @@ struct ProviderIconResourcesTests { } } + @Test + func `groq and grok provider icons are distinct`() throws { + let root = try Self.repoRoot() + let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) + let groq = try String(contentsOf: resources.appending(path: "ProviderIcon-groq.svg"), encoding: .utf8) + let grok = try String(contentsOf: resources.appending(path: "ProviderIcon-grok.svg"), encoding: .utf8) + + #expect(groq != grok) + } + + @Test + func `provider brand icons are cached after first load`() throws { + ProviderBrandIcon.resetCacheForTesting() + defer { ProviderBrandIcon.resetCacheForTesting() } + + let first = try #require(ProviderBrandIcon.image(for: .codex)) + let second = try #require(ProviderBrandIcon.image(for: .codex)) + + #expect(first === second) + #expect(first.size == NSSize(width: 16, height: 16)) + #expect(first.isTemplate) + } + + @Test + func `ollama provider icon uses template rendering`() throws { + ProviderBrandIcon.resetCacheForTesting() + defer { ProviderBrandIcon.resetCacheForTesting() } + + let image = try #require(ProviderBrandIcon.image(for: .ollama)) + + #expect(image.size == NSSize(width: 16, height: 16)) + #expect(image.isTemplate) + + let bitmap = try #require(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: 16, + pixelsHigh: 16, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0)) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + context.cgContext.clear(CGRect(x: 0, y: 0, width: 16, height: 16)) + image.draw(in: NSRect(x: 0, y: 0, width: 16, height: 16)) + NSGraphicsContext.restoreGraphicsState() + + var visiblePixels = 0 + for y in 0.. 0 + { + visiblePixels += 1 + } + } + #expect(visiblePixels > 40) + #expect(visiblePixels < 240) + } + + @Test + func `registered providers resolve bundled brand icons`() { + ProviderBrandIcon.resetCacheForTesting() + defer { ProviderBrandIcon.resetCacheForTesting() } + + for provider in UsageProvider.allCases { + let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) + #expect( + ProviderBrandIcon.image(for: provider) != nil, + "Missing icon resource \(descriptor.branding.iconResourceName).svg for \(provider.rawValue)") + } + } + private static func repoRoot() throws -> URL { var dir = URL(filePath: #filePath).deletingLastPathComponent() for _ in 0..<12 { diff --git a/Tests/CodexBarTests/ProviderLabelMetadataCharacterizationTests.swift b/Tests/CodexBarTests/ProviderLabelMetadataCharacterizationTests.swift new file mode 100644 index 0000000000..9d3c01e9b4 --- /dev/null +++ b/Tests/CodexBarTests/ProviderLabelMetadataCharacterizationTests.swift @@ -0,0 +1,63 @@ +import CodexBarCore +import Testing + +struct ProviderLabelMetadataCharacterizationTests { + // MARK: - Label non-empty constraints + + @Test + func `displayName is non-empty for all providers`() { + for descriptor in ProviderDescriptorRegistry.all { + #expect( + !descriptor.metadata.displayName.isEmpty, + "Provider \(descriptor.id.rawValue) has empty displayName.") + } + } + + @Test + func `sessionLabel is non-empty for all providers`() { + for descriptor in ProviderDescriptorRegistry.all { + #expect( + !descriptor.metadata.sessionLabel.isEmpty, + "Provider \(descriptor.id.rawValue) has empty sessionLabel.") + } + } + + // MARK: - Known empty weeklyLabel exceptions + + @Test + func `weeklyLabel empty providers are explicitly characterized`() { + // Allowlist of providers known to have empty weeklyLabel on current main. + // If a new provider is added with empty weeklyLabel, this test fails and + // requires a deliberate decision to add it here — preventing silent regressions. + let knownEmptyWeeklyLabelProviders: Set = [.mistral] + for descriptor in ProviderDescriptorRegistry.all where descriptor.metadata.weeklyLabel.isEmpty { + #expect( + knownEmptyWeeklyLabelProviders.contains(descriptor.id), + "Provider \(descriptor.id.rawValue) has empty weeklyLabel and is not in the known exception list.") + } + } + + // MARK: - Invariant: supportsOpus implies opusLabel + + @Test + func `supportsOpus providers declare non-empty opusLabel`() { + for descriptor in ProviderDescriptorRegistry.all where descriptor.metadata.supportsOpus { + #expect( + descriptor.metadata.opusLabel != nil && !descriptor.metadata.opusLabel!.isEmpty, + "Provider \(descriptor.id.rawValue) has supportsOpus=true but opusLabel is nil or empty.") + } + } + + // MARK: - opusLabel structural constraint + + @Test + func `opusLabel is nil or non-empty`() { + for descriptor in ProviderDescriptorRegistry.all { + if let opusLabel = descriptor.metadata.opusLabel { + #expect( + !opusLabel.isEmpty, + "Provider \(descriptor.id.rawValue) has empty opusLabel string instead of nil.") + } + } + } +} diff --git a/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift b/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift index c4f3f23673..3ca12b2336 100644 --- a/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift +++ b/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift @@ -1,10 +1,9 @@ import Testing @testable import CodexBarCore -@Suite struct ProviderMetadataStatusLinkTests { @Test - func workspaceStatusLinkMatchesProductID() { + func `workspace status link matches product ID`() { for (provider, meta) in ProviderDefaults.metadata { guard let productID = meta.statusWorkspaceProductID else { continue } let expected = "https://www.google.com/appsstatus/dashboard/products/\(productID)/history" @@ -13,4 +12,13 @@ struct ProviderMetadataStatusLinkTests { "Expected \(provider.rawValue) statusLinkURL to be \(expected)") } } + + @Test + func `kimi K2 metadata does not present legacy endpoint as official`() throws { + let meta = try #require(ProviderDefaults.metadata[.kimik2]) + + #expect(meta.displayName == "Kimi K2 (unofficial)") + #expect(meta.toggleTitle == "Show unofficial Kimi K2 usage") + #expect(meta.dashboardURL == nil) + } } diff --git a/Tests/CodexBarTests/ProviderPlanLineParsingTests.swift b/Tests/CodexBarTests/ProviderPlanLineParsingTests.swift new file mode 100644 index 0000000000..11ef471b7c --- /dev/null +++ b/Tests/CodexBarTests/ProviderPlanLineParsingTests.swift @@ -0,0 +1,72 @@ +import Testing +@testable import CodexBarCore + +struct ProviderPlanLineParsingTests { + @Test + func `Claude plan matching does not bridge usage lines`() { + let usageText = """ + Skills, subagents, plugins, and MCP servers + Noattributiondatayet·accumulatesasyouuseClaude + + dtoday·wtoweek + + Usagecredits + Usagecreditsareoff·/usage-creditstoturnthemon + """ + + let identity = ClaudeStatusProbe.parseIdentity(usageText: usageText, statusText: nil) + + #expect(identity.loginMethod == nil) + } + + @Test + func `Claude plan matching keeps single line phrases`() { + let identity = ClaudeStatusProbe.parseIdentity( + usageText: nil, + statusText: "Sonnet 4.6 · Claude Max · you@example.com") + + #expect(identity.loginMethod == "Max") + } + + @Test + func `Kiro legacy plan matching does not bridge lines`() throws { + let output = """ + | + KIRO FREE + ████████████████████████████████████████████████████ 25% + (12.50 of 50 covered in plan), resets on 01/15 + """ + + let snapshot = try KiroStatusProbe().parse(output: output) + + #expect(snapshot.planName == "Kiro") + } + + @Test + func `Kiro estimated usage plan matching does not bridge lines`() throws { + let output = """ + Estimated Usage | resets on 2026-06-01 | + KIRO FREE + ████████████████████████████████████████████████████ 25% + (12.50 of 50 covered in plan), resets on 01/15 + """ + + let snapshot = try KiroStatusProbe().parse(output: output) + + #expect(snapshot.planName == "Kiro") + } + + @Test + func `Kiro labeled plan matching does not bridge lines`() throws { + let output = """ + Plan: + Q Developer Pro + ████████████████████████████████████████████████████ 25% + (12.50 of 50 covered in plan), resets on 01/15 + """ + + let snapshot = try KiroStatusProbe().parse(output: output) + + #expect(snapshot.planName == "Kiro") + } +} diff --git a/Tests/CodexBarTests/ProviderQuotaFixtureContractTests.swift b/Tests/CodexBarTests/ProviderQuotaFixtureContractTests.swift new file mode 100644 index 0000000000..2eda0ad14d --- /dev/null +++ b/Tests/CodexBarTests/ProviderQuotaFixtureContractTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ProviderQuotaFixtureContractTests { + @Test + func `MiniMax fixture preserves quota windows and plan`() throws { + let data = try Self.fixtureData(provider: "MiniMax", name: "token-plan-normal", fileExtension: "json") + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: data, + now: Date(timeIntervalSince1970: 1_780_282_340)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.identity(for: .minimax)?.loginMethod == "Token Plan Plus") + #expect(usage.primary?.usedPercent == 4) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_780_297_200)) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_780_848_000)) + } + + @Test + func `MiniMax fixture keeps windows when reset timestamps are absent`() throws { + let data = try Self.fixtureData( + provider: "MiniMax", + name: "token-plan-missing-reset", + fileExtension: "json") + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains( + data: data, + now: Date(timeIntervalSince1970: 1_780_282_340)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.identity(for: .minimax)?.loginMethod == "Token Plan Plus") + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetsAt == nil) + #expect(usage.secondary?.usedPercent == 40) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.secondary?.resetsAt == nil) + } + + @Test + func `OpenAI fixture preserves quota windows and plan`() throws { + let data = try Self.fixtureData(provider: "OpenAI", name: "pro-normal", fileExtension: "html") + let body = try #require(String(data: data, encoding: .utf8)) + let limits = OpenAIDashboardParser.parseRateLimits(bodyText: body) + + #expect(OpenAIDashboardParser.parsePlanFromHTML(html: body) == "Pro 5x") + #expect(limits.primary?.usedPercent == 28) + #expect(limits.primary?.windowMinutes == 300) + #expect(limits.primary?.resetDescription?.localizedCaseInsensitiveContains("resets") == true) + #expect(limits.secondary?.usedPercent == 59) + #expect(limits.secondary?.windowMinutes == 10080) + #expect(limits.secondary?.resetDescription?.localizedCaseInsensitiveContains("resets") == true) + } + + @Test + func `Claude fixture preserves quota windows and plan`() throws { + let data = try Self.fixtureData(provider: "Claude", name: "weekly-limit", fileExtension: "json") + let snapshot = try #require(ClaudeUsageFetcher.parse(json: data)) + + #expect(snapshot.loginMethod == "Claude Max") + #expect(snapshot.primary.usedPercent == 7) + #expect(snapshot.primary.windowMinutes == 300) + #expect(snapshot.primary.resetDescription?.contains("Europe/Vienna") == true) + #expect(snapshot.secondary?.usedPercent == 21) + #expect(snapshot.secondary?.windowMinutes == 10080) + #expect(snapshot.secondary?.resetDescription?.contains("Europe/Vienna") == true) + } + + private static func fixtureData(provider: String, name: String, fileExtension: String) throws -> Data { + let url = try #require(Bundle.module.url( + forResource: name, + withExtension: fileExtension, + subdirectory: "Fixtures/Providers/\(provider)")) + return try Data(contentsOf: url) + } +} diff --git a/Tests/CodexBarTests/ProviderRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ProviderRefreshCoordinatorTests.swift new file mode 100644 index 0000000000..9829410b79 --- /dev/null +++ b/Tests/CodexBarTests/ProviderRefreshCoordinatorTests.swift @@ -0,0 +1,122 @@ +import Testing +@testable import CodexBar + +@MainActor +struct ProviderRefreshCoordinatorTests { + @Test + func `replacement cancels and orders predecessor while advancing current generation`() async { + let coordinator = ProviderRefreshCoordinator() + let first = coordinator.beginReplacingRequest(for: "codex") + let firstTask = Task { + while !Task.isCancelled { + await Task.yield() + } + } + first.state.install(task: firstTask) + + let second = coordinator.beginReplacingRequest(for: "codex") + + #expect(firstTask.isCancelled) + #expect(second.predecessorStates.count == 1) + #expect(second.predecessorStates[0] === first.state) + #expect(!coordinator.isCurrent(first.generation, for: "codex")) + #expect(coordinator.isCurrent(second.generation, for: "codex")) + await firstTask.value + } + + @Test + func `coalescing returns latest request independently per key`() { + let coordinator = ProviderRefreshCoordinator() + let firstCodex = coordinator.beginReplacingRequest(for: "codex") + let claude = coordinator.beginReplacingRequest(for: "claude") + let latestCodex = coordinator.beginReplacingRequest(for: "codex") + + #expect(coordinator.coalescingState(for: "codex") === latestCodex.state) + #expect(coordinator.coalescingState(for: "claude") === claude.state) + #expect(coordinator.coalescingState(for: "codex") !== firstCodex.state) + } + + @Test + func `canceling one of two waiters keeps shared task alive`() async { + let coordinator = ProviderRefreshCoordinator() + let request = coordinator.beginReplacingRequest(for: "codex") + let gate = ProviderRefreshCoordinatorGate() + let task = Task { + await gate.wait() + } + request.state.install(task: task) + + let owner = Task { + await coordinator.wait(for: "codex", state: request.state) + } + let shared = Task { + await coordinator.wait(for: "codex", state: request.state) + } + await Task.yield() + owner.cancel() + await Task.yield() + + #expect(!task.isCancelled) + + await gate.resume() + coordinator.complete(request.state, for: "codex", retryRequired: false) + _ = await owner.value + _ = await shared.value + } + + @Test + func `wait result exposes retry without leaking task state`() async { + let coordinator = ProviderRefreshCoordinator() + let request = coordinator.beginReplacingRequest(for: "codex") + let task = Task {} + request.state.install(task: task) + coordinator.complete(request.state, for: "codex", retryRequired: true) + + let result = await coordinator.wait(for: "codex", state: request.state) + + #expect(result == .retryRequired) + } + + @Test + func `completion removal and activity counts are key scoped`() async { + let coordinator = ProviderRefreshCoordinator() + let codex = coordinator.beginReplacingRequest(for: "codex") + let claude = coordinator.beginReplacingRequest(for: "claude") + let codexTask = Task {} + let claudeTask = Task {} + codex.state.install(task: codexTask) + claude.state.install(task: claudeTask) + + #expect(coordinator.beginActivity(for: "codex")) + #expect(!coordinator.beginActivity(for: "codex")) + #expect(coordinator.beginActivity(for: "claude")) + #expect(!coordinator.endActivity(for: "codex")) + #expect(coordinator.endActivity(for: "codex")) + #expect(coordinator.endActivity(for: "claude")) + + coordinator.complete(codex.state, for: "codex", retryRequired: true) + coordinator.complete(claude.state, for: "claude", retryRequired: false) + await codexTask.value + await claudeTask.value + await Task.yield() + await Task.yield() + + #expect(coordinator.coalescingState(for: "codex") == nil) + #expect(coordinator.coalescingState(for: "claude") == nil) + } +} + +private actor ProviderRefreshCoordinatorGate { + private var continuation: CheckedContinuation? + + func wait() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } +} diff --git a/Tests/CodexBarTests/ProviderRegistryTests.swift b/Tests/CodexBarTests/ProviderRegistryTests.swift index 9bb79053dd..ec839ef6b4 100644 --- a/Tests/CodexBarTests/ProviderRegistryTests.swift +++ b/Tests/CodexBarTests/ProviderRegistryTests.swift @@ -1,10 +1,10 @@ import CodexBarCore import Testing +@testable import CodexBar -@Suite struct ProviderRegistryTests { @Test - func descriptorRegistryIsCompleteAndDeterministic() { + func `descriptor registry is complete and deterministic`() { let descriptors = ProviderDescriptorRegistry.all let ids = descriptors.map(\.id) @@ -19,7 +19,22 @@ struct ProviderRegistryTests { } @Test - func minimaxSortsAfterZaiInRegistry() { + func `implementation registry is complete and deterministic`() { + let implementations = ProviderImplementationRegistry.all + let ids = implementations.map(\.id) + + #expect(!implementations.isEmpty, "ProviderImplementationRegistry must not be empty.") + #expect(Set(ids).count == ids.count, "ProviderImplementationRegistry contains duplicate IDs.") + + let missing = Set(UsageProvider.allCases).subtracting(ids) + #expect(missing.isEmpty, "Missing implementations for providers: \(missing).") + + let secondPass = ProviderImplementationRegistry.all.map(\.id) + #expect(ids == secondPass, "ProviderImplementationRegistry order changed between reads.") + } + + @Test + func `minimax sorts after zai in registry`() { let ids = ProviderDescriptorRegistry.all.map(\.id) guard let zaiIndex = ids.firstIndex(of: .zai), let minimaxIndex = ids.firstIndex(of: .minimax) diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 9d77ac5a76..c115258d6f 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -5,63 +5,16 @@ import Testing @testable import CodexBar @MainActor -@Suite struct ProviderSettingsDescriptorTests { @Test - func toggleIDsAreUniqueAcrossProviders() throws { - let suite = "ProviderSettingsDescriptorTests-unique" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - var statusByID: [String: String] = [:] - var lastRunAtByID: [String: Date] = [:] + func `toggle I ds are unique across providers`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-unique") var seenToggleIDs: Set = [] var seenActionIDs: Set = [] var seenPickerIDs: Set = [] for provider in UsageProvider.allCases { - let context = ProviderSettingsContext( - provider: provider, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { id in statusByID[id] }, - setStatusText: { id, text in - if let text { - statusByID[id] = text - } else { - statusByID.removeValue(forKey: id) - } - }, - lastAppActiveRunAt: { id in lastRunAtByID[id] }, - setLastAppActiveRunAt: { id, date in - if let date { - lastRunAtByID[id] = date - } else { - lastRunAtByID.removeValue(forKey: id) - } - }, - requestConfirmation: { _ in }) - + let context = fixture.settingsContext(provider: provider) let impl = try #require(ProviderCatalog.implementation(for: provider)) let toggles = impl.settingsToggles(context: context) for toggle in toggles { @@ -83,40 +36,24 @@ struct ProviderSettingsDescriptorTests { } @Test - func codexExposesUsageAndCookiePickers() throws { - let suite = "ProviderSettingsDescriptorTests-codex" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + func `openai exposes project id setting`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-openai-project") + let context = fixture.settingsContext(provider: .openai) + + let fields = OpenAIAPIProviderImplementation().settingsFields(context: context) + let project = try #require(fields.first(where: { $0.id == "openai-project-id" })) + project.binding.wrappedValue = "proj_abc" + + #expect(project.title == "Project ID") + #expect(project.subtitle.contains(OpenAIAPISettingsReader.projectIDEnvironmentKey)) + #expect(fixture.settings.openAIAPIProjectID == "proj_abc") + #expect(fixture.settings.providerConfig(for: .openai)?.sanitizedWorkspaceID == "proj_abc") + } - let context = ProviderSettingsContext( - provider: .codex, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + @Test + func `codex exposes usage and cookie pickers`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex") + let context = fixture.settingsContext(provider: .codex) let pickers = CodexProviderImplementation().settingsPickers(context: context) let toggles = CodexProviderImplementation().settingsToggles(context: context) @@ -126,44 +63,48 @@ struct ProviderSettingsDescriptorTests { } @Test - func claudeExposesUsageAndCookiePickers() throws { - let suite = "ProviderSettingsDescriptorTests-claude" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = false - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + func `antigravity usage source picker clarifies local ide and agy`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-antigravity-source") + let context = fixture.settingsContext(provider: .antigravity) + + let pickers = AntigravityProviderImplementation().settingsPickers(context: context) + let usagePicker = try #require(pickers.first(where: { $0.id == "antigravity-usage-source" })) + + #expect(usagePicker.options.map(\.title) == ["Auto", "Google OAuth", "Local API / agy CLI"]) + #expect(usagePicker.subtitle == + "Auto tries Antigravity app, agy CLI, then IDE; OAuth follows for selected or signed-in accounts.") + } + + @Test + func `codex exposes open AI web extras toggle as default off opt in`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex-openai-toggle") + let context = fixture.settingsContext(provider: .codex) + + let toggles = CodexProviderImplementation().settingsToggles(context: context) + let extrasToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-extras" })) + #expect(extrasToggle.binding.wrappedValue == false) + #expect(extrasToggle.subtitle.contains("Optional.")) + #expect(extrasToggle.subtitle.contains("Turn this on")) + + let batterySaverToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-battery-saver" })) + #expect(batterySaverToggle.binding.wrappedValue == false) + #expect(batterySaverToggle.isVisible?() == false) + + fixture.settings.openAIWebAccessEnabled = true + #expect(batterySaverToggle.isVisible?() == true) + } + + @Test + func `claude exposes usage and cookie pickers`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude") + fixture.settings.debugDisableKeychainAccess = false + let context = fixture.settingsContext(provider: .claude) - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) #expect(pickers.contains(where: { $0.id == "claude-usage-source" })) #expect(pickers.contains(where: { $0.id == "claude-cookie-source" })) + let toggles = ClaudeProviderImplementation().settingsToggles(context: context) + #expect(!toggles.contains(where: { $0.id == "claude-peak-hours" })) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) let optionIDs = Set(keychainPicker.options.map(\.id)) #expect(optionIDs.contains(ClaudeOAuthKeychainPromptMode.never.rawValue)) @@ -173,43 +114,12 @@ struct ProviderSettingsDescriptorTests { } @Test - func claudePromptPolicyPickerHiddenWhenExperimentalReaderSelected() throws { - let suite = "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = false - settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental - - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + func `claude prompt policy picker hidden when experimental reader selected`() throws { + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental") + fixture.settings.debugDisableKeychainAccess = false + fixture.settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental + let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) @@ -217,41 +127,30 @@ struct ProviderSettingsDescriptorTests { } @Test - func claudeKeychainPromptPolicyPickerDisabledWhenGlobalKeychainDisabled() throws { - let suite = "ProviderSettingsDescriptorTests-claude-keychain-disabled" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = true - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + func `claude avoid keychain prompts toggle is disabled when global keychain disabled`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-prompt-free-disabled") + fixture.settings.debugDisableKeychainAccess = true + fixture.settings.claudeOAuthPromptFreeCredentialsEnabled = true + let context = fixture.settingsContext(provider: .claude) + + let toggles = ClaudeProviderImplementation().settingsToggles(context: context) + let promptFreeToggle = try #require(toggles.first(where: { $0.id == "claude-oauth-prompt-free-credentials" })) + #expect(promptFreeToggle.isEnabled?() == false) + #expect(promptFreeToggle.binding.wrappedValue == true) + + promptFreeToggle.binding.wrappedValue = false + #expect(fixture.settings.claudeOAuthPromptFreeCredentialsEnabled == true) - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + fixture.settings.debugDisableKeychainAccess = false + #expect(promptFreeToggle.isEnabled?() == true) + #expect(promptFreeToggle.binding.wrappedValue == true) + } + + @Test + func `claude keychain prompt policy picker disabled when global keychain disabled`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-keychain-disabled") + fixture.settings.debugDisableKeychainAccess = true + let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) @@ -261,16 +160,9 @@ struct ProviderSettingsDescriptorTests { } @Test - func claudeWebExtrasAutoDisablesWhenLeavingCLI() throws { - let suite = "ProviderSettingsDescriptorTests-claude-invariant" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + func `claude web extras auto disables when leaving CLI`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-invariant") + let settings = fixture.settings settings.debugMenuEnabled = true settings.claudeUsageDataSource = .cli settings.claudeWebExtrasEnabled = true @@ -280,48 +172,204 @@ struct ProviderSettingsDescriptorTests { } @Test - func kiloExposesUsageSourcePickerAndApiFieldOnly() throws { - let suite = "ProviderSettingsDescriptorTests-kilo" + func `kilo exposes usage source picker and api field only`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-kilo") + let context = fixture.settingsContext(provider: .kilo) + + let implementation = KiloProviderImplementation() + let toggles = implementation.settingsToggles(context: context) + let pickers = implementation.settingsPickers(context: context) + let fields = implementation.settingsFields(context: context) + + #expect(toggles.isEmpty) + #expect(pickers.contains(where: { $0.id == "kilo-usage-source" })) + #expect(fields.contains(where: { $0.id == "kilo-api-key" })) + } + + @Test + func `copilot budget secondary picker appears before cookie picker`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-copilot-budget-pickers") + fixture.settings.copilotBudgetExtrasEnabled = true + let context = fixture.settingsContext(provider: .copilot) + + let pickers = CopilotProviderImplementation().settingsPickers(context: context) + + #expect(pickers.map(\.id) == ["copilot-icon-secondary-window", "copilot-budget-cookie-source"]) + #expect(pickers.first?.title == "Menu bar secondary metric") + } + + @Test + func `copilot manual cookie field is labelled and refreshable`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-copilot-budget-field") + fixture.settings.copilotBudgetExtrasEnabled = true + fixture.settings.copilotBudgetCookieSource = .manual + let context = fixture.settingsContext(provider: .copilot) + + let fields = CopilotProviderImplementation().settingsFields(context: context) + let field = try #require(fields.first { $0.id == "copilot-budget-cookie-header" }) + + #expect(field.title == "Manual GitHub Cookie header") + #expect(field.subtitle.contains("Treat this value like a password")) + #expect(field.actions.map(\.id) == ["refresh-copilot-budget-cookie"]) + } + + @Test + func `kimi exposes usage source picker plus api and cookie fields`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-kimi") + let context = fixture.settingsContext(provider: .kimi) + + let implementation = KimiProviderImplementation() + let pickers = implementation.settingsPickers(context: context) + let fields = implementation.settingsFields(context: context) + + #expect(pickers.contains(where: { $0.id == "kimi-usage-source" })) + #expect(pickers.contains(where: { $0.id == "kimi-cookie-source" })) + #expect(fields.contains(where: { $0.id == "kimi-api-key" })) + #expect(fields.contains(where: { $0.id == "kimi-cookie" })) + } + + @Test + func `kimi presentation follows selected source label`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-kimi-presentation") + fixture.settings.kimiUsageDataSource = .api + let metadata = try #require(ProviderDescriptorRegistry.metadata[.kimi]) + let context = fixture.presentationContext(provider: .kimi, metadata: metadata) + + let detailLine = KimiProviderImplementation() + .presentation(context: context) + .detailLine(context) + + #expect(detailLine == "api") + } + + @Test + func `deepgram exposes api key and project id fields`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepgram") + let context = fixture.settingsContext(provider: .deepgram) + + let implementation = DeepgramProviderImplementation() + let fields = implementation.settingsFields(context: context) + + #expect(fields.contains(where: { $0.id == "deepgram-api-key" })) + #expect(fields.contains(where: { $0.id == "deepgram-project-id" })) + + // Basic presence checks for Deepgram settings fields (layout copied from OpenRouter) + _ = try #require(fields.first(where: { $0.id == "deepgram-project-id" })) + _ = try #require(fields.first(where: { $0.id == "deepgram-api-key" })) + } + + @Test + func `alibaba presentation follows store source label`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-presentation") + let metadata = try #require(ProviderDescriptorRegistry.metadata[.alibaba]) + let context = fixture.presentationContext(provider: .alibaba, metadata: metadata) + + let detailLine = AlibabaCodingPlanProviderImplementation() + .presentation(context: context) + .detailLine(context) + + #expect(detailLine == fixture.store.sourceLabel(for: .alibaba)) + } + + @Test + func `devin presentation follows store source label`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-devin-presentation") + fixture.store.lastSourceLabels[.devin] = "web" + let metadata = try #require(ProviderDescriptorRegistry.metadata[.devin]) + let context = fixture.presentationContext(provider: .devin, metadata: metadata) + + let detailLine = DevinProviderImplementation() + .presentation(context: context) + .detailLine(context) + + #expect(detailLine == "web") + } + + @Test + func `alibaba token plan settings expose cookie controls`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-token-plan-settings") + fixture.settings.alibabaTokenPlanCookieSource = .manual + let context = fixture.settingsContext(provider: .alibabatokenplan) + let implementation = AlibabaTokenPlanProviderImplementation() + let pickers = implementation.settingsPickers(context: context) + let fields = implementation.settingsFields(context: context) + + #expect(pickers.contains(where: { $0.id == "alibaba-token-plan-cookie-source" })) + #expect(fields.contains(where: { $0.id == "alibaba-token-plan-cookie" })) + #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) + } + + private func makeSettingsFixture(suite: String) throws -> ProviderSettingsFixture { let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) let settings = SettingsStore( userDefaults: defaults, - configStore: configStore, + configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + return ProviderSettingsFixture(settings: settings, store: store) + } - let context = ProviderSettingsContext( - provider: .kilo, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + private struct ProviderSettingsFixture { + let settings: SettingsStore + let store: UsageStore + private let state = ProviderSettingsContextState() - let implementation = KiloProviderImplementation() - let toggles = implementation.settingsToggles(context: context) - let pickers = implementation.settingsPickers(context: context) - let fields = implementation.settingsFields(context: context) + @MainActor + func settingsContext(provider: UsageProvider) -> ProviderSettingsContext { + let settings = self.settings + let store = self.store + let state = self.state + return ProviderSettingsContext( + provider: provider, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { id in state.statusByID[id] }, + setStatusText: { id, text in + if let text { + state.statusByID[id] = text + } else { + state.statusByID.removeValue(forKey: id) + } + }, + lastAppActiveRunAt: { id in state.lastRunAtByID[id] }, + setLastAppActiveRunAt: { id, date in + if let date { + state.lastRunAtByID[id] = date + } else { + state.lastRunAtByID.removeValue(forKey: id) + } + }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } - #expect(toggles.isEmpty) - #expect(pickers.contains(where: { $0.id == "kilo-usage-source" })) - #expect(fields.contains(where: { $0.id == "kilo-api-key" })) + @MainActor + func presentationContext(provider: UsageProvider, metadata: ProviderMetadata) -> ProviderPresentationContext { + ProviderPresentationContext( + provider: provider, + settings: self.settings, + store: self.store, + metadata: metadata) + } + } + + private final class ProviderSettingsContextState { + var statusByID: [String: String] = [:] + var lastRunAtByID: [String: Date] = [:] } } diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift new file mode 100644 index 0000000000..78898609b1 --- /dev/null +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -0,0 +1,552 @@ +import AppKit +import CodexBarCore +import Foundation +import Observation +import Testing +@testable import CodexBar + +struct ProviderStorageFootprintTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + + @Test + func `scanner sums nested regular files and skips symlink targets`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let nested = root.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try Data(repeating: 1, count: 5).write(to: root.appendingPathComponent("a.jsonl")) + try Data(repeating: 2, count: 7).write(to: nested.appendingPathComponent("b.jsonl")) + + let external = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: external) } + let target = external.appendingPathComponent("outside.bin") + try Data(repeating: 3, count: 100).write(to: target) + let link = root.appendingPathComponent("linked.bin") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + + let footprint = ProviderStorageScanner().scan(provider: .codex, candidatePaths: [root.path]) + + #expect(footprint.totalBytes == 12) + #expect(footprint.paths == [root.path]) + #expect(footprint.missingPaths.isEmpty) + #expect(footprint.components.map(\.name) == ["nested", "a.jsonl"]) + #expect(footprint.components.map(\.totalBytes) == [7, 5]) + } + + @Test + func `scanner does not follow symlinked candidate roots`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let target = root.appendingPathComponent("target", isDirectory: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try Data(repeating: 1, count: 64).write(to: target.appendingPathComponent("session.jsonl")) + let symlinkRoot = root.appendingPathComponent("codex-link", isDirectory: true) + try FileManager.default.createSymbolicLink(at: symlinkRoot, withDestinationURL: target) + + let footprint = ProviderStorageScanner().scan(provider: .codex, candidatePaths: [symlinkRoot.path]) + + #expect(footprint.paths == [symlinkRoot.path]) + #expect(footprint.totalBytes == 0) + #expect(footprint.components.isEmpty) + } + + @Test + func `scanner records missing paths without failing`() throws { + let root = try Self.makeTemporaryDirectory() + let missing = root.appendingPathComponent("missing") + defer { try? FileManager.default.removeItem(at: root) } + + let footprint = ProviderStorageScanner().scan(provider: .claude, candidatePaths: [missing.path]) + + #expect(footprint.totalBytes == 0) + #expect(footprint.paths.isEmpty) + #expect(footprint.missingPaths == [missing.path]) + } + + @Test + func `codex path catalog uses CODEX_HOME and managed homes`() { + let managed = ManagedCodexAccount( + id: UUID(), + email: "user@example.com", + managedHomePath: "/tmp/codex-managed-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: nil) + + let paths = ProviderStoragePathCatalog.candidatePaths( + for: .codex, + environment: ["CODEX_HOME": "/tmp/codex-home"], + managedCodexAccounts: [managed]) + + #expect(paths == ["/tmp/codex-home", "/tmp/codex-managed-home"]) + } + + @Test + func `codex path catalog falls back to default home`() { + let paths = ProviderStoragePathCatalog.candidatePaths(for: .codex, environment: [:]) + let expected = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex", isDirectory: true) + .path + + #expect(paths.first == expected) + } + + @Test + func `cursor path catalog includes application data and caches`() { + let home = FileManager.default.homeDirectoryForCurrentUser + let paths = ProviderStoragePathCatalog.candidatePaths(for: .cursor, environment: [:]) + + #expect(paths == [ + home.appendingPathComponent("Library/Application Support/Cursor", isDirectory: true).path, + home.appendingPathComponent( + "Library/Application Support/Caches/cursor-updater", + isDirectory: true).path, + home.appendingPathComponent(".cursor", isDirectory: true).path, + home.appendingPathComponent("Library/Caches/Cursor", isDirectory: true).path, + home.appendingPathComponent("Library/Caches/com.todesktop.230313mzl4w4u92", isDirectory: true).path, + home.appendingPathComponent("Library/Caches/com.todesktop.230313mzl4w4u92.ShipIt", isDirectory: true).path, + home.appendingPathComponent("Library/Caches/cursor-compile-cache", isDirectory: true).path, + home.appendingPathComponent( + "Library/HTTPStorages/com.todesktop.230313mzl4w4u92", + isDirectory: true).path, + ]) + } + + @Test + func `claude recommendations use documented cleanup categories`() { + let root = "/Users/test/.claude" + let footprint = ProviderStorageFootprint( + provider: .claude, + totalBytes: 28, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 10), + .init(path: "\(root)/file-history", totalBytes: 8), + .init(path: "\(root)/paste-cache", totalBytes: 6), + .init(path: "\(root)/settings.json", totalBytes: 4), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let recommendations = footprint.cleanupRecommendations + + #expect(recommendations.map(\.path) == [ + "\(root)/projects", + "\(root)/file-history", + "\(root)/paste-cache", + ]) + #expect(recommendations[0].consequence.contains("resume")) + #expect(recommendations.allSatisfy { $0.riskLevel == .manualCleanup }) + } + + @Test + func `codex recommendations stay under known homes and exclude auth and config`() { + let root = "/Users/test/.codex" + let footprint = ProviderStorageFootprint( + provider: .codex, + totalBytes: 51, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/sessions", totalBytes: 20), + .init(path: "\(root)/archived_sessions", totalBytes: 15), + .init(path: "\(root)/log", totalBytes: 12), + .init(path: "\(root)/logs_2.sqlite", totalBytes: 11), + .init(path: "\(root)/cache", totalBytes: 10), + .init(path: "\(root)/shell_snapshots", totalBytes: 9), + .init(path: "\(root)/auth.json", totalBytes: 4), + .init(path: "\(root)/config.toml", totalBytes: 2), + .init(path: "/tmp/outside/sessions", totalBytes: 99), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let recommendations = footprint.cleanupRecommendations + + #expect(recommendations.map(\.path) == [ + "\(root)/sessions", + "\(root)/archived_sessions", + "\(root)/cache", + "\(root)/log", + "\(root)/logs_2.sqlite", + "\(root)/shell_snapshots", + ]) + #expect(recommendations.map(\.bytes) == [20, 15, 10, 12, 11, 9]) + } + + @Test + func `unknown provider storage returns no cleanup recommendations`() { + let footprint = ProviderStorageFootprint( + provider: .gemini, + totalBytes: 10, + paths: ["/Users/test/.gemini"], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "/Users/test/.gemini/cache", totalBytes: 10), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(footprint.cleanupRecommendations.isEmpty) + } + + @Test + @MainActor + func `overview row carries storage text outside provider detail model`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: nil) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let overview = OverviewMenuCardRowView(model: model, storageText: "1.5 GB", width: 310) + let detail = UsageMenuCardView(model: model, width: 310) + + #expect(overview.storageText == "1.5 GB") + #expect(detail.model.provider == UsageProvider.claude) + } + + @Test + @MainActor + func `storage detail view exposes cleanup recommendations while overview remains number only`() throws { + let root = "/Users/test/.claude" + let footprint = ProviderStorageFootprint( + provider: .claude, + totalBytes: 10, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 10), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let detailView = StorageBreakdownMenuView(footprint: footprint, width: 310) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date(timeIntervalSince1970: 0))) + let overview = OverviewMenuCardRowView(model: model, storageText: "10 B", width: 310) + + #expect(detailView.cleanupRecommendations.map(\.path) == ["\(root)/projects"]) + #expect(overview.storageText == "10 B") + } + + @Test + @MainActor + func `storage detail view exposes copyable exact paths`() { + let root = "/Users/test/.claude" + let footprint = ProviderStorageFootprint( + provider: .claude, + totalBytes: 110, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 100), + .init(path: "\(root)/file-history", totalBytes: 10), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let detailView = StorageBreakdownMenuView(footprint: footprint, width: 310) + + #expect(detailView.copyablePaths.contains("\(root)/projects")) + #expect(detailView.copyablePaths.contains("\(root)/file-history")) + } + + @Test + @MainActor + func `manual storage refresh updates deleted provider data`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + let sessions = codexHome.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + try Data(repeating: 1, count: 32).write(to: sessions.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-storage-refresh-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + settings.providerStorageFootprintsEnabled = true + store.managedCodexAccountsForStorageOverride = [] + + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + + try FileManager.default.removeItem(at: sessions) + await store.refreshStorageFootprintsForOverviewNow() + + #expect(store.storageFootprint(for: .codex)?.totalBytes == 0) + #expect(store.storageFootprintText(for: .codex) == "No local data found") + } + + @Test + @MainActor + func `repeated identical storage refresh does not republish observable footprints`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + let sessions = codexHome.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + try Data(repeating: 1, count: 32).write(to: sessions.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-identity-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + settings.providerStorageFootprintsEnabled = true + store.managedCodexAccountsForStorageOverride = [] + + await store.refreshStorageFootprintsNow(for: [.codex]) + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + + // A second scan over identical on-disk data must not re-assign the observable property. + // Storage scans run on every menu open and every ~5 min; an unconditional re-publish wakes + // the controller's `menuObservationToken` -> `invalidateMenus` path for no value change. + let didRepublish = ObservationFlag() + withObservationTracking { + _ = store.providerStorageFootprints + } onChange: { + didRepublish.set() + } + await store.refreshStorageFootprintsNow(for: [.codex]) + try? await Task.sleep(for: .milliseconds(50)) + + #expect(didRepublish.get() == false) + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + } + + @Test + @MainActor + func `storage refresh is opt in and clears stale footprints when disabled`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + try Data(repeating: 1, count: 16).write(to: codexHome.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-storage-opt-in-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + store.managedCodexAccountsForStorageOverride = [] + + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex) == nil) + + settings.providerStorageFootprintsEnabled = true + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex)?.totalBytes == 16) + + settings.providerStorageFootprintsEnabled = false + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex) == nil) + #expect(store.providerStorageFootprints.isEmpty) + } + + @Test + @MainActor + func `forced scheduled storage refresh does not restart identical in flight scan`() throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + + let suite = "ProviderStorageFootprintTests-storage-in-flight-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + settings.providerStorageFootprintsEnabled = true + store.storageRefreshGeneration = 41 + store.storageRefreshInFlightSignature = "codex=\(codexHome.path)" + store.storageRefreshTask = Task.detached { + try? await Task.sleep(for: .seconds(30)) + } + defer { + store.storageRefreshTask?.cancel() + store.storageRefreshTask = nil + store.storageRefreshInFlightSignature = nil + } + + store.scheduleStorageFootprintRefresh(for: [.codex], force: true) + + #expect(store.storageRefreshGeneration == 41) + } + + @Test + @MainActor + func `scheduled storage refresh notices managed Codex home changes`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let ambientHome = home.appendingPathComponent("ambient", isDirectory: true) + let firstManagedHome = home.appendingPathComponent("managed-a", isDirectory: true) + let secondManagedHome = home.appendingPathComponent("managed-b", isDirectory: true) + try FileManager.default.createDirectory(at: ambientHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: firstManagedHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondManagedHome, withIntermediateDirectories: true) + try Data(repeating: 1, count: 16).write(to: firstManagedHome.appendingPathComponent("session.jsonl")) + try Data(repeating: 2, count: 32).write(to: secondManagedHome.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-managed-refresh-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": ambientHome.path]) + settings.providerStorageFootprintsEnabled = true + store.managedCodexAccountsForStorageOverride = [ + Self.managedCodexAccount(homePath: firstManagedHome.path), + ] + + store.scheduleStorageFootprintRefresh(for: [.codex]) + for _ in 0..<100 where store.isStorageRefreshInFlight { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(store.storageFootprint(for: .codex)?.totalBytes == 16) + + store.managedCodexAccountsForStorageOverride = [ + Self.managedCodexAccount(homePath: secondManagedHome.path), + ] + store.scheduleStorageFootprintRefresh(for: [.codex]) + for _ in 0..<100 where store.isStorageRefreshInFlight { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + } + + private static func managedCodexAccount(homePath: String) -> ManagedCodexAccount { + ManagedCodexAccount( + id: UUID(), + email: "storage@example.com", + managedHomePath: homePath, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: nil) + } + + private static func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ProviderStorageFootprintTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} diff --git a/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift b/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift new file mode 100644 index 0000000000..27cc01113c --- /dev/null +++ b/Tests/CodexBarTests/ProviderSwitcherEventPeekGateTests.swift @@ -0,0 +1,149 @@ +import AppKit +import CoreGraphics +import Testing +@testable import CodexBar + +@MainActor +struct ProviderSwitcherEventPeekGateTests { + @Test + func `first check always peeks`() { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + } + + @Test + func `unchanged counters skip the peek`() { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown, .leftMouseDown], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + #expect(!gate.shouldPeek()) + } + + @Test + func `any advanced counter re-enables the peek`() { + var keyDownCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown, .leftMouseDown], + counterProvider: { type in type == .keyDown ? keyDownCount : 3 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + keyDownCount += 1 + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `counter change keeps one follow up peek for AppKit queue delivery`() { + var keyDownCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyDown], + counterProvider: { _ in keyDownCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + keyDownCount += 1 + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `queued unhandled event burst keeps peeking until the queue is empty`() throws { + var eventCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyUp], + counterProvider: { _ in eventCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + eventCount += 3 + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `handled event keeps peeking for delayed sibling from same counter snapshot`() throws { + var eventCount: UInt32 = 1 + let gate = ProviderSwitcherEventPeekGate( + eventTypes: [.keyUp], + counterProvider: { _ in eventCount }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + eventCount += 2 + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + gate.observeQueueEmpty(afterFindingEvent: true) + + #expect(gate.shouldPeek()) + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + gate.observeQueueEmpty(afterFindingEvent: true) + + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + @Test + func `held key keeps peeking for uncounted autorepeat events`() throws { + let gate = ProviderSwitcherEventPeekGate(eventTypes: [.keyDown, .keyUp], counterProvider: { _ in 7 }) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + + try gate.observe(Self.keyEvent(type: .keyDown, keyCode: 124)) + #expect(gate.shouldPeek()) + #expect(gate.shouldPeek()) + + try gate.observe(Self.keyEvent(type: .keyUp, keyCode: 124)) + #expect(gate.shouldPeek()) + gate.observeQueueEmpty(afterFindingEvent: false) + #expect(!gate.shouldPeek()) + } + + private static func keyEvent(type: NSEvent.EventType, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: type, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode)) + } +} diff --git a/Tests/CodexBarTests/ProviderToggleStoreTests.swift b/Tests/CodexBarTests/ProviderToggleStoreTests.swift index 6de1adfd78..ce4f812af4 100644 --- a/Tests/CodexBarTests/ProviderToggleStoreTests.swift +++ b/Tests/CodexBarTests/ProviderToggleStoreTests.swift @@ -3,10 +3,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct ProviderToggleStoreTests { @Test - func defaultsMatchMetadata() throws { + func `defaults match metadata`() throws { let defaults = try #require(UserDefaults(suiteName: "ProviderToggleStoreTests-defaults")) defaults.removePersistentDomain(forName: "ProviderToggleStoreTests-defaults") let store = ProviderToggleStore(userDefaults: defaults) @@ -19,7 +18,7 @@ struct ProviderToggleStoreTests { } @Test - func persistsChanges() throws { + func `persists changes`() throws { let suite = "ProviderToggleStoreTests-persist" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -35,7 +34,7 @@ struct ProviderToggleStoreTests { } @Test - func purgesLegacyKeys() throws { + func `purges legacy keys`() throws { let suite = "ProviderToggleStoreTests-purge" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/ProviderTokenResolverTests.swift b/Tests/CodexBarTests/ProviderTokenResolverTests.swift index fcfc5bd74f..e7887cd795 100644 --- a/Tests/CodexBarTests/ProviderTokenResolverTests.swift +++ b/Tests/CodexBarTests/ProviderTokenResolverTests.swift @@ -2,10 +2,9 @@ import CodexBarCore import Foundation import Testing -@Suite struct ProviderTokenResolverTests { @Test - func zaiResolutionUsesEnvironmentToken() { + func `zai resolution uses environment token`() { let env = [ZaiSettingsReader.apiTokenKey: "token"] let resolution = ProviderTokenResolver.zaiResolution(environment: env) #expect(resolution?.token == "token") @@ -13,14 +12,14 @@ struct ProviderTokenResolverTests { } @Test - func copilotResolutionTrimsToken() { + func `copilot resolution trims token`() { let env = ["COPILOT_API_TOKEN": " token "] let resolution = ProviderTokenResolver.copilotResolution(environment: env) #expect(resolution?.token == "token") } @Test - func warpResolutionUsesEnvironmentToken() { + func `warp resolution uses environment token`() { let env = ["WARP_API_KEY": "wk-test-token"] let resolution = ProviderTokenResolver.warpResolution(environment: env) #expect(resolution?.token == "wk-test-token") @@ -28,21 +27,35 @@ struct ProviderTokenResolverTests { } @Test - func warpResolutionTrimsToken() { + func `warp resolution trims token`() { let env = ["WARP_API_KEY": " wk-token "] let resolution = ProviderTokenResolver.warpResolution(environment: env) #expect(resolution?.token == "wk-token") } @Test - func warpResolutionReturnsNilWhenMissing() { + func `warp resolution returns nil when missing`() { let env: [String: String] = [:] let resolution = ProviderTokenResolver.warpResolution(environment: env) #expect(resolution == nil) } @Test - func kiloResolutionPrefersEnvironmentOverAuthFile() throws { + func `doubao resolution uses first supported environment token`() { + let env = ["ARK_API_KEY": "ark-token"] + let resolution = ProviderTokenResolver.doubaoResolution(environment: env) + #expect(resolution?.token == "ark-token") + #expect(resolution?.source == .environment) + } + + @Test + func `doubao settings reader trims quoted token`() { + let env = ["DOUBAO_API_KEY": " 'doubao-token' "] + #expect(DoubaoSettingsReader.apiKey(environment: env) == "doubao-token") + } + + @Test + func `kilo resolution prefers environment over auth file`() throws { let fileURL = try self.makeKiloAuthFile(contents: #"{"kilo":{"access":"file-token"}}"#) defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } @@ -54,7 +67,7 @@ struct ProviderTokenResolverTests { } @Test - func kiloResolutionFallsBackToAuthFile() throws { + func `kilo resolution falls back to auth file`() throws { let fileURL = try self.makeKiloAuthFile(contents: #"{"kilo":{"access":"file-token"}}"#) defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } @@ -65,7 +78,7 @@ struct ProviderTokenResolverTests { } @Test - func kiloResolutionReturnsNilForMalformedAuthFile() throws { + func `kilo resolution returns nil for malformed auth file`() throws { let fileURL = try self.makeKiloAuthFile(contents: #"{not-json}"#) defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } @@ -81,4 +94,61 @@ struct ProviderTokenResolverTests { try contents.write(to: fileURL, atomically: true, encoding: .utf8) return fileURL } + + @Test + func `codebuff resolution prefers environment over credentials file`() throws { + let fileURL = try self.makeCodebuffCredentialsFile( + contents: #"{"authToken":"file-token"}"#) + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let env = [CodebuffSettingsReader.apiTokenKey: "env-token"] + let resolution = ProviderTokenResolver.codebuffResolution( + environment: env, + authFileURL: fileURL) + + #expect(resolution?.token == "env-token") + #expect(resolution?.source == .environment) + } + + @Test + func `codebuff resolution falls back to credentials file`() throws { + let fileURL = try self.makeCodebuffCredentialsFile( + contents: #"{"authToken":"file-token","fingerprintId":"fp"}"#) + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let resolution = ProviderTokenResolver.codebuffResolution( + environment: [:], + authFileURL: fileURL) + + #expect(resolution?.token == "file-token") + #expect(resolution?.source == .authFile) + } + + @Test + func `codebuff resolution returns nil for malformed credentials file`() throws { + let fileURL = try self.makeCodebuffCredentialsFile(contents: #"{not-json}"#) + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let resolution = ProviderTokenResolver.codebuffResolution( + environment: [:], + authFileURL: fileURL) + #expect(resolution == nil) + } + + @Test + func `poe resolution uses manual api key`() { + let env = [PoeSettingsReader.apiKeyEnvironmentKey: "manual-key"] + let resolution = ProviderTokenResolver.poeResolution(environment: env) + #expect(resolution?.token == "manual-key") + #expect(resolution?.source == .environment) + } + + private func makeCodebuffCredentialsFile(contents: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("credentials.json", isDirectory: false) + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } } diff --git a/Tests/CodexBarTests/ProviderVersionDetectorTests.swift b/Tests/CodexBarTests/ProviderVersionDetectorTests.swift new file mode 100644 index 0000000000..320a4e4a2e --- /dev/null +++ b/Tests/CodexBarTests/ProviderVersionDetectorTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import CodexBarCore + +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +final class ProviderVersionDetectorTests: XCTestCase { + func test_run_returnsFirstLineForSuccessfulCommand() { + let version = ProviderVersionDetector.run( + path: "/bin/sh", + args: ["-c", "printf 'gemini 1.2.3\\nextra\\n'"], + timeout: 1.0) + + XCTAssertEqual(version, "gemini 1.2.3") + } + + func test_run_returnsNilAfterTimeout() { + let start = Date() + let version = ProviderVersionDetector.run( + path: "/bin/sh", + args: ["-c", "sleep 5"], + timeout: 0.1) + let duration = Date().timeIntervalSince(start) + + XCTAssertNil(version) + XCTAssertLessThan(duration, 2.0) + } + + func test_run_returnsOutputWhenDetachedChildKeepsPipeOpen() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-version-drain-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path + let script = """ + (trap '' HUP; sleep 5) & + child=$! + printf '%s' "$child" > "$CODEXBAR_TEST_CHILD_PID_FILE" + printf 'grok 1.2.3\\n' + """ + + let start = Date() + let version = ProviderVersionDetector.run( + path: "/bin/sh", + args: ["-c", script], + timeout: 1.0, + environment: environment) + let duration = Date().timeIntervalSince(start) + + XCTAssertEqual(version, "grok 1.2.3") + XCTAssertLessThan(duration, 2.0) + let childPID = try XCTUnwrap( + pid_t(String(contentsOf: childPIDFile, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines))) + XCTAssertEqual(kill(childPID, 0), 0, "Descendant should still hold the inherited pipe open") + } +} diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index e5ea668def..446aeef862 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -1,13 +1,14 @@ +import AppKit import CodexBarCore import Foundation +import SwiftUI import Testing @testable import CodexBar @MainActor -@Suite struct ProvidersPaneCoverageTests { @Test - func exercisesProvidersPaneViews() { + func `exercises providers pane views`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests") let store = Self.makeUsageStore(settings: settings) @@ -15,36 +16,474 @@ struct ProvidersPaneCoverageTests { } @Test - func openRouterMenuBarMetricPicker_showsOnlyAutomaticAndPrimary() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") + func `claude token account descriptor shows organization field`() throws { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-claude-org-field") let store = Self.makeUsageStore(settings: settings) let pane = ProvidersPane(settings: settings, store: store) - let picker = pane._test_menuBarMetricPicker(for: .openrouter) - #expect(picker?.options.map(\.id) == [ + let claudeDescriptor = try #require(pane._test_tokenAccountDescriptor(for: .claude)) + #expect(claudeDescriptor.showsOrganizationField) + + let copilotDescriptor = try #require(pane._test_tokenAccountDescriptor(for: .copilot)) + #expect(!copilotDescriptor.showsOrganizationField) + } + + @Test + func `provider search filters display names and raw ids`() { + let providers: [UsageProvider] = [.codex, .claude, .openrouter, .deepseek] + let names: [UsageProvider: String] = [ + .codex: "Codex", + .claude: "Claude", + .openrouter: "OpenRouter", + .deepseek: "DeepSeek", + ] + + #expect( + ProvidersPane.filteredProviders(providers, query: " ", displayName: { names[$0] ?? $0.rawValue }) + == providers) + #expect( + ProvidersPane.filteredProviders(providers, query: "router", displayName: { names[$0] ?? $0.rawValue }) + == [.openrouter]) + #expect( + ProvidersPane.filteredProviders(providers, query: "CLA", displayName: { names[$0] ?? $0.rawValue }) + == [.claude]) + #expect( + ProvidersPane.filteredProviders(providers, query: "deepseek", displayName: { _ in "API" }) + == [.deepseek]) + } + + @Test + func `provider reordering is inert while alphabetical sorting is enabled`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-sorted-reorder") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + let original = settings.orderedProviders() + + settings.providersSortedAlphabetically = true + pane._test_moveProviders(fromOffsets: IndexSet(integer: 0), toOffset: original.count) + #expect(settings.orderedProviders() == original) + + settings.providersSortedAlphabetically = false + pane._test_moveProviders(fromOffsets: IndexSet(integer: 0), toOffset: original.count) + #expect(settings.orderedProviders().last == original.first) + } + + @Test + func `selected provider sidebar palette uses contrasting selected text colors`() { + let palette = ProviderSidebarRowPalette(isSelected: true) + + #expect(palette.primary.isEqual(NSColor.alternateSelectedControlTextColor)) + #expect(palette.secondary.alphaComponent == 0.82) + #expect(palette.tertiary.alphaComponent == 0.65) + } + + @Test + func `unselected provider sidebar palette uses standard label colors`() { + let palette = ProviderSidebarRowPalette(isSelected: false) + + #expect(palette.primary.isEqual(NSColor.labelColor)) + #expect(palette.secondary.isEqual(NSColor.secondaryLabelColor)) + #expect(palette.tertiary.isEqual(NSColor.tertiaryLabelColor)) + } + + @Test + func `copilot menu card preview follows budget extras setting`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-copilot-budget-preview") + let store = Self.makeUsageStore(settings: settings) + let budgetTitle = "Budget - Copilot Agent Premium Requests" + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: budgetTitle, + window: RateWindow( + usedPercent: 65, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()), + provider: .copilot) + let pane = ProvidersPane(settings: settings, store: store) + + #expect(!pane._test_menuCardModel(for: .copilot).metrics.map(\.title).contains(budgetTitle)) + + settings.copilotBudgetExtrasEnabled = true + #expect(pane._test_menuCardModel(for: .copilot).metrics.map(\.title).contains(budgetTitle)) + } + + @Test + func `open router menu bar metric picker shows only automatic and primary`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .openrouter) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + MenuBarMetricPreference.primary.rawValue, + ]) + #expect(picker?.options.map(\.title) == [ + "Automatic", + "Primary (API key limit)", + ]) + } + } + + @Test + func `deepseek menu bar metric picker shows balance only copy`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-deepseek-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .deepseek) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows the DeepSeek balance in the menu bar.") + } + } + + @Test + func `moonshot menu bar metric picker shows balance only copy`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-moonshot-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .moonshot) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows the Moonshot / Kimi API balance in the menu bar.") + } + } + + @Test + func `mistral menu bar metric picker shows payg and monthly plan options`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-mistral-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .mistral) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + MenuBarMetricPreference.monthlyPlan.rawValue, + ]) + #expect(picker?.options.first?.title == "Pay-as-you-go") + #expect(picker?.options.last?.title == "Monthly Plan") + #expect(picker?.subtitle == "Shows current-month Mistral API spend in the menu bar.") + } + } + + @Test + func `kimi k2 menu bar metric picker shows credits only copy`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-kimik2-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .kimik2) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows Kimi K2 API-key credits in the menu bar.") + } + } + + @Test + func `cursor menu bar metric picker omits tertiary api lane when snapshot has no api metric`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(!ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + } + + @Test + func `cursor menu bar metric picker includes tertiary api lane when snapshot has api metric`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } + #expect(tertiaryOption?.title == "Tertiary (API)") + } + } + + @Test + func `cursor menu bar metric picker omits extra usage when on demand budget is missing`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(!ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + } + + @Test + func `cursor menu bar metric picker includes extra usage when on demand budget is available`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 15, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + let option = picker?.options.first { $0.id == MenuBarMetricPreference.extraUsage.rawValue } + #expect(option?.title == "Extra usage") + } + } + + @Test + func `claude menu bar metric picker includes extra usage when spend limit is available`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-claude-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Spend limit", + updatedAt: Date()), + updatedAt: Date()), + provider: .claude) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .claude) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + } + + @Test + func `zai menu bar metric picker omits tertiary lane when snapshot has no 5-hour metric`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-no-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .zai) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids == [ MenuBarMetricPreference.automatic.rawValue, MenuBarMetricPreference.primary.rawValue, + MenuBarMetricPreference.secondary.rawValue, ]) - #expect(picker?.options.map(\.title) == [ - "Automatic", - "Primary (API key limit)", - ]) } @Test - func providerDetailPlanRow_formatsOpenRouterAsBalance() { - let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") + func `zai menu bar metric picker includes tertiary 5-hour lane when snapshot has it`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .zai) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .zai) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } + #expect(tertiaryOption?.title == "Tertiary (5-hour)") + } + } + + @Test + func `gemini menu bar metric picker omits tertiary lane`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-gemini-no-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .gemini) + let ids = picker?.options.map(\.id) ?? [] + #expect(!ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + } + + @Test + func `provider detail plan row formats open router as balance`() { + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") + + #expect(row?.label == "Balance") + #expect(row?.value == "$4.61") + } + } + + @Test + func `provider detail plan row formats moonshot as balance`() { + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .moonshot, planText: "Balance: $49.58") + + #expect(row?.label == "Balance") + #expect(row?.value == "$49.58") + } + } + + @Test + func `provider detail plan row keeps plan label for non open router`() { + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro") + + #expect(row?.label == "Plan") + #expect(row?.value == "Pro") + } + } + + @Test + func `provider detail renders metric status without progress`() { + let metric = UsageMenuCardView.Model.Metric( + id: "fixture", + title: "Example quota", + percent: 0, + percentStyle: .left, + statusText: "Unavailable", + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: false) - #expect(row?.label == "Balance") - #expect(row?.value == "$4.61") + #expect(ProviderDetailView.metricInlinePresentation(metric) == .status("Unavailable")) } @Test - func providerDetailPlanRow_keepsPlanLabelForNonOpenRouter() { - let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro") + func `provider detail renders ordinary metric progress`() { + let metric = UsageMenuCardView.Model.Metric( + id: "fixture", + title: "Example quota", + percent: 50, + percentStyle: .left, + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: false) + + #expect(ProviderDetailView.metricInlinePresentation(metric) == .progress) + } + + @Test + func `opencode manual cookie source hides cached browser trailing text`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-opencode-manual") + let store = Self.makeUsageStore(settings: settings) + settings.opencodeCookieSource = .manual + CookieHeaderCache.store(provider: .opencode, cookieHeader: "auth=cache", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .opencode) } + + let pane = ProvidersPane(settings: settings, store: store) + let picker = pane._test_settingsPickers(for: .opencode).first { $0.id == "opencode-cookie-source" } - #expect(row?.label == "Plan") - #expect(row?.value == "Pro") + #expect(picker?.dynamicSubtitle?() == "Paste a Cookie header captured from the billing page.") + #expect(picker?.trailingText?() == nil) + } + + @Test + func `opencode go manual cookie source hides cached browser trailing text`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-opencodego-manual") + let store = Self.makeUsageStore(settings: settings) + settings.opencodegoCookieSource = .manual + CookieHeaderCache.store(provider: .opencodego, cookieHeader: "auth=cache", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .opencodego) } + + let pane = ProvidersPane(settings: settings, store: store) + let picker = pane._test_settingsPickers(for: .opencodego).first { $0.id == "opencodego-cookie-source" } + + #expect(picker?.dynamicSubtitle?() == "Paste a Cookie header captured from the billing page.") + #expect(picker?.trailingText?() == nil) + } + + @Test + func `codex providers pane uses managed account fallback instead of ambient account`() throws { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-codex-managed-fallback") + let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + defer { + try? FileManager.default.removeItem(at: ambientHome) + try? FileManager.default.removeItem(at: managedHome) + } + + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "ambient@example.com", plan: "plus") + try Self.writeCodexAuthFile(homeURL: managedHome, email: "managed@example.com", plan: "enterprise") + let managedAccountID = UUID() + settings.codexActiveSource = .managedAccount(id: managedAccountID) + settings._test_activeManagedCodexAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + + let store = UsageStore( + fetcher: UsageFetcher(environment: ["CODEX_HOME": ambientHome.path]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: nil), + provider: .codex) + + let pane = ProvidersPane(settings: settings, store: store) + let model = pane._test_menuCardModel(for: .codex) + + #expect(model.email == "managed@example.com") + #expect(model.planText == "Enterprise") } private static func makeSettingsStore(suite: String) -> SettingsStore { @@ -78,4 +517,38 @@ struct ProvidersPaneCoverageTests { browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) } + + private static func withEnglishLocalization(perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue("en", operation: body) + } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let auth = [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan), + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } } diff --git a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift new file mode 100644 index 0000000000..b0bd32c7f1 --- /dev/null +++ b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift @@ -0,0 +1,152 @@ +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct QuotaWarningNotificationLogicTests { + @Test + func `quota warning copy includes current remaining and threshold`() { + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 20, + currentRemaining: 12.4) + + #expect(copy.title == "Codex session quota low") + #expect(copy.body == "12% left. Reached your 20% session warning threshold.") + } + } + + @Test + func `quota warning copy clamps current remaining`() { + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .weekly, + threshold: 50, + currentRemaining: -3) + + #expect(copy.title == "Codex weekly quota low") + #expect(copy.body == "0% left. Reached your 50% weekly warning threshold.") + } + } + + @Test + func `quota warning copy includes account when provided`() { + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 50, + currentRemaining: 45, + accountDisplayName: "person@example.com") + + #expect(copy.title == "Codex session quota low") + #expect(copy.body == "Account person@example.com. 45% left. Reached your 50% session warning threshold.") + } + } + + @Test + func `quota warning copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 50, + currentRemaining: 45, + accountDisplayName: "person@example.com") + + #expect(copy.title == "Codex 工作階段配額偏低") + #expect(copy.body == "帳號 person@example.com。剩餘 45%。已達到 50% 工作階段提醒門檻。") + } + } + + @Test + func `does nothing without crossing`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 60, + currentRemaining: 55, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == nil) + } + + @Test + func `detects downward crossing`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 55, + currentRemaining: 45, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 50) + } + + @Test + func `skips already fired thresholds`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 55, + currentRemaining: 45, + thresholds: [50, 20], + alreadyFired: [50]) + + #expect(crossed == nil) + } + + @Test + func `chooses most severe threshold when crossing several at once`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 80, + currentRemaining: 10, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 20) + } + + @Test + func `startup below threshold warns once at most severe threshold`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: nil, + currentRemaining: 10, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 20) + } + + @Test + func `warning marks threshold and higher thresholds fired`() { + let fired = QuotaWarningNotificationLogic.firedThresholdsAfterWarning( + threshold: 20, + thresholds: [50, 20]) + + #expect(fired == [50, 20]) + } + + @Test + func `recovery clears only thresholds below current remaining`() { + let cleared = QuotaWarningNotificationLogic.thresholdsToClear( + currentRemaining: 30, + alreadyFired: [50, 20]) + + #expect(cleared == [20]) + } + + @Test + func `zero threshold does not post quota warning`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 10, + currentRemaining: 0, + thresholds: [10, 0], + alreadyFired: [10]) + + #expect(crossed == nil) + #expect(QuotaWarningNotificationLogic.firedThresholdsAfterWarning(threshold: 10, thresholds: [10, 0]) == [10]) + } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } +} diff --git a/Tests/CodexBarTests/ResetTimeBackfillTests.swift b/Tests/CodexBarTests/ResetTimeBackfillTests.swift new file mode 100644 index 0000000000..e03bcf36ce --- /dev/null +++ b/Tests/CodexBarTests/ResetTimeBackfillTests.swift @@ -0,0 +1,142 @@ +import CodexBarCore +import Foundation +import XCTest + +final class ResetTimeBackfillTests: XCTestCase { + func test_backfillsMissingResetMetadataFromCachedWindow() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let cached = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: reset, + resetDescription: "Resets in 1h", + nextRegenPercent: 9) + let fresh = RateWindow( + usedPercent: 62, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil, + nextRegenPercent: 4) + + let result = fresh.backfillingResetTime(from: cached, now: now) + + XCTAssertEqual(result.usedPercent, 62) + XCTAssertEqual(result.windowMinutes, 300) + XCTAssertEqual(result.resetsAt, reset) + XCTAssertEqual(result.resetDescription, "Resets in 1h") + XCTAssertEqual(result.nextRegenPercent, 4) + } + + func test_backfillsZeroWindowDurationFromCachedWindow() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let cached = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: reset, + resetDescription: nil) + let fresh = RateWindow(usedPercent: 62, windowMinutes: 0, resetsAt: nil, resetDescription: nil) + + let result = fresh.backfillingResetTime(from: cached, now: now) + + XCTAssertEqual(result.windowMinutes, 300) + XCTAssertEqual(result.resetsAt, reset) + } + + func test_skipsExpiredCachedReset() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let cached = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(-60), + resetDescription: "Expired") + let fresh = RateWindow(usedPercent: 62, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + + let result = fresh.backfillingResetTime(from: cached, now: now) + + XCTAssertNil(result.resetsAt) + XCTAssertNil(result.windowMinutes) + XCTAssertNil(result.resetDescription) + } + + func test_snapshotBackfillPreservesCurrentSnapshotFields() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "peter@example.com", + accountOrganization: "Org", + loginMethod: "OAuth") + let cached = UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: 300, resetsAt: reset, resetDescription: "Soon"), + secondary: nil, + updatedAt: now.addingTimeInterval(-300), + identity: identity) + let extra = NamedRateWindow( + id: "overflow", + title: "Overflow", + window: RateWindow( + usedPercent: 12, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil, + nextRegenPercent: 2)) + let fresh = UsageSnapshot( + primary: RateWindow( + usedPercent: 66, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil, + nextRegenPercent: 7), + secondary: nil, + extraRateWindows: [extra], + cursorRequests: CursorRequestUsage(used: 10, limit: 50), + subscriptionExpiresAt: reset.addingTimeInterval(86400), + subscriptionRenewsAt: reset.addingTimeInterval(43200), + updatedAt: now, + identity: identity) + + let result = fresh.backfillingResetTimes(from: cached, now: now) + + XCTAssertEqual(result.primary?.resetsAt, reset) + XCTAssertEqual(result.primary?.usedPercent, 66) + XCTAssertEqual(result.primary?.nextRegenPercent, 7) + XCTAssertEqual(result.extraRateWindows?.first?.id, "overflow") + XCTAssertEqual(result.extraRateWindows?.first?.window.nextRegenPercent, 2) + XCTAssertEqual(result.cursorRequests?.used, 10) + XCTAssertEqual(result.subscriptionExpiresAt, reset.addingTimeInterval(86400)) + XCTAssertEqual(result.subscriptionRenewsAt, reset.addingTimeInterval(43200)) + XCTAssertEqual(result.identity?.accountEmail, "peter@example.com") + } + + func test_snapshotBackfillSkipsDifferentAccounts() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let cached = UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Soon"), + secondary: nil, + updatedAt: now.addingTimeInterval(-300), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: nil)) + let fresh = UsageSnapshot( + primary: RateWindow(usedPercent: 66, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "new@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let result = fresh.backfillingResetTimes(from: cached, now: now) + + XCTAssertNil(result.primary?.resetsAt) + } +} diff --git a/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift b/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift index 96cbcc52e5..2c43d31791 100644 --- a/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift +++ b/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift @@ -1,36 +1,64 @@ import Testing @testable import CodexBar -@Suite +@Suite(.serialized) struct SessionQuotaNotificationLogicTests { @Test - func doesNothingWithoutPreviousValue() { + func `does nothing without previous value`() { let transition = SessionQuotaNotificationLogic.transition(previousRemaining: nil, currentRemaining: 0) #expect(transition == .none) } @Test - func detectsDepletedTransition() { + func `detects depleted transition`() { let transition = SessionQuotaNotificationLogic.transition(previousRemaining: 12, currentRemaining: 0) #expect(transition == .depleted) } @Test - func detectsRestoredTransition() { + func `detects restored transition`() { let transition = SessionQuotaNotificationLogic.transition(previousRemaining: 0, currentRemaining: 5) #expect(transition == .restored) } @Test - func ignoresNonTransitions() { + func `ignores non transitions`() { #expect(SessionQuotaNotificationLogic.transition(previousRemaining: 0, currentRemaining: 0) == .none) #expect(SessionQuotaNotificationLogic.transition(previousRemaining: 10, currentRemaining: 10) == .none) #expect(SessionQuotaNotificationLogic.transition(previousRemaining: 10, currentRemaining: 9) == .none) } @Test - func treatsTinyPositiveRemainingAsDepleted() { + func `treats tiny positive remaining as depleted`() { let transition = SessionQuotaNotificationLogic.transition(previousRemaining: 0, currentRemaining: 0.00001) #expect(transition == .none) } + + @Test + func `depleted notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = SessionQuotaNotificationLogic.notificationCopy( + transition: .depleted, + providerName: "Codex") + + #expect(copy.title == "Codex 工作階段已用完") + #expect(copy.body == "剩餘 0%。恢復可用時會再通知。") + } + } + + @Test + func `restored notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = SessionQuotaNotificationLogic.notificationCopy( + transition: .restored, + providerName: "Codex") + + #expect(copy.title == "Codex 工作階段已恢復") + #expect(copy.body == "工作階段配額已恢復可用。") + } + } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } } diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 7ec91584c5..145630cb5d 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -4,24 +4,123 @@ import Testing @testable import CodexBar @MainActor -@Suite struct SettingsStoreAdditionalTests { @Test - func menuBarMetricPreferenceHandlesZaiAndAverage() { + @MainActor + func `antigravity two pool migration preserves released metric meaning`() { + let primaryDefaults = UserDefaults(suiteName: #function + ".primary")! + primaryDefaults.removePersistentDomain(forName: #function + ".primary") + primaryDefaults.set( + [UsageProvider.antigravity.rawValue: MenuBarMetricPreference.primary.rawValue], + forKey: "menuBarMetricPreferences") + + let primarySettings = SettingsStore(userDefaults: primaryDefaults) + + #expect(primarySettings.menuBarMetricPreference(for: .antigravity) == .secondary) + #expect(primaryDefaults.bool(forKey: "antigravityTwoPoolMetricPreferenceMigrated")) + + let secondaryDefaults = UserDefaults(suiteName: #function + ".secondary")! + secondaryDefaults.removePersistentDomain(forName: #function + ".secondary") + secondaryDefaults.set( + [UsageProvider.antigravity.rawValue: MenuBarMetricPreference.secondary.rawValue], + forKey: "menuBarMetricPreferences") + + let secondarySettings = SettingsStore(userDefaults: secondaryDefaults) + + #expect(secondarySettings.menuBarMetricPreference(for: .antigravity) == .primary) + + let reloadedSettings = SettingsStore(userDefaults: secondaryDefaults) + #expect(reloadedSettings.menuBarMetricPreference(for: .antigravity) == .primary) + + let tertiaryDefaults = UserDefaults(suiteName: #function + ".tertiary")! + tertiaryDefaults.removePersistentDomain(forName: #function + ".tertiary") + tertiaryDefaults.set( + [UsageProvider.antigravity.rawValue: MenuBarMetricPreference.tertiary.rawValue], + forKey: "menuBarMetricPreferences") + + let tertiarySettings = SettingsStore(userDefaults: tertiaryDefaults) + + #expect(tertiarySettings.menuBarMetricPreference(for: .antigravity) == .primary) + + let migratedDefaults = UserDefaults(suiteName: #function + ".migrated")! + migratedDefaults.removePersistentDomain(forName: #function + ".migrated") + migratedDefaults.set( + [UsageProvider.antigravity.rawValue: MenuBarMetricPreference.primary.rawValue], + forKey: "menuBarMetricPreferences") + migratedDefaults.set(true, forKey: "antigravityTwoPoolMetricPreferenceMigrated") + + let migratedSettings = SettingsStore(userDefaults: migratedDefaults) + + #expect(migratedSettings.menuBarMetricPreference(for: .antigravity) == .primary) + } + + @Test + func `menu bar metric preference handles zai and average`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-metric") + #expect(settings.menuBarMetricPreference(for: .zai) == .automatic) + settings.setMenuBarMetricPreference(.average, for: .zai) - #expect(settings.menuBarMetricPreference(for: .zai) == .primary) + #expect(settings.menuBarMetricPreference(for: .zai) == .automatic) + + settings.setMenuBarMetricPreference(.secondary, for: .zai) + #expect(settings.menuBarMetricPreference(for: .zai) == .secondary) + + settings.setMenuBarMetricPreference(.tertiary, for: .zai) + #expect(settings.menuBarMetricPreference(for: .zai) == .tertiary) + #expect(settings.menuBarMetricPreference(for: .zai, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsTertiary(for: .zai, snapshot: nil) == false) settings.setMenuBarMetricPreference(.average, for: .codex) #expect(settings.menuBarMetricPreference(for: .codex) == .automatic) + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + #expect(settings.menuBarMetricPreference(for: .codex) == .primaryAndSecondary) + #expect(settings.menuBarMetricSupportsPrimaryAndSecondary(for: .codex)) + + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) + #expect(settings.menuBarMetricPreference(for: .claude) == .automatic) + #expect(!settings.menuBarMetricSupportsPrimaryAndSecondary(for: .claude)) + + settings.setMenuBarMetricPreference(.monthlyPlan, for: .codex) + #expect(settings.menuBarMetricPreference(for: .codex) == .automatic) + + settings.menuBarMetricPreferencesRaw[UsageProvider.codex.rawValue] = MenuBarMetricPreference.monthlyPlan + .rawValue + #expect(settings.menuBarMetricPreference(for: .codex) == .automatic) + settings.setMenuBarMetricPreference(.average, for: .gemini) #expect(settings.menuBarMetricPreference(for: .gemini) == .average) + + settings.setMenuBarMetricPreference(.tertiary, for: .codex) + #expect(settings.menuBarMetricPreference(for: .codex) == .automatic) + + settings.setMenuBarMetricPreference(.tertiary, for: .cursor) + #expect(settings.menuBarMetricPreference(for: .cursor) == .tertiary) + #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsTertiary(for: .cursor, snapshot: nil) == false) + + settings.setMenuBarMetricPreference(.extraUsage, for: .cursor) + #expect(settings.menuBarMetricPreference(for: .cursor) == .extraUsage) + #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsExtraUsage(for: .cursor, snapshot: nil) == false) + + settings.setMenuBarMetricPreference(.extraUsage, for: .claude) + #expect(settings.menuBarMetricPreference(for: .claude) == .extraUsage) + #expect(settings.menuBarMetricPreference(for: .claude, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsExtraUsage(for: .claude, snapshot: nil) == false) + + settings.setMenuBarMetricPreference(.tertiary, for: .perplexity) + #expect(settings.menuBarMetricPreference(for: .perplexity) == .tertiary) + #expect(settings.menuBarMetricPreference(for: .perplexity, snapshot: nil) == .tertiary) + #expect(settings.menuBarMetricSupportsTertiary(for: .perplexity, snapshot: nil)) + + settings.setMenuBarMetricPreference(.tertiary, for: .gemini) + #expect(settings.menuBarMetricPreference(for: .gemini) == .automatic) } @Test - func menuBarMetricPreferenceRestrictsOpenRouterToAutomaticOrPrimary() { + func `menu bar metric preference restricts open router to automatic or primary`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-openrouter-metric") settings.setMenuBarMetricPreference(.secondary, for: .openrouter) @@ -32,10 +131,40 @@ struct SettingsStoreAdditionalTests { settings.setMenuBarMetricPreference(.primary, for: .openrouter) #expect(settings.menuBarMetricPreference(for: .openrouter) == .primary) + + settings.setMenuBarMetricPreference(.tertiary, for: .openrouter) + #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) + + settings.setMenuBarMetricPreference(.extraUsage, for: .openrouter) + #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) + } + + @Test + func `menu bar metric preference restricts mistral to payg or monthly plan`() { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-mistral-metric") + + settings.setMenuBarMetricPreference(.monthlyPlan, for: .mistral) + #expect(settings.menuBarMetricPreference(for: .mistral) == .monthlyPlan) + + settings.setMenuBarMetricPreference(.secondary, for: .mistral) + #expect(settings.menuBarMetricPreference(for: .mistral) == .automatic) + } + + @Test + func `menu bar metric preference restricts text only balance providers to automatic`() { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-text-only-metric") + + for provider in [UsageProvider.deepseek, .kimik2, .poe] { + settings.setMenuBarMetricPreference(.primary, for: provider) + #expect(settings.menuBarMetricPreference(for: provider) == .automatic) + + settings.setMenuBarMetricPreference(.secondary, for: provider) + #expect(settings.menuBarMetricPreference(for: provider) == .automatic) + } } @Test - func minimaxAuthModeUsesStoredValues() { + func `minimax auth mode uses stored values`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-minimax") settings.minimaxAPIToken = "sk-api-test-token" settings.minimaxCookieHeader = "cookie=value" @@ -47,7 +176,7 @@ struct SettingsStoreAdditionalTests { } @Test - func tokenAccountsSetManualCookieSourceWhenRequired() { + func `token accounts set manual cookie source when required`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-token-accounts") settings.addTokenAccount(provider: .claude, label: "Primary", token: "token-1") @@ -57,7 +186,7 @@ struct SettingsStoreAdditionalTests { } @Test - func ollamaTokenAccountsSetManualCookieSourceWhenRequired() { + func `ollama token accounts set manual cookie source when required`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-ollama-token-accounts") settings.addTokenAccount(provider: .ollama, label: "Primary", token: "session=token-1") @@ -67,7 +196,7 @@ struct SettingsStoreAdditionalTests { } @Test - func detectsTokenCostUsageSourcesFromFilesystem() throws { + func `detects token cost usage sources from filesystem`() throws { let fm = FileManager.default let root = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let sessions = root.appendingPathComponent("sessions", isDirectory: true) diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 242de898ff..4c42e461a3 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct SettingsStoreCoverageTests { @Test - func providerOrderingAndCaching() throws { + func `provider ordering and caching`() throws { let suite = "SettingsStoreCoverageTests-ordering" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -37,7 +36,22 @@ struct SettingsStoreCoverageTests { } @Test - func menuBarMetricPreferencesAndDisplayModes() { + func `disabling selected provider clears menu selection`() throws { + let settings = Self.makeSettingsStore() + let metadata = ProviderRegistry.shared.metadata + + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: true) + settings.selectedMenuProvider = .claude + + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: false) + + #expect(settings.selectedMenuProvider == nil) + #expect(settings.enabledProvidersOrdered(metadataByProvider: metadata) == [.codex]) + } + + @Test + func `menu bar metric preferences and display modes`() { let settings = Self.makeSettingsStore() settings.setMenuBarMetricPreference(.average, for: .codex) @@ -48,7 +62,7 @@ struct SettingsStoreCoverageTests { #expect(settings.menuBarMetricSupportsAverage(for: .gemini)) settings.setMenuBarMetricPreference(.secondary, for: .zai) - #expect(settings.menuBarMetricPreference(for: .zai) == .primary) + #expect(settings.menuBarMetricPreference(for: .zai) == .secondary) settings.menuBarDisplayMode = .pace #expect(settings.menuBarDisplayMode == .pace) @@ -61,7 +75,83 @@ struct SettingsStoreCoverageTests { } @Test - func tokenAccountMutationsApplySideEffects() { + func `minimax settings snapshot uses selected token account as manual cookie`() { + let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-minimax-token-account") + settings.minimaxCookieSource = .auto + settings.minimaxCookieHeader = "HERTZ-SESSION=global" + settings.addTokenAccount(provider: .minimax, label: "account", token: "HERTZ-SESSION=selected") + + let snapshot = settings.minimaxSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=selected") + } + + @Test + func `minimax settings snapshot falls back to global cookie without token accounts`() { + let settings = Self.makeSettingsStore(suiteName: "SettingsStoreCoverageTests-minimax-global-cookie") + settings.minimaxCookieSource = .auto + settings.minimaxCookieHeader = "HERTZ-SESSION=global" + + let snapshot = settings.minimaxSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .auto) + #expect(snapshot.manualCookieHeader == "HERTZ-SESSION=global") + } + + @Test + func `copilot budget extras default off and persist in provider snapshot`() throws { + let suite = "SettingsStoreCoverageTests-copilot-budget-extras" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.copilotBudgetExtrasEnabled == false) + #expect(initial.copilotSettingsSnapshot(tokenOverride: nil).budgetExtrasEnabled == false) + + initial.copilotBudgetExtrasEnabled = true + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.copilotBudgetExtrasEnabled) + #expect(reloaded.copilotSettingsSnapshot(tokenOverride: nil).budgetExtrasEnabled) + } + + @Test + func `multi account menu layout persists and bridges legacy show all token accounts`() throws { + let suite = "SettingsStoreCoverageTests-multi-account-layout" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.multiAccountMenuLayout == .segmented) + + initial.multiAccountMenuLayout = .stacked + #expect(defaults.string(forKey: "multiAccountMenuLayout") == MultiAccountMenuLayout.stacked.rawValue) + #expect(initial.showAllTokenAccountsInMenu) + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.multiAccountMenuLayout == .stacked) + reloaded.showAllTokenAccountsInMenu = false + #expect(reloaded.multiAccountMenuLayout == .segmented) + } + + @Test + func `legacy show all token accounts migrates to stacked layout`() throws { + let suite = "SettingsStoreCoverageTests-legacy-token-account-layout" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "showAllTokenAccountsInMenu") + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + #expect(settings.multiAccountMenuLayout == .stacked) + } + + @Test + func `token account mutations apply side effects`() { let settings = Self.makeSettingsStore() settings.addTokenAccount(provider: .claude, label: "Primary", token: "token") @@ -83,7 +173,174 @@ struct SettingsStoreCoverageTests { } @Test - func tokenCostUsageSourceDetection() throws { + func `token account update preserves identity and selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "token-2") + settings.setActiveTokenAccountIndex(0, for: .copilot) + + let original = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.updateTokenAccount( + provider: .copilot, + accountID: original.id, + label: "Primary (Pro)", + token: "token-1b") + + let updated = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(updated.id == original.id) + #expect(updated.label == "Primary (Pro)") + #expect(updated.token == "token-1b") + #expect(settings.tokenAccounts(for: .copilot).count == 2) + } + + @Test + func `copilot token accounts clear legacy api key fallback`() throws { + let settings = Self.makeSettingsStore() + settings.copilotAPIToken = "legacy-token" + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == "token-1") + + settings.copilotAPIToken = "legacy-token" + let account = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.removeTokenAccount(provider: .copilot, accountID: account.id) + + #expect(settings.tokenAccounts(for: .copilot).isEmpty) + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == nil) + } + + @Test + func `copilot settings snapshot carries selected account identifier`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount( + provider: .copilot, + label: "octocat (Pro)", + token: "token-1", + externalIdentifier: "github:user:123") + + let snapshot = settings.copilotSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.apiToken == "token-1") + #expect(snapshot.selectedAccountExternalIdentifier == "github:user:123") + } + + @Test + func `copilot enterprise host persists in provider config`() throws { + let suite = "SettingsStoreCoverageTests-copilot-enterprise-host" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + first.copilotEnterpriseHost = "https://octocorp.ghe.com/login" + #expect(first.copilotEnterpriseHost == "https://octocorp.ghe.com/login") + #expect(first.copilotSettingsSnapshot(tokenOverride: nil).enterpriseHost == "octocorp.ghe.com") + + let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(second.copilotEnterpriseHost == "https://octocorp.ghe.com/login") + + second.copilotEnterpriseHost = "github.com" + #expect(second.copilotEnterpriseHost == "github.com") + #expect(second.copilotSettingsSnapshot(tokenOverride: nil).enterpriseHost == nil) + } + + @Test + func `removing another token account preserves active selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "A", token: "token-a") + settings.addTokenAccount(provider: .copilot, label: "B", token: "token-b") + settings.addTokenAccount(provider: .copilot, label: "C", token: "token-c") + settings.setActiveTokenAccountIndex(1, for: .copilot) + + let activeBefore = try #require(settings.selectedTokenAccount(for: .copilot)) + let accountToRemove = try #require(settings.tokenAccounts(for: .copilot).first) + settings.removeTokenAccount(provider: .copilot, accountID: accountToRemove.id) + + let activeAfter = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(activeAfter.id == activeBefore.id) + #expect(activeAfter.label == "B") + #expect(settings.tokenAccounts(for: .copilot).map(\.label) == ["B", "C"]) + } + + @Test + func `claude snapshot uses OAuth routing for OAuth token accounts`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount(provider: .claude, label: "OAuth", token: "Bearer sk-ant-oat-account-token") + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.cookieSource == .off) + #expect(snapshot.manualCookieHeader?.isEmpty == true) + } + + @Test + func `claude snapshot uses manual cookie routing for session key accounts`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount(provider: .claude, label: "Cookie", token: "sk-ant-session-token") + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "sessionKey=sk-ant-session-token") + } + + @Test + func `claude snapshot normalizes config manual cookie input through shared route`() { + let settings = Self.makeSettingsStore() + settings.claudeCookieSource = .manual + settings.claudeCookieHeader = "Cookie: sessionKey=sk-ant-session-token; foo=bar" + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "sessionKey=sk-ant-session-token; foo=bar") + } + + @Test + func `claude snapshot does not fall back to config cookie for malformed selected token account`() { + let settings = Self.makeSettingsStore() + settings.claudeCookieSource = .manual + settings.claudeCookieHeader = "Cookie: sessionKey=sk-ant-config-cookie" + settings.addTokenAccount(provider: .claude, label: "Malformed", token: "Cookie:") + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader?.isEmpty == true) + } + + @Test + func `opencode go token accounts force manual cookie routing`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount(provider: .opencodego, label: "Go", token: "auth=go-cookie") + + let snapshot = settings.opencodegoSettingsSnapshot(tokenOverride: nil) + + #expect(settings.opencodegoCookieSource == .manual) + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "auth=go-cookie") + } + + @Test + func `opencode go snapshot preserves nil workspace id when settings are unset`() { + let settings = Self.makeSettingsStore() + + let snapshot = settings.opencodegoSettingsSnapshot(tokenOverride: nil) + + #expect(settings.opencodegoWorkspaceID.isEmpty) + #expect(snapshot.workspaceID == nil) + } + + @Test + func `token cost usage source detection`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent( "token-cost-\(UUID().uuidString)", @@ -111,7 +368,7 @@ struct SettingsStoreCoverageTests { } @Test - func ensureTokenLoadersExecute() { + func `ensure token loaders execute`() { let settings = Self.makeSettingsStore() settings.ensureZaiAPITokenLoaded() @@ -136,7 +393,7 @@ struct SettingsStoreCoverageTests { } @Test - func keychainDisableForcesManualCookieSources() throws { + func `keychain disable forces manual cookie sources`() throws { let suite = "SettingsStoreCoverageTests-keychain" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -154,13 +411,13 @@ struct SettingsStoreCoverageTests { } @Test - func claudeKeychainPromptMode_defaultsToOnlyOnUserAction() { + func `claude keychain prompt mode defaults to only on user action`() { let settings = Self.makeSettingsStore() #expect(settings.claudeOAuthKeychainPromptMode == .onlyOnUserAction) } @Test - func claudeKeychainPromptMode_persistsAcrossStoreReload() throws { + func `claude keychain prompt mode persists across store reload`() throws { let suite = "SettingsStoreCoverageTests-claude-keychain-prompt-mode" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -177,7 +434,7 @@ struct SettingsStoreCoverageTests { } @Test - func claudeKeychainPromptMode_invalidRawFallsBackToOnlyOnUserAction() throws { + func `claude keychain prompt mode invalid raw falls back to only on user action`() throws { let suite = "SettingsStoreCoverageTests-claude-keychain-prompt-mode-invalid" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -189,13 +446,13 @@ struct SettingsStoreCoverageTests { } @Test - func claudeKeychainReadStrategy_defaultsToSecurityFramework() { + func `claude keychain read strategy defaults to security CLI experimental`() { let settings = Self.makeSettingsStore() - #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) } @Test - func claudeKeychainReadStrategy_persistsAcrossStoreReload() throws { + func `claude keychain read strategy persists across store reload`() throws { let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -212,7 +469,7 @@ struct SettingsStoreCoverageTests { } @Test - func claudeKeychainReadStrategy_invalidRawFallsBackToSecurityFramework() throws { + func `claude keychain read strategy invalid raw falls back to security framework`() throws { let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy-invalid" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -224,28 +481,211 @@ struct SettingsStoreCoverageTests { } @Test - func claudePromptFreeCredentialsToggle_mapsToReadStrategy() { + func `claude prompt free credentials toggle maps to read strategy`() { let settings = Self.makeSettingsStore() - #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == false) + #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == true) + + settings.claudeOAuthPromptFreeCredentialsEnabled = false + #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) settings.claudeOAuthPromptFreeCredentialsEnabled = true #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) + } - settings.claudeOAuthPromptFreeCredentialsEnabled = false - #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + @Test + func `upsert antigravity oauth account adds and updates active token account`() throws { + let settings = Self.makeSettingsStore() + let first = AntigravityOAuthCredentials( + accessToken: "first-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "user@example.com") + let updated = AntigravityOAuthCredentials( + accessToken: "updated-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_100), + email: "user@example.com") + + settings.upsertAntigravityOAuthAccount(first) + settings.upsertAntigravityOAuthAccount(updated) + + let accounts = settings.tokenAccounts(for: .antigravity) + #expect(accounts.count == 1) + let account = try #require(accounts.first) + #expect(account.label == "user@example.com") + #expect(account.externalIdentifier == "user@example.com") + #expect(settings.selectedTokenAccount(for: .antigravity)?.id == account.id) + + let decoded = try #require(AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: account.token)) + #expect(decoded.accessToken == "updated-access") + } + + @Test + func `upsert antigravity oauth account does not merge missing email accounts by fallback label`() { + let settings = Self.makeSettingsStore() + let first = AntigravityOAuthCredentials( + accessToken: "first-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: nil) + let second = AntigravityOAuthCredentials( + accessToken: "second-access", + refreshToken: "second-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_100), + email: nil) + + settings.upsertAntigravityOAuthAccount(first) + settings.upsertAntigravityOAuthAccount(second) + + let accounts = settings.tokenAccounts(for: .antigravity) + #expect(accounts.count == 2) + #expect(accounts.map(\.label) == ["Google Account 1", "Google Account 2"]) + #expect(settings.selectedTokenAccount(for: .antigravity)?.id == accounts.last?.id) + } + + @Test + func `removing last antigravity oauth account clears matching shared credentials`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-shared-removal-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sharedStore = AntigravityOAuthCredentialsStore( + fileURL: root.appendingPathComponent("oauth_creds.json")) + let credentials = AntigravityOAuthCredentials( + accessToken: "shared-access", + refreshToken: "shared-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "user@example.com") + try sharedStore.save(credentials) + let settings = Self.makeSettingsStore( + suiteName: "SettingsStoreCoverageTests-antigravity-remove-shared", + antigravityOAuthCredentialsStore: sharedStore) + + settings.upsertAntigravityOAuthAccount(credentials) + let account = try #require(settings.selectedTokenAccount(for: .antigravity)) + settings.removeTokenAccount(provider: .antigravity, accountID: account.id) + + #expect(settings.tokenAccounts(for: .antigravity).isEmpty) + #expect(try sharedStore.load() == nil) + } + + @Test + func `removing antigravity oauth account preserves freshly reauthenticated credentials`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-shared-reauth-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sharedStore = AntigravityOAuthCredentialsStore( + fileURL: root.appendingPathComponent("oauth_creds.json")) + let removed = AntigravityOAuthCredentials( + accessToken: "removed-access", + refreshToken: "removed-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "user@example.com") + let refreshed = AntigravityOAuthCredentials( + accessToken: "fresh-access", + refreshToken: "fresh-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_100), + email: "user@example.com") + try sharedStore.save(refreshed) + let settings = Self.makeSettingsStore( + suiteName: "SettingsStoreCoverageTests-antigravity-preserve-reauth", + antigravityOAuthCredentialsStore: sharedStore) + + settings.upsertAntigravityOAuthAccount(removed) + let account = try #require(settings.selectedTokenAccount(for: .antigravity)) + settings.removeTokenAccount(provider: .antigravity, accountID: account.id) + + #expect(try sharedStore.load() == refreshed) + } + + @Test + func `removing antigravity oauth account preserves different shared credentials`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-shared-preserve-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let sharedStore = AntigravityOAuthCredentialsStore( + fileURL: root.appendingPathComponent("oauth_creds.json")) + let removed = AntigravityOAuthCredentials( + accessToken: "removed-access", + refreshToken: "removed-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "removed@example.com") + let shared = AntigravityOAuthCredentials( + accessToken: "shared-access", + refreshToken: "shared-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "shared@example.com") + try sharedStore.save(shared) + let settings = Self.makeSettingsStore( + suiteName: "SettingsStoreCoverageTests-antigravity-preserve-shared", + antigravityOAuthCredentialsStore: sharedStore) + + settings.upsertAntigravityOAuthAccount(removed) + let account = try #require(settings.selectedTokenAccount(for: .antigravity)) + settings.removeTokenAccount(provider: .antigravity, accountID: account.id) + + #expect(settings.tokenAccounts(for: .antigravity).isEmpty) + try await Task.sleep(nanoseconds: 50_000_000) + #expect(try sharedStore.load() == shared) } - private static func makeSettingsStore(suiteName: String = "SettingsStoreCoverageTests") -> SettingsStore { + @Test + func `weekly progress work days defaults to nil and persists across store reload`() throws { + let suite = "SettingsStoreCoverageTests-weekly-progress-work-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let fresh = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(fresh.weeklyProgressWorkDays == nil) + + fresh.weeklyProgressWorkDays = 5 + #expect(defaults.object(forKey: "weeklyProgressWorkDays") as? Int == 5) + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.weeklyProgressWorkDays == 5) + + fresh.weeklyProgressWorkDays = 4 + #expect(reloaded.weeklyProgressWorkDays == 5) + + let reloaded2 = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded2.weeklyProgressWorkDays == 4) + + reloaded2.weeklyProgressWorkDays = 7 + let reloaded3 = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded3.weeklyProgressWorkDays == 7) + + reloaded3.weeklyProgressWorkDays = nil + #expect(defaults.object(forKey: "weeklyProgressWorkDays") == nil) + let reloaded4 = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded4.weeklyProgressWorkDays == nil) + } + + private static func makeSettingsStore( + suiteName: String = "SettingsStoreCoverageTests", + antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore()) + -> SettingsStore + { let defaults = UserDefaults(suiteName: suiteName)! defaults.removePersistentDomain(forName: suiteName) defaults.set(false, forKey: "debugDisableKeychainAccess") let configStore = testConfigStore(suiteName: suiteName) - return Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + return Self.makeSettingsStore( + userDefaults: defaults, + configStore: configStore, + antigravityOAuthCredentialsStore: antigravityOAuthCredentialsStore) } private static func makeSettingsStore( userDefaults: UserDefaults, - configStore: CodexBarConfigStore) -> SettingsStore + configStore: CodexBarConfigStore, + antigravityOAuthCredentialsStore: AntigravityOAuthCredentialsStore = AntigravityOAuthCredentialsStore()) + -> SettingsStore { SettingsStore( userDefaults: userDefaults, @@ -264,6 +704,22 @@ struct SettingsStoreCoverageTests { augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), - tokenAccountStore: InMemoryTokenAccountStore()) + tokenAccountStore: InMemoryTokenAccountStore(), + antigravityOAuthCredentialsStore: antigravityOAuthCredentialsStore) + } + + private static func waitForSharedAntigravityCredentials( + in store: AntigravityOAuthCredentialsStore, + matches predicate: (AntigravityOAuthCredentials?) -> Bool) async throws + -> AntigravityOAuthCredentials? + { + for _ in 0..<100 { + let credentials = try store.load() + if predicate(credentials) { + return credentials + } + try await Task.sleep(nanoseconds: 10_000_000) + } + return try store.load() } } diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 04ca555165..7785f655f8 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -4,11 +4,29 @@ import Observation import Testing @testable import CodexBar +@Suite(.serialized) @MainActor -@Suite +// swiftlint:disable:next type_body_length struct SettingsStoreTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + @Test - func defaultRefreshFrequencyIsFiveMinutes() throws { + func `default refresh frequency is five minutes`() throws { let suite = "SettingsStoreTests-default" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -22,10 +40,29 @@ struct SettingsStoreTests { #expect(store.refreshFrequency == .fiveMinutes) #expect(store.refreshFrequency.seconds == 300) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + } + + @Test + func `repairs unrecognized refresh frequency raw value`() throws { + let suite = "SettingsStoreTests-invalid-refresh" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set("legacyValue", forKey: "refreshFrequency") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) } @Test - func persistsRefreshFrequencyAcrossInstances() throws { + func `persists refresh frequency across instances`() throws { let suite = "SettingsStoreTests-persist" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -50,7 +87,159 @@ struct SettingsStoreTests { } @Test - func persistsSelectedMenuProviderAcrossInstances() throws { + func `weekly confetti setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-weekly-confetti" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.confettiOnWeeklyLimitResetsEnabled == false) + storeA.confettiOnWeeklyLimitResetsEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.confettiOnWeeklyLimitResetsEnabled == true) + } + + @Test + func `provider storage setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-provider-storage" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.providerStorageFootprintsEnabled == false) + #expect(defaultsA.bool(forKey: "providerStorageFootprintsEnabled") == false) + storeA.providerStorageFootprintsEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.providerStorageFootprintsEnabled == true) + } + + @Test + func `providers sorted alphabetically defaults off and persists`() throws { + let suite = "SettingsStoreTests-providers-sorted-alpha" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.providersSortedAlphabetically == false) + storeA.providersSortedAlphabetically = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.providersSortedAlphabetically == true) + } + + @Test + func `alphabetical provider order puts enabled first then sorts by name`() { + let metadata = ProviderDescriptorRegistry.metadata + let enabled: Set = [.cursor, .claude, .codex] + let ordered = CodexBarConfig.alphabeticalProviderOrder( + enablement: { enabled.contains($0) }) + + #expect(Set(ordered) == Set(UsageProvider.allCases)) + + let displayName: (UsageProvider) -> String = { metadata[$0]?.displayName ?? $0.rawValue } + let enabledPart = ordered.filter { enabled.contains($0) } + let disabledPart = ordered.filter { !enabled.contains($0) } + // Enabled providers occupy the top of the list, ahead of every disabled provider. + #expect(Array(ordered.prefix(enabled.count)) == enabledPart) + #expect(ordered == enabledPart + disabledPart) + let isSortedByName: ([UsageProvider]) -> Bool = { group in + group == group.sorted { + displayName($0).localizedCaseInsensitiveCompare(displayName($1)) == .orderedAscending + } + } + #expect(isSortedByName(enabledPart)) + #expect(isSortedByName(disabledPart)) + } + + @Test + func `provider changelog links setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-provider-changelog-links" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.providerChangelogLinksEnabled == false) + #expect(defaultsA.bool(forKey: "providerChangelogLinksEnabled") == false) + storeA.providerChangelogLinksEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.providerChangelogLinksEnabled == true) + } + + @Test + func `hide critters setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-hide-critters" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.menuBarHidesCritters == false) + #expect(defaultsA.bool(forKey: "menuBarHidesCritters") == false) + storeA.menuBarHidesCritters = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.menuBarHidesCritters == true) + } + + @Test + func `persists selected menu provider across instances`() throws { let suite = "SettingsStoreTests-selectedMenuProvider" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -74,7 +263,7 @@ struct SettingsStoreTests { } @Test - func persistsMergedMenuLastSelectedWasOverviewAcrossInstances() throws { + func `persists merged menu last selected was overview across instances`() throws { let suite = "SettingsStoreTests-merged-last-overview" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -98,7 +287,7 @@ struct SettingsStoreTests { } @Test - func mergedOverviewSelectedProvidersPersistsAndNormalizesAcrossInstances() throws { + func `merged overview selected providers persists and normalizes across instances`() throws { let suite = "SettingsStoreTests-merged-overview-selection" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -123,7 +312,7 @@ struct SettingsStoreTests { } @Test - func mergedOverviewSelectedProvidersIgnoresInvalidRawValues() throws { + func `merged overview selected providers ignores invalid raw values`() throws { let suite = "SettingsStoreTests-merged-overview-invalid-raw" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -139,7 +328,7 @@ struct SettingsStoreTests { } @Test - func resolvedMergedOverviewProvidersDefaultsToFirstThreeWhenSelectionEmpty() throws { + func `resolved merged overview providers defaults to first three when selection empty`() throws { let suite = "SettingsStoreTests-merged-overview-default-first-three" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -157,7 +346,7 @@ struct SettingsStoreTests { } @Test - func resolvedMergedOverviewProvidersHonorsExplicitEmptySelection() throws { + func `resolved merged overview providers honors explicit empty selection`() throws { let suite = "SettingsStoreTests-merged-overview-explicit-empty" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -176,7 +365,7 @@ struct SettingsStoreTests { } @Test - func resolvedMergedOverviewProvidersUsesProviderOrderNotSelectionOrder() throws { + func `resolved merged overview providers uses provider order not selection order`() throws { let suite = "SettingsStoreTests-merged-overview-order" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -195,7 +384,7 @@ struct SettingsStoreTests { } @Test - func reconcileMergedOverviewSelectionRemovesUnavailableWithoutAutoFill() throws { + func `reconcile merged overview selection removes unavailable without auto fill`() throws { let suite = "SettingsStoreTests-merged-overview-reconcile" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -216,7 +405,7 @@ struct SettingsStoreTests { } @Test - func reconcileMergedOverviewSelectionDoesNotClobberStoredPreferenceWhenThreeOrFewer() throws { + func `reconcile merged overview selection does not clobber stored preference when three or fewer`() throws { let suite = "SettingsStoreTests-merged-overview-three-or-fewer" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -237,7 +426,9 @@ struct SettingsStoreTests { } @Test - func reconcileMergedOverviewSelectionIgnoresStaleSubsetWithoutPersistingAutoFillWhenThreeOrFewer() throws { + func `reconcile merged overview selection ignores stale subset without persisting auto fill when three or fewer`() + throws + { let suite = "SettingsStoreTests-merged-overview-three-or-fewer-subset" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -258,7 +449,7 @@ struct SettingsStoreTests { } @Test - func mergedOverviewSelectionAllowsDeselectingProvidersWhenThreeOrFewer() throws { + func `merged overview selection allows deselecting providers when three or fewer`() throws { let suite = "SettingsStoreTests-merged-overview-deselect-three-or-fewer" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -282,7 +473,7 @@ struct SettingsStoreTests { } @Test - func mergedOverviewSelectionAppliesWhenSameActiveSetIsReordered() throws { + func `merged overview selection applies when same active set is reordered`() throws { let suite = "SettingsStoreTests-merged-overview-ordered-context" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -306,7 +497,7 @@ struct SettingsStoreTests { } @Test - func mergedOverviewSelectionAllowsDeselectingProvidersWhenMoreThanThreeActive() throws { + func `merged overview selection allows deselecting providers when more than three active`() throws { let suite = "SettingsStoreTests-merged-overview-deselect-subset" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -330,7 +521,7 @@ struct SettingsStoreTests { } @Test - func reconcileMergedOverviewSelectionPreservesStoredSubsetWhenActiveDropsToThreeOrFewer() throws { + func `reconcile merged overview selection preserves stored subset when active drops to three or fewer`() throws { let suite = "SettingsStoreTests-merged-overview-preserve-subset-across-drop" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -364,7 +555,7 @@ struct SettingsStoreTests { } @Test - func reconcileMergedOverviewSelectionClearsPreferenceWhenNoProvidersActive() throws { + func `reconcile merged overview selection clears preference when no providers active`() throws { let suite = "SettingsStoreTests-merged-overview-clear-on-empty-active" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -390,7 +581,7 @@ struct SettingsStoreTests { } @Test - func persistsOpenCodeWorkspaceIDAcrossInstances() throws { + func `persists open code workspace ID across instances`() throws { let suite = "SettingsStoreTests-opencode-workspace" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -412,7 +603,7 @@ struct SettingsStoreTests { } @Test - func defaultsSessionQuotaNotificationsToEnabled() throws { + func `defaults session quota notifications to enabled`() throws { let key = "sessionQuotaNotificationsEnabled" let suite = "SettingsStoreTests-sessionQuotaNotifications" let defaults = try #require(UserDefaults(suiteName: suite)) @@ -428,7 +619,145 @@ struct SettingsStoreTests { } @Test - func defaultsClaudeUsageSourceToAuto() throws { + func `defaults quota warnings to disabled with global thresholds and sound`() throws { + let suite = "SettingsStoreTests-quota-warning-defaults" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.quotaWarningNotificationsEnabled == false) + #expect(store.quotaWarningThresholds == [50, 20]) + #expect(store.quotaWarningWindowEnabled(.session) == true) + #expect(store.quotaWarningWindowEnabled(.weekly) == true) + #expect(store.quotaWarningSoundEnabled == true) + #expect(store.quotaWarningMarkersVisible == true) + #expect(defaults.array(forKey: "quotaWarningThresholds") as? [Int] == [50, 20]) + #expect(defaults.object(forKey: "quotaWarningSessionEnabled") as? Bool == true) + #expect(defaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool == true) + #expect(defaults.bool(forKey: "quotaWarningSoundEnabled") == true) + #expect(defaults.object(forKey: "quotaWarningMarkersVisible") as? Bool == true) + } + + @Test + func `global quota warning windows persist independently`() throws { + let suite = "SettingsStoreTests-quota-warning-window-enabled" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.setQuotaWarningWindowEnabled(.weekly, enabled: false) + + #expect(store.quotaWarningWindowEnabled(.session) == true) + #expect(store.quotaWarningWindowEnabled(.weekly) == false) + #expect(defaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool == false) + } + + @Test + func `sanitizes invalid quota warning thresholds from defaults`() throws { + let suite = "SettingsStoreTests-quota-warning-sanitize" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set([120, 20, 20, -5, 50], forKey: "quotaWarningThresholds") + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.quotaWarningThresholds == [99, 50, 20, 0]) + #expect(defaults.array(forKey: "quotaWarningThresholds") as? [Int] == [99, 50, 20, 0]) + } + + @Test + func `quota warning threshold pair resolves blanks and clamps bounds`() { + #expect(QuotaWarningThresholds.resolved(upper: nil, lower: nil) == [50, 20]) + #expect(QuotaWarningThresholds.resolved(upper: nil, lower: 10) == [50, 10]) + #expect(QuotaWarningThresholds.resolved(upper: 10, lower: nil) == [10, 0]) + #expect(QuotaWarningThresholds.resolved(upper: 120, lower: -5) == [99, 0]) + } + + @Test + func `provider quota warning override resolves before global thresholds`() throws { + let suite = "SettingsStoreTests-quota-warning-provider-override" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + store.quotaWarningThresholds = [50, 20] + + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + store.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: [10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .weekly) == [50, 20]) + + store.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: nil) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + } + + @Test + func `global quota warning thresholds resolve independently by window`() throws { + let suite = "SettingsStoreTests-quota-warning-window-thresholds" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.setQuotaWarningThresholds(.session, thresholds: [25]) + store.setQuotaWarningThresholds(.weekly, thresholds: [75, 10]) + + #expect(store.quotaWarningThresholds(.session) == [25]) + #expect(store.quotaWarningThresholds(.weekly) == [75, 10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [25]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .weekly) == [75, 10]) + } + + @Test + func `provider quota warning windows override global enablement independently`() throws { + let suite = "SettingsStoreTests-quota-warning-provider-window-override" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.setQuotaWarningWindowEnabled(.weekly, enabled: false) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == false) + + store.setQuotaWarningWindowEnabled(provider: .codex, window: .weekly, enabled: true) + store.setQuotaWarningWindowEnabled(provider: .codex, window: .session, enabled: false) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == true) + #expect(store.quotaWarningEnabled(provider: .codex, window: .session) == false) + #expect(store.hasQuotaWarningOverride(provider: .codex, window: .weekly) == true) + #expect(store.hasQuotaWarningOverride(provider: .codex, window: .session) == true) + + store.setQuotaWarningWindowEnabled(provider: .codex, window: .weekly, enabled: nil) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == false) + } + + @Test + func `defaults claude usage source to auto`() throws { let suite = "SettingsStoreTests-claude-source" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -444,7 +773,7 @@ struct SettingsStoreTests { } @Test - func defaultsCodexUsageSourceToAuto() throws { + func `defaults codex usage source to auto`() throws { let suite = "SettingsStoreTests-codex-source" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -460,7 +789,7 @@ struct SettingsStoreTests { } @Test - func defaultsKiloUsageSourceToAuto() throws { + func `defaults kilo usage source to auto`() throws { let suite = "SettingsStoreTests-kilo-source" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -476,7 +805,7 @@ struct SettingsStoreTests { } @Test - func persistsKiloUsageSourceAcrossInstances() throws { + func `persists kilo usage source across instances`() throws { let suite = "SettingsStoreTests-kilo-source-persist" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -500,7 +829,7 @@ struct SettingsStoreTests { } @Test - func kiloExtrasOnlyApplyInAutoMode() throws { + func `kilo extras only apply in auto mode`() throws { let suite = "SettingsStoreTests-kilo-extras" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -523,7 +852,7 @@ struct SettingsStoreTests { @Test @MainActor - func applyExternalConfigDoesNotBroadcast() throws { + func `apply external config does not broadcast`() throws { let suite = "SettingsStoreTests-external-config" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -567,7 +896,7 @@ struct SettingsStoreTests { } @Test - func persistsZaiAPIRegionAcrossInstances() throws { + func `persists zai API region across instances`() throws { let suite = "SettingsStoreTests-zai-region" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -589,7 +918,7 @@ struct SettingsStoreTests { } @Test - func persistsMiniMaxAPIRegionAcrossInstances() throws { + func `persists mini max API region across instances`() throws { let suite = "SettingsStoreTests-minimax-region" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -611,13 +940,86 @@ struct SettingsStoreTests { } @Test - func defaultsOpenAIWebAccessToEnabled() throws { + func `defaults open AI web access to disabled`() throws { let suite = "SettingsStoreTests-openai-web" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defaults.set(false, forKey: "debugDisableKeychainAccess") let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == false) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + #expect(store.codexCookieSource == .off) + } + + @Test + func `infers open AI web access enabled for legacy configured codex cookies`() throws { + let suite = "SettingsStoreTests-openai-web-legacy" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.removeObject(forKey: "openAIWebAccessEnabled") + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig(providers: [ + ProviderConfig(id: .codex, cookieSource: .auto), + ])) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == true) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + #expect(store.codexCookieSource == .auto) + } + + @Test + func `imports legacy open AI web access defaults key`() throws { + let suite = "SettingsStoreTests-openai-web-legacy-key" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.removeObject(forKey: "openAIWebAccessEnabled") + defaults.set(false, forKey: "openAIWebAccess") + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig(providers: [ + ProviderConfig(id: .codex, cookieSource: .auto), + ])) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == false) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + } + + @Test + func `infers open AI web access enabled for legacy codex config with implicit auto cookies`() throws { + let suite = "SettingsStoreTests-openai-web-legacy-implicit-auto" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.removeObject(forKey: "openAIWebAccessEnabled") + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig(providers: [ + ProviderConfig(id: .codex), + ])) + let store = SettingsStore( userDefaults: defaults, configStore: configStore, @@ -626,11 +1028,62 @@ struct SettingsStoreTests { #expect(store.openAIWebAccessEnabled == true) #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + #expect(store.codexCookieSource == .auto) + } + + @Test + func `disabling open AI web access turns codex cookie source off`() throws { + let suite = "SettingsStoreTests-openai-web-toggle" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.codexCookieSource = .auto + #expect(store.codexCookieSource == .auto) + + store.openAIWebAccessEnabled = false + #expect(store.codexCookieSource == .off) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + + store.openAIWebAccessEnabled = true #expect(store.codexCookieSource == .auto) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + } + + @Test + func `open AI web battery saver persists separately from extras availability`() throws { + let suite = "SettingsStoreTests-openai-web-battery-saver" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebBatterySaverEnabled == false) + + store.openAIWebBatterySaverEnabled = false + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + + store.openAIWebAccessEnabled = true + #expect(store.openAIWebBatterySaverEnabled == false) } @Test - func menuObservationTokenUpdatesOnDefaultsChange() async throws { + func `menu observation token updates on defaults change`() async throws { let suite = "SettingsStoreTests-observation-defaults" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -642,24 +1095,111 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - var didChange = false + let didChange = ObservationFlag() withObservationTracking { _ = store.menuObservationToken } onChange: { - Task { @MainActor in - didChange = true - } + didChange.set() } store.statusChecksEnabled.toggle() try? await Task.sleep(nanoseconds: 50_000_000) - #expect(didChange == true) + #expect(didChange.get() == true) + } + + @Test + func `menu observation token ignores merged switcher selection churn`() async throws { + let suite = "SettingsStoreTests-observation-switcher-selection" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.selectedMenuProvider = .claude + store.mergedMenuLastSelectedWasOverview.toggle() + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == false) + } + + @Test + func `menu observation token updates on per-window quota threshold changes`() async throws { + let suite = "SettingsStoreTests-observation-quota-threshold-windows" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + func expectObservation( + for window: QuotaWarningWindow, + thresholds: [Int]) async + { + let didChange = ObservationFlag() + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.setQuotaWarningThresholds(window, thresholds: thresholds) + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) + } + + await expectObservation(for: .session, thresholds: [70, 30]) + await expectObservation(for: .weekly, thresholds: [80, 40]) + } + + @Test + func `menu observation token updates on weekly progress work days changes`() async throws { + let suite = "SettingsStoreTests-observation-weekly-progress-work-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.weeklyProgressWorkDays = 5 + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) } @Test - func configBackedSettingsTriggerObservation() async throws { + func `config backed settings trigger observation`() async throws { let suite = "SettingsStoreTests-observation-config" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -671,24 +1211,49 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - var didChange = false + let didChange = ObservationFlag() withObservationTracking { _ = store.codexCookieSource } onChange: { - Task { @MainActor in - didChange = true - } + didChange.set() } store.codexCookieSource = .manual try? await Task.sleep(nanoseconds: 50_000_000) - #expect(didChange == true) + #expect(didChange.get() == true) + } + + @Test + func `menu observation token updates on codex active source change`() async throws { + let suite = "SettingsStoreTests-observation-codex-active-source" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.codexActiveSource = .liveSystem + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) } @Test - func providerOrder_defaultsToAllCases() throws { + func `provider order defaults to all cases`() throws { let suite = "SettingsStoreTests-providerOrder-default" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -704,7 +1269,7 @@ struct SettingsStoreTests { } @Test - func providerOrder_persistsAndAppendsNewProviders() throws { + func `provider order persists and appends new providers`() throws { let suite = "SettingsStoreTests-providerOrder-persist" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -723,30 +1288,9 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(storeA.orderedProviders() == [ - .gemini, - .codex, - .claude, - .cursor, - .opencode, - .factory, - .antigravity, - .copilot, - .zai, - .minimax, - .kimi, - .kilo, - .kiro, - .vertexai, - .augment, - .jetbrains, - .kimik2, - .amp, - .ollama, - .synthetic, - .warp, - .openrouter, - ]) + let legacyOrder: [UsageProvider] = [.gemini, .codex] + let appendedProviders = UsageProvider.allCases.filter { !legacyOrder.contains($0) } + #expect(storeA.orderedProviders() == legacyOrder + appendedProviders) // Move one provider; ensure it's persisted across instances. let antigravityIndex = try #require(storeA.orderedProviders().firstIndex(of: .antigravity)) @@ -761,4 +1305,47 @@ struct SettingsStoreTests { #expect(storeB.orderedProviders().first == .antigravity) } + + @Test + func `setting alibaba API key enables provider`() throws { + let suite = "SettingsStoreTests-alibaba-enable-on-token" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let metadata = try #require(ProviderDescriptorRegistry.metadata[.alibaba]) + store.setProviderEnabled(provider: .alibaba, metadata: metadata, enabled: false) + + store.alibabaCodingPlanAPIToken = "cpk-test-token" + + #expect(store.isProviderEnabled(provider: .alibaba, metadata: metadata)) + } + + @Test + func `alibaba provider auto enables on startup when token exists`() throws { + let suite = "SettingsStoreTests-alibaba-auto-enable-startup" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .alibaba, enabled: false, apiKey: "cpk-startup-token"), + ]) + try configStore.save(config) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let metadata = try #require(ProviderDescriptorRegistry.metadata[.alibaba]) + #expect(store.isProviderEnabled(provider: .alibaba, metadata: metadata)) + } } diff --git a/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift b/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift new file mode 100644 index 0000000000..79bbdebd0a --- /dev/null +++ b/Tests/CodexBarTests/SettingsWindowAppearanceTests.swift @@ -0,0 +1,66 @@ +import AppKit +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +struct SettingsWindowAppearanceTests { + @Test + func `bridge pulses exact effective appearance then restores inheritance`() { + let application = NSApplication.shared + let effectiveAppearance = application.effectiveAppearance + let staleSource = NSView() + staleSource.appearance = NSAppearance(named: .aqua) + let resetCapture = ResetCapture() + let bridge = SettingsWindowAppearanceView { resetCapture.actions.append($0) } + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false) + window.appearance = NSAppearance(named: .aqua) + window.appearanceSource = staleSource + window.contentView = bridge + + let pulseMatchesEffectiveAppearance = window.appearance === effectiveAppearance + let sourceIsApplication = (window.appearanceSource as AnyObject?) === application + #expect(pulseMatchesEffectiveAppearance) + #expect(sourceIsApplication) + #expect(resetCapture.actions.count == 1) + + resetCapture.actions[0]() + + #expect(window.appearance == nil) + #expect(window.viewsNeedDisplay) + } + + @Test + func `repeated theme updates cannot leave an explicit appearance`() { + let resetCapture = ResetCapture() + let bridge = SettingsWindowAppearanceView { resetCapture.actions.append($0) } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled], + backing: .buffered, + defer: false) + window.contentView = bridge + + bridge.refreshWindowAppearance(for: .light) + bridge.refreshWindowAppearance(for: .light) + bridge.refreshWindowAppearance(for: .dark) + #expect(resetCapture.actions.count == 3) + for action in resetCapture.actions { + action() + } + + let sourceIsApplication = (window.appearanceSource as AnyObject?) === NSApplication.shared + #expect(window.appearance == nil) + #expect(sourceIsApplication) + } +} + +@MainActor +private final class ResetCapture { + var actions: [SettingsWindowAppearance.ResetAction] = [] +} diff --git a/Tests/CodexBarTests/SpawnedProcessGroupTests.swift b/Tests/CodexBarTests/SpawnedProcessGroupTests.swift new file mode 100644 index 0000000000..f0906bfb6d --- /dev/null +++ b/Tests/CodexBarTests/SpawnedProcessGroupTests.swift @@ -0,0 +1,604 @@ +import Foundation +import Testing +@testable import CodexBarCore +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +struct SpawnedProcessGroupTests { + @Test + func `pipe cleanup preserves standard descriptors`() { + let descriptors = SpawnedProcessGroup.pipeDescriptorsToClose([0, 1, 2, 3, 4, 3]) + + #expect(descriptors == [3, 4]) + } + + @Test + func `musl close-from selects numeric descriptors at or above minimum`() throws { + let descriptors = try PosixSpawnFileActionsCloseFrom.descriptorsToClose(startingAt: 4) { path in + #expect(path == "/proc/self/fd") + return ["8", "cwd", "3", "4"] + } + + #expect(descriptors == [4, 8]) + } + + @Test + func `musl close-from fails when descriptor enumeration fails`() { + #expect(throws: PosixSpawnFileActionsCloseFrom.CloseFromError.self) { + try PosixSpawnFileActionsCloseFrom.descriptorsToClose(startingAt: 3) { _ in + throw CocoaError(.fileReadNoPermission) + } + } + } + + @Test + func `launch captures child output`() async throws { + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe) + let stderrCapture = ProcessPipeCapture(pipe: stderrPipe) + stdoutCapture.start() + stderrCapture.start() + + let process = try SpawnedProcessGroup.launch( + binary: "/bin/sh", + arguments: ["-c", "printf stdout-value; printf stderr-value >&2"], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + while process.isRunning { + try await Task.sleep(for: .milliseconds(20)) + } + await process.terminateResidualProcesses() + await process.finish() + + async let stdout = stdoutCapture.finish(timeout: .seconds(1)) + async let stderr = stderrCapture.finish(timeout: .seconds(1)) + let output = await (stdout, stderr) + + #expect(process.terminationStatus == 0) + #expect(String(data: output.0, encoding: .utf8) == "stdout-value") + #expect(String(data: output.1, encoding: .utf8) == "stderr-value") + } + + @Test + func `launch closes unrelated parent descriptors`() async throws { + let sourceFD = open("/dev/null", O_RDONLY) + let inheritedFD = fcntl(sourceFD, F_DUPFD, 200) + close(sourceFD) + let resolvedFD = try #require(inheritedFD >= 200 ? inheritedFD : nil) + defer { close(resolvedFD) } + _ = fcntl(resolvedFD, F_SETFD, 0) + + #if canImport(Darwin) + let descriptorPath = "/dev/fd/\(resolvedFD)" + #else + let descriptorPath = "/proc/self/fd/\(resolvedFD)" + #endif + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/bin/sh", + arguments: ["-c", "test ! -e \(descriptorPath)"], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + while process.isRunning { + try await Task.sleep(for: .milliseconds(20)) + } + await process.terminateResidualProcesses() + await process.finish() + + #expect(process.terminationStatus == 0) + } + + @Test + func `termination waits for grace before killing escaped descendants`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-\(UUID().uuidString).pid") + defer { try? FileManager.default.removeItem(at: childPIDFile) } + + let script = """ + import subprocess + import sys + import time + + child = subprocess.Popen( + [ + sys.executable, + "-c", + "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)", + ], + start_new_session=True, + ) + with open(sys.argv[1], "w") as handle: + handle.write(str(child.pid)) + time.sleep(30) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, childPIDFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + var childPID: pid_t? + for _ in 0..<500 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + guard let escapedPID = childPID else { + await process.terminate(grace: 0) + Issue.record("Timed out waiting for escaped child PID") + return + } + defer { _ = kill(escapedPID, SIGKILL) } + + let start = Date() + await process.terminate(grace: 0.3) + let elapsed = Date().timeIntervalSince(start) + + #expect(elapsed >= 0.25, "Termination should honor the grace period before SIGKILL") + #expect(kill(escapedPID, 0) == -1) + } + + @Test + func `termination kills reparented process group members after root exit`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-member-\(UUID().uuidString).pid") + defer { try? FileManager.default.removeItem(at: childPIDFile) } + + let script = """ + import os + import signal + import sys + import time + + intermediate = os.fork() + if intermediate == 0: + child = os.fork() + if child > 0: + os._exit(0) + os.close(1) + os.close(2) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(sys.argv[1], "w") as handle: + handle.write(str(os.getpid())) + time.sleep(30) + os._exit(0) + + os.waitpid(intermediate, 0) + time.sleep(30) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, childPIDFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let reparentedPID = try #require(childPID) + defer { _ = kill(reparentedPID, SIGKILL) } + + await process.terminate(grace: 0.2) + + #expect(kill(reparentedPID, 0) == -1) + } + + @Test + func `termination gives reparented process group members grace`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-grace-\(UUID().uuidString).pid") + let termReceivedFile = childPIDFile.appendingPathExtension("term") + let gracefulExitFile = childPIDFile.appendingPathExtension("graceful") + defer { + try? FileManager.default.removeItem(at: childPIDFile) + try? FileManager.default.removeItem(at: termReceivedFile) + try? FileManager.default.removeItem(at: gracefulExitFile) + } + + let script = """ + import os + import signal + import sys + import time + + intermediate = os.fork() + if intermediate == 0: + child = os.fork() + if child > 0: + os._exit(0) + os.close(1) + os.close(2) + def handle_term(_signal, _frame): + with open(sys.argv[2], "w") as handle: + handle.write("term") + time.sleep(0.1) + with open(sys.argv[3], "w") as handle: + handle.write("graceful") + os._exit(0) + signal.signal(signal.SIGTERM, handle_term) + signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM}) + with open(sys.argv[1], "w") as handle: + handle.write(str(os.getpid())) + time.sleep(30) + os._exit(0) + + os.waitpid(intermediate, 0) + time.sleep(30) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, childPIDFile.path, termReceivedFile.path, gracefulExitFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let reparentedPID = try #require(childPID) + defer { _ = kill(reparentedPID, SIGKILL) } + for _ in 0..<100 + where TTYProcessTreeTerminator.descendantPIDs(of: process.pid).contains(reparentedPID) + { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(!TTYProcessTreeTerminator.descendantPIDs(of: process.pid).contains(reparentedPID)) + #expect(getpgid(reparentedPID) == process.processGroup) + + await process.terminate(grace: 0.3) + + #expect(FileManager.default.fileExists(atPath: termReceivedFile.path)) + #expect(FileManager.default.fileExists(atPath: gracefulExitFile.path)) + #expect(kill(reparentedPID, 0) == -1) + } + + @Test + func `residual termination cleans same group helpers spawned during SIGTERM`() async throws { + let readyFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-term-\(UUID().uuidString).ready") + let childPIDFile = readyFile.appendingPathExtension("pid") + let heartbeatFile = readyFile.appendingPathExtension("heartbeat") + defer { + try? FileManager.default.removeItem(at: readyFile) + try? FileManager.default.removeItem(at: childPIDFile) + try? FileManager.default.removeItem(at: heartbeatFile) + } + + let script = """ + import os + import signal + import sys + import time + + def handle_term(_signal, _frame): + reader, writer = os.pipe() + child = os.fork() + if child == 0: + os.close(reader) + os.close(1) + os.close(2) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(sys.argv[2], "w") as handle: + handle.write(str(os.getpid())) + with open(sys.argv[3], "w") as heartbeat: + heartbeat.write("1") + heartbeat.flush() + os.write(writer, b"1") + os.close(writer) + counter = 1 + while True: + counter += 1 + heartbeat.seek(0) + heartbeat.write(str(counter)) + heartbeat.truncate() + heartbeat.flush() + time.sleep(0.02) + os.close(writer) + os.read(reader, 1) + os.close(reader) + os._exit(0) + + signal.signal(signal.SIGTERM, handle_term) + signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM}) + with open(sys.argv[1], "w") as handle: + handle.write("ready") + time.sleep(30) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, readyFile.path, childPIDFile.path, heartbeatFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + for _ in 0..<100 where !FileManager.default.fileExists(atPath: readyFile.path) { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(FileManager.default.fileExists(atPath: readyFile.path)) + + await process.terminateResidualProcesses(grace: 0.2) + await process.finish() + + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let resolvedChildPID = try #require(childPID) + defer { _ = kill(resolvedChildPID, SIGKILL) } + let heartbeatAfterCleanup = try String(contentsOf: heartbeatFile, encoding: .utf8) + try await Task.sleep(for: .milliseconds(200)) + let heartbeatAfterSettle = try String(contentsOf: heartbeatFile, encoding: .utf8) + #expect(heartbeatAfterSettle == heartbeatAfterCleanup) + } + + @Test + func `normal exit cleans a session escaped output holder`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-holder-\(UUID().uuidString).pid") + defer { try? FileManager.default.removeItem(at: childPIDFile) } + + let script = """ + import subprocess + import sys + + child = subprocess.Popen( + [ + sys.executable, + "-c", + "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)", + ], + start_new_session=True, + ) + with open(sys.argv[1], "w") as handle: + handle.write(str(child.pid)) + print("parent complete", flush=True) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, childPIDFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + while process.isRunning { + try await Task.sleep(for: .milliseconds(20)) + } + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let resolvedChildPID = try #require(childPID) + defer { _ = kill(resolvedChildPID, SIGKILL) } + #expect(kill(resolvedChildPID, 0) == 0) + + await process.terminateResidualProcesses(grace: 0.2) + await process.finish() + + #expect(kill(resolvedChildPID, 0) == -1) + } + + @Test + func `normal exit cleans a same group helper without output pipes`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-holder-\(UUID().uuidString).pid") + let heartbeatFile = childPIDFile.appendingPathExtension("heartbeat") + defer { + try? FileManager.default.removeItem(at: childPIDFile) + try? FileManager.default.removeItem(at: heartbeatFile) + } + + let script = """ + import os + import signal + import sys + import time + + child = os.fork() + if child == 0: + os.close(1) + os.close(2) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(sys.argv[1], "w") as handle: + handle.write(str(os.getpid())) + counter = 0 + with open(sys.argv[2], "w") as heartbeat: + while True: + counter += 1 + heartbeat.seek(0) + heartbeat.write(str(counter)) + heartbeat.truncate() + heartbeat.flush() + time.sleep(0.02) + os._exit(0) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, childPIDFile.path, heartbeatFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + while process.isRunning { + try await Task.sleep(for: .milliseconds(20)) + } + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let resolvedChildPID = try #require(childPID) + defer { _ = kill(resolvedChildPID, SIGKILL) } + #expect(getpgid(resolvedChildPID) == process.processGroup) + #expect(kill(resolvedChildPID, 0) == 0) + for _ in 0..<100 where !FileManager.default.fileExists(atPath: heartbeatFile.path) { + try await Task.sleep(for: .milliseconds(20)) + } + let heartbeatBefore = try String(contentsOf: heartbeatFile, encoding: .utf8) + var heartbeatWhileRunning = heartbeatBefore + for _ in 0..<100 where heartbeatWhileRunning == heartbeatBefore { + try await Task.sleep(for: .milliseconds(20)) + heartbeatWhileRunning = try String(contentsOf: heartbeatFile, encoding: .utf8) + } + #expect(heartbeatWhileRunning != heartbeatBefore) + + await process.terminateResidualProcesses(grace: 0.2) + await process.finish() + + try await Task.sleep(for: .milliseconds(100)) + let heartbeatAfterCleanup = try String(contentsOf: heartbeatFile, encoding: .utf8) + try await Task.sleep(for: .milliseconds(200)) + let heartbeatAfterSettle = try String(contentsOf: heartbeatFile, encoding: .utf8) + #expect(heartbeatAfterSettle == heartbeatAfterCleanup) + } + + @Test + func `normal exit cleanup catches helper spawned during SIGTERM`() async throws { + let readyFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-process-group-post-exit-\(UUID().uuidString).ready") + let childPIDFile = readyFile.appendingPathExtension("pid") + let heartbeatFile = readyFile.appendingPathExtension("heartbeat") + defer { + try? FileManager.default.removeItem(at: readyFile) + try? FileManager.default.removeItem(at: childPIDFile) + try? FileManager.default.removeItem(at: heartbeatFile) + } + + let script = """ + import os + import signal + import sys + import time + + helper = os.fork() + if helper == 0: + os.close(1) + os.close(2) + def handle_term(_signal, _frame): + reader, writer = os.pipe() + child = os.fork() + if child == 0: + os.close(reader) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + with open(sys.argv[2], "w") as handle: + handle.write(str(os.getpid())) + with open(sys.argv[3], "w") as heartbeat: + heartbeat.write("1") + heartbeat.flush() + os.write(writer, b"1") + os.close(writer) + counter = 1 + while True: + counter += 1 + heartbeat.seek(0) + heartbeat.write(str(counter)) + heartbeat.truncate() + heartbeat.flush() + time.sleep(0.02) + os.close(writer) + os.read(reader, 1) + os.close(reader) + os._exit(0) + + signal.signal(signal.SIGTERM, handle_term) + signal.pthread_sigmask(signal.SIG_UNBLOCK, {signal.SIGTERM}) + with open(sys.argv[1], "w") as handle: + handle.write("ready") + time.sleep(30) + os._exit(0) + + while not os.path.exists(sys.argv[1]): + time.sleep(0.01) + os._exit(0) + """ + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let process = try SpawnedProcessGroup.launch( + binary: "/usr/bin/python3", + arguments: ["-c", script, readyFile.path, childPIDFile.path, heartbeatFile.path], + environment: ProcessInfo.processInfo.environment, + stdoutPipe: stdoutPipe, + stderrPipe: stderrPipe) + + while process.isRunning { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(FileManager.default.fileExists(atPath: readyFile.path)) + + await process.terminateResidualProcesses(grace: 0.2) + await process.finish() + + var childPID: pid_t? + for _ in 0..<100 { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let parsedPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + childPID = parsedPID + break + } + try await Task.sleep(for: .milliseconds(20)) + } + let resolvedChildPID = try #require(childPID) + defer { _ = kill(resolvedChildPID, SIGKILL) } + let heartbeatAfterCleanup = try String(contentsOf: heartbeatFile, encoding: .utf8) + try await Task.sleep(for: .milliseconds(200)) + let heartbeatAfterSettle = try String(contentsOf: heartbeatFile, encoding: .utf8) + #expect(heartbeatAfterSettle == heartbeatAfterCleanup) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift b/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift new file mode 100644 index 0000000000..25dc34b444 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift @@ -0,0 +1,51 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusItemAnimationCodexCreditsTests { + @Test + func `codex icon keeps credits only rendering when usage is missing`() { + let settings = testSettingsStore(suiteName: "StatusItemAnimationTests-credits-only-icon") + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + controller.applyIcon(for: .codex, phase: nil) + + guard let image = controller.statusItems[.codex]?.button?.image else { + #expect(Bool(false)) + return + } + let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + #expect(rep != nil) + guard let rep else { return } + + let creditsOnlyAlpha = (rep.colorAt(x: 18, y: 17) ?? .clear).alphaComponent + #expect(creditsOnlyAlpha > 0.05) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift new file mode 100644 index 0000000000..a2451ff737 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -0,0 +1,503 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemAnimationSignatureTests { + @Test + func `merged render signature changes when unified icon style changes`() { + let suite = "StatusItemAnimationSignatureTests-merged-style-signature" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic]) + #expect(store.enabledProviders() == [.codex, .synthetic]) + #expect(store.iconStyle == .combined) + controller.applyIcon(phase: nil) + let combinedSignature = controller.lastAppliedMergedIconRenderSignature + + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: false) + } + + #expect(store.enabledProvidersForDisplay() == [.codex]) + #expect(store.enabledProviders() == [.codex]) + #expect(store.iconStyle == .codex) + controller.applyIcon(phase: nil) + let codexSignature = controller.lastAppliedMergedIconRenderSignature + + #expect(combinedSignature != nil) + #expect(codexSignature != nil) + #expect(combinedSignature != codexSignature) + #expect(codexSignature?.contains("style=codex") == true) + } + + @Test + func `merged antigravity icon resolves quota summary with provider style`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-antigravity-provider-style" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .antigravity + settings.menuBarShowsBrandIconWithPercent = false + settings.usageBarsShowUsed = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 16, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Session", + window: RateWindow(usedPercent: 1, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Weekly", + window: RateWindow( + usedPercent: 99, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude + GPT Session", + window: RateWindow(usedPercent: 2, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude + GPT Weekly", + window: RateWindow( + usedPercent: 16, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()), + provider: .antigravity) + + #expect(store.iconStyle == .combined) + #expect(controller.primaryProviderForUnifiedIcon() == .antigravity) + + controller.applyIcon(phase: nil) + let signature = try #require(controller.lastAppliedMergedIconRenderSignature) + + #expect(signature.contains("provider=antigravity")) + #expect(signature.contains("style=combined")) + #expect(signature.contains("primary=99.000")) + #expect(signature.contains("weekly=1.000")) + } + + @Test + func `merged brand percent reapplies title when cached render is skipped`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-brand-percent-title-restore" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 23, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let displayText = try #require(controller.menuBarDisplayText(for: .codex, snapshot: snapshot)) + let expectedTitle = StatusItemController.buttonTitle(displayText, hasImage: true) + controller.applyIcon(phase: nil) + let button = try #require(controller.statusItem.button) + #expect(button.title == expectedTitle) + #expect(button.imagePosition == .imageLeft) + + button.title = "" + button.imagePosition = .imageOnly + + let skipped = controller.applyIcon(phase: nil) + + #expect(skipped) + #expect(button.title == expectedTitle) + #expect(button.imagePosition == .imageLeft) + } + + @Test + func `merged icon render defers while merged menu is tracking`() async throws { + let suite = "StatusItemAnimationSignatureTests-merged-icon-defers-during-tracking" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .synthetic) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + func snapshot(usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + } + + store._setSnapshotForTesting(snapshot(usedPercent: 20), provider: .codex) + controller.updateIcons() + #expect(controller.animationDriver == nil) + controller.applyIcon(phase: nil) + let initialSignature = try #require(controller.lastAppliedMergedIconRenderSignature) + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.menuWillOpen(menu) + #expect(controller.isMergedMenuOpen) + + store._setSnapshotForTesting(nil, provider: .codex) + controller.updateIcons() + #expect(controller.animationDriver != nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + + store._setSnapshotForTesting(snapshot(usedPercent: 80), provider: .codex) + controller.updateIcons() + #expect(controller.animationDriver == nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + #expect(controller.lastAppliedMergedIconRenderSignature == initialSignature) + + controller.startQuotaWarningFlash(provider: .codex) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=1") == true) + + let quotaWarningTask = controller.quotaWarningFlashTasks[.codex] + controller.clearExpiredQuotaWarningFlash(provider: .codex, now: .distantFuture) + quotaWarningTask?.cancel() + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=0") == true) + + controller.menuDidClose(menu) + + #expect(!controller.deferredMergedIconRenderAfterTracking) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=0") == true) + + controller.menuWillOpen(menu) + settings.selectedMenuProvider = .synthetic + #expect(controller.primaryProviderForUnifiedIcon() == .synthetic) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + + controller.startQuotaWarningFlash(provider: .codex) + let switchedProviderWarningTask = controller.quotaWarningFlashTasks[.codex] + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=synthetic") == true) + controller.clearExpiredQuotaWarningFlash(provider: .codex, now: .distantFuture) + switchedProviderWarningTask?.cancel() + controller.menuDidClose(menu) + + settings.selectedMenuProvider = .codex + for _ in 0..<10 where controller.primaryProviderForUnifiedIcon() != .codex { + await Task.yield() + } + + controller.menuWillOpen(menu) + store._setSnapshotForTesting(nil, provider: .codex) + controller.updateAnimationState() + controller.applyIcon(phase: controller.animationPhase) + #expect(controller.animationDriver != nil) + #expect(controller.deferredMergedIconRenderAfterTracking) + + controller.animationDriver?.stop() + controller.animationDriver = nil + controller.animationPhase = 0 + controller.menuDidClose(menu) + + #expect(controller.animationDriver == nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("primary=nil") == true) + } + + @Test + func `merged fallback provider follows enabled provider order`() { + let suite = "StatusItemAnimationSignatureTests-merged-provider-order" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + settings.setProviderOrder([.synthetic, .codex]) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .synthetic) + + controller.applyIcon(phase: nil) + + #expect(store.enabledProviders().prefix(2) == [.synthetic, .codex]) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=synthetic") == true) + } + + @Test + func `merged icon status indicator follows rendered provider`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-status-provider-scope" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = true + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + let codexMeta = try #require(registry.metadata[.codex]) + let claudeMeta = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .claude) + store.statuses[.claude] = ProviderStatus( + indicator: .major, + description: "Claude status issue", + updatedAt: Date(timeIntervalSince1970: 20)) + + controller.applyIcon(phase: nil) + + #expect(controller.primaryProviderForUnifiedIcon() == .codex) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("status=none") == true) + + settings.selectedMenuProvider = .claude + controller.applyIcon(phase: nil) + + #expect(controller.primaryProviderForUnifiedIcon() == .claude) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("status=major") == true) + } + + @Test + func `merged icon follows overview provider order when first overview provider is loading`() { + let suite = "StatusItemAnimationSignatureTests-merged-overview-provider-order" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + settings.menuBarShowsBrandIconWithPercent = false + settings.setProviderOrder([.cursor, .codex, .claude]) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .claude) + + #expect(store.enabledProvidersForDisplay().prefix(3) == [.cursor, .codex, .claude]) + #expect(settings.resolvedMergedOverviewProviders(activeProviders: store.enabledProvidersForDisplay()) == [ + .cursor, + .codex, + .claude, + ]) + + controller.applyIcon(phase: nil) + + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=cursor") == true) + } + + @Test + func `split provider icon skips unchanged render signature`() { + let suite = "StatusItemAnimationSignatureTests-split-provider-signature" + let settings = testSettingsStore(suiteName: suite) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(controller.applyIcon(for: .codex, phase: nil) == false) + #expect(controller.applyIcon(for: .codex, phase: nil) == true) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(controller.applyIcon(for: .codex, phase: nil) == false) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index 0e6f0e33f9..f019d0f71c 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -4,7 +4,8 @@ import Testing @testable import CodexBar @MainActor -@Suite +@Suite(.serialized) +// swiftlint:disable:next type_body_length struct StatusItemAnimationTests { private func maxAlpha(in rep: NSBitmapImageRep) -> CGFloat { var maxAlpha: CGFloat = 0 @@ -20,15 +21,13 @@ struct StatusItemAnimationTests { } private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } @Test - func mergedIconLoadingAnimationTracksSelectedProviderOnly() { + func `merged icon loading animation tracks selected provider only`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-merged"), zaiTokenStore: NoopZaiTokenStore(), @@ -42,9 +41,10 @@ struct StatusItemAnimationTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) } + settings.openRouterAPIToken = "or-token" if let geminiMeta = registry.metadata[.gemini] { settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) } @@ -58,6 +58,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -73,7 +74,7 @@ struct StatusItemAnimationTests { } @Test - func mergedIconLoadingAnimationDoesNotFlipLayoutWhenWeeklyHitsZero() { + func `merged icon loading animation does not flip layout when weekly hits zero`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-weekly"), zaiTokenStore: NoopZaiTokenStore(), @@ -88,9 +89,10 @@ struct StatusItemAnimationTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) } + settings.openRouterAPIToken = "or-token" let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -109,6 +111,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Enter loading state: no data, no stale error. store._setSnapshotForTesting(nil, provider: .codex) @@ -138,7 +141,7 @@ struct StatusItemAnimationTests { } @Test - func warpNoBonusLayoutIsPreservedInShowUsedModeWhenBonusIsExhausted() { + func `warp no bonus layout is preserved in show used mode when bonus is exhausted`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-warp-no-bonus-used"), zaiTokenStore: NoopZaiTokenStore(), @@ -163,6 +166,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Primary used=10%. Bonus exhausted: used=100% (remaining=0%). let snapshot = UsageSnapshot( @@ -191,7 +195,7 @@ struct StatusItemAnimationTests { } @Test - func warpBonusLaneIsPreservedInShowUsedModeWhenBonusIsUnused() { + func `warp bonus lane is preserved in show used mode when bonus is unused`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-warp-unused-bonus-used"), zaiTokenStore: NoopZaiTokenStore(), @@ -216,6 +220,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Bonus exists but is unused: used=0% (remaining=100%). let snapshot = UsageSnapshot( @@ -244,7 +249,139 @@ struct StatusItemAnimationTests { } @Test - func menuBarPercentUsesConfiguredMetric() { + func `open router without key limit uses meter icon when brand percent is disabled`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-openrouter-no-limit-meter"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) + } + settings.openRouterAPIToken = "or-token" + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45, + balance: 5, + usedPercent: 90, + keyDataFetched: true, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + controller.applyIcon(for: .openrouter, phase: nil) + + guard let image = controller.statusItems[.openrouter]?.button?.image else { + #expect(Bool(false)) + return + } + + #expect(image.size.width == 18) + #expect(image.size.height == 18) + #expect(snapshot.openRouterUsage?.keyQuotaStatus == .noLimitConfigured) + #expect(controller.statusItems[.openrouter]?.button?.title.isEmpty == true) + #expect(MenuBarDisplayText.percentText(window: snapshot.primary, showUsed: false) == nil) + + // With no key limit, the primary bar has no fill — just the dim track. + // A brand logo would be fully opaque here; the track is not. + let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + #expect(rep != nil) + if let rep { + let alpha = (rep.colorAt(x: 8, y: 25) ?? .clear).alphaComponent + #expect(alpha < 0.5) + } + } + + @Test + func `open router key data not fetched still uses meter icon when brand percent is disabled`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-openrouter-no-fetch-meter"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) + } + settings.openRouterAPIToken = "or-token" + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45, + balance: 5, + usedPercent: 90, + keyDataFetched: false, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + controller.applyIcon(for: .openrouter, phase: nil) + + guard let image = controller.statusItems[.openrouter]?.button?.image else { + #expect(Bool(false)) + return + } + + #expect(image.size.width == 18) + #expect(image.size.height == 18) + #expect(snapshot.openRouterUsage?.keyQuotaStatus == .unavailable) + + // Even with no key data, OpenRouter still renders a meter rather than the brand logo. + // A brand logo would be fully opaque here; the unfilled track is not. + let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + #expect(rep != nil) + if let rep { + let alpha = (rep.colorAt(x: 8, y: 25) ?? .clear).alphaComponent + #expect(alpha < 0.5) + } + } + + @Test + func `menu bar percent uses configured metric`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-metric"), zaiTokenStore: NoopZaiTokenStore()) @@ -268,6 +405,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -283,7 +421,48 @@ struct StatusItemAnimationTests { } @Test - func menuBarPercentAutomaticPrefersRateLimitForKimi() { + func `combined codex menu bar metric window uses most constrained visible lane`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-codex-combined-window"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .codex) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 91, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + let window = controller.menuBarMetricWindow(for: .codex, snapshot: snapshot) + + #expect(window?.usedPercent == 91) + #expect(window?.windowMinutes == 7 * 24 * 60) + } + + @Test + func `menu bar percent automatic prefers rate limit for kimi`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-kimi-automatic"), zaiTokenStore: NoopZaiTokenStore()) @@ -322,7 +501,7 @@ struct StatusItemAnimationTests { } @Test - func menuBarPercentUsesAverageForGemini() { + func `menu bar percent uses average for gemini`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-average"), zaiTokenStore: NoopZaiTokenStore()) @@ -346,6 +525,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -361,7 +541,370 @@ struct StatusItemAnimationTests { } @Test - func menuBarDisplayTextFormatsPercentAndPace() { + func `menu bar percent automatic keeps gemini primary over higher tertiary`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-gemini-automatic-primary"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .gemini + settings.setMenuBarMetricPreference(.automatic, for: .gemini) + + let registry = ProviderRegistry.shared + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 95, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .gemini) + store._setErrorForTesting(nil, provider: .gemini) + + let window = controller.menuBarMetricWindow(for: .gemini, snapshot: snapshot) + + #expect(window?.usedPercent == 20) + } + + @Test + func `menu bar percent automatic picks highest cursor lane including api`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-cursor-automatic-tertiary"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .cursor + settings.setMenuBarMetricPreference(.automatic, for: .cursor) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 90) + } + + @Test + func `menu bar percent automatic falls back to purchased perplexity lane when bonus is exhausted`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-perplexity-automatic-purchased"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .perplexity + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .perplexity) + store._setErrorForTesting(nil, provider: .perplexity) + + let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot) + + #expect(window?.usedPercent == 20) + } + + @Test + func `menu bar percent automatic falls through after recurring perplexity credits are exhausted`() { + let settings = SettingsStore( + configStore: testConfigStore( + suiteName: "StatusItemAnimationTests-perplexity-automatic-recurring-exhausted"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .perplexity + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 32, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .perplexity) + store._setErrorForTesting(nil, provider: .perplexity) + + let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot) + + #expect(window?.usedPercent == 32) + } + + @Test + func `menu bar percent automatic prefers purchased perplexity credits before bonus`() { + let settings = SettingsStore( + configStore: testConfigStore( + suiteName: "StatusItemAnimationTests-perplexity-automatic-purchased-before-bonus"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .perplexity + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .perplexity) + store._setErrorForTesting(nil, provider: .perplexity) + + let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot) + + #expect(window?.usedPercent == 45) + } + + @Test + func `menu bar percent primary preference stays on recurring perplexity credits`() { + let settings = SettingsStore( + configStore: testConfigStore( + suiteName: "StatusItemAnimationTests-perplexity-primary-recurring-exhausted"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .perplexity + settings.setMenuBarMetricPreference(.primary, for: .perplexity) + + let registry = ProviderRegistry.shared + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 32, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .perplexity) + store._setErrorForTesting(nil, provider: .perplexity) + + let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot) + + #expect(window?.usedPercent == 100) + } + + @Test + func `menu bar percent tertiary preference uses purchased perplexity lane`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-perplexity-tertiary-pref"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .perplexity + settings.setMenuBarMetricPreference(.tertiary, for: .perplexity) + + let registry = ProviderRegistry.shared + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 28, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .perplexity) + store._setErrorForTesting(nil, provider: .perplexity) + + let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot) + + #expect(window?.usedPercent == 28) + } + + @Test + func `menu bar percent tertiary preference uses api lane for cursor`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-cursor-tertiary-pref"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .cursor + settings.setMenuBarMetricPreference(.tertiary, for: .cursor) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 72) + } + + @Test + func `menu bar tertiary preference falls back to automatic when cursor api lane is missing`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-cursor-tertiary-missing-api"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .cursor + settings.setMenuBarMetricPreference(.tertiary, for: .cursor) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 72) + } + + @Test + func `menu bar display text formats percent and pace`() { let now = Date(timeIntervalSince1970: 0) let percentWindow = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let paceWindow = RateWindow( @@ -393,14 +936,49 @@ struct StatusItemAnimationTests { } @Test - func menuBarDisplayTextHidesWhenPaceUnavailable() { - let now = Date(timeIntervalSince1970: 0) + func `menu bar display text formats codex combined percent lanes`() { + let sessionWindow = RateWindow(usedPercent: 7, windowMinutes: 300, resetsAt: nil, resetDescription: nil) + let weeklyWindow = RateWindow(usedPercent: 18, windowMinutes: 10080, resetsAt: nil, resetDescription: nil) + + let remaining = MenuBarDisplayText.codexCombinedPercentText( + sessionWindow: sessionWindow, + weeklyWindow: weeklyWindow, + showUsed: false) + let used = MenuBarDisplayText.codexCombinedPercentText( + sessionWindow: sessionWindow, + weeklyWindow: weeklyWindow, + showUsed: true) + let weeklyOnly = MenuBarDisplayText.codexCombinedPercentText( + sessionWindow: nil, + weeklyWindow: weeklyWindow, + showUsed: false) + let nineHour = MenuBarDisplayText.codexCombinedPercentText( + sessionWindow: RateWindow( + usedPercent: 7, + windowMinutes: 540, + resetsAt: nil, + resetDescription: nil), + weeklyWindow: weeklyWindow, + showUsed: false) + let unknownSessionDuration = MenuBarDisplayText.codexCombinedPercentText( + sessionWindow: RateWindow( + usedPercent: 7, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + weeklyWindow: weeklyWindow, + showUsed: false) + + #expect(remaining == "5h 93% · W 82%") + #expect(used == "5h 7% · W 18%") + #expect(weeklyOnly == "W 82%") + #expect(nineHour == "9h 93% · W 82%") + #expect(unknownSessionDuration == "S 93% · W 82%") + } + + @Test + func `menu bar display text falls back to percent when pace unavailable`() { let percentWindow = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - let paceWindow = RateWindow( - usedPercent: 30, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(60 * 60 * 24 * 6), - resetDescription: nil) let pace = MenuBarDisplayText.displayText( mode: .pace, @@ -411,19 +989,13 @@ struct StatusItemAnimationTests { percentWindow: percentWindow, showUsed: true) - #expect(pace == nil) - #expect(both == nil) + #expect(pace == "40%") + #expect(both == "40%") } @Test - func menuBarDisplayTextRequiresProvidedPaceForCodex() { - let now = Date(timeIntervalSince1970: 0) + func `menu bar display text falls back to percent when pace nil for codex`() { let percentWindow = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - let paceWindow = RateWindow( - usedPercent: 30, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(60 * 60 * 24 * 6), - resetDescription: nil) let pace = MenuBarDisplayText.displayText( mode: .pace, @@ -436,12 +1008,110 @@ struct StatusItemAnimationTests { pace: nil, showUsed: true) - #expect(pace == nil) - #expect(both == nil) + #expect(pace == "40%") + #expect(both == "40%") + } + + @Test + func `claude primary menu bar metric computes pace from selected session window`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-claude-primary-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primary, for: .claude) + + let registry = ProviderRegistry.shared + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(24 * 60 * 60), + resetDescription: nil), + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + #expect(displayText == "20% · +60%") + } + + @Test + func `codex menu bar pace does not fall back to session when weekly projection is unavailable`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-codex-no-weekly-pace"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.primary, for: .codex) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: nil, + updatedAt: now) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setErrorForTesting(nil, provider: .codex) + + let displayText = controller.menuBarDisplayText(for: .codex, snapshot: snapshot) + + #expect(displayText == "20%") } @Test - func menuBarDisplayTextUsesCreditsWhenCodexWeeklyIsExhausted() { + func `menu bar display text uses credits when codex weekly is exhausted`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-fallback"), zaiTokenStore: NoopZaiTokenStore()) @@ -487,7 +1157,7 @@ struct StatusItemAnimationTests { } @Test - func menuBarDisplayTextUsesCreditsWhenCodexSessionIsExhausted() { + func `menu bar display text uses credits when codex session is exhausted`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-fallback-session"), zaiTokenStore: NoopZaiTokenStore()) @@ -533,7 +1203,7 @@ struct StatusItemAnimationTests { } @Test - func menuBarDisplayTextShowsZeroPercentForKiloZeroTotalEdge() { + func `menu bar display text shows zero percent for kilo zero total edge`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-kilo-zero-edge"), zaiTokenStore: NoopZaiTokenStore(), @@ -579,7 +1249,7 @@ struct StatusItemAnimationTests { } @Test - func brandImageWithStatusOverlayReturnsOriginalImageWhenNoIssue() { + func `brand image with status overlay returns original image when no issue`() { let brand = NSImage(size: NSSize(width: 16, height: 16)) brand.isTemplate = true @@ -589,7 +1259,7 @@ struct StatusItemAnimationTests { } @Test - func brandImageWithStatusOverlayDrawsIssueMark() throws { + func `brand image with status overlay draws issue mark`() throws { let size = NSSize(width: 16, height: 16) let brand = NSImage(size: size) brand.lockFocus() diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift new file mode 100644 index 0000000000..2e0670ed6e --- /dev/null +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -0,0 +1,647 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusItemBalanceDisplayTests { + @Test + func `menu bar display text uses open router balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-openrouter-balance", + provider: .openrouter) + settings.setMenuBarMetricPreference(.automatic, for: .openrouter) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.openRouterSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + let displayText = controller.menuBarDisplayText(for: .openrouter, snapshot: snapshot) + + #expect(displayText == "$12.34") + } + + @Test + func `reset time mode preserves automatic open router balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-openrouter-reset-time", + provider: .openrouter) + settings.menuBarDisplayMode = .resetTime + settings.setMenuBarMetricPreference(.automatic, for: .openrouter) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.openRouterSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + let displayText = controller.menuBarDisplayText(for: .openrouter, snapshot: snapshot) + + #expect(displayText == "$12.34") + } + + @Test + func `menu bar display text uses zen balance when open code has no subscription`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-opencodego-zen-only", + provider: .opencodego) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 23.75, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .opencodego) + store._setErrorForTesting(nil, provider: .opencodego) + + let displayText = controller.menuBarDisplayText(for: .opencodego, snapshot: snapshot) + + #expect(displayText == "$23.75") + } + + @Test + func `menu bar display text uses negative zen balance when open code is in deficit`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-opencodego-zen-deficit", + provider: .opencodego) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: -4.25, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .opencodego) + store._setErrorForTesting(nil, provider: .opencodego) + + let displayText = controller.menuBarDisplayText(for: .opencodego, snapshot: snapshot) + + #expect(displayText == "-$4.25") + } + + @Test + func `menu bar display text keeps open code subscription percentage`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-opencodego-subscription", + provider: .opencodego) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 23.75, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .opencodego) + store._setErrorForTesting(nil, provider: .opencodego) + + let displayText = controller.menuBarDisplayText(for: .opencodego, snapshot: snapshot) + + #expect(displayText == "12%") + } + + @Test + func `reset time mode preserves balance when provider has no quota window`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-moonshot-reset-time", + provider: .moonshot) + settings.menuBarDisplayMode = .resetTime + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .moonshot, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: $49.58 · $0.42 in deficit")) + + store._setSnapshotForTesting(snapshot, provider: .moonshot) + store._setErrorForTesting(nil, provider: .moonshot) + + let displayText = controller.menuBarDisplayText(for: .moonshot, snapshot: snapshot) + + #expect(displayText == "$49.58") + } + + @Test + func `menu bar display text respects open router primary metric preference`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-openrouter-primary-metric", + provider: .openrouter) + settings.setMenuBarMetricPreference(.primary, for: .openrouter) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.openRouterSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + let displayText = controller.menuBarDisplayText(for: .openrouter, snapshot: snapshot) + + #expect(displayText == "25%") + } + + @Test + func `menu bar display text skips exhausted cursor api subquota when total remains usable`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-cursor-exhausted-api", + provider: .cursor) + settings.usageBarsShowUsed = false + settings.setMenuBarMetricPreference(.automatic, for: .cursor) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 67, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "Total"), + secondary: RateWindow( + usedPercent: 34, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "Auto"), + tertiary: RateWindow(usedPercent: 100, windowMinutes: 30 * 24 * 60, resetsAt: nil, resetDescription: "API"), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "33%") + } + + @Test + func `menu bar display text uses deepseek balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-deepseek-balance", + provider: .deepseek) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$9.32 (Paid: $9.32 / Granted: $0.00)"), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .deepseek) + store._setErrorForTesting(nil, provider: .deepseek) + + let displayText = controller.menuBarDisplayText(for: .deepseek, snapshot: snapshot) + + #expect(displayText == "$9.32") + } + + @Test + func `menu bar display text uses mimo balance without token plan`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mimo-balance", + provider: .mimo) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + cashBalance: 20, + giftBalance: 5.51, + updatedAt: Date()) + .toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .mimo) + store._setErrorForTesting(nil, provider: .mimo) + + let displayText = controller.menuBarDisplayText(for: .mimo, snapshot: snapshot) + + #expect(displayText == "$25.51") + } + + @Test + func `menu bar display text uses selected mimo balance with token plan`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mimo-token-plan", + provider: .mimo) + settings.setMenuBarMetricPreference(.secondary, for: .mimo) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + tokenUsed: 10, + tokenLimit: 100, + tokenPercent: 0.1, + updatedAt: Date()) + .toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .mimo) + store._setErrorForTesting(nil, provider: .mimo) + + let displayText = controller.menuBarDisplayText(for: .mimo, snapshot: snapshot) + + #expect(displayText == "$25.51") + } + + @Test + func `menu bar display text uses moonshot balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-moonshot-balance", + provider: .moonshot) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .moonshot, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: $49.58 · $0.42 in deficit")) + + store._setSnapshotForTesting(snapshot, provider: .moonshot) + store._setErrorForTesting(nil, provider: .moonshot) + + let displayText = controller.menuBarDisplayText(for: .moonshot, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(displayText == "$49.58") + } + + @Test + func `menu bar display text uses mistral current month api spend`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mistral-spend", + provider: .mistral) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .mistral) + store._setErrorForTesting(nil, provider: .mistral) + + let displayText = controller.menuBarDisplayText(for: .mistral, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(snapshot.identity?.loginMethod == "API spend: €1.2345 this month") + #expect(displayText == "€1.2345") + } + + @Test + func `menu bar display text uses kimi k2 api key credits`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kimik2-credits", + provider: .kimik2) + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = KimiK2UsageSummary( + consumed: 75, + remaining: 1234.5, + averageTokens: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kimik2) + store._setErrorForTesting(nil, provider: .kimik2) + + let displayText = controller.menuBarDisplayText(for: .kimik2, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(snapshot.identity?.loginMethod == "Credits: 1234.5 left") + #expect(displayText == "1234.5") + } + + @Test + func `kiro menu bar automatic uses credits left`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-automatic", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .automatic + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83") + } + + @Test + func `kiro menu bar credits and percent combines values`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-both", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .creditsAndPercent + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83 · 0%") + } + + @Test + func `kiro menu bar hidden suppresses text value`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-hidden", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .hidden + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == nil) + } + + @Test + func `kiro menu bar used and total formats credits`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-used-total", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .usedAndTotal + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "0.17 / 50") + } + + @Test + func `kiro menu bar overage credits mode shows overage credits when exhausted`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-credits", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.exhaustedKiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "40.29 over") + } + + @Test + func `kiro menu bar overage cost mode shows cost when exhausted`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-cost", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.exhaustedKiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "$1.61 over") + } + + @Test + func `kiro menu bar overage credits and cost mode shows both when exhausted`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-both", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.exhaustedKiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "40.29 · $1.61") + } + + @Test + func `kiro menu bar overage mode keeps credits left before exhaustion`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-not-exhausted", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83") + } + + @Test + func `kiro menu bar overage mode ignores disabled overage values`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-disabled", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = Self.exhaustedKiroSnapshot(overagesStatus: "Disabled") + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "0") + } + + @Test + func `kiro managed plan display falls back to percent`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-managed", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .automatic + settings.usageBarsShowUsed = false + let (store, controller) = self.makeStoreAndController(settings: settings) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = KiroUsageSnapshot( + planName: "Q Developer Pro", + creditsUsed: 0, + creditsTotal: 0, + creditsPercent: 0, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + resetsAt: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "100%") + } + + @Test + func `mistral primary window is nil even when billing end date is set`() { + let endDate = Date(timeIntervalSinceNow: 3600) + let snapshot = MistralUsageSnapshot( + totalCost: 0.5, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 1000, + totalOutputTokens: 500, + totalCachedTokens: 0, + modelCount: 1, + startDate: nil, + endDate: endDate, + updatedAt: Date()).toUsageSnapshot() + + // Mistral doesn't expose a reset time — primary is always nil. + #expect(snapshot.primary == nil) + } + + @Test + func `button title spacing only applies when image is present`() { + #expect(StatusItemController.buttonTitle("42%", hasImage: true) == " 42%") + #expect(StatusItemController.buttonTitle("42%", hasImage: false) == "42%") + #expect(StatusItemController.buttonTitle(nil, hasImage: true).isEmpty) + #expect(StatusItemController.buttonTitle("", hasImage: true).isEmpty) + } + + private func makeSettings(suiteName: String, provider: UsageProvider) -> SettingsStore { + let settings = testSettingsStore(suiteName: suiteName) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = provider + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = true + + let registry = ProviderRegistry.shared + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: true) + } + return settings + } + + private func makeStoreAndController(settings: SettingsStore) -> (UsageStore, StatusItemController) { + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + return (store, controller) + } + + private static func openRouterSnapshot() -> UsageSnapshot { + OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 37.66, + balance: 12.34, + usedPercent: 75.32, + keyLimit: 20, + keyUsage: 5, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + } + + private static func kiroSnapshot() -> UsageSnapshot { + KiroUsageSnapshot( + planName: "KIRO FREE", + accountEmail: "person@example.com", + authMethod: "Google", + creditsUsed: 0.17, + creditsTotal: 50, + creditsPercent: 0, + bonusCreditsUsed: 45.53, + bonusCreditsTotal: 2000, + bonusExpiryDays: 19, + overagesStatus: "Disabled", + manageURL: "https://app.kiro.dev/account/usage", + contextUsage: KiroContextUsageSnapshot( + totalPercentUsed: 1.3, + contextFilesPercent: 0.5, + toolsPercent: 0.8, + kiroResponsesPercent: 0, + promptsPercent: 0), + resetsAt: Date(), + updatedAt: Date()).toUsageSnapshot() + } + + private static func exhaustedKiroSnapshot(overagesStatus: String = "Enabled billed at $0.04 per request") + -> UsageSnapshot + { + KiroUsageSnapshot( + planName: "KIRO FREE", + accountEmail: "person@example.com", + authMethod: "Google", + creditsUsed: 50, + creditsTotal: 50, + creditsPercent: 100, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + overagesStatus: overagesStatus, + overageCreditsUsed: 40.29, + estimatedOverageCostUSD: 1.61, + manageURL: "https://app.kiro.dev/account/usage", + resetsAt: Date(), + updatedAt: Date()).toUsageSnapshot() + } +} diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 976a0d9656..9e3f2d3de8 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -1,30 +1,66 @@ +import AppKit import CodexBarCore import Foundation import Testing @testable import CodexBar -@Suite struct StatusItemControllerMenuTests { - private func makeSnapshot(primary: RateWindow?, secondary: RateWindow?) -> UsageSnapshot { - UsageSnapshot(primary: primary, secondary: secondary, updatedAt: Date()) + @MainActor + private final class RecordingUpdater: UpdaterProviding { + var automaticallyChecksForUpdates = false + var automaticallyDownloadsUpdates = false + let isAvailable = true + let unavailableReason: String? = nil + let updateStatus = UpdateStatus(isUpdateReady: true) + var checkForUpdatesCount = 0 + var installUpdateCount = 0 + + func checkForUpdates(_ sender: Any?) { + _ = sender + self.checkForUpdatesCount += 1 + } + + func installUpdate() { + self.installUpdateCount += 1 + } + } + + private func makeSnapshot( + primary: RateWindow?, + secondary: RateWindow?, + tertiary: RateWindow? = nil, + providerCost: ProviderCostSnapshot? = nil) + -> UsageSnapshot + { + UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + providerCost: providerCost, + updatedAt: Date()) } @Test - func cursorSwitcherFallsBackToSecondaryWhenPlanExhaustedAndShowingRemaining() { + func `cursor switcher falls back to on demand budget when plan exhausted and showing remaining`() { let primary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = RateWindow(usedPercent: 36, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - let snapshot = self.makeSnapshot(primary: primary, secondary: secondary) + let providerCost = ProviderCostSnapshot( + used: 12, + limit: 200, + currencyCode: "USD", + updatedAt: Date()) + let snapshot = self.makeSnapshot(primary: primary, secondary: secondary, providerCost: providerCost) let percent = StatusItemController.switcherWeeklyMetricPercent( for: .cursor, snapshot: snapshot, showUsed: false) - #expect(percent == 64) + #expect(percent == 94) } @Test - func cursorSwitcherUsesPrimaryWhenShowingUsed() { + func `cursor switcher uses primary when showing used`() { let primary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = RateWindow(usedPercent: 36, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let snapshot = self.makeSnapshot(primary: primary, secondary: secondary) @@ -38,7 +74,7 @@ struct StatusItemControllerMenuTests { } @Test - func cursorSwitcherKeepsPrimaryWhenRemainingIsPositive() { + func `cursor switcher keeps primary when remaining is positive`() { let primary = RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let snapshot = self.makeSnapshot(primary: primary, secondary: secondary) @@ -52,57 +88,77 @@ struct StatusItemControllerMenuTests { } @Test - func openRouterBrandFallbackEnabledWhenNoKeyLimitConfigured() { - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45, - balance: 5, - usedPercent: 90, - keyDataFetched: true, - keyLimit: nil, - keyUsage: nil, - rateLimit: nil, - updatedAt: Date()).toUsageSnapshot() - - #expect(StatusItemController.shouldUseOpenRouterBrandFallback( - provider: .openrouter, - snapshot: snapshot)) - #expect(MenuBarDisplayText.percentText(window: snapshot.primary, showUsed: false) == nil) + func `cursor switcher does not treat auto lane as extra remaining quota`() { + let primary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let secondary = RateWindow(usedPercent: 36, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let snapshot = self.makeSnapshot(primary: primary, secondary: secondary) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .cursor, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 0) + } + + @Test + func `perplexity switcher falls back after recurring credits are exhausted`() { + let primary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let secondary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let tertiary = RateWindow(usedPercent: 24, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let snapshot = self.makeSnapshot(primary: primary, secondary: secondary, tertiary: tertiary) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .perplexity, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 76) } @Test - func openRouterBrandFallbackDisabledWhenKeyQuotaFetchUnavailable() { - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45, - balance: 5, - usedPercent: 90, - keyDataFetched: false, - keyLimit: nil, - keyUsage: nil, - rateLimit: nil, - updatedAt: Date()).toUsageSnapshot() - - #expect(!StatusItemController.shouldUseOpenRouterBrandFallback( - provider: .openrouter, - snapshot: snapshot)) + @MainActor + func `menu card width stays at base width when menu accessories are present`() { + let shortcutMenu = NSMenu() + let refreshItem = NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "r") + shortcutMenu.addItem(refreshItem) + #expect(ceil(shortcutMenu.size.width) < 310) + + let submenuMenu = NSMenu() + let parentItem = NSMenuItem(title: "Session", action: nil, keyEquivalent: "") + parentItem.submenu = NSMenu(title: "Session") + submenuMenu.addItem(parentItem) + #expect(ceil(submenuMenu.size.width) < 310) } @Test - func openRouterBrandFallbackDisabledWhenKeyQuotaAvailable() { - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45, - balance: 5, - usedPercent: 90, - keyLimit: 20, - keyUsage: 2, - rateLimit: nil, - updatedAt: Date()).toUsageSnapshot() - - #expect(!StatusItemController.shouldUseOpenRouterBrandFallback( - provider: .openrouter, - snapshot: snapshot)) - #expect(snapshot.primary?.usedPercent == 10) + @MainActor + func `update menu action installs prepared update instead of checking again`() throws { + let suite = "StatusItemControllerMenuTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let updater = RecordingUpdater() + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: updater, + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + controller.installUpdate() + + #expect(updater.installUpdateCount == 1) + #expect(updater.checkForUpdatesCount == 0) } } diff --git a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift new file mode 100644 index 0000000000..34e1ea0cd4 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift @@ -0,0 +1,134 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemControllerShutdownTests { + @Test + func `app shutdown closes tracked menus and removes status items`() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = !SettingsStore.isRunningTests + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + if let codexMetadata = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + if let claudeMetadata = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.menuRefreshTasks[key] = Task { try? await Task.sleep(for: .seconds(30)) } + controller.menuReadinessSignatures[key] = "readiness" + controller.menuIdentitySignatures[key] = "identity" + + #expect(controller.openMenus[key] === menu) + #expect(controller.mergedMenu != nil) + #expect(controller.statusItem.menu === controller.mergedMenu) + + controller.prepareForAppShutdown() + controller.prepareForAppShutdown() + + #expect(controller.hasPreparedForAppShutdown) + #expect(controller.openMenus.isEmpty) + #expect(controller.menuRefreshTasks.isEmpty) + #expect(controller.menuReadinessSignatures.isEmpty) + #expect(controller.menuIdentitySignatures.isEmpty) + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + #expect(controller.providerMenus.isEmpty) + #expect(controller.mergedMenu == nil) + } + + @Test + func `status menu quit defers shutdown until menu tracking can unwind`() { + let controller = self.makeController() + defer { + StatusItemController.menuCardRenderingEnabled = !SettingsStore.isRunningTests + StatusItemController.resetMenuRefreshEnabledForTesting() + } + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + + var scheduledTermination: (@MainActor () -> Void)? + var didTerminate = false + controller.scheduleQuitTermination = { operation in + scheduledTermination = operation + } + controller.terminateApplicationForQuit = { + didTerminate = true + } + + controller.quit() + + #expect(scheduledTermination != nil) + #expect(!controller.hasPreparedForAppShutdown) + #expect(!didTerminate) + #expect(controller.openMenus[key] === menu) + + scheduledTermination?() + + #expect(controller.hasPreparedForAppShutdown) + #expect(controller.openMenus.isEmpty) + #expect(controller.statusItem.menu == nil) + #expect(didTerminate) + } + + private func makeController() -> StatusItemController { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + if let codexMetadata = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusItemControllerShutdownTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift new file mode 100644 index 0000000000..6d4f605390 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -0,0 +1,503 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemControllerSplitLifecycleTests { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeStatusBarForTesting() -> NSStatusBar { + .system + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusItemControllerSplitLifecycleTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func containsHostingView(_ view: NSView) -> Bool { + if String(describing: type(of: view)).contains("NSHostingView") { + return true + } + return view.subviews.contains { self.containsHostingView($0) } + } + + private func makeSplitController() throws -> (SettingsStore, StatusItemController) { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.providerDetectionCompleted = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: false) + } + } + try settings.setProviderEnabled(provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(registry.metadata[.claude]), + enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + return (settings, controller) + } + + @Test + func `merged mode removes split provider status items`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.statusItems[.codex] != nil) + #expect(controller.statusItems[.claude] != nil) + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + + #expect(controller.statusItem.isVisible == true) + #expect(controller.statusItems.isEmpty) + } + + @Test + func `removing split provider status items clears all menu lifecycle state`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let menus = try [UsageProvider.codex, .claude].map { provider in + try #require(controller.providerMenus[provider]) + } + let keys = menus.map(ObjectIdentifier.init) + for (menu, key) in zip(menus, keys) { + controller.menuProviders[key] = .codex + controller.menuReadinessSignatures[key] = "readiness" + controller.menuIdentitySignatures[key] = "identity" + controller.menuSession.markFresh(key) + controller.menuSession.deferUntilNextOpen(key) + controller.menuSession.deferParentRebuild(key) + controller.openMenus[key] = menu + controller.menuRefreshTasks[key] = Task { + try? await Task.sleep(for: .seconds(60)) + } + controller.closedMenuRebuildTasks[key] = Task { + try? await Task.sleep(for: .seconds(60)) + } + controller.openMenuRebuildTasks[key] = Task { + try? await Task.sleep(for: .seconds(60)) + } + _ = controller.closedMenuRebuildRequests.replaceRequest(for: key) + _ = controller.openMenuRebuildRequests.replaceRequest(for: key) + controller.openMenuRebuildsClosingHostedSubviewMenus.insert(key) + controller.highlightedMenuItems[key] = NSMenuItem(title: "Highlighted", action: nil, keyEquivalent: "") + } + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + + for key in keys { + #expect(controller.menuProviders[key] == nil) + #expect(controller.menuReadinessSignatures[key] == nil) + #expect(controller.menuIdentitySignatures[key] == nil) + #expect(controller.menuSession.renderedVersion(for: key) == nil) + #expect(!controller.menuSession.isDeferredUntilNextOpen(key)) + #expect(!controller.menuSession.isParentRebuildDeferred(key)) + #expect(controller.openMenus[key] == nil) + #expect(controller.menuRefreshTasks[key] == nil) + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.openMenuRebuildTasks[key] == nil) + #expect(controller.closedMenuRebuildRequests.tokens[key] == nil) + #expect(controller.openMenuRebuildRequests.tokens[key] == nil) + #expect(!controller.openMenuRebuildsClosingHostedSubviewMenus.contains(key)) + #expect(controller.highlightedMenuItems[key] == nil) + } + } + + @Test + func `menu bar icons stay appkit hosted`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let codexButton = try #require(controller.statusItems[.codex]?.button) + #expect(codexButton.image != nil) + #expect(!self.containsHostingView(codexButton)) + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + + let mergedButton = try #require(controller.statusItem.button) + #expect(mergedButton.image != nil) + #expect(!self.containsHostingView(mergedButton)) + } + + @Test + func `status items publish stable manager identity`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let codexButton = try #require(controller.statusItems[.codex]?.button) + let claudeButton = try #require(controller.statusItems[.claude]?.button) + + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(controller.statusItems[.codex]?.autosaveName == "codexbar-codex") + #expect(controller.statusItems[.claude]?.autosaveName == "codexbar-claude") + #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") + #expect(codexButton.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + #expect(claudeButton.accessibilityIdentifier() == "CodexBar.StatusItem.claude") + #expect(controller.statusItem.button?.accessibilityTitle() == "CodexBar") + #expect(codexButton.accessibilityTitle() == "CodexBar") + #expect(claudeButton.accessibilityTitle() == "CodexBar") + } + + @Test + func `status item identity returns stable autosave names`() { + #expect(StatusItemController.StatusItemIdentity.merged.autosaveName == "codexbar-merged") + #expect(StatusItemController.StatusItemIdentity.provider(.codex).autosaveName == "codexbar-codex") + #expect(StatusItemController.StatusItemIdentity.provider(.claude).autosaveName == "codexbar-claude") + } + + @Test + func `status item placement preflight leaves fresh install placement unset`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-missing-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `status item placement preflight preserves missing new key when legacy item placement exists`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + } + + @Test + func `status item placement preflight clears suspicious matching legacy placement`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-high-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0, + maximumPreferredPosition: 3000)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.object(forKey: "NSStatusItem Preferred Position Item-0") == nil) + } + + @Test + func `status item placement preflight preserves missing new key when mixed legacy placements exist`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-mixed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 11298) + } + + @Test + func `status item placement preflight clears provider matching suspicious legacy placement`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-mixed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1, + maximumPreferredPosition: 3000)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.object(forKey: "NSStatusItem Preferred Position Item-1") == nil) + } + + @Test + func `status item placement preflight leaves provider key unset when only merged legacy placement exists`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-single-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + } + + @Test + func `status item placement preflight preserves provider key with matching legacy placement`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-matching-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(58, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 58) + } + + @Test + func `status item placement preflight clears suspicious high position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-high-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(11298, forKey: key) + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + maximumPreferredPosition: 3000)) + + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `status item placement preflight clears old forced zero position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-zero-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(0, forKey: key) + + #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `status item placement preflight clears malformed position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-malformed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set("not-a-position", forKey: key) + + #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.object(forKey: key) == nil) + } + + @Test + func `status item placement preflight preserves reasonable position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-preserve-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(42, forKey: key) + + #expect(!MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.double(forKey: key) == 42) + } + + @Test + func `status item placement preflight preserves large display position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-preserve-large-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(2500, forKey: key) + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + maximumPreferredPosition: 2560)) + + #expect(defaults.double(forKey: key) == 2500) + } + + @Test + func `status item defaults repair removes stale hidden Control Center keys once`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-repair-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "NSStatusItem VisibleCC Item-0") + defaults.set(0, forKey: "NSStatusItem VisibleCC Item-12") + defaults.set(false, forKey: "NSStatusItem VisibleCC codexbar-merged") + defaults.set(true, forKey: "NSStatusItem VisibleCC Item-1") + defaults.set(false, forKey: "NSStatusItem VisibleCC com.apple.clock") + defer { + defaults.removePersistentDomain(forName: suite) + } + + let repairedKeys = MenuBarStatusItemDefaultsRepair.repairHiddenVisibilityDefaultsIfNeeded(defaults: defaults) + + #expect(repairedKeys == [ + "NSStatusItem VisibleCC Item-0", + "NSStatusItem VisibleCC Item-12", + "NSStatusItem VisibleCC codexbar-merged", + ]) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-0") == nil) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-12") == nil) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC codexbar-merged") == nil) + #expect(defaults.bool(forKey: "NSStatusItem VisibleCC Item-1")) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC com.apple.clock") != nil) + + defaults.set(false, forKey: "NSStatusItem VisibleCC Item-2") + #expect(MenuBarStatusItemDefaultsRepair.repairHiddenVisibilityDefaultsIfNeeded(defaults: defaults).isEmpty) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-2") != nil) + } + + @Test + func `non destructive visibility refresh preserves split provider status items`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let oldCodexItem = try #require(controller.statusItems[.codex]) + let oldClaudeItem = try #require(controller.statusItems[.claude]) + let oldCodexButton = try #require(oldCodexItem.button) + + controller.refreshExistingStatusItemsForVisibilityRecovery() + + let newCodexItem = try #require(controller.statusItems[.codex]) + let newClaudeItem = try #require(controller.statusItems[.claude]) + #expect(newCodexItem === oldCodexItem) + #expect(newClaudeItem === oldClaudeItem) + #expect(newCodexItem.button === oldCodexButton) + #expect(newCodexItem.autosaveName == "codexbar-codex") + #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + } + + @Test + func `non destructive visibility refresh preserves merged status item`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + let oldMergedItem = controller.statusItem + let oldMergedButton = try #require(controller.statusItem.button) + + controller.refreshExistingStatusItemsForVisibilityRecovery() + + #expect(controller.statusItem === oldMergedItem) + #expect(controller.statusItem.button === oldMergedButton) + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") + } + + @Test + func `recreation produces immediately healthy snapshots for synchronous guidance check`() throws { + // verifyScreenChangeRecoveryIfNeeded does a synchronous re-check immediately after + // the single recreation to decide whether to show macOS 26 Allow-in-Menu-Bar guidance. + // AppKit must materialise the button and window before returning from + // recreateStatusItemsForVisibilityRecovery, so the item must not appear blocked at + // that point. Only a genuine system-level block would leave it blocked — which is + // exactly the case where guidance is useful. + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + controller.recreateStatusItemsForVisibilityRecovery() + + let allItems = [controller.statusItem] + Array(controller.statusItems.values) + let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(allItems) + #expect(!MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(snapshots)) + } + + @Test + func `visibility recovery recreates split provider status items`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let oldCodexItem = try #require(controller.statusItems[.codex]) + controller.recreateStatusItemsForVisibilityRecovery() + + let newCodexItem = try #require(controller.statusItems[.codex]) + #expect(newCodexItem !== oldCodexItem) + #expect(newCodexItem.autosaveName == "codexbar-codex") + #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + } + + @Test + func `visibility recovery renders replacement merged status item`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + let renderedSignature = try #require(controller.lastAppliedMergedIconRenderSignature) + + controller.lastAppliedMergedIconRenderSignature = renderedSignature + controller.recreateStatusItemsForVisibilityRecovery() + + let mergedButton = try #require(controller.statusItem.button) + #expect(mergedButton.image != nil) + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(mergedButton.accessibilityIdentifier() == "CodexBar.StatusItem") + } +} diff --git a/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift new file mode 100644 index 0000000000..28edeb6be4 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift @@ -0,0 +1,269 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusItemExtraUsageMetricTests { + @Test + func `menu bar extra usage preference uses cursor on demand budget`() { + let (store, controller) = self.makeCursorController(suiteName: "StatusItemExtraUsageMetricTests-budget") + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 15, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 15) + } + + @Test + func `menu bar extra usage preference falls back to automatic when cursor on demand budget is missing`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-missing-budget", + provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 72) + } + + @Test + func `menu bar extra usage preference honors percent used display for cursor`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-cursor-spend-text", + provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "12%") + } + + @Test + func `menu bar extra usage preference honors percent remaining display for cursor`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-cursor-remaining-text", + provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } + controller.settings.usageBarsShowUsed = false + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "88%") + } + + @Test + func `menu bar extra usage preference keeps cursor currency fallback in pace mode`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-cursor-pace-spend-text", + provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } + controller.settings.menuBarDisplayMode = .pace + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "$12.34") + } + + @Test + func `menu bar extra usage preference uses percent in combined mode`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-cursor-combined-text", + provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } + controller.settings.menuBarDisplayMode = .both + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 56, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "56%") + } + + @Test + func `menu bar extra usage preference preserves claude currency display`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-claude-spend-text", + provider: .claude) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 88.8, + limit: 200, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + #expect(displayText == "$88.80") + } + + @Test + func `menu bar extra usage preference falls back to existing percent text when provider cost is unavailable`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-fallback-percent", + provider: .cursor) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "72%") + } + + @Test + func `reset time mode uses extra usage reset instead of spend`() { + let resetsAt = Date().addingTimeInterval(2 * 24 * 3600) + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-reset-time", + provider: .cursor, + displayMode: .resetTime, + resetTimesShowAbsolute: true) + defer { controller.releaseStatusItemsForTesting() } + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 100, + currencyCode: "USD", + period: "Monthly", + resetsAt: resetsAt, + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "↻ \(UsageFormatter.resetDescription(from: resetsAt))") + } + + private func makeCursorController(suiteName: String) -> (UsageStore, StatusItemController) { + self.makeController(suiteName: suiteName, provider: .cursor) + } + + private func makeController( + suiteName: String, + provider: UsageProvider, + displayMode: MenuBarDisplayMode = .percent, + resetTimesShowAbsolute: Bool = false) -> (UsageStore, StatusItemController) + { + let settings = testSettingsStore(suiteName: suiteName) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = provider + settings.menuBarDisplayMode = displayMode + settings.resetTimesShowAbsolute = resetTimesShowAbsolute + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.extraUsage, for: provider) + + let registry = ProviderRegistry.shared + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + return (store, controller) + } +} diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift new file mode 100644 index 0000000000..d4210ac541 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -0,0 +1,323 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemIconObservationSignatureTests { + private func makeController(suiteName: String) -> (SettingsStore, UsageStore, StatusItemController) { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = true + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = false + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(Self.makeSnapshot(provider: .codex, email: "icon@example.com"), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + return (settings, store, controller) + } + + @Test + func `store icon observation signature ignores refresh and status metadata churn`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-refresh-metadata") + defer { controller.releaseStatusItemsForTesting() } + + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "initial", + updatedAt: Date(timeIntervalSince1970: 10)) + let baseline = controller.storeIconObservationSignature() + + store.isRefreshing = true + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "same indicator, newer timestamp", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `store icon observation signature ignores non visual snapshot churn`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-snapshot-metadata") + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "rotated-account@example.com", + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + let signature = controller.storeIconObservationSignature() + + #expect(signature == baseline) + #expect(!signature.contains("rotated-account@example.com")) + } + + @Test + func `merged store icon observation signature ignores non primary snapshot churn`() throws { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-snapshot") + defer { controller.releaseStatusItemsForTesting() } + + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + store._setSnapshotForTesting( + Self.makeSnapshot(provider: .claude, email: "claude@example.com"), + provider: .claude) + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .claude, + email: "changed@example.com", + primaryUsedPercent: 99, + secondaryUsedPercent: 88, + updatedAt: Date(timeIntervalSince1970: 300)), + provider: .claude) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `store icon observation signature changes when icon percentages change`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-percent-change") + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "icon@example.com", + primaryUsedPercent: 42, + secondaryUsedPercent: 63), + provider: .codex) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature tracks selected copilot budget`() throws { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-copilot-budget") + defer { controller.releaseStatusItemsForTesting() } + + let registry = ProviderRegistry.shared + let codexMetadata = try #require(registry.metadata[.codex]) + let copilotMetadata = try #require(registry.metadata[.copilot]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: false) + settings.setProviderEnabled(provider: .copilot, metadata: copilotMetadata, enabled: true) + settings.selectedMenuProvider = .copilot + settings.copilotBudgetExtrasEnabled = true + settings.copilotIconSecondaryWindowID = "copilot-budget-agent" + + store._setSnapshotForTesting( + Self.makeCopilotSnapshot(budgetUsedPercent: 25), + provider: .copilot) + let baseline = controller.storeIconObservationSignature() + + store._setSnapshotForTesting( + Self.makeCopilotSnapshot(budgetUsedPercent: 75), + provider: .copilot) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature changes when credit fallback changes`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-credit-fallback") + defer { controller.releaseStatusItemsForTesting() } + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "icon@example.com", + primaryUsedPercent: 100, + secondaryUsedPercent: 20), + provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date(timeIntervalSince1970: 100)) + let baseline = controller.storeIconObservationSignature() + + store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date(timeIntervalSince1970: 200)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature ignores unused credit balance`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-unused-credits") + defer { controller.releaseStatusItemsForTesting() } + + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date(timeIntervalSince1970: 100)) + let baseline = controller.storeIconObservationSignature() + + store.credits = CreditsSnapshot(remaining: 42, events: [], updatedAt: Date(timeIntervalSince1970: 200)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `merged store icon observation signature ignores non primary status changes`() throws { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-merged-secondary-status") + defer { controller.releaseStatusItemsForTesting() } + + let registry = ProviderRegistry.shared + let claudeMetadata = try #require(registry.metadata[.claude]) + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + let baseline = controller.storeIconObservationSignature() + + store.statuses[.claude] = ProviderStatus( + indicator: .major, + description: "Claude status issue", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `store icon observation signature changes when status indicator changes`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-status-indicator") + defer { controller.releaseStatusItemsForTesting() } + + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "initial", + updatedAt: Date(timeIntervalSince1970: 10)) + let baseline = controller.storeIconObservationSignature() + + store.statuses[.codex] = ProviderStatus( + indicator: .major, + description: "major outage", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature changes when hide critters toggles`() { + let (settings, _, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-hide-critters") + defer { controller.releaseStatusItemsForTesting() } + + settings.menuBarHidesCritters = false + let baseline = controller.storeIconObservationSignature() + + settings.menuBarHidesCritters = true + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `updateIcons reuses a precomputed store icon signature instead of recomputing it`() { + let (_, _, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-precomputed-reuse") + defer { controller.releaseStatusItemsForTesting() } + + let precomputed = "precomputed-store-icon-signature-sentinel" + controller.updateIcons(precomputedStoreIconSignature: precomputed) + + // A supplied signature must be stored verbatim; if updateIcons recomputed it, the gate would + // never equal the sentinel value. + #expect(controller.lastObservedStoreIconWorkSignature == precomputed) + } + + @Test + func `updateIcons recomputes the store icon signature when none is provided`() { + let (_, _, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-recompute-default") + defer { controller.releaseStatusItemsForTesting() } + + controller.updateIcons() + + #expect(controller.lastObservedStoreIconWorkSignature == controller.storeIconObservationSignature()) + } + + private static func makeSnapshot( + provider: UsageProvider, + email: String, + primaryUsedPercent: Double = 10, + secondaryUsedPercent: Double = 20, + updatedAt: Date = Date(timeIntervalSince1970: 100)) + -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: primaryUsedPercent, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: secondaryUsedPercent, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "plus")) + } + + private static func makeCopilotSnapshot(budgetUsedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "copilot-budget-agent", + title: "Budget - Copilot Agent Premium Requests", + window: RateWindow( + usedPercent: budgetUsedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date(timeIntervalSince1970: 100), + identity: ProviderIdentitySnapshot( + providerID: .copilot, + accountEmail: "copilot@example.com", + accountOrganization: nil, + loginMethod: "individual")) + } +} diff --git a/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift b/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift new file mode 100644 index 0000000000..1f2069e7f4 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift @@ -0,0 +1,63 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct StatusItemPurchaseURLTests { + @Test + @MainActor + func `purchase URL accepts ChatGPT hosts`() { + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://chatgpt.com/settings/billing") + == "https://chatgpt.com/settings/billing") + #expect( + StatusItemController + .sanitizedCreditsPurchaseURL("https://chatgpt.com/usage/credits?token=secret#fragment") + == "https://chatgpt.com/usage/credits") + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://team.chatgpt.com/settings/billing") + == "https://team.chatgpt.com/settings/billing") + } + + @Test + @MainActor + func `purchase URL rejects lookalike hosts`() { + #expect( + StatusItemController + .sanitizedCreditsPurchaseURL("https://chatgpt.com.evil.example/settings/billing") == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://evil-chatgpt.com/settings/billing") + == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://notchatgpt.com/settings/billing") + == nil) + } + + @Test + @MainActor + func `purchase URL rejects non HTTPS and unrelated paths`() { + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("http://chatgpt.com/settings/billing") + == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://chatgpt.com/backend-api/accounts") + == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://chatgpt.com/backend-api/settings-token") + == nil) + #expect(StatusItemController.sanitizedCreditsPurchaseURL("not a url") == nil) + } + + @Test + @MainActor + func `scoped purchase window requires an account email`() { + let scope = CookieHeaderCache.Scope.profileHome("/tmp/codex-profile") + + #expect(!OpenAICreditsPurchaseWindowController.canOpenPurchaseWindow(accountEmail: nil, cacheScope: scope)) + #expect(!OpenAICreditsPurchaseWindowController.canOpenPurchaseWindow(accountEmail: " ", cacheScope: scope)) + #expect(OpenAICreditsPurchaseWindowController.canOpenPurchaseWindow( + accountEmail: " owner@example.com ", + cacheScope: scope)) + #expect(OpenAICreditsPurchaseWindowController.canOpenPurchaseWindow(accountEmail: nil, cacheScope: nil)) + } +} diff --git a/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift b/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift new file mode 100644 index 0000000000..abf10e6d43 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift @@ -0,0 +1,98 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemQuotaWarningFlashTests { + private func makeStatusBarForTesting() -> NSStatusBar { + NSStatusBar.system + } + + @Test + func `quota warning flash state lasts for configured duration`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemQuotaWarningFlashTests-duration"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let now = Date() + controller.startQuotaWarningFlash(provider: .codex, postedAt: now) + + #expect(controller.quotaWarningFlashActive(provider: .codex, now: now.addingTimeInterval(59)) == true) + #expect(controller.quotaWarningFlashActive(provider: .codex, now: now.addingTimeInterval(61)) == false) + } + + @Test + func `quota warning flash image draws non template red overlay`() throws { + let size = NSSize(width: 16, height: 16) + let base = NSImage(size: size) + base.lockFocus() + NSColor.black.setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() + base.unlockFocus() + base.isTemplate = true + + let output = StatusItemController.quotaWarningFlashImage(base: base) + let outputData = try #require(output.tiffRepresentation) + let outputRep = try #require(NSBitmapImageRep(data: outputData)) + let center = try #require(outputRep.colorAt(x: 8, y: 8)) + + #expect(output.isTemplate == false) + #expect(center.redComponent > center.blueComponent) + } + + @Test + func `merged icon render signature includes quota warning flash for selected provider`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemQuotaWarningFlashTests-merged"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) + } + settings.openRouterAPIToken = "or-token" + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + controller.startQuotaWarningFlash(provider: .codex) + + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=1") == true) + } +} diff --git a/Tests/CodexBarTests/StatusMenuAppearanceTests.swift b/Tests/CodexBarTests/StatusMenuAppearanceTests.swift new file mode 100644 index 0000000000..4cf96a17f8 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuAppearanceTests.swift @@ -0,0 +1,60 @@ +import AppKit +import Testing +@testable import CodexBar + +@MainActor +struct StatusMenuAppearanceTests { + private final class AppearanceTrackingMenu: NSMenu { + var appearanceAssignmentCount = 0 + + override var appearance: NSAppearance? { + didSet { + self.appearanceAssignmentCount += 1 + } + } + } + + @Test + func `pin uses the exact application effective appearance`() { + let menu = NSMenu() + let effectiveAppearance = NSApplication.shared.effectiveAppearance + + StatusMenuAppearance.pin(menu) + + #expect(menu.appearance === effectiveAppearance) + } + + @Test + func `pin reassigns an appearance even when its name is unchanged`() throws { + let menu = AppearanceTrackingMenu() + let appearance = try #require(NSAppearance(named: .aqua)) + menu.appearance = appearance + let assignmentsBeforePin = menu.appearanceAssignmentCount + + StatusMenuAppearance.pin(menu, to: appearance) + + #expect(menu.appearance === appearance) + #expect(menu.appearanceAssignmentCount == assignmentsBeforePin + 1) + } + + @Test + func `submenus inherit each refreshed root appearance`() throws { + let menu = NSMenu() + let submenu = NSMenu() + let item = NSMenuItem(title: "Details", action: nil, keyEquivalent: "") + item.submenu = submenu + menu.addItem(item) + + let lightAppearance = try #require(NSAppearance(named: .aqua)) + StatusMenuAppearance.pin(menu, to: lightAppearance) + #expect(menu.appearance === lightAppearance) + #expect(submenu.appearance == nil) + #expect(submenu.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua) + + let darkAppearance = try #require(NSAppearance(named: .darkAqua)) + StatusMenuAppearance.pin(menu, to: darkAppearance) + #expect(menu.appearance === darkAppearance) + #expect(submenu.appearance == nil) + #expect(submenu.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua) + } +} diff --git a/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift new file mode 100644 index 0000000000..a3b528adef --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuClosedPreparationTests.swift @@ -0,0 +1,307 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +private final class ClosedMenuManualRefreshGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + if self.isOpen { + self.isOpen = false + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + if let continuation = self.continuation { + continuation.resume() + self.continuation = nil + } else { + self.isOpen = true + } + } +} + +extension StatusMenuTests { + @Test + func `stale data refresh suppresses icon attached closed menu preparation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + for _ in 0..<20 { + await Task.yield() + } + let menu = controller.makeMenu() + // Simulate a closed menu that was attached by an icon update but has never been opened. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + let key = ObjectIdentifier(menu) + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.prepareAttachedClosedMenusIfNeeded() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == nil) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `stale refresh completion requeues required closed menu preparation blocked by refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + for _ in 0..<20 { + await Task.yield() + } + let menu = controller.makeMenu() + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + let requiredVersion = controller.latestRequiredMenuRebuildVersion + store.isRefreshing = true + for _ in 0..<40 where controller.closedMenuRebuildTasks[key] != nil { + await Task.yield() + } + + #expect(requiredVersion > (openedVersion ?? -1)) + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.menuVersions[key] == openedVersion) + + store.isRefreshing = false + controller.fallbackMenu = menu + controller.statusItem.menu = menu + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `manual refresh completion requeues required closed menu preparation`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.fallbackMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let initialVersion = controller.menuVersions[key] + + let gate = ClosedMenuManualRefreshGate() + controller._test_manualRefreshOperation = { await gate.wait() } + defer { + gate.resume() + controller._test_manualRefreshOperation = nil + } + controller.refreshNow() + let task = try #require(controller.manualRefreshTask) + + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.menuVersions[key] == initialVersion) + + gate.resume() + await task.value + for _ in 0..<40 where controller.menuVersions[key] == initialVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed menu prewarm waits for other menu tracking to end`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.milliseconds(50)) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let closedMenu = controller.makeMenu(for: .claude) + controller.providerMenus[.claude] = closedMenu + controller.populateMenu(closedMenu, provider: .claude) + controller.markMenuFresh(closedMenu) + let closedKey = ObjectIdentifier(closedMenu) + let closedVersion = controller.menuVersions[closedKey] + + controller.invalidateMenus() + controller.rebuildClosedMenuIfNeeded(closedMenu) + #expect(controller.closedMenuRebuildTasks[closedKey] != nil) + + let visibleMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(visibleMenu) + try? await Task.sleep(for: .milliseconds(80)) + for _ in 0..<20 where controller.closedMenuRebuildTasks[closedKey] != nil { + await Task.yield() + } + + #expect(controller.menuVersions[closedKey] == closedVersion) + #expect(controller.openMenus[ObjectIdentifier(visibleMenu)] != nil) + + controller.menuDidClose(visibleMenu) + try? await Task.sleep(for: .milliseconds(80)) + for _ in 0..<20 where controller.menuVersions[closedKey] == closedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[closedKey] == controller.menuContentVersion) + } + + @Test + func `data refresh while persistent menu is open rebuilds on close`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuDidClose(menu) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] != openedVersion) + } +} diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift new file mode 100644 index 0000000000..58352c341c --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift @@ -0,0 +1,400 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusMenuCodexSwitcherPresentationTests { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuCodexSwitcherPresentationTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeManagedAccountStoreURL(accounts: [ManagedCodexAccount]) throws -> URL { + let storeURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = FileManagedCodexAccountStore(fileURL: storeURL) + try store.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: accounts)) + return storeURL + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + private func snapshot(email: String, percent: Double = 12) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(300), + resetDescription: nil), + secondary: RateWindow( + usedPercent: percent, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Plus")) + } + + @Test + func `codex account ordering keeps workspace groups contiguous`() { + let teamActive = CodexVisibleAccount( + id: "team-a-active", + email: "active@example.com", + workspaceLabel: "Team A", + workspaceAccountID: "team-a", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let teamHighQuota = CodexVisibleAccount( + id: "team-b-high-quota", + email: "high@example.com", + workspaceLabel: "Team B", + workspaceAccountID: "team-b", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let teamSibling = CodexVisibleAccount( + id: "team-a-sibling", + email: "sibling@example.com", + workspaceLabel: "Team A", + workspaceAccountID: "team-a", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let accounts = [teamActive, teamHighQuota, teamSibling] + let snapshots = [ + CodexAccountUsageSnapshot( + account: teamActive, + snapshot: self.snapshot(email: teamActive.email, percent: 95), + error: nil, + sourceLabel: "test"), + CodexAccountUsageSnapshot( + account: teamHighQuota, + snapshot: self.snapshot(email: teamHighQuota.email, percent: 10), + error: nil, + sourceLabel: "test"), + CodexAccountUsageSnapshot( + account: teamSibling, + snapshot: self.snapshot(email: teamSibling.email, percent: 20), + error: nil, + sourceLabel: "test"), + ] + + let ordered = CodexAccountPresentationOrdering.orderedAccounts( + accounts, + snapshots: snapshots, + activeVisibleAccountID: teamActive.id) + + #expect(ordered.map(\.id) == ["team-a-active", "team-a-sibling", "team-b-high-quota"]) + #expect(ordered.codexWorkspaceSections().map(\.title) == ["Team A", "Team B"]) + #expect(ordered.codexWorkspaceSections().first?.accounts.map(\.id) == ["team-a-active", "team-a-sibling"]) + } + + @Test + func `codex stacked menu orders by quota and groups workspaces`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let lowID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let highID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let low = ManagedCodexAccount( + id: lowID, + email: "low@example.com", + workspaceLabel: "Team Low", + workspaceAccountID: "team-low", + managedHomePath: "/tmp/low-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let high = ManagedCodexAccount( + id: highID, + email: "high@example.com", + workspaceLabel: "Team High", + workspaceAccountID: "team-high", + managedHomePath: "/tmp/high-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [low, high]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "active@example.com", + workspaceLabel: "Personal", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.codexAccountSnapshots = settings.codexVisibleAccountProjection.visibleAccounts.map { account in + let usedPercent = switch account.email { + case "high@example.com": + 10.0 + case "low@example.com": + 80.0 + default: + 95.0 + } + return CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(email: account.email, percent: usedPercent), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let display = try #require(controller.codexAccountMenuDisplay(for: .codex)) + #expect(display.accounts.map(\.email) == ["active@example.com", "high@example.com", "low@example.com"]) + #expect(display.workspaceSections.map(\.title) == ["Personal", "Team High", "Team Low"]) + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + #expect(self.representedIDs(in: menu).count(where: { $0.hasPrefix("codexWorkspace-") }) == 3) + } + + @Test + func `codex stacked menu surfaces account health labels`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let visibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.email == "managed@example.com" }) + + #expect(CodexAccountHealth.status(for: visibleAccount, error: "401 Unauthorized") + .label == "Needs re-auth") + } + + @Test + func `codex account snapshot store hydrates current visible accounts`() { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let account = CodexVisibleAccount( + id: "active@example.com", + email: "active@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + store.store([ + CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(email: account.email, percent: 17), + error: nil, + sourceLabel: "test"), + ]) + + let hydrated = store.load(for: [account]) + + #expect(hydrated.map(\.id) == [account.id]) + #expect(hydrated.first?.snapshot?.primary?.usedPercent == 17) + #expect(hydrated.first?.account.email == account.email) + } + + @Test + func `codex account snapshot store rejects mismatched workspace records`() { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let oldAccountID = UUID() + let newAccountID = UUID() + let oldAccount = CodexVisibleAccount( + id: "workspace@example.com", + email: "workspace@example.com", + workspaceLabel: "Old Team", + workspaceAccountID: "acct-old", + storedAccountID: oldAccountID, + selectionSource: .managedAccount(id: oldAccountID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let newAccount = CodexVisibleAccount( + id: "workspace@example.com", + email: "workspace@example.com", + workspaceLabel: "New Team", + workspaceAccountID: "acct-new", + storedAccountID: newAccountID, + selectionSource: .managedAccount(id: newAccountID), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + store.store([ + CodexAccountUsageSnapshot( + account: oldAccount, + snapshot: self.snapshot(email: oldAccount.email, percent: 71), + error: nil, + sourceLabel: "test"), + ]) + + let hydrated = store.load(for: [newAccount]) + + #expect(hydrated.isEmpty) + } + + @Test + func `codex account snapshot store rejects same stored account after auth fingerprint changes`() { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let accountID = UUID() + let oldAccount = CodexVisibleAccount( + id: "reauth@example.com", + email: "reauth@example.com", + authFingerprint: "old-auth-fingerprint", + storedAccountID: accountID, + selectionSource: .managedAccount(id: accountID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let newAccount = CodexVisibleAccount( + id: "reauth@example.com", + email: "reauth@example.com", + authFingerprint: "new-auth-fingerprint", + storedAccountID: accountID, + selectionSource: .managedAccount(id: accountID), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + store.store([ + CodexAccountUsageSnapshot( + account: oldAccount, + snapshot: self.snapshot(email: oldAccount.email, percent: 71), + error: nil, + sourceLabel: "test"), + ]) + + let hydrated = store.load(for: [newAccount]) + + #expect(hydrated.isEmpty) + } + + @Test + func `codex account snapshot store rejects legacy workspace records without identity`() throws { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + let payload = """ + { + "records" : [ + { + "error" : "cached", + "id" : "legacy@example.com", + "snapshot" : null, + "sourceLabel" : "legacy" + } + ], + "version" : 1 + } + """ + try Data(payload.utf8).write(to: fileURL) + + let accountID = UUID() + let workspaceAccount = CodexVisibleAccount( + id: "legacy@example.com", + email: "legacy@example.com", + workspaceLabel: "New Team", + workspaceAccountID: "acct-new", + storedAccountID: accountID, + selectionSource: .managedAccount(id: accountID), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + + let hydrated = store.load(for: [workspaceAccount]) + + #expect(hydrated.isEmpty) + } +} diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift new file mode 100644 index 0000000000..ff16899eb1 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -0,0 +1,1414 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusMenuCodexSwitcherTests { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuCodexSwitcherTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeStatusBarForTesting() -> NSStatusBar { + .system + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func makeManagedAccountStoreURL(accounts: [ManagedCodexAccount]) throws -> URL { + let storeURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = FileManagedCodexAccountStore(fileURL: storeURL) + try store.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: accounts)) + return storeURL + } + + private func actionLabels(in descriptor: MenuDescriptor) -> [String] { + descriptor.sections.flatMap(\.entries).compactMap { entry in + guard case let .action(label, _) = entry else { return nil } + return label + } + } + + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + private func snapshot(email: String, percent: Double = 12) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(300), + resetDescription: nil), + secondary: RateWindow( + usedPercent: percent, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Plus")) + } + + private func selectCodexVisibleAccountForStatusMenu( + id: String, + settings: SettingsStore, + store: UsageStore) -> Task? + { + guard settings.selectCodexVisibleAccount(id: id) else { return nil } + _ = store.prepareCodexAccountScopedRefreshIfNeeded() + return Task { @MainActor in + await store.refreshCodexAccountScopedState(allowDisabled: true) + } + } + + private func installBlockingCodexProvider(on store: UsageStore, blocker: BlockingStatusMenuCodexFetchStrategy) { + let baseSpec = store.providerSpecs[.codex]! + store.providerSpecs[.codex] = Self.makeCodexProviderSpec(baseSpec: baseSpec) { + try await blocker.awaitResult() + } + } + + private static func makeCodexProviderSpec( + baseSpec: ProviderSpec, + loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec + { + let baseDescriptor = baseSpec.descriptor + let strategy = StatusMenuTestCodexFetchStrategy(loader: loader) + let descriptor = ProviderDescriptor( + id: .codex, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli, .oauth], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli) + return ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + } + + @Test + func `codex menu shows account switcher and add account action for multiple visible accounts`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let projection = settings.codexVisibleAccountProjection + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + + #expect(projection.visibleAccounts.map(\.email) == ["live@example.com", "managed@example.com"]) + #expect(projection.activeVisibleAccountID == "live@example.com") + let actionLabels = self.actionLabels(in: descriptor) + #expect(actionLabels.contains("Add Account...")) + #expect(actionLabels.contains("Switch Account...") == false) + } + + @Test + func `codex menu hides account switcher when only one visible account exists`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "solo@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + defer { settings._test_liveSystemCodexAccount = nil } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + + #expect(settings.codexVisibleAccountProjection.visibleAccounts.map(\.email) == ["solo@example.com"]) + #expect(self.actionLabels(in: descriptor).contains("Add Account...")) + } + + @Test + func `codex segmented multi account layout shows account switcher`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first != nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard"]) + } + + @Test + func `merged codex menu smart refresh keeps account switcher visible`() throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.multiAccountMenuLayout = .segmented + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 1) + + controller.menuContentVersion &+= 1 + controller.refreshOpenMenusIfNeeded() + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 1) + + settings._test_liveSystemCodexAccount = nil + controller.menuContentVersion &+= 1 + controller.refreshOpenMenusIfNeeded() + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 1) + + controller.menuDidClose(menu) + controller.menuContentVersion &+= 1 + controller.menuWillOpen(menu) + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 0) + } + + @Test + func `codex menu can select preserved switcher row during transient account projection`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .managedAccount(id: managedAccountID) + + let liveAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.selectionSource == .liveSystem }) + settings._test_liveSystemCodexAccount = nil + + #expect(settings.codexVisibleAccountProjection.visibleAccounts.map(\.email) == ["managed@example.com"]) + settings.selectDisplayedCodexVisibleAccount(liveAccount) + #expect(settings.codexActiveSource == .liveSystem) + } + + @Test + func `codex stacked multi account layout shows account cards`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let projection = settings.codexVisibleAccountProjection + store.codexAccountSnapshots = projection.visibleAccounts.enumerated().map { index, account in + CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(email: account.email, percent: Double(10 + index)), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first == nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard-0", "menuCard-1"]) + } + + @Test + func `codex stacked multi account layout shows account cards before per account snapshots load`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + _ = settings.codexVisibleAccountProjection + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "live@example.com"), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first == nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard-0", "menuCard-1"]) + } + + @Test + func `codex switcher suppresses personal labels while preserving team workspace tooltips`() { + let accounts = [ + CodexVisibleAccount( + id: "live:provider:account-personal", + email: "pl.fr@yandex.com", + workspaceLabel: "Personal", + workspaceAccountID: "account-personal", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email: "pl.fr@yandex.com", + workspaceLabel: "IDconcepts", + workspaceAccountID: "account-team", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { _ in }) + + let titles = view._test_buttonTitles() + let toolTips = view._test_buttonToolTips() + + #expect(titles.count == 2) + #expect(titles[0] != titles[1]) + #expect(titles.allSatisfy { $0.lowercased().contains("pl.") }) + #expect(titles[0].contains("|") == false) + #expect(titles[0].lowercased().contains("pers") == false) + #expect(titles[1].lowercased().contains("id")) + #expect(toolTips == accounts.map(\.menuDisplayName)) + #expect(accounts[0].displayName == "pl.fr@yandex.com — Personal") + #expect(accounts[0].menuDisplayName == "pl.fr@yandex.com") + } + + @Test + func `codex switcher reports fixed menu width for long account labels`() { + let accounts = [ + CodexVisibleAccount( + id: "live:provider:account-personal", + email: "managed-account-with-a-very-long-name@example.com", + workspaceLabel: nil, + workspaceAccountID: "account-managed", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email: "steipete-with-a-very-long-label@gmail.com", + workspaceLabel: nil, + workspaceAccountID: "account-gmail", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 310, + onSelect: { _ in }) + + #expect(view.frame.width == 310) + #expect(view.intrinsicContentSize.width == 310) + #expect(view.fittingSize.width == 310) + } + + @Test + func `codex switcher middle truncates long account emails`() { + let accounts = [ + CodexVisibleAccount( + id: "live:provider:account-personal", + email: "local-person-with-an-extremely-long-name@example.com", + workspaceLabel: nil, + workspaceAccountID: "account-managed", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email: "second-person-with-an-extremely-long-name@example.com", + workspaceLabel: nil, + workspaceAccountID: "account-gmail", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { _ in }) + let titles = view._test_buttonTitles() + + #expect(titles.count == 2) + #expect(titles[0].hasPrefix("local")) + #expect(titles[0].contains("…")) + #expect(titles[0].hasSuffix(".com")) + #expect(titles[1].hasPrefix("second")) + #expect(titles[1].contains("…")) + #expect(titles[1].hasSuffix(".com")) + } + + @Test + func `codex account switcher passes the selected displayed account`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + view._test_selectAccount(id: "managed@example.com") + + #expect(selectedAccount == accounts[1]) + } + + @Test + func `codex menu switcher selection activates the visible managed account`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + #expect(settings.selectCodexVisibleAccount(id: "managed@example.com")) + + #expect(settings.codexActiveSource == .managedAccount(id: managedAccountID)) + } + + @Test + func `codex menu switcher clears stale account state on the first click`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = false + settings.codexCookieSource = .off + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) + } + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "live@example.com", + accountOrganization: nil, + loginMethod: "Pro")), + provider: .codex) + store.lastCodexAccountScopedRefreshGuard = store + .currentCodexAccountScopedRefreshGuard(preferCurrentSnapshot: false) + + let blocker = BlockingStatusMenuCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = try #require( + self.selectCodexVisibleAccountForStatusMenu( + id: "managed@example.com", + settings: settings, + store: store)) + + await blocker.waitUntilStarted() + #expect(settings.codexActiveSource == .managedAccount(id: managedAccountID)) + #expect(store.snapshots[.codex] == nil) + + await blocker.resume(with: .success( + UsageSnapshot( + primary: RateWindow(usedPercent: 9, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed@example.com", + accountOrganization: nil, + loginMethod: "Pro")))) + for _ in 0..<10 where store.snapshots[.codex]?.accountEmail(for: .codex) != "managed@example.com" { + try? await Task.sleep(for: .milliseconds(20)) + } + await refreshTask.value + #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "managed@example.com") + } + + @Test + func `codex account state disables add account while managed authentication is in flight`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + defer { settings._test_liveSystemCodexAccount = nil } + + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let runner = BlockingManagedCodexLoginRunnerForStatusMenuTests() + let service = ManagedCodexAccountService( + store: InMemoryManagedCodexAccountStoreForStatusMenuTests(), + homeFactory: TestManagedCodexHomeFactoryForStatusMenuTests(root: root), + loginRunner: runner, + identityReader: StubManagedCodexIdentityReaderForStatusMenuTests(email: "managed@example.com"), + workspaceResolver: StubManagedCodexWorkspaceResolverForStatusMenuTests()) + let coordinator = ManagedCodexAccountCoordinator(service: service) + let authTask = Task { try await coordinator.authenticateManagedAccount() } + await runner.waitUntilStarted() + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let pane = ProvidersPane( + settings: settings, + store: store, + managedCodexAccountCoordinator: coordinator) + let state = try #require(pane._test_codexAccountsSectionState()) + + #expect(state.canAddAccount == false) + #expect(state.isAuthenticatingManagedAccount) + #expect(state.addAccountTitle == "Adding Account…") + + await runner.resume() + _ = try await authTask.value + } + + @Test + func `codex account state disables add account when managed store is unreadable`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings._test_unreadableManagedCodexAccountStore = true + defer { + settings._test_liveSystemCodexAccount = nil + settings._test_unreadableManagedCodexAccountStore = false + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + let state = try #require(pane._test_codexAccountsSectionState()) + + #expect(state.hasUnreadableManagedAccountStore) + #expect(state.canAddAccount == false) + } + + @Test + func `codex menu switcher can select managed row when same email rows split by identity`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "same@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "same@example.com", + plan: "pro", + accountID: "account-managed") + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "SAME@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "same@example.com")) + settings.codexActiveSource = .liveSystem + + let projection = settings.codexVisibleAccountProjection + #expect(projection.visibleAccounts.count == 2) + let managedVisibleAccount = try #require(projection.visibleAccounts + .first { $0.storedAccountID == managedAccountID }) + + #expect(settings.selectCodexVisibleAccount(id: managedVisibleAccount.id)) + #expect(settings.codexActiveSource == .managedAccount(id: managedAccountID)) + } +} + +extension StatusMenuCodexSwitcherTests { + @Test + func `codex account switcher swallows child button hit testing for first click`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { _ in }) + + #expect(view.acceptsFirstMouse(for: nil) == true) + #expect(view._test_hitTestSwallowsChildButton(id: "managed@example.com") == true) + #expect(view._test_toolTipAfterHitTest(id: "managed@example.com") == "managed@example.com") + } + + @Test + func `codex account switcher routes runtime click path to selected account`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + #expect(view._test_simulateRuntimeClick(id: "managed@example.com") == true) + #expect(selectedAccount == accounts[1]) + } + + @Test + func `codex account switcher runtime click resolves second row buttons`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let secondManagedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + CodexVisibleAccount( + id: "team@example.com", + email: "team@example.com", + storedAccountID: secondManagedID, + selectionSource: .managedAccount(id: secondManagedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + CodexVisibleAccount( + id: "second-row@example.com", + email: "second-row@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: true, + canReauthenticate: true, + canRemove: false), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + #expect(view._test_simulateRuntimeClick(id: "second-row@example.com") == true) + #expect(selectedAccount == accounts[3]) + } +} + +@MainActor +extension StatusMenuCodexSwitcherTests { + @Test + func `codex account switch defers open menu rebuild until after switcher action`() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.multiAccountMenuLayout = .segmented + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "live@example.com", percent: 11), provider: .codex) + store.lastCodexAccountScopedRefreshGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + let blocker = BlockingStatusMenuCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first) + let managedVisibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.storedAccountID == managedAccountID }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + switcher._test_selectAccount(id: managedVisibleAccount.id) + + #expect(rebuildCount == 0) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + #expect(rebuildCount == 1) + + await blocker.waitUntilStarted() + await blocker.resume(with: .success(self.snapshot(email: "managed@example.com", percent: 17))) + } + + @Test + func `codex account scoped refresh does not retain status controller while in flight`() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.multiAccountMenuLayout = .segmented + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "live@example.com", percent: 11), provider: .codex) + store.lastCodexAccountScopedRefreshGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + let blocker = BlockingStatusMenuCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + var controller: StatusItemController? = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + let releasedController = WeakStatusItemControllerReference(controller) + + do { + let activeController = try #require(controller) + let menu = activeController.makeMenu() + activeController.menuWillOpen(menu) + let switcher = try #require(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first) + let managedVisibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.storedAccountID == managedAccountID }) + + switcher._test_selectAccount(id: managedVisibleAccount.id) + } + + await blocker.waitUntilStarted() + controller?.releaseStatusItemsForTesting() + controller = nil + for _ in 0..<20 where releasedController.value != nil { + await Task.yield() + } + #expect(releasedController.value == nil) + + await blocker.resume(with: .success(self.snapshot(email: "managed@example.com", percent: 17))) + } + + @Test + func `codex stacked refresh discards selected outcome when visible selection changes mid flight`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "managed@example.com", percent: 77), provider: .codex) + + let projection = settings.codexVisibleAccountProjection + let originalVisibleAccountID = projection.activeVisibleAccountID + let originalSelectionSource = originalVisibleAccountID.flatMap { + projection.source(forVisibleAccountID: $0) + } + #expect(store.codexVisibleSelectionStillMatches( + originalVisibleAccountID: originalVisibleAccountID, + originalSelectionSource: originalSelectionSource)) + + #expect(settings.selectCodexVisibleAccount(id: "managed@example.com")) + + #expect(settings.codexActiveSource == .managedAccount(id: managedAccountID)) + #expect(store.codexVisibleSelectionStillMatches( + originalVisibleAccountID: originalVisibleAccountID, + originalSelectionSource: originalSelectionSource) == false) + #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "managed@example.com") + #expect(store.snapshots[.codex]?.primary?.usedPercent == 77) + } + + private static func writeCodexAuthFile( + homeURL: URL, + email: String, + plan: String, + accountID: String? = nil) throws + { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + var tokens: [String: Any] = [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan, accountID: accountID), + ] + if let accountID { + tokens["account_id"] = accountID + } + let auth = ["tokens": tokens] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String, accountID: String? = nil) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + var payloadObject: [String: Any] = [ + "email": email, + "chatgpt_plan_type": plan, + ] + if let accountID { + payloadObject["https://api.openai.com/auth"] = [ + "chatgpt_account_id": accountID, + ] + } + let payload = (try? JSONSerialization.data(withJSONObject: payloadObject)) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } +} + +private final class WeakStatusItemControllerReference { + weak var value: StatusItemController? + + init(_ value: StatusItemController?) { + self.value = value + } +} + +private struct StatusMenuTestCodexFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable () async throws -> UsageSnapshot + + var id: String { + "status-menu-test-codex" + } + + var kind: ProviderFetchKind { + .cli + } + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.loader() + return self.makeResult(usage: snapshot, sourceLabel: "status-menu-test-codex") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private actor BlockingStatusMenuCodexFetchStrategy { + private var waiters: [CheckedContinuation, Never>] = [] + private var startedWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var startCount = 0 + + func awaitResult() async throws -> UsageSnapshot { + let result = await withCheckedContinuation { continuation in + self.waiters.append(continuation) + self.startCount += 1 + self.resumeStartedWaitersIfReady() + } + return try result.get() + } + + func waitUntilStarted() async { + await self.waitForStartCount(1) + } + + func waitForStartCount(_ count: Int) async { + if self.startCount >= count { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append((count, continuation)) + } + } + + func resume(with result: Result) { + self.waiters.forEach { $0.resume(returning: result) } + self.waiters.removeAll() + } + + private func resumeStartedWaitersIfReady() { + let readyWaiters = self.startedWaiters.filter { self.startCount >= $0.count } + self.startedWaiters.removeAll { self.startCount >= $0.count } + readyWaiters.forEach { $0.continuation.resume() } + } +} + +private actor BlockingManagedCodexLoginRunnerForStatusMenuTests: ManagedCodexLoginRunning { + private var waiters: [CheckedContinuation] = [] + private var startedWaiters: [CheckedContinuation] = [] + private var didStart = false + + func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + self.didStart = true + self.startedWaiters.forEach { $0.resume() } + self.startedWaiters.removeAll() + } + } + + func waitUntilStarted() async { + if self.didStart { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append(continuation) + } + } + + func resume() { + let result = CodexLoginRunner.Result(outcome: .success, output: "ok") + self.waiters.forEach { $0.resume(returning: result) } + self.waiters.removeAll() + } +} + +private final class InMemoryManagedCodexAccountStoreForStatusMenuTests: ManagedCodexAccountStoring, +@unchecked Sendable { + private var snapshot = ManagedCodexAccountSet(version: 1, accounts: []) + + func loadAccounts() throws -> ManagedCodexAccountSet { + self.snapshot + } + + func storeAccounts(_ accounts: ManagedCodexAccountSet) throws { + self.snapshot = accounts + } + + func ensureFileExists() throws -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } +} + +private struct StubManagedCodexWorkspaceResolverForStatusMenuTests: ManagedCodexWorkspaceResolving { + func resolveWorkspaceIdentity( + homePath _: String, + providerAccountID _: String) async -> CodexOpenAIWorkspaceIdentity? + { + nil + } +} + +private struct TestManagedCodexHomeFactoryForStatusMenuTests: ManagedCodexHomeProducing { + let root: URL + + func makeHomeURL() -> URL { + self.root.appendingPathComponent(UUID().uuidString, isDirectory: true) + } + + func validateManagedHomeForDeletion(_ url: URL) throws { + try ManagedCodexHomeFactory(root: self.root).validateManagedHomeForDeletion(url) + } +} + +private struct StubManagedCodexIdentityReaderForStatusMenuTests: ManagedCodexIdentityReading { + let email: String + + func loadAccountIdentity(homePath _: String) throws -> CodexAuthBackedAccount { + CodexAuthBackedAccount( + identity: CodexIdentityResolver.resolve(accountId: nil, email: self.email), + email: self.email, + plan: "Pro") + } +} diff --git a/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift new file mode 100644 index 0000000000..67df6ad396 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift @@ -0,0 +1,181 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuCostMenuCardTests { + @Test + func `cost menu shows no detail lines`() { + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $74.83 - 87M tokens", + monthLine: "Last 30 days: $4,279.64 - 5.7B tokens", + hintLine: "Costs are estimated from local usage.", + errorLine: "Cost refresh failed.", + errorCopyText: nil) + + let visibleLines = StatusItemController.costMenuVisibleDetailLines( + tokenUsage: tokenUsage, + hasSubmenu: true) + #expect(visibleLines == []) + + let fallbackTitle = StatusItemController.costMenuFallbackAttributedTitle(visibleDetailLines: visibleLines) + #expect(fallbackTitle.string == "Cost") + } + + @Test + func `cost menu preserves summary lines without history submenu`() { + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $74.83 - 87M tokens", + monthLine: "Last 30 days: $4,279.64 - 5.7B tokens", + hintLine: "Costs are estimated from local usage.", + errorLine: "Cost refresh failed.", + errorCopyText: nil) + + let visibleLines = StatusItemController.costMenuVisibleDetailLines( + tokenUsage: tokenUsage, + hasSubmenu: false) + #expect(visibleLines == [ + "Today: $74.83 - 87M tokens", + "Last 30 days: $4,279.64 - 5.7B tokens", + "Cost refresh failed.", + ]) + + let fallbackTitle = StatusItemController.costMenuFallbackAttributedTitle(visibleDetailLines: visibleLines) + #expect(fallbackTitle.string.contains("Today: $74.83 - 87M tokens")) + #expect(fallbackTitle.string.contains("Last 30 days: $4,279.64 - 5.7B tokens")) + #expect(fallbackTitle.string.contains("Cost refresh failed.")) + } + + @Test + func `cost menu tooltip preserves hint and error details`() { + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $1.00", + monthLine: "Last 30 days: $9.00", + hintLine: "Costs are estimated from local usage.", + errorLine: "Cost refresh failed.", + errorCopyText: nil) + + #expect(StatusItemController.costMenuTooltipLines(tokenUsage: tokenUsage) == [ + "Today: $1.00", + "Last 30 days: $9.00", + "Costs are estimated from local usage.", + "Cost refresh failed.", + ]) + } + + @Test + func `cost menu with history submenu omits native tooltip`() { + let settings = self.makeSettings() + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $1.00", + monthLine: "Last 30 days: $9.00", + hintLine: "Costs are estimated from local usage.", + errorLine: nil, + errorCopyText: nil) + let submenu = NSMenu() + + let item = controller.makeCostMenuCardItem( + model: self.makeModel(tokenUsage: tokenUsage), + submenu: submenu, + width: StatusItemController.menuCardBaseWidth) + + #expect(item.submenu === submenu) + #expect(item.toolTip == nil) + } + + @Test + func `rendered cost menu keeps long dynamic details inside fixed row width`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let width = StatusItemController.menuCardBaseWidth + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $227.42 - 267M tokens - " + String(repeating: "wide ", count: 20), + monthLine: "Last 30 days: $52,431.09 - 77B tokens - " + String(repeating: "wide ", count: 20), + hintLine: "Costs are estimated from local usage.", + errorLine: nil, + errorCopyText: nil) + let model = self.makeModel(tokenUsage: tokenUsage) + + // No history submenu — detail lines are visible and must be clipped to the row width. + let item = controller.makeCostMenuCardItem( + model: model, + submenu: nil, + width: width) + let view = try #require(item.view) + + #expect(view is any MenuCardMeasuring) + #expect(abs(view.frame.width - width) <= 0.5) + #expect(item.title == "Cost") + #expect(item.toolTip?.contains("$52,431.09") == true) + #expect(item.submenu == nil) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuCostMenuCardTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeModel( + tokenUsage: UsageMenuCardView.Model.TokenUsageSection) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "user@example.com", + subtitleText: "Updated now", + subtitleStyle: .info, + planText: "Pro", + metrics: [], + usageNotes: [], + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: nil, + creditsRemaining: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: tokenUsage, + placeholder: nil, + progressColor: .blue) + } +} diff --git a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift new file mode 100644 index 0000000000..18f27e501d --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift @@ -0,0 +1,302 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `menu card sizing uses displayed hosting view`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + let counter = MenuCardRepresentableCounter() + let item = controller.makeMenuCardItem( + CountingMenuCardRepresentable(counter: counter), + id: "countingCard-\(UUID().uuidString)", + width: 320, + heightCacheScope: "counting", + heightCacheFingerprint: "counting-\(UUID().uuidString)") + let view = try #require(item.view) + + view.layoutSubtreeIfNeeded() + + #expect(counter.makeViewCount == 1) + } + + @Test + func `menu card height cache is reused for stable card content`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstKeys = Set(controller.menuCardHeightCache.keys) + + #expect(!firstKeys.isEmpty) + + controller.populateMenu(menu, provider: .codex) + #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) + + controller.invalidateMenus() + #expect(Set(controller.menuCardHeightCache.keys) == firstKeys) + } + + @Test + func `standard menu width cache is reused for stable action rows`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + let firstCache = controller.measuredStandardMenuWidthCache + + #expect(!firstCache.isEmpty) + #expect(firstCache.keys.allSatisfy { + $0.contains("font=\(StatusItemController.menuCardHeightTextScaleToken())") + }) + + controller.populateMenu(menu, provider: .codex) + #expect(controller.measuredStandardMenuWidthCache == firstCache) + } + + @Test + func `fingerprinted menu card height cache survives content version invalidation`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + measureCount += 1 + return 42 + } + + controller.invalidateMenus() + + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 42) + #expect(measureCount == 1) + } + + @Test + func `fingerprinted menu card height cache remeasures when content changes`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:a") + { + measureCount += 1 + return 42 + } + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:b") + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 99) + #expect(measureCount == 2) + } + + @Test + func `unfingerprinted menu card height cache remains content version scoped`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + var measureCount = 0 + let first = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320) + { + measureCount += 1 + return 42 + } + + controller.invalidateMenus() + + let second = controller.cachedMenuCardHeight( + for: "menuCard", + scope: UsageProvider.codex.rawValue, + width: 320) + { + measureCount += 1 + return 99 + } + + #expect(first == 42) + #expect(second == 99) + #expect(measureCount == 2) + } + + @Test + func `menu invalidation prunes old version scoped height cache entries`() { + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + _ = controller.cachedMenuCardHeight( + for: "versioned", + scope: UsageProvider.codex.rawValue, + width: 320) + { + 42 + } + _ = controller.cachedMenuCardHeight( + for: "fingerprinted", + scope: UsageProvider.codex.rawValue, + width: 320, + fingerprint: "content:stable") + { + 99 + } + + controller.invalidateMenus() + + #expect(controller.menuCardHeightCache.keys.allSatisfy { !$0.fingerprint.hasPrefix("version:") }) + #expect(controller.menuCardHeightCache.keys.contains { $0.fingerprint == "content:stable" }) + } + + @Test + func `menu card height cache scopes same row ids by provider`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "Claude Pro")), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.populateMenu(menu, provider: .claude) + + let scopes = Set(controller.menuCardHeightCache.keys.map(\.scope)) + #expect(scopes.contains(UsageProvider.codex.rawValue)) + #expect(scopes.contains(UsageProvider.claude.rawValue)) + } + + private func makeHeightCacheController() -> StatusItemController { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + return StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + } +} + +@MainActor +private final class MenuCardRepresentableCounter { + var makeViewCount = 0 +} + +private struct CountingMenuCardRepresentable: NSViewRepresentable { + let counter: MenuCardRepresentableCounter + + func makeNSView(context: Context) -> NSTextField { + self.counter.makeViewCount += 1 + return NSTextField(labelWithString: "Counted") + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + _ = nsView + _ = context + } +} diff --git a/Tests/CodexBarTests/StatusMenuHighlightTests.swift b/Tests/CodexBarTests/StatusMenuHighlightTests.swift new file mode 100644 index 0000000000..96fccbb561 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHighlightTests.swift @@ -0,0 +1,55 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +extension StatusMenuTests { + final class HighlightProbeView: NSView, MenuCardHighlighting { + private(set) var states: [Bool] = [] + + func setHighlighted(_ highlighted: Bool) { + self.states.append(highlighted) + } + } + + @Test + func `menu highlight updates only previous and current custom rows`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let firstView = HighlightProbeView() + let secondView = HighlightProbeView() + let thirdView = HighlightProbeView() + let first = NSMenuItem() + first.view = firstView + first.isEnabled = true + let second = NSMenuItem() + second.view = secondView + second.isEnabled = true + let third = NSMenuItem() + third.view = thirdView + third.isEnabled = true + menu.addItem(first) + menu.addItem(second) + menu.addItem(third) + + controller.menu(menu, willHighlight: first) + controller.menu(menu, willHighlight: second) + controller.menu(menu, willHighlight: second) + + #expect(firstView.states == [true, false]) + #expect(secondView.states == [true]) + #expect(thirdView.states.isEmpty) + } +} diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift new file mode 100644 index 0000000000..6c4b91007e --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -0,0 +1,569 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuHostedSubmenuRefreshTests { + @Test + func `storage native row preserves its plain menu title`() throws { + let settings = Self.makeSettings() + settings.providerStorageFootprintsEnabled = true + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + Self.seedStorageFootprint(in: store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + #expect(controller.addStorageMenuCardSection( + to: menu, + provider: .claude, + width: StatusItemController.menuCardBaseWidth)) + let item = try #require(menu.items.first) + #expect(item.title.hasPrefix(L("Storage"))) + #expect(item.title == item.attributedTitle?.string) + #expect(item.view == nil) + #expect(item.isEnabled) + #expect(item.submenu != nil) + } + + @Test + func `open parent menu defers data rebuild until parent tracking ends`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.costUsageEnabled = true + Self.enableOnlyClaude(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + Self.seedClaudeSnapshots(in: store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = false + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let parentKey = ObjectIdentifier(menu) + controller.openMenus[parentKey] = menu + controller.menuVersions[parentKey] = controller.menuContentVersion + + let costItem = try #require(menu.items.first { ($0.representedObject as? String) == "menuCardCost" }) + #expect(costItem.view == nil) + #expect(costItem.title == StatusItemController.costMenuTitle) + #expect(costItem.isEnabled) + let submenu = try #require(costItem.submenu) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.minimumWidth >= StatusItemController.menuCardBaseWidth) + #expect(submenu.items.first?.view == nil) + + controller.menuRefreshEnabledOverrideForTesting = true + controller.menuWillOpen(submenu) + let submenuKey = ObjectIdentifier(submenu) + #expect(controller.openMenus[submenuKey] === submenu) + #expect(submenu.items.first?.view != nil) + + let oldParentVersion = try #require(controller.menuVersions[parentKey]) + controller.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true) + #expect(controller.menuVersions[parentKey] == oldParentVersion) + controller.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true) + #expect(controller.menuVersions[parentKey] == oldParentVersion) + + controller.menuDidClose(submenu) + #expect(controller.openMenus[submenuKey] == nil) + + for _ in 0..<40 where controller.menuVersions[parentKey] != oldParentVersion { + await Task.yield() + } + #expect(controller.menuVersions[parentKey] == oldParentVersion) + + controller.menuDidClose(menu) + for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { + await Task.yield() + } + if controller.menuVersions[parentKey] != controller.menuContentVersion { + controller.menuWillOpen(menu) + } + for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { + await Task.yield() + } + #expect(controller.menuVersions[parentKey] == controller.menuContentVersion) + } + + @Test + func `open hosted submenu rebuilds from unavailable placeholder when data arrives`() async { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.costUsageEnabled = true + Self.enableOnlyClaude(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + let submenuKey = ObjectIdentifier(submenu) + #expect(controller.openMenus[submenuKey] === submenu) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.view == nil) + #expect(submenu.items.first?.title == "No data available") + + let openedVersion = controller.menuContentVersion + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + controller.invalidateMenus(refreshOpenMenus: true) + + for _ in 0..<40 { + if controller.menuContentVersion != openedVersion, + submenu.items.first?.view != nil + { + break + } + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.view != nil) + #expect(submenu.items.first?.title != "No data available") + } + + @Test + func `open hydrated provider submenu preserves identity across refresh`() throws { + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + seed: Self.seedClaudeSnapshots) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.costHistoryChartID, + provider: .openai, + seed: Self.seedOpenAICostSnapshot) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + seed: Self.seedPlanUtilizationHistory) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.storageBreakdownID, + provider: .claude, + seed: Self.seedStorageFootprint) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.zaiHourlyUsageChartID, + provider: .zai, + seed: Self.seedZaiHourlyUsage) + } + + @Test + func `hosted chart items size to the displayed view without a throwaway controller`() throws { + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + seed: Self.seedClaudeSnapshots) + { controller, submenu, width in + controller.appendCostHistoryChartItem(to: submenu, provider: .claude, width: width) + } + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + seed: Self.seedPlanUtilizationHistory) + { controller, submenu, width in + controller.appendUsageHistoryChartItem(to: submenu, provider: .claude, width: width) + } + try self.assertHostedChartItemHeightMatchesRefresh( + chartID: StatusItemController.storageBreakdownID, + provider: .claude, + seed: Self.seedStorageFootprint) + { controller, submenu, width in + controller.appendStorageBreakdownItem(to: submenu, provider: .claude, width: width) + } + } + + @Test + func `zai chart render signature follows time range boundaries`() throws { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + let beforeMidnight = try #require(formatter.date(from: "2026-01-01 23:30")) + let afterMidnight = try #require(formatter.date(from: "2026-01-02 00:30")) + let modelUsage = ZaiModelUsageData( + xTime: ["2026-01-01 23:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [100]), + ]) + + let before = StatusItemController.zaiHourlyUsageRenderSignature( + modelUsage: modelUsage, + now: beforeMidnight) + let after = StatusItemController.zaiHourlyUsageRenderSignature( + modelUsage: modelUsage, + now: afterMidnight) + + #expect(before != after) + } + + @Test + func `utilization chart invalidates when active account changes`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + Self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + let accounts = settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + Self.seedClaudeSnapshots(in: store) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [ + aliceKey: [Self.makePlanHistory(usedPercent: 20)], + bobKey: [Self.makePlanHistory(usedPercent: 50)], + ]) + settings.setActiveTokenAccountIndex(0, for: .claude) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + let aliceView = try #require(submenu.items.first?.view) + + settings.setActiveTokenAccountIndex(1, for: .claude) + controller.refreshHostedSubviewMenu(submenu) + + let bobView = try #require(submenu.items.first?.view) + #expect(bobView !== aliceView) + } + + private func assertHostedChartItemHeightMatchesRefresh( + chartID: String, + provider: UsageProvider, + seed: (UsageStore) -> Void, + append: (StatusItemController, NSMenu, CGFloat) -> Bool) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = true + settings.providerStorageFootprintsEnabled = true + Self.enableOnly(settings, provider: provider) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + seed(store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let width = StatusItemController.menuCardBaseWidth + let submenu = NSMenu() + submenu.minimumWidth = width + #expect(append(controller, submenu, width)) + + let item = try #require(submenu.items.first) + let view = try #require(item.view) + let heightFromAppend = view.frame.height + // The height the append path assigns must match the authoritative re-measure pass; otherwise + // dropping the throwaway NSHostingController would have changed sizing behavior. + controller.refreshHostedSubviewHeights(in: submenu) + #expect(view.frame.height == heightFromAppend) + #expect(heightFromAppend > 1) + } + + private func assertHostedSubmenuPreservesIdentity( + chartID: String, + provider: UsageProvider, + seed: (UsageStore) -> Void) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = provider + settings.costUsageEnabled = true + settings.providerStorageFootprintsEnabled = true + Self.enableOnly(settings, provider: provider) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + seed(store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: chartID, + provider: provider, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + + let hydratedItem = try #require(submenu.items.first) + #expect(hydratedItem.representedObject as? String == chartID) + #expect(hydratedItem.toolTip == provider.rawValue) + #expect(hydratedItem.view != nil) + #expect(hydratedItem.title != "No data available") + let hydratedView = hydratedItem.view + let inflatedHeight = hydratedView.map { view -> CGFloat in + let inflatedHeight = view.frame.height + 100 + if chartID == StatusItemController.zaiHourlyUsageChartID { + view.frame.size.height = inflatedHeight + } + return inflatedHeight + } + + controller.refreshHostedSubviewMenu(submenu) + + let refreshedItem = try #require(submenu.items.first) + #expect(refreshedItem.representedObject as? String == chartID) + #expect(refreshedItem.toolTip == provider.rawValue) + #expect(refreshedItem.view != nil) + #expect(refreshedItem.title != "No data available") + #expect(refreshedItem.view === hydratedView) + if chartID == StatusItemController.zaiHourlyUsageChartID { + #expect(refreshedItem.view?.frame.height != inflatedHeight) + } + + if chartID == StatusItemController.costHistoryChartID, provider == .claude { + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(dailyCost: 2.34), provider: .claude) + controller.refreshHostedSubviewMenu(submenu) + + let changedItem = try #require(submenu.items.first) + #expect(changedItem.view != nil) + #expect(changedItem.view !== hydratedView) + } + } + + private static func makeSettings() -> SettingsStore { + let suite = "StatusMenuHostedSubmenuRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func enableOnlyClaude(_ settings: SettingsStore) { + self.enableOnly(settings, provider: .claude) + } + + private static func enableOnly(_ settings: SettingsStore, provider enabledProvider: UsageProvider) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == enabledProvider) + } + } + + private static func seedClaudeSnapshots(in store: UsageStore) { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Team")) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + } + + private static func seedOpenAICostSnapshot(in store: UsageStore) { + let day = Date(timeIntervalSince1970: 1_700_000_000) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2025-12-23", + startTime: day, + endTime: day.addingTimeInterval(86400), + costUSD: 1.23, + requests: 12, + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 40, + totalTokens: 160, + lineItems: [], + models: []), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_086_400)) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + openAIAPIUsage: apiUsage, + updatedAt: Date(timeIntervalSince1970: 1_700_086_400), + identity: ProviderIdentitySnapshot( + providerID: .openai, + accountEmail: "openai@example.com", + accountOrganization: nil, + loginMethod: "API")) + store._setSnapshotForTesting(snapshot, provider: .openai) + } + + private static func seedPlanUtilizationHistory(in store: UsageStore) { + self.seedClaudeSnapshots(in: store) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + unscoped: [ + self.makePlanHistory(usedPercent: 24), + ]) + } + + private static func makePlanHistory(usedPercent: Double) -> PlanUtilizationSeriesHistory { + PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: usedPercent, + resetsAt: Date(timeIntervalSince1970: 1_700_018_000)), + ]) + } + + private static func seedStorageFootprint(in store: UsageStore) { + let root = "/Users/test/.claude" + store.providerStorageFootprints[.claude] = ProviderStorageFootprint( + provider: .claude, + totalBytes: 1024, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [.init(path: "\(root)/projects", totalBytes: 1024)], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + } + + private static func seedZaiHourlyUsage(in store: UsageStore) { + let modelUsage = ZaiModelUsageData( + xTime: ["2026-05-26 00:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [512]), + ]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + zaiUsage: ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: nil, + planName: "Pro", + modelUsage: modelUsage, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: "zai@example.com", + accountOrganization: nil, + loginMethod: "OAuth")) + store._setSnapshotForTesting(snapshot, provider: .zai) + } + + private static func makeTokenSnapshot(dailyCost: Double = 1.23) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: dailyCost, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: dailyCost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()) + } +} diff --git a/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift new file mode 100644 index 0000000000..cc308f0e2a --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuInstantOpenTests.swift @@ -0,0 +1,1594 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `opening fresh menu does not schedule deferred refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + var providerRefreshCount = 0 + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + providerRefreshCount += 1 + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(providerRefreshCount == 0) + #expect(!controller.deferredMenuInteractionRefreshPending) + + controller.menuDidClose(menu) + for _ in 0..<40 { + await Task.yield() + } + + #expect(providerRefreshCount == 0) + #expect(refreshInteractions.isEmpty) + } + + @Test + func `delayed open refresh does not rebuild fresh menu after unrelated data invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.milliseconds(50)) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + // Models first-open storage-footprint publication: menu content becomes stale, but no + // displayed provider was missing or failed when this menu opened. + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + try? await Task.sleep(for: .milliseconds(150)) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + #expect(rebuildCount == 0) + } + + @Test + func `menu open with missing data refreshes asynchronously while tracking`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + var providerRefreshCount = 0 + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + providerRefreshCount += 1 + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + #expect(controller.deferredMenuInteractionRefreshPending) + + for _ in 0..<40 where providerRefreshCount == 0 { + await Task.yield() + } + + #expect(providerRefreshCount == 1) + #expect(refreshInteractions == [.background]) + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + #expect(!controller.deferredMenuInteractionRefreshPending) + controller.menuDidClose(menu) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `menu open renders cached data immediately after data only invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + var providerRefreshCount = 0 + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + providerRefreshCount += 1 + } + defer { store._test_providerRefreshOverride = nil } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedItemCount = menu.items.count + let cachedVersion = controller.menuVersions[key] + controller.lastMenuAdjunctReadinessSignature = "stale-baseline" + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + let dataOnlyVersion = controller.menuContentVersion + var asyncRebuilds = 0 + controller._test_openMenuRebuildObserver = { _ in + asyncRebuilds += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.menuWillOpen(menu) + + #expect(cachedVersion != dataOnlyVersion) + #expect(menu.items.count == cachedItemCount) + #expect(controller.menuVersions[key] == cachedVersion) + #expect(asyncRebuilds == 0) + #expect(!controller.deferredMenuInteractionRefreshPending) + + for _ in 0..<40 where asyncRebuilds == 0 { + await Task.yield() + } + + #expect(asyncRebuilds == 1) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(!controller.didMenuAdjunctReadinessChange()) + controller.menuDidClose(menu) + for _ in 0..<40 { + await Task.yield() + } + #expect(providerRefreshCount == 0) + } + + @Test + func `closing before cached menu rebuild keeps next open stale`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedVersion = controller.menuVersions[key] + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + var rebuildGateEntries = 0 + var rebuildGate: CheckedContinuation? + controller._test_openMenuRefreshYieldOverride = { + rebuildGateEntries += 1 + await withCheckedContinuation { continuation in + rebuildGate = continuation + } + } + defer { + rebuildGate?.resume() + controller._test_openMenuRefreshYieldOverride = nil + } + + controller.menuWillOpen(menu) + for _ in 0..<40 where rebuildGateEntries == 0 { + await Task.yield() + } + + #expect(rebuildGateEntries == 1) + #expect(controller.menuVersions[key] == cachedVersion) + controller.menuDidClose(menu) + #expect(controller.menuNeedsRefresh(menu)) + + rebuildGate?.resume() + rebuildGate = nil + controller._test_openMenuRefreshYieldOverride = nil + for _ in 0..<20 { + await Task.yield() + } + + controller.menuWillOpen(menu) + for _ in 0..<40 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(!controller.menuNeedsRefresh(menu)) + controller.menuDidClose(menu) + } + + @Test + func `menu open rebuilds synchronously after provider identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com"), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com"), + provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `overview menu rebuilds synchronously after secondary provider identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.codex, .claude], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "codex@example.com"), + provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + controller.selectedMenuProvider = .codex + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com", provider: .claude), + provider: .claude) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `stacked Codex menu rebuilds synchronously after secondary account identity changes`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "selected@example.com"), + provider: .codex) + let selectedAccount = CodexVisibleAccount( + id: "selected", + email: "selected@example.com", + workspaceLabel: nil, + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: false, + canRemove: false) + let secondaryAccount = CodexVisibleAccount( + id: "secondary", + email: "secondary@example.com", + workspaceLabel: nil, + workspaceAccountID: nil, + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: false, + canReauthenticate: false, + canRemove: false) + store.codexAccountSnapshots = [ + CodexAccountUsageSnapshot( + account: selectedAccount, + snapshot: self.instantOpenSnapshot(email: "selected@example.com"), + error: nil, + sourceLabel: "test"), + CodexAccountUsageSnapshot( + account: secondaryAccount, + snapshot: self.instantOpenSnapshot(email: "old@example.com"), + error: nil, + sourceLabel: "test"), + ] + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let cachedVersion = controller.menuVersions[ObjectIdentifier(menu)] + + store.codexAccountSnapshots[1] = CodexAccountUsageSnapshot( + account: secondaryAccount, + snapshot: self.instantOpenSnapshot(email: "new@example.com"), + error: nil, + sourceLabel: "test") + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[ObjectIdentifier(menu)] != cachedVersion) + #expect(controller.menuVersions[ObjectIdentifier(menu)] == controller.menuContentVersion) + } + + @Test + func `cache preserving structural invalidation rebuilds synchronously on open`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedVersion = controller.menuVersions[key] + + controller.preservingMergedSwitcherContentCachesDuringInvalidation { + controller.invalidateMenus() + } + #expect(controller.menuVersions[key] == cachedVersion) + #expect(controller.menuContentVersion != controller.latestDataOnlyMenuContentVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `data invalidation after cache preserving structural invalidation still rebuilds synchronously`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let cachedVersion = controller.menuVersions[key] + + controller.preservingMergedSwitcherContentCachesDuringInvalidation { + controller.invalidateMenus() + } + let structuralVersion = controller.menuContentVersion + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + #expect(controller.menuVersions[key] == cachedVersion) + #expect(controller.latestStructuralMenuContentVersion == structuralVersion) + #expect(controller.menuContentVersion == controller.latestDataOnlyMenuContentVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `menu open does not overlap provider specific refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + defer { store._test_providerRefreshOverride = nil } + let existingRefreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<40 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(store.refreshingProviders.contains(.codex)) + await refreshGate.releaseFirst() + await existingRefreshTask.value + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + #expect(!store.isRefreshing) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `cached menu rebuilds after active provider refresh completes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + let existingRefreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + #expect(controller.menuNeedsRefresh(menu)) + + await refreshGate.releaseFirst() + await existingRefreshTask.value + for _ in 0..<80 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(!controller.menuNeedsRefresh(menu)) + } + + @Test + func `menu rebuilds after displayed provider completes while another provider refreshes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "old@example.com"), + provider: .codex) + store.refreshingProviders = [.claude] + defer { store.refreshingProviders = [] } + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "new@example.com"), + provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<80 where controller.menuNeedsRefresh(menu) { + await Task.yield() + } + + #expect(!controller.menuNeedsRefresh(menu)) + } + + @Test + func `user refresh supersedes background provider refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let backgroundRefreshTask = Task { + await ProviderInteractionContext.$current.withValue(.background) { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + } + await refreshGate.waitUntilStarted(count: 1) + + let userRefreshTask = Task { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await store.refresh() + } + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + await refreshGate.releaseFirst() + await refreshGate.waitUntilStarted(count: 2) + await userRefreshTask.value + await backgroundRefreshTask.value + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .userInitiated]) + } + + @Test + func `settings refresh supersedes background provider refresh without becoming user initiated`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let backgroundRefreshTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + await refreshGate.waitUntilStarted(count: 1) + + let settingsRefreshTask = Task { + await store.refreshForSettingsChange() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + await refreshGate.releaseFirst() + await refreshGate.waitUntilStarted(count: 2) + await settingsRefreshTask.value + await backgroundRefreshTask.value + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .background]) + } + + @Test + func `superseded provider refresh drains before newer result`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.claude], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderRefresh() + let baseSpec = try #require(store.providerSpecs[.claude]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderFetchStrategy { + await refreshes.awaitSnapshot() + } + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .claude, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli, .oauth], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.claude) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.claude) + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshes.startCount == 1) + + await refreshes.resume( + call: 1, + snapshot: self.instantOpenSnapshot( + email: "old@example.com", + provider: .claude, + percent: 10)) + await refreshes.waitUntilStarted(count: 2) + await refreshes.resume( + call: 2, + snapshot: self.instantOpenSnapshot( + email: "new@example.com", + provider: .claude, + percent: 80)) + await newerTask.value + await olderTask.value + + #expect(store.snapshot(for: .claude)?.primary?.usedPercent == 80) + #expect(store.snapshot(for: .claude)?.accountEmail(for: .claude) == "new@example.com") + } + + @Test + func `superseded provider refresh cannot overwrite manually changed token`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.stepfun], settings: settings) + settings.stepfunToken = "initial-token" + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderMutation() + let baseSpec = try #require(store.providerSpecs[.stepfun]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderMutationFetchStrategy(mutations: refreshes) + store.providerSpecs[.stepfun] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .stepfun, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.stepfun) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.stepfun) + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshes.startCount == 1) + + settings.stepfunToken = "user-token" + await refreshes.resume(call: 1, token: "old-token") + await refreshes.waitUntilStarted(count: 2) + #expect(settings.stepfunToken == "user-token") + await refreshes.resume(call: 2, token: "new-token") + await newerTask.value + await olderTask.value + + #expect(settings.stepfunToken == "new-token") + } + + @Test + func `superseded provider refresh preserves rotated token when credential is unchanged`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableProvidersForInstantOpenTesting([.stepfun], settings: settings) + settings.stepfunToken = "initial-token" + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshes = OrderedInstantOpenProviderMutation() + let baseSpec = try #require(store.providerSpecs[.stepfun]) + let baseDescriptor = baseSpec.descriptor + let strategy = InstantOpenProviderMutationFetchStrategy(mutations: refreshes) + store.providerSpecs[.stepfun] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: ProviderDescriptor( + id: .stepfun, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli), + makeFetchContext: baseSpec.makeFetchContext) + + let olderTask = Task { + await store.refreshProvider(.stepfun) + } + await refreshes.waitUntilStarted(count: 1) + let newerTask = Task { + await store.refreshProvider(.stepfun) + } + + await refreshes.resume(call: 1, token: "rotated-token") + await refreshes.waitUntilStarted(count: 2) + #expect(settings.stepfunToken == "rotated-token") + await refreshes.resume(call: 2, token: "newer-token") + await newerTask.value + await olderTask.value + + #expect(settings.stepfunToken == "newer-token") + } + + @Test + func `canceling provider refresh cancels its owned probe task`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshWasCancelled = false + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + refreshWasCancelled = Task.isCancelled + } + defer { store._test_providerRefreshOverride = nil } + + let refreshTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + refreshTask.cancel() + await refreshGate.releaseFirst() + await refreshTask.value + + #expect(refreshWasCancelled) + } + + @Test + func `canceling refresh owner keeps shared provider probe alive`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshWasCancelled = false + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + refreshWasCancelled = Task.isCancelled + } + defer { store._test_providerRefreshOverride = nil } + + let ownerTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + let sharedWaiterTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + for _ in 0..<40 { + await Task.yield() + } + + ownerTask.cancel() + await refreshGate.releaseFirst() + await ownerTask.value + await sharedWaiterTask.value + + #expect(!refreshWasCancelled) + #expect(await refreshGate.startCount == 1) + } + + @Test + func `background refresh retries canceled provider probe with cached data`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "cached@example.com"), + provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let ownerTask = Task { + await store.refreshProvider(.codex) + } + await refreshGate.waitUntilStarted(count: 1) + ownerTask.cancel() + let backgroundTask = Task { + await store.refreshProvider(.codex, coalesceIfRefreshing: true) + } + for _ in 0..<40 { + await Task.yield() + } + await refreshGate.releaseFirst() + await ownerTask.value + await backgroundTask.value + + #expect(await refreshGate.startCount == 2) + } + + @Test + func `menu open refresh only retries the displayed provider`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store.refreshingProviders.insert(.claude) + defer { store.refreshingProviders.remove(.claude) } + var refreshedProviders: [UsageProvider] = [] + store._test_providerRefreshOverride = { provider in + refreshedProviders.append(provider) + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuProviders[ObjectIdentifier(menu)] = .codex + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + for _ in 0..<40 where refreshedProviders.isEmpty { + await Task.yield() + } + + #expect(refreshedProviders == [.codex]) + } + + @Test + func `opening fresh split menu preserves another provider deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "claude@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.seconds(60)) + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.seconds(60)) + defer { + StatusItemController.resetMenuOpenRefreshDelayForTesting() + StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() + } + + let codexMenu = controller.makeMenu(for: .codex) + controller.menuWillOpen(codexMenu) + controller.menuDidClose(codexMenu) + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + let claudeMenu = controller.makeMenu(for: .claude) + controller.menuWillOpen(claudeMenu) + defer { controller.menuDidClose(claudeMenu) } + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + #expect(controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `overview defers only providers that need retry`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.claude, .codex], settings: settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting( + self.instantOpenSnapshot(email: "claude@example.com", provider: .claude), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.seconds(60)) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.deferredMenuInteractionRefreshProviders == [.codex]) + } + + @Test + func `closing overview menu stops before refreshing another provider`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.mergedMenuLastSelectedWasOverview = true + self.enableProvidersForInstantOpenTesting([.codex, .openai], settings: settings) + settings.updateProviderConfig(provider: .openai) { config in + config.apiKey = "test-openai-key" + } + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store._setSnapshotForTesting(nil, provider: .openai) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { _ in + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.seconds(60)) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + controller.menuDidClose(menu) + await refreshGate.releaseFirst() + for _ in 0..<80 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `closing menu during missing data refresh preserves deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + await refreshGate.run() + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + #expect(controller.deferredMenuInteractionRefreshPending) + #expect(store.refreshingProviders.contains(.codex)) + + let periodicRefreshTask = Task { + await store.refresh() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(await refreshGate.startCount == 1) + + controller.menuDidClose(menu) + #expect(controller.deferredMenuInteractionRefreshPending) + await refreshGate.releaseFirst() + await periodicRefreshTask.value + for _ in 0..<40 where store.isRefreshing { + await Task.yield() + } + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.deferredMenuInteractionRefreshPending) + + controller.scheduleDeferredMenuInteractionRefreshIfNeeded(delay: .zero) + await refreshGate.waitUntilStarted(count: 2) + for _ in 0..<40 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + + #expect(await refreshGate.startCount == 2) + #expect(refreshInteractions == [.background, .background]) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + @Test + func `closing menu during successful missing data refresh clears deferred retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodexForInstantOpenTesting(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + let refreshGate = BlockingInstantOpenProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await refreshGate.run() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + StatusItemController.setMenuOpenRefreshDelayForTesting(.zero) + defer { StatusItemController.resetMenuOpenRefreshDelayForTesting() } + var deferredRefreshCount = 0 + controller.onDeferredMenuInteractionRefreshForTesting = { + deferredRefreshCount += 1 + } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + await refreshGate.waitUntilStarted(count: 1) + controller.menuDidClose(menu) + for _ in 0..<80 { + await Task.yield() + } + #expect(deferredRefreshCount == 0) + await refreshGate.releaseFirst() + + for _ in 0..<80 where controller.deferredMenuInteractionRefreshPending { + await Task.yield() + } + for _ in 0..<40 { + await Task.yield() + } + + #expect(await refreshGate.startCount == 1) + #expect(deferredRefreshCount == 0) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + + private func enableOnlyCodexForInstantOpenTesting(_ settings: SettingsStore) { + self.enableProvidersForInstantOpenTesting([.codex], settings: settings) + } + + private func instantOpenSnapshot( + email: String, + provider: UsageProvider = .codex, + percent: Double = 25) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "ChatGPT")) + } + + private func enableProvidersForInstantOpenTesting( + _ enabledProviders: Set, + settings: SettingsStore) + { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabledProviders.contains(provider)) + } + } +} + +private struct InstantOpenProviderFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable () async -> UsageSnapshot + + var id: String { + "instant-open-provider-refresh-test" + } + + var kind: ProviderFetchKind { + .cli + } + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + let usage = await self.loader() + return self.makeResult(usage: usage, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private struct InstantOpenProviderMutationFetchStrategy: ProviderFetchStrategy { + let mutations: OrderedInstantOpenProviderMutation + + let id = "instant-open-provider-mutation-test" + let kind: ProviderFetchKind = .web + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let token = await self.mutations.awaitToken() + await context.providerManualTokenUpdater?(.stepfun, token) + let usage = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + return self.makeResult(usage: usage, sourceLabel: self.id) + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private actor OrderedInstantOpenProviderRefresh { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var continuations: [Int: CheckedContinuation] = [:] + + var startCount: Int { + self.started + } + + func awaitSnapshot() async -> UsageSnapshot { + self.started += 1 + let call = self.started + self.resumeReadyStartWaiters() + return await withCheckedContinuation { continuation in + self.continuations[call] = continuation + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func resume(call: Int, snapshot: UsageSnapshot) { + self.continuations.removeValue(forKey: call)?.resume(returning: snapshot) + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} + +private actor OrderedInstantOpenProviderMutation { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var continuations: [Int: CheckedContinuation] = [:] + + var startCount: Int { + self.started + } + + func awaitToken() async -> String { + self.started += 1 + let call = self.started + self.resumeReadyStartWaiters() + return await withCheckedContinuation { continuation in + self.continuations[call] = continuation + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func resume(call: Int, token: String) { + self.continuations.removeValue(forKey: call)?.resume(returning: token) + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} + +private actor BlockingInstantOpenProviderRefresh { + private var started = 0 + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var firstReleaseWaiters: [CheckedContinuation] = [] + private var firstReleased = false + + var startCount: Int { + self.started + } + + func run() async { + self.started += 1 + self.resumeReadyStartWaiters() + guard self.started == 1, !self.firstReleased else { return } + await withCheckedContinuation { continuation in + self.firstReleaseWaiters.append(continuation) + } + } + + func waitUntilStarted(count: Int) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func releaseFirst() { + self.firstReleased = true + let waiters = self.firstReleaseWaiters + self.firstReleaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} diff --git a/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift b/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift new file mode 100644 index 0000000000..ef8c1fc74d --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift @@ -0,0 +1,133 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuLocalizationRefreshTests { + @Test + func `open merged menu refreshes localized switcher and cost title when language changes`() async { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + if let previousAppleLanguages { + UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages") + } else { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } + } + + Self.disableMenuCardsForTesting() + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.switcherShowsIcons = false + settings.selectedMenuProvider = .codex + settings.costUsageEnabled = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: Self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + CodexBarLocalizationOverride.$appLanguage.withValue("es") { + controller.menuWillOpen(menu) + } + controller.openMenus[ObjectIdentifier(menu)] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + #expect(Self.switcherButtons(in: menu).first?.title == "Resumen") + #expect(menu.items.first(where: { $0.representedObject as? String == "menuCardCost" })?.title == "Coste") + + let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView + let initialSwitcherID = initialSwitcher.map(ObjectIdentifier.init) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + settings.appLanguage = "en" + controller.handleProviderConfigChange(reason: "appLanguage") + } + + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + #expect(rebuildCount == 1) + let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView + #expect(Self.switcherButtons(in: menu).first?.title == "Overview") + #expect(menu.items.first(where: { $0.representedObject as? String == "menuCardCost" })?.title == "Cost") + if let initialSwitcherID, let updatedSwitcher { + #expect(initialSwitcherID != ObjectIdentifier(updatedSwitcher)) + } + } + + private static func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private static func makeStatusBarForTesting() -> NSStatusBar { + .system + } + + private static func makeSettings() -> SettingsStore { + let suite = "StatusMenuLocalizationRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func switcherButtons(in menu: NSMenu) -> [NSButton] { + guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] } + return switcherView.subviews + .compactMap { $0 as? NSButton } + .sorted { $0.tag < $1.tag } + } +} diff --git a/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift new file mode 100644 index 0000000000..2bb35b2509 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuMergedOverviewRefreshTests.swift @@ -0,0 +1,92 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuMergedOverviewRefreshTests { + @Test + func `overview stays busy for an omitted provider refresh`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = true + let activeProviders: Set = [.claude, .codex, .cursor, .opencode] + self.enableOnly(activeProviders, settings: settings) + settings.mergedOverviewSelectedProviders = [.claude, .codex, .cursor] + settings.mergedMenuLastSelectedWasOverview = true + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let visibleProviders = settings.resolvedMergedOverviewProviders( + activeProviders: controller.store.enabledProvidersForDisplay()) + #expect(!visibleProviders.contains(.opencode)) + + controller.store.refreshingProviders.insert(.opencode) + controller.updatePersistentRefreshItemsEnabled() + #expect(controller.isRefreshActionInFlight(for: menu)) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + #expect(!refreshItem.isEnabled) + + var requestCount = 0 + controller._test_manualRefreshOperation = { requestCount += 1 } + controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) + for _ in 0..<20 { + await Task.yield() + } + + #expect(requestCount == 0) + #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshProvider == nil) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuMergedOverviewRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeController(settings: SettingsStore) -> StatusItemController { + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + private func enableOnly(_ providers: Set, settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + } + + private func keyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode)) + } +} diff --git a/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift b/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift new file mode 100644 index 0000000000..1a5cfba2f3 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuNativeSectionSpacingTests.swift @@ -0,0 +1,110 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuNativeSectionSpacingTests { + @Test + func `storage credits and cost never create adjacent separators`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.costUsageEnabled = true + settings.providerStorageFootprintsEnabled = true + self.enableOnlyCodex(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let storageRoot = "/Users/test/.codex" + store.providerStorageFootprints[.codex] = ProviderStorageFootprint( + provider: .codex, + totalBytes: 1024, + paths: [storageRoot], + missingPaths: [], + unreadablePaths: [], + components: [.init(path: storageRoot, totalBytes: 1024)], + updatedAt: Date()) + store.credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: Date()) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: Date()) + store.openAIDashboardAttachmentAuthorized = true + store.openAIDashboardRequiresLogin = false + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + let storageIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardStorage" + }) + let creditsIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardCredits" + }) + let costIndex = try #require(menu.items.firstIndex { + ($0.representedObject as? String) == "menuCardCost" + }) + #expect(storageIndex < creditsIndex) + #expect(creditsIndex < costIndex) + #expect(menu.items[costIndex + 1].isSeparatorItem) + #expect(!zip(menu.items, menu.items.dropFirst()).contains { first, second in + first.isSeparatorItem && second.isSeparatorItem + }) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuNativeSectionSpacingTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } +} diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift new file mode 100644 index 0000000000..7f0f219313 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -0,0 +1,1710 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `store observation marks open menu stale without rebuilding during tracking`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = controller.menuVersions[key] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 33, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")), + provider: .codex) + + for _ in 0..<20 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + #expect(rebuildCount == 0) + } + + @Test + func `closed merged menu defers rebuild until next open instead of pre-warming`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + for _ in 0..<20 { + await Task.yield() + } + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + for _ in 0..<40 { + await Task.yield() + } + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.cancelAllClosedMenuRebuilds() + controller.closedMenusDeferredUntilNextOpen.removeAll(keepingCapacity: false) + let openedVersion = controller.menuVersions[key] + + // Background data-refresh tick (stale allowed): closed prep is skipped entirely, so + // the closed merged menu must not be pre-warmed or marked deferred. + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.openMenus.isEmpty) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + #expect(!controller.closedMenusDeferredUntilNextOpen.contains(key)) + + // A required (non-stale) invalidation must also leave the closed merged menu deferred. + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.closedMenusDeferredUntilNextOpen.contains(key)) + + // The deferred merged menu is repopulated synchronously on the next open. + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(!controller.closedMenusDeferredUntilNextOpen.contains(key)) + } + + @Test + func `data refresh invalidation does not rebuild closed non merged attached menu`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + // Use a non-merged attached menu: stale data-refresh invalidations should not pre-warm any + // closed attached menu, while required invalidations still may prepare non-merged menus. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + for _ in 0..<40 { + await Task.yield() + } + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.cancelAllClosedMenuRebuilds() + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `required non merged closed menu preparation survives later data refresh invalidation`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + // Use a non-merged attached menu so this covers the delayed closed-menu rebuild path. Merged + // menus are intentionally deferred until next open on current main (#1274). + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + controller.invalidateMenus() + let requiredVersion = controller.latestRequiredMenuRebuildVersion + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(requiredVersion > (openedVersion ?? -1)) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed attached menu preparation waits for store refresh to finish`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + // Use a non-merged attached menu: the merged menu is intentionally never pre-warmed while + // closed (#1274), so the in-flight-refresh prep machinery is exercised via the fallback menu. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.menuVersions[key] == openedVersion) + + store.isRefreshing = false + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed attached menu preparation waits for token refresh to finish`() async { + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + // Use a non-merged attached menu: the merged menu is intentionally never pre-warmed while + // closed (#1274), so the in-flight-refresh prep machinery is exercised via the fallback menu. + controller.fallbackMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.tokenRefreshInFlight.insert(.codex) + controller.invalidateMenus() + for _ in 0..<40 { + await Task.yield() + } + + #expect(controller.menuVersions[key] == openedVersion) + + store.tokenRefreshInFlight.remove(.codex) + controller.invalidateMenus() + for _ in 0..<40 where controller.menuVersions[key] == openedVersion { + await Task.yield() + } + + #expect(controller.openMenus.isEmpty) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `closed menu rebuild cleanup runs when weak menu disappears`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + let key: ObjectIdentifier + do { + let menu = NSMenu() + key = ObjectIdentifier(menu) + controller.rebuildClosedMenuIfNeeded(menu) + #expect(controller.closedMenuRebuildTasks[key] != nil) + #expect(controller.closedMenuRebuildTokens[key] != nil) + } + + for _ in 0..<40 where controller.closedMenuRebuildTasks[key] != nil { + await Task.yield() + } + + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.closedMenuRebuildTokens[key] == nil) + } + + @Test + func `merged menu close defers stale rebuild until next open`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + StatusItemController.setClosedMenuPreparationDelayForTesting(.zero) + defer { StatusItemController.resetClosedMenuPreparationDelayForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + controller.invalidateMenus(refreshOpenMenus: false) + #expect(controller.menuNeedsRefresh(menu)) + + controller.menuDidClose(menu) + await self.waitUntilClosedMenuRebuildRemainsDeferred(controller, key: key, openedVersion: openedVersion) + + #expect(controller.closedMenuRebuildTasks[key] == nil) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `menu open keeps stale nonempty content while store refresh is active`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + let openedItemCount = menu.items.count + + store.isRefreshing = true + defer { store.isRefreshing = false } + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.menuContentVersion != openedVersion) + #expect(menu.items.count == openedItemCount) + #expect(controller.openMenus[key] === menu) + } + + @Test + func `menu open rebuilds stale content after privacy setting changes during refresh`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + defer { store.isRefreshing = false } + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + settings.hidePersonalInfo = true + controller.invalidateMenus() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == controller.menuContentVersion) + #expect(controller.menuVersions[key] != openedVersion) + } + + @Test + func `menu open keeps stale nonempty content while token refresh is active`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + let menu = controller.makeMenu() + controller.mergedMenu = menu + controller.statusItem.menu = menu + + controller.populateMenu(menu, provider: nil) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + let openedItemCount = menu.items.count + + store.tokenRefreshInFlight.insert(.codex) + defer { store.tokenRefreshInFlight.remove(.codex) } + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.menuContentVersion != openedVersion) + #expect(menu.items.count == openedItemCount) + #expect(controller.openMenus[key] === menu) + } + + @Test + func `explicit store actions defer visible parent menu rebuild`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = controller.menuVersions[key] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + for _ in 0..<20 { + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[key] == openedVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key)) + } + + @Test + func `repeated explicit store actions keep parent rebuild deferred`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + controller.refreshOpenMenusAfterExplicitStoreAction() + controller.refreshOpenMenusAfterExplicitStoreAction() + + for _ in 0..<20 { + await Task.yield() + } + + #expect(rebuildCount == 0) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(key)) + } + + @Test + func `explicit refresh rebuilds stale parent after hosted submenu closes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = controller.menuVersions[menuKey] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + for _ in 0..<20 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + #expect(controller.menuVersions[menuKey] == openedVersion) + + controller.menuDidClose(submenu) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + #expect(!controller.parentMenuRebuildsDeferredDuringTracking.contains(menuKey)) + } + + @Test + func `hosted submenu close waits for active refresh before rebuilding parent`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = controller.menuVersions[menuKey] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + store.isRefreshing = true + controller.refreshOpenMenusAfterExplicitStoreAction() + controller.menuDidClose(submenu) + for _ in 0..<20 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 0) + #expect(controller.menuVersions[menuKey] == openedVersion) + #expect(controller.parentMenuRebuildPendingAfterHostedSubviewClose) + + store.isRefreshing = false + controller.handleObservedStoreMenuChange() + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + #expect(!controller.parentMenuRebuildPendingAfterHostedSubviewClose) + #expect(!controller.parentMenuRebuildsDeferredDuringTracking.contains(menuKey)) + } + + @Test + func `plain open menu refresh preserves pending switcher hosted submenu cleanup`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + controller.menuRefreshEnabledOverrideForTesting = true + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + controller.refreshOpenMenuIfStillVisible(menu, provider: .codex) + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + } + + @Test + func `rapid switcher rebuild requests coalesce before populating open menu`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + controller.menuRefreshEnabledOverrideForTesting = true + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = 0 + defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + var refreshGateEntries = 0 + var pendingRefreshGates: [CheckedContinuation] = [] + func resumePendingRefreshGates() { + let gates = pendingRefreshGates + pendingRefreshGates.removeAll(keepingCapacity: true) + for gate in gates { + gate.resume() + } + } + controller._test_openMenuRefreshYieldOverride = { + refreshGateEntries += 1 + await withCheckedContinuation { continuation in + pendingRefreshGates.append(continuation) + } + } + defer { + resumePendingRefreshGates() + controller._test_openMenuRefreshYieldOverride = nil + } + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + for _ in 0..<20 where refreshGateEntries == 0 { + await Task.yield() + } + #expect(refreshGateEntries == 1) + #expect(rebuildCount == 0) + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + resumePendingRefreshGates() + for _ in 0..<20 where refreshGateEntries < 2 { + await Task.yield() + } + #expect(refreshGateEntries == 2) + #expect(rebuildCount == 0) + resumePendingRefreshGates() + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + for _ in 0..<20 { + await Task.yield() + } + #expect(rebuildCount == 1) + } + + @Test + func `codex parent menu open defers stale OpenAI web refresh until tracking ends`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + var refreshInteractions: [ProviderInteraction] = [] + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + refreshInteractions.append(ProviderInteractionContext.current) + return try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(await blocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.menuDidClose(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + #expect(refreshInteractions == [.background]) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `programmatic parent menu close schedules deferred OpenAI web refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.forgetClosedMenu(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `deferred OpenAI web refresh retries after active store refresh completes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store.isRefreshing = true + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + controller.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") + controller.scheduleDeferredMenuInteractionRefreshIfNeeded() + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await blocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + store.isRefreshing = false + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `deferred OpenAI web refresh waits for deferred store refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + let providerBlocker = BlockingStatusMenuProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await providerBlocker.awaitRelease() + } + defer { store._test_providerRefreshOverride = nil } + let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardBlocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + await providerBlocker.waitUntilStarted() + #expect(await dashboardBlocker.startedCount() == 0) + + await providerBlocker.resumeNext() + await dashboardBlocker.waitUntilStarted(count: 1) + #expect(await dashboardBlocker.startedCount() == 1) + + await dashboardBlocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `reopened menu keeps dashboard refresh deferred after store refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setSnapshotForTesting(nil, provider: .codex) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + let providerBlocker = BlockingStatusMenuProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await providerBlocker.awaitRelease() + } + defer { store._test_providerRefreshOverride = nil } + let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardBlocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + await providerBlocker.waitUntilStarted() + + let reopenedMenu = controller.makeMenu() + controller.menuWillOpen(reopenedMenu) + await providerBlocker.resumeNext() + try? await Task.sleep(for: .milliseconds(50)) + #expect(await dashboardBlocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.menuDidClose(reopenedMenu) + await dashboardBlocker.waitUntilStarted(count: 1) + #expect(await dashboardBlocker.startedCount() == 1) + + await dashboardBlocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `codex parent menu close refreshes recent dashboard cache with no chart history`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: Date()) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(await blocker.startedCount() == 0) + + controller.menuDidClose(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: Date()))) + } + + @Test + func `codex parent menu open throttles recent empty dashboard retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: now.addingTimeInterval(-120)) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + store.lastOpenAIDashboardAttemptAt = now.addingTimeInterval(-60) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + try? await Task.sleep(for: .milliseconds(150)) + #expect(await blocker.startedCount() == 0) + } + + @Test + func `credits history arriving after open rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.showOptionalCreditsAndExtraUsage = true + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: true) + store.credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: now) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(self.menuItem(in: menu, id: "menuCardCredits") == nil) + + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now.addingTimeInterval(10)) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let creditsItem = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + #expect( + creditsItem.submenu?.items.first?.representedObject as? String == + StatusItemController.creditsHistoryChartID) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `fresh dashboard history with same day count rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.showOptionalCreditsAndExtraUsage = true + self.enableOnlyCodex(settings) + + let now = Date(timeIntervalSince1970: 100) + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: true) + store.credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: now) + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + _ = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 99), + ], + updatedAt: now.addingTimeInterval(10)) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let creditsItem = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + #expect(creditsItem.submenu?.items.first?.representedObject as? String == StatusItemController + .creditsHistoryChartID) + } + + @Test + func `token cost history arriving after open rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(self.menuItem(in: menu, id: "menuCardCost") == nil) + + store._setTokenSnapshotForTesting(self.makeCodexTokenCostSnapshot(), provider: .codex) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let costItem = try #require(self.menuItem(in: menu, id: "menuCardCost")) + #expect(costItem.submenu?.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `fresh token cost history with same day count rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting( + self.makeCodexTokenCostSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 456, + last30DaysCostUSD: 1.23, + updatedAt: Date(timeIntervalSince1970: 100)), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + _ = try #require(self.menuItem(in: menu, id: "menuCardCost")) + + store._setTokenSnapshotForTesting( + self.makeCodexTokenCostSnapshot( + sessionTokens: 999, + sessionCostUSD: 0.99, + last30DaysTokens: 888, + last30DaysCostUSD: 8.88, + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let costItem = try #require(self.menuItem(in: menu, id: "menuCardCost")) + #expect(costItem.submenu?.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + } + + @Test + func `plan utilization history arriving after open rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + let usageHistoryItem = try #require(self.menuItem(in: menu, id: "usageHistorySubmenu")) + #expect(usageHistoryItem.submenu?.items.first?.representedObject as? String == StatusItemController + .usageHistoryChartID) + let openedRevision = store.planUtilizationHistoryRevision + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: self.makeCodexPlanUtilizationSnapshot(), + now: Date()) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(store.planUtilizationHistoryRevision > openedRevision) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + } + + @Test + func `dashboard attachment authorization arriving after open rebuilds parent menu after close`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(store.openAIDashboardAttachmentRevision == 0) + + store.openAIDashboardAttachmentAuthorized = true + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(store.openAIDashboardAttachmentRevision == 1) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func menuItem(in menu: NSMenu, id: String) -> NSMenuItem? { + menu.items.first { ($0.representedObject as? String) == id } + } + + private func waitUntilMenuVersionChanges( + _ controller: StatusItemController, + from version: Int?) async + { + for _ in 0..<20 where controller.menuContentVersion == version { + await Task.yield() + } + } + + private func waitUntilOpenMenuStaysStale( + _ controller: StatusItemController, + key: ObjectIdentifier, + after version: Int?) async + { + for _ in 0..<40 { + guard controller.menuContentVersion != version else { + await Task.yield() + continue + } + guard controller.menuVersions[key] == version else { + await Task.yield() + continue + } + return + } + } + + private func closeMenuAndWaitUntilFresh( + _ controller: StatusItemController, + menu: NSMenu, + key: ObjectIdentifier) async + { + controller.menuDidClose(menu) + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } + if controller.menuVersions[key] != controller.menuContentVersion { + controller.menuWillOpen(menu) + } + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + private func waitUntilClosedMenuRebuildRemainsDeferred( + _ controller: StatusItemController, + key: ObjectIdentifier, + openedVersion: Int?) async + { + for _ in 0..<40 + where controller.closedMenuRebuildTasks[key] != nil || + controller.menuVersions[key] != openedVersion + { + await Task.yield() + } + } + + private func makeOpenAIDashboard( + dailyBreakdown: [OpenAIDashboardDailyBreakdown], + updatedAt: Date) -> OpenAIDashboardSnapshot + { + OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: dailyBreakdown, + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: updatedAt) + } + + private func makeCodexTokenCostSnapshot( + sessionTokens: Int = 123, + sessionCostUSD: Double = 0.12, + last30DaysTokens: Int = 456, + last30DaysCostUSD: Double = 1.23, + updatedAt: Date = Date()) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: sessionTokens, + sessionCostUSD: sessionCostUSD, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-05-24", + inputTokens: nil, + outputTokens: nil, + totalTokens: sessionTokens, + costUSD: last30DaysCostUSD, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: updatedAt) + } + + private func makeCodexPlanUtilizationSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 35, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(1800), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")) + } +} + +private actor BlockingStatusMenuProviderRefresh { + private var continuations: [CheckedContinuation] = [] + private var startWaiters: [CheckedContinuation] = [] + private var started = 0 + + func awaitRelease() async { + self.started += 1 + self.resumeStartWaiters() + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func waitUntilStarted() async { + if self.started > 0 { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func resumeNext() { + guard !self.continuations.isEmpty else { return } + self.continuations.removeFirst().resume() + } + + private func resumeStartWaiters() { + let waiters = self.startWaiters + self.startWaiters = [] + for waiter in waiters { + waiter.resume() + } + } +} diff --git a/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift b/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift new file mode 100644 index 0000000000..320cc1222c --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOverviewScrollTests.swift @@ -0,0 +1,216 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct StatusMenuOverviewScrollTests { + private func makeController(suiteName: String) -> StatusItemController { + _ = NSApplication.shared + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + private func makeOverviewMenu() -> NSMenu { + let menu = NSMenu() + for provider in ["claude", "codex"] { + let item = NSMenuItem() + item.representedObject = "\(StatusItemController.overviewRowIdentifierPrefix)\(provider)" + item.isEnabled = true + menu.addItem(item) + } + return menu + } + + private func makeScrollEvent(deltaY: Double, precise: Bool) -> NSEvent? { + guard let cgEvent = CGEvent( + scrollWheelEvent2Source: nil, + units: precise ? .pixel : .line, + wheelCount: 1, + wheel1: Int32(deltaY), + wheel2: 0, + wheel3: 0) + else { return nil } + return NSEvent(cgEvent: cgEvent) + } + + @Test + func `scroll steps move highlight and respect direction`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Direction") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let scrollUp = try #require(self.makeScrollEvent(deltaY: 30, precise: true)) + #expect(controller.handleOverviewScrollWheel(scrollUp, menu: menu)) + #expect(steps == [.up]) + + steps = [] + let scrollDown = try #require(self.makeScrollEvent(deltaY: -30, precise: true)) + #expect(controller.handleOverviewScrollWheel(scrollDown, menu: menu)) + #expect(steps == [.down]) + } + + @Test + func `navigation targets only overview rows`() { + let controller = self.makeController(suiteName: "OverviewScroll-Targets") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + let refresh = NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "") + refresh.isEnabled = true + menu.addItem(refresh) + let rows = Array(menu.items.prefix(2)) + + #expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[0]) + #expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[1]) + + controller.highlightedMenuItems[ObjectIdentifier(menu)] = rows[0] + #expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[1]) + #expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[0]) + + controller.highlightedMenuItems[ObjectIdentifier(menu)] = rows[1] + #expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[1]) + #expect(controller.overviewScrollTargetItem(in: menu, step: .up) === rows[0]) + + controller.highlightedMenuItems[ObjectIdentifier(menu)] = refresh + #expect(controller.overviewScrollTargetItem(in: menu, step: .down) === rows[0]) + } + + @Test + func `small precise deltas accumulate before stepping`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Accumulate") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let smallScroll = try #require(self.makeScrollEvent(deltaY: 10, precise: true)) + #expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu)) + #expect(steps.isEmpty) + #expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu)) + #expect(steps.isEmpty) + #expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu)) + #expect(steps == [.up]) + } + + @Test + func `direction change resets accumulated distance`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Flip") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let upBelowThreshold = try #require(self.makeScrollEvent(deltaY: 20, precise: true)) + #expect(controller.handleOverviewScrollWheel(upBelowThreshold, menu: menu)) + #expect(steps.isEmpty) + + let downBelowThreshold = try #require(self.makeScrollEvent(deltaY: -20, precise: true)) + #expect(controller.handleOverviewScrollWheel(downBelowThreshold, menu: menu)) + #expect(steps.isEmpty) + #expect(controller.handleOverviewScrollWheel(downBelowThreshold, menu: menu)) + #expect(steps == [.down]) + } + + @Test + func `coarse wheel lines step immediately`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Wheel") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let wheelNotch = try #require(self.makeScrollEvent(deltaY: -1, precise: false)) + #expect(controller.handleOverviewScrollWheel(wheelNotch, menu: menu)) + #expect(steps == [.down]) + } + + @Test + func `fast flick is capped per event`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Cap") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let flick = try #require(self.makeScrollEvent(deltaY: 500, precise: true)) + #expect(controller.handleOverviewScrollWheel(flick, menu: menu)) + #expect(steps == [.up, .up, .up]) + } + + @Test + func `capped flick discards leftover distance`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-CapRemainder") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let flick = try #require(self.makeScrollEvent(deltaY: 500, precise: true)) + #expect(controller.handleOverviewScrollWheel(flick, menu: menu)) + #expect(steps == [.up, .up, .up]) + + steps = [] + let smallScroll = try #require(self.makeScrollEvent(deltaY: 10, precise: true)) + #expect(controller.handleOverviewScrollWheel(smallScroll, menu: menu)) + #expect(steps.isEmpty) + } + + @Test + func `open submenu suspends scroll navigation`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-Submenu") + defer { controller.releaseStatusItemsForTesting() } + let menu = self.makeOverviewMenu() + let submenu = NSMenu() + controller.openMenus[ObjectIdentifier(menu)] = menu + controller.openMenus[ObjectIdentifier(submenu)] = submenu + defer { controller.openMenus.removeAll() } + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let scroll = try #require(self.makeScrollEvent(deltaY: 30, precise: true)) + #expect(!controller.handleOverviewScrollWheel(scroll, menu: menu)) + #expect(steps.isEmpty) + } + + @Test + func `menus without overview rows ignore scrolling`() throws { + let controller = self.makeController(suiteName: "OverviewScroll-NonOverview") + defer { controller.releaseStatusItemsForTesting() } + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "")) + + var steps: [OverviewScrollStep] = [] + controller.overviewScrollNavigationHandlerForTesting = { steps.append($0) } + + let scroll = try #require(self.makeScrollEvent(deltaY: 30, precise: true)) + #expect(!controller.handleOverviewScrollWheel(scroll, menu: menu)) + #expect(steps.isEmpty) + #expect(!menu.items.isEmpty) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift new file mode 100644 index 0000000000..4311deaa57 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -0,0 +1,271 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `overview rows expose provider detail submenus`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .openai + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .openai || provider == .codex + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 9, + requests: 12, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let openAIRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-openai" + }) + #expect(openAIRow.submenu?.items.contains { + ($0.representedObject as? String) == StatusItemController.costHistoryChartID + } == true) + } + + @Test + func `overview row submenu action does not switch provider detail`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .zai || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: ZaiLimitEntry( + type: .timeLimit, + unit: .minutes, + number: 1, + usage: 100, + currentValue: 50, + remaining: 50, + percentage: 50, + usageDetails: [ZaiUsageDetail(modelCode: "glm-4.5", usage: 512)], + nextResetTime: now.addingTimeInterval(3600)), + planName: "Pro", + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .zai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let zaiRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-zai" + }) + #expect(zaiRow.submenu != nil) + + let action = try #require(zaiRow.action) + let target = try #require(zaiRow.target as? StatusItemController) + _ = target.perform(action, with: zaiRow) + + #expect(settings.mergedMenuLastSelectedWasOverview) + #expect(settings.selectedMenuProvider == .claude) + #expect(menu.items.contains { + ($0.representedObject as? String) == "overviewRow-zai" + }) + } + + @Test + func `selecting overview row defers provider detail rebuild`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let cursorRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-cursor" + }) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let action = try #require(cursorRow.action) + let target = try #require(cursorRow.target as? StatusItemController) + _ = target.perform(action, with: cursorRow) + + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .cursor) + #expect(rebuildCount == 0) + #expect(menu.items.contains { + ($0.representedObject as? String)?.hasPrefix("overviewRow-") == true + }) + + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + let representedIDs = menu.items.compactMap { $0.representedObject as? String } + let switcherButtons = (menu.items.first?.view as? ProviderSwitcherView)?.subviews + .compactMap { $0 as? NSButton } ?? [] + #expect(rebuildCount == 1) + #expect(representedIDs.contains("menuCard")) + #expect(representedIDs.contains(where: { $0.hasPrefix("overviewRow-") }) == false) + #expect(switcherButtons.first(where: { $0.state == .on })?.tag == 2) + } + + @Test + func `overview row action close renders selected provider on next open`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let cursorRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-cursor" + }) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let action = try #require(cursorRow.action) + let target = try #require(cursorRow.target as? StatusItemController) + _ = target.perform(action, with: cursorRow) + controller.menuDidClose(menu) + + await Task.yield() + await Task.yield() + #expect(rebuildCount == 0) + #expect(settings.selectedMenuProvider == .cursor) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let representedIDs = menu.items.compactMap { $0.representedObject as? String } + let switcherButtons = (menu.items.first?.view as? ProviderSwitcherView)?.subviews + .compactMap { $0 as? NSButton } ?? [] + #expect(representedIDs.contains("menuCard")) + #expect(representedIDs.contains(where: { $0.hasPrefix("overviewRow-") }) == false) + #expect(switcherButtons.first(where: { $0.state == .on })?.tag == 2) + } +} diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift new file mode 100644 index 0000000000..0644e270ce --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -0,0 +1,949 @@ +import AppKit +import CodexBarCore +import SwiftUI +import Testing +@testable import CodexBar + +private final class RefreshShortcutRecorder: StatusItemMenuPersistentActionDelegate { + var refreshCount = 0 + var refreshMenuIDs: [ObjectIdentifier] = [] + var settingsCount = 0 + var quitCount = 0 + var navigationDirections: [StatusItemMenuProviderNavigationDirection] = [] + + func performPersistentRefreshAction(in menuID: ObjectIdentifier) { + self.refreshCount += 1 + self.refreshMenuIDs.append(menuID) + } + + func performPersistentSettingsAction() { + self.settingsCount += 1 + } + + func performPersistentQuitAction() { + self.quitCount += 1 + } + + func performProviderNavigation(_ direction: StatusItemMenuProviderNavigationDirection) { + self.navigationDirections.append(direction) + } +} + +@MainActor +private final class UpdateReadyUpdater: UpdaterProviding { + var automaticallyChecksForUpdates = false + var automaticallyDownloadsUpdates = false + let isAvailable = true + let unavailableReason: String? = nil + let updateStatus = UpdateStatus(isUpdateReady: true) + + func checkForUpdates(_: Any?) {} + func installUpdate() {} +} + +@MainActor +private final class ManualRefreshGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + if self.isOpen { + self.isOpen = false + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + if let continuation = self.continuation { + continuation.resume() + self.continuation = nil + } else { + self.isOpen = true + } + } +} + +@MainActor +@Suite(.serialized) +struct StatusMenuPersistentRefreshTests { + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuPersistentRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeController( + settings: SettingsStore, + updater: UpdaterProviding = DisabledUpdaterController(), + account: AccountInfo? = nil) -> StatusItemController + { + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + if let account { + store.accountInfoCache[.codex] = UsageStore.AccountInfoCacheEntry( + account: account, + configRevision: settings.configRevision, + expiresAt: .distantFuture) + } + return StatusItemController( + store: store, + settings: settings, + account: account ?? fetcher.loadAccountInfo(), + updater: updater, + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + private func enableOnly(_ providers: Set, settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + } + + private static func makeTokenSnapshot() -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 456, + last30DaysCostUSD: 1.23, + daily: [], + updatedAt: Date()) + } + + @Test + func `refresh row is custom and appears above settings`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + let settingsItem = try #require(menu.items.first { $0.title == "Settings..." }) + let refreshIndex = try #require(menu.items.firstIndex(where: { $0 === refreshItem })) + let settingsIndex = try #require(menu.items.firstIndex(where: { $0 === settingsItem })) + + #expect(refreshItem.action == nil) + #expect(refreshItem.target == nil) + #expect(refreshItem.view != nil) + #expect(controller.isPersistentRefreshItem(refreshItem)) + #expect(refreshIndex < settingsIndex) + } + + @Test + func `refresh row is custom while remaining persistent action items stay native`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings, updater: UpdateReadyUpdater()) + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let updateItem = try #require(menu.items.first { $0.title == "Update ready, restart now?" }) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(MenuDescriptor.MenuAction.installUpdate.systemImageName == "arrow.down.circle") + #expect(updateItem.image != nil) + #expect(refreshItem.view != nil) + #expect(refreshItem.action == nil) + #expect(controller.isPersistentRefreshItem(refreshItem)) + + for title in ["Update ready, restart now?", "Settings...", "About CodexBar", "Quit"] { + let item = try #require(menu.items.first { $0.title == title }) + #expect(item.view == nil, "'\(title)' should be a native NSMenuItem with no custom view") + #expect(item.action != nil) + #expect(item.target === controller) + } + } + + @Test + func `persistent refresh row reflects scoped global and manual refresh state`() throws { + let previousRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { StatusItemController.menuCardRenderingEnabled = previousRendering } + + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(refreshItem.view != nil) + #expect(controller.isPersistentRefreshItem(refreshItem)) + #expect(controller.persistentRefreshItems.allObjects.contains { $0 === refreshItem }) + #expect(refreshItem.isEnabled) + + controller.store.refreshingProviders.insert(.claude) + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + controller.store.refreshingProviders.insert(.codex) + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + controller.store.refreshingProviders.removeAll() + controller.store.isRefreshing = true + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + controller.store.isRefreshing = false + controller.manualRefreshProvider = .claude + controller.manualRefreshTask = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + controller.manualRefreshTask = nil + controller.manualRefreshProvider = nil + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + + refreshItem.representedObject = "notRefresh" + controller.manualRefreshTask = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(refreshItem.isEnabled) + #expect(!controller.persistentRefreshItems.allObjects.contains { $0 === refreshItem }) + controller.manualRefreshTask = nil + } + + @Test + func `refresh monitor follows refresh success and failure`() { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let monitor = controller.menuCardRefreshMonitor + let fallback = MenuCardLiveSubtitle(text: "Fallback", style: .info) + + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .info) + + controller.store.isRefreshing = true + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) + + controller.store.isRefreshing = false + monitor.isManualRefreshInFlight = true + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) + monitor.isManualRefreshInFlight = false + + controller.store.isRefreshing = false + let now = Date() + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let success = monitor.subtitle(for: .codex, fallback: fallback) + #expect(success.style == .info) + #expect(success.text == UsageFormatter.updatedString(from: now, now: Date())) + + controller.store.errors[.codex] = "Refresh failed" + let failure = monitor.subtitle(for: .codex, fallback: fallback) + #expect(failure.style == .error) + #expect(failure.text == "Refresh failed") + + monitor.isManualRefreshInFlight = true + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) + } + + @Test + func `scoped refresh monitor leaves unrelated providers unchanged`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let monitor = controller.menuCardRefreshMonitor + let codexModel = try #require(controller.menuCardModel(for: .codex)) + let fallback = MenuCardLiveSubtitle(text: "Claude idle", style: .info) + let expectedClaude = monitor.subtitle(for: .claude, fallback: fallback) + + monitor.beginManualRefresh(frozenModels: [.codex: codexModel], provider: .codex) + defer { monitor.endManualRefresh() } + + #expect(monitor.isManualRefreshInFlight(for: .codex)) + #expect(!monitor.isManualRefreshInFlight(for: .claude)) + #expect(monitor.subtitle(for: .codex, fallback: fallback).style == .loading) + let actualClaude = monitor.subtitle(for: .claude, fallback: fallback) + #expect(actualClaude.text == expectedClaude.text) + #expect(actualClaude.style == expectedClaude.style) + } + + @Test + func `refresh monitor updates compatible usage values after manual refresh completes`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let now = Date() + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + updatedAt: now) + let fallback = try #require(controller.menuCardModel(for: .claude)) + controller.menuCardRefreshMonitor.isManualRefreshInFlight = true + + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 65, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 75, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + updatedAt: now.addingTimeInterval(1)) + + let inFlight = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + #expect(inFlight.metrics.map(\.percent) == fallback.metrics.map(\.percent)) + + controller.menuCardRefreshMonitor.isManualRefreshInFlight = false + let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + let expected = try #require(controller.menuCardModel(for: .claude)) + + #expect(refreshed.metrics.map(\.percent) == expected.metrics.map(\.percent)) + #expect(refreshed.metrics.map(\.percent) != fallback.metrics.map(\.percent)) + } + + @Test + func `manual refresh keeps frozen quota even if menu rebuilds before completion`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let now = Date() + for provider in [UsageProvider.claude, .codex] { + controller.store.snapshots[provider] = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: provider)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [provider: frozen]) + + controller.store.snapshots[provider] = UsageSnapshot( + primary: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(1)) + let rebuiltFallback = try #require(controller.menuCardModel(for: provider)) + let inFlight = controller.menuCardRefreshMonitor.model(for: provider, fallback: rebuiltFallback) + + #expect(frozen.metrics.first?.percentLabel == "79% left") + #expect(rebuiltFallback.metrics.first?.percentLabel == "82% left") + #expect(inFlight.metrics.first?.percentLabel == "79% left") + + controller.menuCardRefreshMonitor.endManualRefresh() + let completed = controller.menuCardRefreshMonitor.model(for: provider, fallback: frozen) + #expect(completed.metrics.first?.percentLabel == "82% left") + } + } + + @Test + func `manual refresh uses fallback when frozen quota layout is incompatible`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let now = Date() + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .claude)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [.claude: frozen]) + + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 18, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now.addingTimeInterval(1)) + let rebuiltFallback = try #require(controller.menuCardModel(for: .claude)) + let inFlight = controller.menuCardRefreshMonitor.model(for: .claude, fallback: rebuiltFallback) + + #expect(frozen.metrics.count == 1) + #expect(rebuiltFallback.metrics.count == 2) + #expect(inFlight.metrics.count == 2) + #expect(inFlight.metrics.map(\.id) == rebuiltFallback.metrics.map(\.id)) + } + + @Test + func `manual refresh preserves frozen quota when supplemental metric remains`() throws { + let settings = self.makeSettings() + let controller = self.makeController( + settings: settings, + account: AccountInfo(email: "test@example.com", plan: "pro")) + let now = Date() + controller.store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "test@example.com", + codeReviewRemainingPercent: 88, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: now) + controller.store.openAIDashboardAttachmentAuthorized = true + controller.store.openAIDashboardRequiresLogin = false + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .codex)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [.codex: frozen]) + + controller.store.snapshots[.codex] = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now.addingTimeInterval(1)) + let fallback = try #require(controller.menuCardModel(for: .codex)) + let inFlight = controller.menuCardRefreshMonitor.model(for: .codex, fallback: fallback) + + #expect(frozen.metrics.count == 3) + #expect(fallback.metrics.map(\.id) == ["code-review"]) + #expect(inFlight.metrics.map(\.id) == frozen.metrics.map(\.id)) + #expect(inFlight.metrics.first?.percentLabel == "79% left") + } + + @Test + func `manual refresh uses fallback when empty quota gains credit content`() throws { + let settings = self.makeSettings() + let controller = self.makeController( + settings: settings, + account: AccountInfo(email: "test@example.com", plan: "pro")) + let now = Date() + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .codex)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [.codex: frozen]) + + controller.store.snapshots[.codex] = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now.addingTimeInterval(1)) + controller.store.credits = CreditsSnapshot( + remaining: 42, + events: [], + updatedAt: now.addingTimeInterval(1)) + let fallback = try #require(controller.menuCardModel(for: .codex)) + let inFlight = controller.menuCardRefreshMonitor.model(for: .codex, fallback: fallback) + + #expect(frozen.metrics.count == 1) + #expect(fallback.metrics.isEmpty) + #expect(fallback.creditsText != nil) + #expect(inFlight.metrics.isEmpty) + #expect(inFlight.creditsText == fallback.creditsText) + } + + @Test + func `manual refresh uses fallback when empty quota gains a placeholder`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let now = Date() + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 21, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + let frozen = try #require(controller.menuCardModel(for: .claude)) + controller.menuCardRefreshMonitor.beginManualRefresh(frozenModels: [.claude: frozen]) + + controller.store.snapshots.removeValue(forKey: .claude) + let fallback = try #require(controller.menuCardModel(for: .claude)) + let inFlight = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + + #expect(frozen.metrics.count == 1) + #expect(fallback.metrics.isEmpty) + #expect(fallback.placeholder != nil) + #expect(inFlight.metrics.isEmpty) + #expect(inFlight.placeholder == fallback.placeholder) + } + + @Test + func `refresh monitor updates single line credit balances`() throws { + let settings = self.makeSettings() + let controller = self.makeController( + settings: settings, + account: AccountInfo(email: "test@example.com", plan: "pro")) + let now = Date() + controller.store.snapshots[.codex] = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now) + controller.store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: now) + let fallback = try #require(controller.menuCardModel(for: .codex)) + + controller.store.credits = CreditsSnapshot( + remaining: 42, + events: [], + updatedAt: now.addingTimeInterval(1)) + let refreshed = controller.menuCardRefreshMonitor.model(for: .codex, fallback: fallback) + + #expect(refreshed.creditsRemaining == 42) + #expect(refreshed.creditsText != fallback.creditsText) + } + + @Test + func `refresh monitor preserves multiline workspace credit text`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + controller.store.snapshots[.amp] = UsageSnapshot( + primary: nil, + secondary: nil, + ampUsage: AmpUsageDetails( + individualCredits: 12, + workspaceBalances: [AmpWorkspaceBalance(name: "Team", remaining: 7)]), + updatedAt: Date()) + let fallback = try #require(controller.menuCardModel(for: .amp)) + + controller.store.snapshots[.amp] = UsageSnapshot( + primary: nil, + secondary: nil, + ampUsage: AmpUsageDetails( + individualCredits: 10, + workspaceBalances: [AmpWorkspaceBalance(name: "Team", remaining: 3)]), + updatedAt: Date()) + let refreshed = controller.menuCardRefreshMonitor.model(for: .amp, fallback: fallback) + + #expect(refreshed.creditsText == fallback.creditsText) + } + + @Test + func `refresh monitor preserves tracked layout when refresh adds usage sections`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let fallback = try #require(controller.menuCardModel(for: .claude)) + #expect(fallback.metrics.isEmpty) + + controller.store.snapshots[.claude] = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + + let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + + #expect(refreshed.metrics.isEmpty) + #expect(refreshed.placeholder == fallback.placeholder) + } + + @Test + func `refresh monitor preserves tracked layout when token error appears`() throws { + let settings = self.makeSettings() + settings.costUsageEnabled = true + let controller = self.makeController(settings: settings) + controller.store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + let fallback = try #require(controller.menuCardModel(for: .claude)) + #expect(fallback.tokenUsage?.errorLine == nil) + + controller.store._setTokenErrorForTesting("New token usage error", provider: .claude) + let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + + #expect(refreshed.tokenUsage?.errorLine == nil) + } + + @Test + func `refresh monitor preserves tracked layout when token error text changes`() throws { + let settings = self.makeSettings() + settings.costUsageEnabled = true + let controller = self.makeController(settings: settings) + controller.store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + controller.store._setTokenErrorForTesting("Old token usage error", provider: .claude) + let fallback = try #require(controller.menuCardModel(for: .claude)) + + controller.store._setTokenErrorForTesting( + "A longer replacement error that could occupy more lines", + provider: .claude) + let refreshed = controller.menuCardRefreshMonitor.model(for: .claude, fallback: fallback) + + #expect(refreshed.tokenUsage?.errorLine == "Old token usage error") + } + + @Test + func `live subtitle preserves canonical model error filtering`() throws { + let settings = self.makeSettings() + let controller = self.makeController( + settings: settings, + account: AccountInfo(email: "test@example.com", plan: "pro")) + controller.store.errors[.codex] = UsageError.noRateLimitsFound.errorDescription + let model = try #require(controller.menuCardModel(for: .codex)) + let fallback = MenuCardLiveSubtitle(text: "Fallback", style: .error) + + let liveSubtitle = controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback) + + #expect(liveSubtitle.text == model.subtitleText) + #expect(liveSubtitle.style == model.subtitleStyle) + #expect(liveSubtitle.text != UsageError.noRateLimitsFound.errorDescription) + #expect(liveSubtitle.style != .error) + } + + @Test + func `override cards keep their own subtitle`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + let liveModel = try #require(controller.menuCardModel(for: .codex)) + let overrideModel = try #require(controller.menuCardModel( + for: .codex, + errorOverride: "Account unavailable", + forceOverrideCard: true)) + + #expect(liveModel.usesLiveSubtitle) + #expect(!overrideModel.usesLiveSubtitle) + #expect(overrideModel.subtitleText == "Account unavailable") + } + + @Test + func `live failure keeps the measured card height`() throws { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + + func fittingHeight(for model: UsageMenuCardView.Model) -> CGFloat { + NSHostingView(rootView: UsageMenuCardView(model: model, width: 320) + .environment(\.menuCardRefreshMonitor, controller.menuCardRefreshMonitor)) + .fittingSize.height + } + + let idleModel = try #require(controller.menuCardModel(for: .codex)) + let idleHeight = fittingHeight(for: idleModel) + controller.store.errors[.codex] = "Short error" + let failureHeight = fittingHeight(for: idleModel) + + #expect(failureHeight == idleHeight) + + let errorModel = try #require(controller.menuCardModel(for: .codex)) + let errorHeight = fittingHeight(for: errorModel) + controller.store.errors[.codex] = + "Refresh failed with a much longer replacement message that must not resize the tracked menu" + let replacementErrorHeight = fittingHeight(for: errorModel) + controller.menuCardRefreshMonitor.isManualRefreshInFlight = true + let retryHeight = fittingHeight(for: errorModel) + + #expect(replacementErrorHeight == errorHeight) + let fallback = MenuCardLiveSubtitle(text: errorModel.subtitleText, style: errorModel.subtitleStyle) + #expect(controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback).style == .loading) + #expect(retryHeight == errorHeight) + } + + @Test + func `manual refresh is suppressed after shutdown preparation`() { + let settings = self.makeSettings() + let controller = self.makeController(settings: settings) + var requestCount = 0 + controller._test_manualRefreshOperation = { + requestCount += 1 + } + + controller.prepareForAppShutdown() + controller.refreshNow() + + #expect(requestCount == 0) + #expect(controller.manualRefreshTask == nil) + #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) + } + + @Test + func `repeated manual refresh clicks share one lifecycle`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + + let gate = ManualRefreshGate() + var requestCount = 0 + controller._test_manualRefreshOperation = { + requestCount += 1 + await gate.wait() + } + + controller.refreshNow() + let task = try #require(controller.manualRefreshTask) + controller.refreshNow() + controller.refreshNow() + await Task.yield() + + #expect(requestCount == 1) + #expect(controller.menuCardRefreshMonitor.isManualRefreshInFlight) + + gate.resume() + await task.value + + #expect(controller.manualRefreshTask == nil) + #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) + } + + @Test + func `provider menu persistent refresh row and command R refresh only that provider`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnly([.claude, .codex], settings: settings) + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu(for: .claude) as? StatusItemMenu) + let codexMenu = try #require(controller.makeMenu(for: .codex) as? StatusItemMenu) + controller.menuWillOpen(menu) + controller.menuWillOpen(codexMenu) + defer { + controller.menuDidClose(codexMenu) + controller.menuDidClose(menu) + } + + let mouseGate = ManualRefreshGate() + var requestCount = 0 + controller._test_manualRefreshOperation = { + requestCount += 1 + await mouseGate.wait() + } + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + for _ in 0..<20 where controller.manualRefreshTask == nil { + await Task.yield() + } + let mouseTask = try #require(controller.manualRefreshTask) + #expect(controller.manualRefreshProvider == .claude) + #expect(controller.isRefreshActionInFlight(for: codexMenu)) + #expect(controller.isRefreshActionInFlight(for: NSMenu())) + let codexRefreshItem = try #require(codexMenu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(codexRefreshItem)) + controller.performPersistentRefreshAction(in: ObjectIdentifier(codexMenu)) + await Task.yield() + #expect(requestCount == 1) + mouseGate.resume() + await mouseTask.value + + let keyboardGate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await keyboardGate.wait() } + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) + for _ in 0..<20 where controller.manualRefreshTask == nil { + await Task.yield() + } + let keyboardTask = try #require(controller.manualRefreshTask) + #expect(controller.manualRefreshProvider == .claude) + keyboardGate.resume() + await keyboardTask.value + } + + @Test + func `provider menu does not replace matching scoped refresh`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnly([.claude, .codex], settings: settings) + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu(for: .claude) as? StatusItemMenu) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + controller.store.refreshingProviders.insert(.claude) + var requestCount = 0 + controller._test_manualRefreshOperation = { requestCount += 1 } + + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) + for _ in 0..<20 { + await Task.yield() + } + + #expect(requestCount == 0) + #expect(controller.manualRefreshTask == nil) + #expect(controller.manualRefreshProvider == nil) + } + + @Test + func `merged overview refreshes globally while selected provider stays scoped`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = true + self.enableOnly([.claude, .codex], settings: settings) + settings.mergedMenuLastSelectedWasOverview = true + + let controller = self.makeController(settings: settings) + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.mergedMenu = menu + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let overviewGate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await overviewGate.wait() } + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + controller.performPersistentRefreshAction(in: ObjectIdentifier(menu)) + for _ in 0..<20 where controller.manualRefreshTask == nil { + await Task.yield() + } + let overviewTask = try #require(controller.manualRefreshTask) + #expect(controller.manualRefreshProvider == nil) + overviewGate.resume() + await overviewTask.value + + settings.mergedMenuLastSelectedWasOverview = false + controller.selectedMenuProvider = .claude + let providerGate = ManualRefreshGate() + controller._test_manualRefreshOperation = { await providerGate.wait() } + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15))) + for _ in 0..<20 where controller.manualRefreshTask == nil { + await Task.yield() + } + let providerTask = try #require(controller.manualRefreshTask) + #expect(controller.manualRefreshProvider == .claude) + providerGate.resume() + await providerTask.value + } + + @Test + func `provider scoped refresh updates status and widget snapshot`() async { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + self.enableOnly([.synthetic], settings: settings) + + let controller = self.makeController(settings: settings) + controller.store._test_providerRefreshOverride = { _ in } + controller.store._test_providerStatusFetchOverride = { provider in + #expect(provider == .synthetic) + return ProviderStatus(indicator: .none, description: "Operational", updatedAt: Date()) + } + var savedSnapshots = 0 + controller.store._test_widgetSnapshotSaveOverride = { _ in + savedSnapshots += 1 + } + + await controller.performStoreRefresh( + for: .synthetic, + refreshOpenMenusWhenComplete: false, + interaction: .userInitiated) + _ = await controller.store.widgetSnapshotPersistTask?.result + + #expect(controller.store.statuses[.synthetic]?.description == "Operational") + #expect(savedSnapshots == 1) + } + + @Test + func `failed manual refresh returns native item to enabled and surfaces error`() async throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + let gate = ManualRefreshGate() + + controller._test_manualRefreshOperation = { + await gate.wait() + controller.store.errors[.codex] = "Refresh failed" + } + + controller.refreshNow() + let task = try #require(controller.manualRefreshTask) + #expect(!refreshItem.isEnabled) + + gate.resume() + await task.value + + #expect(controller.manualRefreshTask == nil) + #expect(refreshItem.isEnabled) + let fallback = MenuCardLiveSubtitle(text: "Fallback", style: .info) + #expect(controller.menuCardRefreshMonitor.subtitle(for: .codex, fallback: fallback).style == .error) + } + + @Test + func `status item menu intercepts persistent shortcuts without native item selection`() throws { + let menu = StatusItemMenu() + let recorder = RefreshShortcutRecorder() + menu.persistentActionDelegate = recorder + + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15)) == true) + #expect(try menu.performKeyEquivalent(with: self.keyEvent(",", keyCode: 43)) == true) + #expect(try menu.performKeyEquivalent(with: self.keyEvent("q", keyCode: 12)) == true) + + #expect(recorder.refreshCount == 1) + #expect(recorder.refreshMenuIDs == [ObjectIdentifier(menu)]) + #expect(recorder.settingsCount == 1) + #expect(recorder.quitCount == 1) + } + + private func keyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode)) + } +} diff --git a/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift new file mode 100644 index 0000000000..f13add449f --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuReadinessBaselineTests.swift @@ -0,0 +1,645 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `reopening root menu resyncs readiness baseline so reverted store data still refreshes`() { + // Regression for the readiness-signature optimization (#1351): the baseline is no longer + // recomputed on every store change while menus are closed, so it must be re-anchored when a + // root menu opens. Otherwise a closed-then-reopened menu built from new data, followed by an + // open-menu change that reverts to the *previous* baseline value, would be treated as + // "unchanged" and skip the rebuild, leaving the visible menu showing stale content. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + + // Root open anchors the baseline to snapshot A. Normalize via an explicit comparison so the + // assertion below is independent of whatever the controller's initial baseline happened to be. + controller.menuWillOpen(menu) + _ = controller.didMenuAdjunctReadinessChange() + controller.menuDidClose(menu) + + // Closed store change to B: the optimization intentionally skips recomputing the baseline here. + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + + // Reopening the root menu rebuilds from B and must re-anchor the baseline to B. + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + // Reverting to A (the value the *first* baseline held) must still register as a change. + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + #expect(controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `root open during in flight refresh preserves stale content and does not resync baseline`() { + // When `refreshMenuForOpenIfNeeded` keeps existing menu content during an in-flight provider + // refresh, the readiness baseline must not be re-anchored to live store data. Otherwise the + // refresh-completion store mutation would compare equal against the prematurely resynced baseline + // and skip the rebuild, leaving stale content visible (#1351). + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + let key = ObjectIdentifier(menu) + let openedVersion = controller.menuVersions[key] + + store.isRefreshing = true + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + // Stale content was preserved: the menu is still behind the current content version. + #expect(controller.menuNeedsRefresh(menu)) + + store.isRefreshing = false + // Refresh completion must still register as a readiness change so the open menu can rebuild. + #expect(controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `native merged menu preparation during in flight refresh preserves stale menu freshness`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + if let claudeMetadata = ProviderRegistry.shared.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + } + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = try #require(controller.statusItem.menu) + #expect(menu === controller.mergedMenu) + controller.menuNeedsUpdate(menu) + let key = ObjectIdentifier(menu) + let preparedVersion = controller.menuVersions[key] + + store.isRefreshing = true + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus(allowStaleContentDuringDataRefresh: true) + + controller.menuNeedsUpdate(menu) + + #expect(controller.menuVersions[key] == preparedVersion) + #expect(controller.menuNeedsRefresh(menu)) + } + + @Test + func `root open before deferred store observation rebuilds and refreshes matching observer`() { + // Store observation invalidates menus from a deferred main-actor task. If a closed menu opens after + // live data changes but before that task runs, it must rebuild from live data and let the matching + // observer invalidate any coalesced non-readiness menu state without losing the readiness baseline. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + // Simulate the live store mutation being visible before the observation task has invalidated menus. + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let key = ObjectIdentifier(menu) + let versionAfterOpen = controller.menuContentVersion + let menuVersionAfterOpen = controller.menuVersions[key] + #expect(!controller.menuNeedsRefresh(menu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuVersions[key] == menuVersionAfterOpen) + #expect(controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `root open before deferred store observation during refresh leaves observer pending`() { + // The pre-observer root-open repair must not bypass the in-flight refresh stale-content path. + // While data is refreshing, the deferred observer should still invalidate the open menu and defer + // parent rebuild instead of marking an intermediate snapshot fresh. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store.isRefreshing = true + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + let versionAfterOpen = controller.menuContentVersion + #expect(!controller.menuNeedsRefresh(menu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `fresh newer-version root open during unrelated refresh still reanchors baseline`() { + // An in-flight refresh elsewhere must not block re-anchoring when this menu was already rebuilt for + // a newer menuContentVersion. Otherwise the stale baseline can still hide a later reverted update. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + + store.isRefreshing = true + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + #expect(controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `equal-signature root open advances baseline version before next pre-observer change`() { + // A root open whose signature still equals the baseline can nevertheless confirm that the visible + // menu is fresh for a newer menuContentVersion. Record that version so a later live-data change before + // its deferred observer does not look like already-rendered data. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu(for: .codex) + controller.providerMenus[.codex] = menu + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + let versionBeforeChange = controller.menuContentVersion + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeChange) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `newer-version root open rebuilds when rendered signature is older than live data`() { + // A menu can be fresh for the current menuContentVersion while still having rendered an older + // readiness signature than the current live store. Root open must rebuild in that pre-observer gap. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + let snapshotC = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 333, + sessionCostUSD: 3.33, + last30DaysTokens: 3333, + last30DaysCostUSD: 33.33, + updatedAt: Date(timeIntervalSince1970: 300)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu(for: .codex) + controller.providerMenus[.codex] = menu + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + let versionBeforeChange = controller.menuContentVersion + store._setTokenSnapshotForTesting(snapshotC, provider: .codex) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeChange) + #expect(!controller.menuNeedsRefresh(menu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `equal-signature root open rebuilds when rendered signature reverted before observer`() { + // A closed provider menu can be rebuilt from B while the readiness baseline remains A. If live data + // reverts to A before the deferred observer runs, root open must still repair the B-rendered menu. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodexForReadinessBaseline(settings) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu(for: .codex) + controller.providerMenus[.codex] = menu + let key = ObjectIdentifier(menu) + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .codex) + controller.invalidateMenus() + controller.populateMenu(menu, provider: .codex) + controller.markMenuFresh(menu) + + let renderedBSignature = controller.menuReadinessSignatures[key] + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let versionBeforeOpen = controller.menuContentVersion + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } + + #expect(controller.menuContentVersion != versionBeforeOpen) + #expect(controller.menuReadinessSignatures[key] != renderedBSignature) + #expect(controller.menuReadinessSignatures[key] == controller.menuAdjunctReadinessSignature()) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + @Test + func `provider root open before deferred store observation leaves sibling provider menu stale`() { + // The readiness signature is global across enabled providers. In split-icon mode, opening one + // provider's menu must not consume a pending global observation while leaving sibling menus marked + // fresh even though their provider data changed. + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableProvidersForReadinessBaseline(settings, providers: [.claude, .codex]) + + let snapshotA = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 111, + sessionCostUSD: 1.11, + last30DaysTokens: 1111, + last30DaysCostUSD: 11.11, + updatedAt: Date(timeIntervalSince1970: 100)) + let snapshotB = self.makeReadinessBaselineTokenSnapshot( + sessionTokens: 222, + sessionCostUSD: 2.22, + last30DaysTokens: 2222, + last30DaysCostUSD: 22.22, + updatedAt: Date(timeIntervalSince1970: 200)) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting(snapshotA, provider: .claude) + store._setTokenSnapshotForTesting(snapshotA, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let claudeMenu = controller.makeMenu(for: .claude) + controller.populateMenu(claudeMenu, provider: .claude) + controller.markMenuFresh(claudeMenu) + let codexMenu = controller.makeMenu(for: .codex) + controller.populateMenu(codexMenu, provider: .codex) + controller.markMenuFresh(codexMenu) + controller.resyncMenuAdjunctReadinessBaseline() + + store._setTokenSnapshotForTesting(snapshotB, provider: .claude) + controller.menuWillOpen(codexMenu) + defer { controller.menuDidClose(codexMenu) } + + let versionAfterOpen = controller.menuContentVersion + #expect(!controller.menuNeedsRefresh(codexMenu)) + #expect(controller.menuNeedsRefresh(claudeMenu)) + + controller.handleObservedStoreMenuChange() + + #expect(controller.menuContentVersion != versionAfterOpen) + #expect(controller.menuNeedsRefresh(codexMenu)) + #expect(controller.menuNeedsRefresh(claudeMenu)) + #expect(!controller.didMenuAdjunctReadinessChange()) + } + + private func enableOnlyCodexForReadinessBaseline(_ settings: SettingsStore) { + self.enableProvidersForReadinessBaseline(settings, providers: [.codex]) + } + + private func enableProvidersForReadinessBaseline(_ settings: SettingsStore, providers: Set) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider)) + } + } + + private func makeReadinessBaselineTokenSnapshot( + sessionTokens: Int, + sessionCostUSD: Double, + last30DaysTokens: Int, + last30DaysCostUSD: Double, + updatedAt: Date) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: sessionTokens, + sessionCostUSD: sessionCostUSD, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-05-24", + inputTokens: nil, + outputTokens: nil, + totalTokens: sessionTokens, + costUSD: last30DaysCostUSD, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: updatedAt) + } +} diff --git a/Tests/CodexBarTests/StatusMenuScopedCodexRefreshTests.swift b/Tests/CodexBarTests/StatusMenuScopedCodexRefreshTests.swift new file mode 100644 index 0000000000..532ec780cd --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuScopedCodexRefreshTests.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuScopedCodexRefreshTests { + @Test + func `scoped refresh reconciles usage after dashboard login expires`() async { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let account = AccountInfo(email: "test@example.com", plan: "pro") + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.accountInfoCache[.codex] = UsageStore.AccountInfoCacheEntry( + account: account, + configRevision: settings.configRevision, + expiresAt: .distantFuture) + let controller = StatusItemController( + store: store, + settings: settings, + account: account, + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + var providerRefreshes = 0 + store._test_providerRefreshOverride = { provider in + #expect(provider == .codex) + providerRefreshes += 1 + } + store._test_tokenUsageRefreshOverride = { _, _ in } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + throw OpenAIDashboardFetcher.FetchError.loginRequired + } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Chrome", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + + await controller.performStoreRefresh( + for: .codex, + refreshOpenMenusWhenComplete: false, + interaction: .userInitiated) + + #expect(store.openAIDashboardRequiresLogin) + #expect(providerRefreshes == 2) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuScopedCodexRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift new file mode 100644 index 0000000000..a9f77216d8 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -0,0 +1,915 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherClickTests { + private func makeStatusBarForTesting() -> NSStatusBar { + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuSwitcherClickTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeInstalledSwitcherShortcutMonitor() -> (controller: StatusItemController, menu: StatusItemMenu) { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = StatusItemMenu() + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let switcherItem = NSMenuItem() + switcherItem.view = switcher + menu.addItem(switcherItem) + menu.addItem(.separator()) + + controller.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) + return (controller, menu) + } + + @Test + func `merged switcher routes runtime clicks after overview round-trip`() throws { + // Regression test for #867: after Provider → Overview, subsequent runtime clicks on a + // sub-provider tab dropped through NSButton's tracking and never updated state. + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + // Step 1: provider → Overview via the runtime click path. + let switcher1 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher1._test_simulateRuntimeClick(buttonTag: 0)) + #expect(settings.mergedMenuLastSelectedWasOverview == true) + + // Step 2: Overview → provider via the runtime click path. Tag 2 is the second provider + // (claude) since tag 0 is Overview and tag 1 is the first provider. + let switcher2 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher2._test_simulateRuntimeClick(buttonTag: 2)) + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + + // Step 3: provider → Overview again. + let switcher3 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher3._test_simulateRuntimeClick(buttonTag: 0)) + #expect(settings.mergedMenuLastSelectedWasOverview == true) + + // Step 4: Overview → other provider. This is the click that previously got dropped. + let switcher4 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher4._test_simulateRuntimeClick(buttonTag: 1)) + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .codex) + } + + @Test + func `merged switcher commits selection on matching mouse up`() throws { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateMouseDown(buttonTag: 0)) + #expect(selections.isEmpty) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 0)) + #expect(switcher.handleMenuTrackingMouseUp(mouseUp)) + #expect(selections == [.overview]) + } + + @Test + func `menu tracking routes switcher pointer sequence before AppKit menu dispatch`() throws { + var selected: ProviderSwitcherSelection? + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selected = $0 }) + let menu = StatusItemMenu() + let item = NSMenuItem() + item.view = switcher + item.isEnabled = false + menu.addItem(item) + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let fetcher = UsageFetcher() + let controller = StatusItemController( + store: UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings), + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 0)) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 0)) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(selected == nil) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUp, menu: menu)) + #expect(selected == .overview) + } + + @Test + func `merged switcher runtime click defers icon rendering until after event handling`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.applyIcon(phase: nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + + #expect(settings.selectedMenuProvider == .claude) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + await controller.providerSelectionUIRefreshTask?.value + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) + } + + @Test + func `merged switcher click marks menu stale before deferred rebuild`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max + defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + + let openedVersion = controller.menuContentVersion + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + + #expect(settings.selectedMenuProvider == .claude) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + + controller.menuDidClose(menu) + #expect(controller.menuVersions[key] != controller.menuContentVersion) + #expect(controller.openMenuRebuildTasks[key] == nil) + } + + @Test + func `merged switcher runtime click updates loading animation state after event handling`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.applyIcon(phase: nil) + #expect(controller.needsMenuBarIconAnimation() == false) + #expect(controller.animationDriver == nil) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + #expect(settings.selectedMenuProvider == .claude) + await controller.providerSelectionUIRefreshTask?.value + #expect(controller.needsMenuBarIconAnimation() == true) + #expect(controller.animationDriver != nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) + + #expect(switcher._test_simulateRuntimeClick(buttonTag: 1)) + #expect(settings.selectedMenuProvider == .codex) + await controller.providerSelectionUIRefreshTask?.value + #expect(controller.needsMenuBarIconAnimation() == false) + #expect(controller.animationDriver == nil) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) + } + + @Test + func `merged switcher switches provider while overview chart submenu is open`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .openai + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .openai || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 9, + requests: 12, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.openMenus[ObjectIdentifier(menu)] = menu + + let openAIRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-openai" + }) + let submenu = try #require(openAIRow.submenu) + controller.openMenus[ObjectIdentifier(submenu)] = submenu + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + #expect(rebuildCount == 1) + #expect(controller.openMenus[ObjectIdentifier(submenu)] == nil) + + let ids = menu.items.compactMap { $0.representedObject as? String } + #expect(ids.contains("menuCard")) + #expect(ids.contains(where: { $0.hasPrefix("overviewRow-") }) == false) + } + + @Test + func `merged switcher handles left and right arrow keyboard navigation`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: [.codex, .claude]) + settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: [.codex, .claude]) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + controller.menuRefreshEnabledOverrideForTesting = true + defer { controller.releaseStatusItemsForTesting() } + + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.menuWillOpen(menu) + #expect(menu.items.first?.view is ProviderSwitcherView) + store.tokenRefreshInFlight.insert(.codex) + defer { store.tokenRefreshInFlight.remove(.codex) } + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 124)) == true) + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + #expect(rebuildCount == 1) + + #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 123)) == true) + for _ in 0..<100 where rebuildCount == 1 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .codex) + #expect(rebuildCount == 2) + } + + @Test + func `merged switcher handles command number shortcuts in visible order`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.menuWillOpen(menu) + #expect(menu.items.first?.view is ProviderSwitcherView) + + #expect(try controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("3", keyCode: 20), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + + #expect(try controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("1", keyCode: 18), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == true) + #expect(settings.selectedMenuProvider == .claude) + + #expect(try !controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("9", keyCode: 25), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == true) + #expect(settings.selectedMenuProvider == .claude) + } + + @Test + func `provider shortcut monitor is removed when tracked menu closes after switcher rebuild`() { + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.providerSwitcherShortcutEventMonitor != nil) + #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) + + menu.removeAllItems() + controller.menuDidClose(menu) + + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.providerSwitcherShortcutMenuID == nil) + } + + @Test + func `switcher shortcut monitor is removed from direct close cleanup`() { + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.providerSwitcherShortcutEventMonitor != nil) + #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) + + controller.forgetClosedMenu(menu) + + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.providerSwitcherShortcutMenuID == nil) + } + + @Test + func `switcher hover styling keeps layout stable`() { + let view = ProviderSwitcherView( + providers: [.codex, .claude, .cursor, .factory, .zai, .minimax, .alibaba], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + + let initialSize = view.intrinsicContentSize + let initialFrames = view._test_buttonFrames() + + view._test_setHoveredButtonTag(3) + view._test_setHoveredButtonTag(6) + view._test_setHoveredButtonTag(nil as Int?) + + #expect(view.intrinsicContentSize == initialSize) + #expect(view._test_buttonFrames() == initialFrames) + } + + @Test + func `switcher quota indicator preserves remaining percentage`() throws { + let view = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + switch provider { + case .claude: + 5 + case .grok: + 95 + default: + nil + } + }, + onSelect: { _ in }) + + let fillRatios = view._test_quotaIndicatorFillRatios() + #expect(fillRatios.count == 2) + let lowRemainingRatio = try #require(fillRatios.first) + let highRemainingRatio = try #require(fillRatios.last) + #expect(lowRemainingRatio < highRemainingRatio) + } + + @Test + func `switcher quota indicator refresh updates fill ratios`() throws { + var claudeRemaining = 5.0 + var grokRemaining = 95.0 + let view = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + switch provider { + case .claude: + claudeRemaining + case .grok: + grokRemaining + default: + nil + } + }, + onSelect: { _ in }) + + let initialRatios = view._test_quotaIndicatorFillRatios() + let initialLow = try #require(initialRatios.first) + let initialHigh = try #require(initialRatios.last) + + claudeRemaining = 80 + grokRemaining = 12 + view.updateQuotaIndicators() + + let updatedRatios = view._test_quotaIndicatorFillRatios() + let updatedLow = try #require(updatedRatios.first) + let updatedHigh = try #require(updatedRatios.last) + #expect(updatedLow > initialLow) + #expect(updatedHigh < initialHigh) + } + + @Test + func `switcher quota indicator renders zero remaining empty`() { + var grokRemaining = 50.0 + let view = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + switch provider { + case .claude: + 100 + case .grok: + grokRemaining + default: + nil + } + }, + onSelect: { _ in }) + + grokRemaining = 0 + view.updateQuotaIndicators() + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + let fillRatios = view._test_quotaIndicatorFillRatios() + let fillFrames = view._test_quotaIndicatorFillFrames() + #expect(fillRatios.last == 0) + #expect(fillFrames.last?.width == 0) + } + + @Test + func `switcher keeps stable height when remaining becomes unavailable`() throws { + var grokRemaining: Double? = 50 + let noQuotaView = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let view = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + switch provider { + case .claude: + 100 + case .grok: + grokRemaining + default: + nil + } + }, + onSelect: { _ in }) + #expect(view._test_quotaIndicatorFillRatios().count == 2) + let noQuotaHeight = try #require(noQuotaView._test_buttonFittingSizes().last?.height) + let quotaHeight = try #require(view._test_buttonFittingSizes().last?.height) + #expect(quotaHeight == noQuotaHeight) + + grokRemaining = nil + view.updateQuotaIndicators() + + #expect(view._test_quotaIndicatorFillRatios().count == 1) + let removedQuotaHeight = try #require(view._test_buttonFittingSizes().last?.height) + #expect(removedQuotaHeight == noQuotaHeight) + } + + @Test + func `text only switcher keeps stable height with quota bars`() throws { + let providers: [UsageProvider] = [.claude, .grok] + let textOnlyWithoutQuota = ProviderSwitcherView( + providers: providers, + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: false, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let textOnlyWithQuota = ProviderSwitcherView( + providers: providers, + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: false, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in 50 }, + onSelect: { _ in }) + + let withoutQuotaHeight = try #require(textOnlyWithoutQuota._test_buttonFittingSizes().first?.height) + let withQuotaHeight = try #require(textOnlyWithQuota._test_buttonFittingSizes().first?.height) + #expect(withQuotaHeight == withoutQuotaHeight) + } + + @Test + func `multi row switcher quota bars stay inside bounds`() { + let view = ProviderSwitcherView( + providers: [.codex, .claude, .cursor, .factory, .zai, .minimax, .alibaba], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in 50 }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + for frame in view._test_buttonFrames() { + #expect(frame.minY >= 0) + #expect(frame.maxY <= view.bounds.maxY) + } + } + + private static func arrowKeyEvent(keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode)) + } + + private static func commandKeyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode)) + } + + @Test + func `multi-row switcher uses compact height and stays inside bounds`() { + // 14 providers + Overview forces the four-row path and includes multi-word titles. + let view = ProviderSwitcherView( + providers: [ + .codex, + .claude, + .cursor, + .factory, + .zai, + .minimax, + .alibaba, + .opencodego, + .grok, + .groq, + .gemini, + .openrouter, + .perplexity, + .kiro, + ], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in 50 }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + // All buttons must stay within switcher bounds (no vertical overflow). + let buttonFrames = view._test_buttonFrames() + let contentFrames = view._test_buttonContentFrames() + let trackFrames = view._test_quotaIndicatorTrackFrames() + for (frame, contentFrame) in zip(buttonFrames, contentFrames) { + #expect(frame.minY >= 0) + #expect(frame.maxY <= view.bounds.maxY) + #expect(abs((contentFrame?.midY ?? -1) - frame.height / 2) <= 0.5) + } + for (buttonFrame, trackFrame) in zip(buttonFrames.dropFirst(), trackFrames) { + #expect(trackFrame.minY >= buttonFrame.minY) + #expect(trackFrame.maxY <= buttonFrame.maxY) + } + + #expect(view._test_rowCount() == 4) + #expect(view._test_rowHeight() == 39) + #expect(view.bounds.height == 168) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherLayoutTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherLayoutTests.swift new file mode 100644 index 0000000000..1e2883c4a5 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherLayoutTests.swift @@ -0,0 +1,129 @@ +import AppKit +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherLayoutTests { + @Test + func `overview switcher segment matches provider segment height when quota bars are present`() throws { + let view = ProviderSwitcherView( + providers: [.claude, .grok, .cursor], + selected: .overview, + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in 50 }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + let frames = view._test_buttonFrames() + #expect(frames.count == 4) + let overviewFrame = try #require(frames.first) + + for frame in frames.dropFirst() { + #expect(frame.height == overviewFrame.height) + #expect(frame.minY == overviewFrame.minY) + #expect(frame.maxY == overviewFrame.maxY) + } + + #expect(view._test_rowHeight() == 36) + } + + @Test + func `quota bars do not offset inline switcher content`() throws { + let view = ProviderSwitcherView( + providers: [.codex, .devin], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + provider == .devin ? 50 : nil + }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + let buttonFrames = view._test_buttonFrames() + let contentFrames = view._test_buttonContentFrames() + let trackFrames = view._test_quotaIndicatorTrackFrames() + #expect(buttonFrames.count == 3) + #expect(contentFrames.count == 3) + #expect(trackFrames.count == 1) + #expect(view._test_rowHeight() == 30) + + let overviewFrame = try #require(buttonFrames.first) + for (buttonFrame, contentFrame) in zip(buttonFrames, contentFrames) { + let contentFrame = try #require(contentFrame) + #expect(buttonFrame.minY == overviewFrame.minY) + #expect(buttonFrame.maxY == overviewFrame.maxY) + #expect(abs(contentFrame.midY - buttonFrame.height / 2) < 0.01) + } + + let devinButtonFrame = try #require(buttonFrames.last) + let devinTrackFrame = try #require(trackFrames.first) + #expect(devinButtonFrame.height == 30) + #expect(devinTrackFrame.minY >= devinButtonFrame.minY) + #expect(devinTrackFrame.maxY <= devinButtonFrame.maxY) + } + + @Test + func `integrated quota indicator selects its provider`() { + let view = ProviderSwitcherView( + providers: [.codex, .devin], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { $0 == .devin ? 50 : nil }, + onSelect: { _ in }) + + #expect(view._test_simulateRuntimeClickOnQuotaIndicator(buttonTag: 2)) + } + + @Test + func `localized inline switcher titles fit without losing equal sizing`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("tr") { + for width in stride(from: CGFloat(280), through: CGFloat(330), by: 1) { + let view = ProviderSwitcherView( + providers: [.codex, .devin], + selected: .overview, + includesOverview: true, + width: width, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + let frames = view._test_buttonFrames() + let desiredWidths = view._test_buttonDesiredWidths() + #expect(frames.count == 3) + #expect(desiredWidths.count == frames.count) + let firstWidth = try #require(frames.first?.width) + + for (frame, desiredWidth) in zip(frames, desiredWidths) { + #expect(frame.width == firstWidth) + let minimalInsetAllowedWidth = floor((width - 12 - 2) / 3) + let evenMinimalInsetAllowedWidth = minimalInsetAllowedWidth + .truncatingRemainder(dividingBy: 2) == 0 + ? minimalInsetAllowedWidth + : minimalInsetAllowedWidth - 1 + let roundedDesiredWidth = ceil(desiredWidth) + let evenDesiredWidth = roundedDesiredWidth.truncatingRemainder(dividingBy: 2) == 0 + ? roundedDesiredWidth + : roundedDesiredWidth + 1 + if evenMinimalInsetAllowedWidth >= evenDesiredWidth { + #expect(frame.width >= desiredWidth) + } + } + } + } + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift new file mode 100644 index 0000000000..da4ce6c915 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -0,0 +1,714 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +private final class SwitcherRefreshManualGate { + private var continuation: CheckedContinuation? + private var isOpen = false + + func wait() async { + if self.isOpen { + self.isOpen = false + return + } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func resume() { + if let continuation = self.continuation { + continuation.resume() + self.continuation = nil + } else { + self.isOpen = true + } + } +} + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherRefreshTests { + @Test + func `native switcher action preserves off tab switches after button state toggles`() { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateNativeAction(buttonTag: 1, state: .on)) + #expect(selections == [.provider(.claude)]) + } + + @Test + func `native switcher action restores active tab after native toggle`() { + var selections: [ProviderSwitcherSelection] = [] + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { selections.append($0) }) + + #expect(switcher._test_simulateNativeAction(buttonTag: 0, state: .off)) + #expect(selections.isEmpty) + #expect(Self.switcherButtons(in: switcher).first { $0.tag == 0 }?.state == .on) + } + + @Test + func `merged provider switch rebuilds stale width switcher rows`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + + let activeProviders: [UsageProvider] = [.codex, .claude] + _ = settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: activeProviders) + _ = settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.isRefreshing = true + defer { store.isRefreshing = false } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + #expect(controller.openMenus[ObjectIdentifier(menu)] === menu) + + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + initialSwitcher.frame.size.width = 250 + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let nextProviderButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: nextProviderButton.tag) == true) + + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + #expect(rebuildCount == 1) + let updatedSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(updatedSwitcher.frame.width == 310) + #expect(Self.switcherButtons(in: menu).first { $0.tag == nextProviderButton.tag }?.state == .on) + } + + @Test + func `selected provider tab click does not rebuild open menu`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = true + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(switcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + try? await Task.sleep(for: .milliseconds(40)) + + #expect(rebuildCount == 0) + #expect(Self.switcherButtons(in: menu).first { $0.tag == selectedButton.tag }?.state == .on) + } + + @Test + func `merged provider switch updates live tab rows in place`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) + #expect(menu.items.indices.contains(contentStartIndex)) + let originalContentID = ObjectIdentifier(menu.items[contentStartIndex]) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + // Provider switches now reconcile matching rows in place instead of parking and + // restoring distinct item sets per tab: the same NSMenuItem objects carry each + // tab's freshly built content, so AppKit never relayouts the open menu per insert. + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) + + let restoredSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(restoredSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(3, rebuildCount: { rebuildCount }) + #expect(menu.items.indices.contains(contentStartIndex)) + #expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID) + + controller.invalidateMenus() + #expect(controller.mergedSwitcherContentCaches.isEmpty) + } + + @Test + func `manual refresh keeps codex quota visible after switching away and back`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_800_000_000) + store._setSnapshotForTesting(Self.quotaSnapshot(usedPercent: 21, updatedAt: now), provider: .codex) + store._setSnapshotForTesting(Self.quotaSnapshot(usedPercent: 44, updatedAt: now), provider: .claude) + let event = CreditEvent(date: now, service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "test@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: now) + store.openAIDashboardAttachmentAuthorized = true + store.openAIDashboardRequiresLogin = false + let controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: "test@example.com", plan: "pro"), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { + controller.manualRefreshTask?.cancel() + controller.releaseStatusItemsForTesting() + } + + let gate = SwitcherRefreshManualGate() + controller._test_manualRefreshOperation = { + await gate.wait() + } + defer { controller._test_manualRefreshOperation = nil } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + + controller.refreshNow() + #expect(controller.menuCardRefreshMonitor.isManualRefreshInFlight) + + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: now.addingTimeInterval(1)), + provider: .codex) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + #expect(settings.selectedMenuProvider == .codex) + + let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } + #expect(usageItem != nil) + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardHeader" } == false) + + let emptyFallback = try #require(controller.menuCardModel(for: .codex)) + let inFlight = controller.menuCardRefreshMonitor.model(for: .codex, fallback: emptyFallback) + let subtitle = controller.menuCardRefreshMonitor.subtitle( + for: .codex, + fallback: MenuCardLiveSubtitle(text: emptyFallback.subtitleText, style: emptyFallback.subtitleStyle)) + + #expect(emptyFallback.metrics.isEmpty) + #expect(subtitle.text == "Refreshing…") + #expect(inFlight.metrics.first?.percentLabel == "79% left") + + gate.resume() + await controller.manualRefreshTask?.value + #expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight) + let completed = controller.menuCardRefreshMonitor.model(for: .codex, fallback: emptyFallback) + #expect(completed.metrics.isEmpty) + } + + @Test + func `completed refresh re-enables cached item when switching back`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { + controller.manualRefreshTask?.cancel() + controller.releaseStatusItemsForTesting() + } + + let gate = SwitcherRefreshManualGate() + controller._test_manualRefreshOperation = { await gate.wait() } + defer { controller._test_manualRefreshOperation = nil } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + let initialRefreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + + controller.refreshNow() + let refreshTask = try #require(controller.manualRefreshTask) + #expect(!initialRefreshItem.isEnabled) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } + defer { controller._test_openMenuRebuildObserver = nil } + + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + + gate.resume() + await refreshTask.value + #expect(controller.manualRefreshTask == nil) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + + let restoredRefreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(restoredRefreshItem.isEnabled) + } + + @Test + func `full cached reattachment resynchronizes detached refresh item`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { + controller.manualRefreshTask?.cancel() + controller.releaseStatusItemsForTesting() + } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let cache = try #require( + controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.codex)]) + let refreshItem = try #require(cache.items.first { $0.title == "Refresh" }) + + controller.manualRefreshTask = Task {} + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + menu.removeAllItems() + #expect(refreshItem.menu == nil) + controller.manualRefreshTask = nil + controller.updatePersistentRefreshItemsEnabled() + #expect(!refreshItem.isEnabled) + + #expect(controller.addCachedMergedSwitcherContent( + for: .provider(.codex), + to: menu, + menuWidth: cache.menuWidth, + codexAccountDisplay: cache.codexAccountDisplay, + tokenAccountDisplay: cache.tokenAccountDisplay)) + #expect(refreshItem.menu === menu) + #expect(refreshItem.isEnabled) + } + + @Test + func `native image menu rows are replaced during reconciliation`() { + let settings = Self.makeSettings() + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = Self.nativeImageItem(title: "Status Page") + menu.addItem(liveItem) + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + + let scratch = NSMenu() + let freshItem = Self.nativeImageItem(title: "Status Page") + scratch.addItem(freshItem) + + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items.count == 1) + #expect(ObjectIdentifier(menu.items[0]) == ObjectIdentifier(freshItem)) + #expect(ObjectIdentifier(menu.items[0]) != ObjectIdentifier(liveItem)) + } + + @Test + func `native image submenu rows reconcile in place`() { + let settings = Self.makeSettings() + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let liveItem = Self.nativeImageItem(title: "System Account") + liveItem.submenu = NSMenu(title: "System Account") + menu.addItem(liveItem) + let shapes = controller.menuContentShapes(in: menu, fromIndex: 0) + + let scratch = NSMenu() + let freshItem = Self.nativeImageItem(title: "System Account") + freshItem.submenu = NSMenu(title: "System Account") + scratch.addItem(freshItem) + + controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch) + + #expect(menu.items.count == 1) + #expect(ObjectIdentifier(menu.items[0]) == ObjectIdentifier(liveItem)) + #expect(ObjectIdentifier(menu.items[0]) != ObjectIdentifier(freshItem)) + } + + @Test + func `provider switch does not cache stale rows after required invalidation`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + Self.disableOverview(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu) + #expect(menu.items.indices.contains(contentStartIndex)) + let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on }) + let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.invalidateMenus() + #expect(controller.mergedSwitcherContentCaches.isEmpty) + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag)) + await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount }) + + let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag)) + await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount }) + + // Rows are reconciled in place, so freshness is guaranteed by rebuilding content + // from current data rather than by minting new items: the live menu must be marked + // fresh and no cached entry may predate the required invalidation. (In-place item + // identity itself is covered deterministically in MenuCardViewRecyclingTests; here + // async gate state may legitimately route a populate through the full rebuild.) + #expect(menu.items.indices.contains(contentStartIndex)) + let menuKey = ObjectIdentifier(menu) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + for entry in controller.mergedSwitcherContentCaches[menuKey]?.values ?? [:].values { + #expect(entry.requiredMenuContentVersion >= controller.latestRequiredMenuRebuildVersion) + } + } + + @Test + func `tab switch does not replace quota indicator constraints`() { + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in 75.0 }, + onSelect: { _ in }) + + let initialConstraints = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(initialConstraints.count == 2, "both providers should have quota indicators") + + switcher.updateQuotaIndicators() + + let afterFirstCall = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(afterFirstCall == initialConstraints, "same ratio: constraints must not be replaced") + } + + @Test + func `quota indicator constraints are replaced when ratio changes`() { + var currentRemaining = 75.0 + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: false, + width: 310, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in currentRemaining }, + onSelect: { _ in }) + + let initialConstraints = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(initialConstraints.count == 2) + + currentRemaining = 40.0 + switcher.updateQuotaIndicators() + + let afterDataChange = switcher._test_quotaIndicatorConstraintIdentifiers() + #expect(afterDataChange != initialConstraints, "changed ratio: constraints should be replaced") + } + + private static func makeSettings() -> SettingsStore { + let suite = "StatusMenuSwitcherRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func enableCodexAndClaude(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + } + + private static func disableOverview(_ settings: SettingsStore) { + let activeProviders: [UsageProvider] = [.codex, .claude] + _ = settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: activeProviders) + _ = settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + } + + private static func quotaSnapshot(usedPercent: Double, updatedAt: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: usedPercent, + windowMinutes: 300, + resetsAt: updatedAt.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: updatedAt) + } + + private static func waitForRebuildCount( + _ expectedCount: Int, + rebuildCount: () -> Int) async + { + for _ in 0..<100 where rebuildCount() < expectedCount { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + } + + private static func switcherButtons(in menu: NSMenu) -> [NSButton] { + guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] } + return self.switcherButtons(in: switcherView) + } + + private static func switcherButtons(in switcherView: ProviderSwitcherView) -> [NSButton] { + switcherView.subviews + .compactMap { $0 as? NSButton } + .sorted { $0.tag < $1.tag } + } + + private static func nativeImageItem(title: String) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.image = NSImage(size: NSSize(width: 16, height: 16)) + return item + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift new file mode 100644 index 0000000000..d93630f812 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherTrackingTests.swift @@ -0,0 +1,209 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherTrackingTests { + @Test + func `switcher rebuild scheduler runs during menu tracking exactly once`() { + var runCount = 0 + ProviderSwitcherTrackingRunLoopScheduler.schedule { + runCount += 1 + } + + CFRunLoopRunInMode( + CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString), + 0.1, + true) + #expect(runCount == 1) + + CFRunLoopRunInMode(.defaultMode, 0.1, true) + #expect(runCount == 1) + } + + @Test + func `pointer switch defers structural menu rebuild until mouse up`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system, + menuRefreshEnabled: false) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuRefreshEnabledOverrideForTesting = true + controller.openMenus[ObjectIdentifier(menu)] = menu + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 2)) + let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 2)) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(settings.selectedMenuProvider == .codex) + #expect(controller.providerSwitcherPointerInteractionMenuID == ObjectIdentifier(menu)) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + + for _ in 0..<20 { + await Task.yield() + } + #expect(rebuildCount == 0) + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUp, menu: menu)) + #expect(settings.selectedMenuProvider == .claude) + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(5)) + } + #expect(rebuildCount == 1) + #expect(controller.providerSwitcherPointerInteractionMenuID == nil) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + } + + @Test + func `pointer switch cancels when mouse up leaves pressed segment`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system, + menuRefreshEnabled: false) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuRefreshEnabledOverrideForTesting = true + controller.openMenus[ObjectIdentifier(menu)] = menu + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 2)) + let mouseUpElsewhere = try #require(switcher._test_mouseUpEvent(buttonTag: 1)) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) + #expect(controller.handleProviderSwitcherTrackingEvent(mouseUpElsewhere, menu: menu)) + #expect(settings.selectedMenuProvider == .codex) + #expect(rebuildCount == 0) + #expect(controller.providerSwitcherPointerInteractionMenuID == nil) + #expect(controller.pendingProviderSwitcherPointerRebuild == nil) + } + + @Test + func `unrelated mouse up remains available to normal menu items`() throws { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let fetcher = UsageFetcher() + let controller = StatusItemController( + store: UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings), + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let item = NSMenuItem() + item.view = switcher + menu.addItem(item) + let unrelatedMouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 1)) + + #expect(!controller.handleProviderSwitcherTrackingEvent(unrelatedMouseUp, menu: menu)) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuSwitcherTrackingTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 3f04c3ff79..cbef9132f2 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -4,31 +4,69 @@ import Testing @testable import CodexBar @MainActor -@Suite +@Suite(.serialized) struct StatusMenuTests { - private func disableMenuCardsForTesting() { + func disableMenuCardsForTesting() { StatusItemController.menuCardRenderingEnabled = false - StatusItemController.menuRefreshEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) } - private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + func makeStatusBarForTesting() -> NSStatusBar { + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } - private func makeSettings() -> SettingsStore { + func makeSettings() -> SettingsStore { let suite = "StatusMenuTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) - return SettingsStore( + let settings = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) + settings.providerDetectionCompleted = true + return settings + } + + func makeCodexStore(settings: SettingsStore, dashboardAuthorized: Bool) -> UsageStore { + let now = Date() + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")), + provider: .codex) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "other@example.com", + codeReviewRemainingPercent: 88, + codeReviewLimit: RateWindow( + usedPercent: 12, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: now) + store.openAIDashboardAttachmentAuthorized = dashboardAuthorized + store.openAIDashboardRequiresLogin = false + return store } private func switcherButtons(in menu: NSMenu) -> [NSButton] { @@ -43,7 +81,126 @@ struct StatusMenuTests { } @Test - func remembersProviderWhenMenuOpens() { + func `alibaba dashboard action follows selected region`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.alibabaCodingPlanAPIRegion = .chinaMainland + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + #expect(controller.dashboardURL(for: .alibaba) == AlibabaCodingPlanAPIRegion.chinaMainland.dashboardURL) + } + + @Test + func `zai dashboard action follows selected region`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + settings.zaiAPIRegion = .global + #expect(controller.dashboardURL(for: .zai) == ZaiAPIRegion.global.dashboardURL) + #expect( + controller.dashboardURL(for: .zai)?.absoluteString == + "https://z.ai/manage-apikey/coding-plan/personal/my-plan") + #expect( + controller.dashboardURL( + for: .zai, + environment: [ZaiSettingsReader.apiHostKey: "open.bigmodel.cn"]) == + ZaiAPIRegion.bigmodelCN.dashboardURL) + + settings.zaiAPIRegion = .bigmodelCN + #expect(controller.dashboardURL(for: .zai) == ZaiAPIRegion.bigmodelCN.dashboardURL) + #expect(controller.dashboardURL(for: .zai)?.absoluteString == "https://bigmodel.cn/coding-plan/personal/usage") + } + + @Test + func `opencode go dashboard action follows configured workspace`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.opencodegoWorkspaceID = "https://opencode.ai/workspace/wrk_abc123/go" + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + #expect(controller.dashboardURL(for: .opencodego)? + .absoluteString == "https://opencode.ai/workspace/wrk_abc123/go") + } + + @Test + func `claude subscription dashboard action opens usage page`() { + for plan in ["Claude Pro", "Claude Team"] { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: plan)), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + #expect(controller.dashboardURL(for: .claude)?.absoluteString == "https://claude.ai/settings/usage") + } + } + + @Test + func `remembers provider when menu opens`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -70,6 +227,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let claudeMenu = controller.makeMenu() controller.menuWillOpen(claudeMenu) @@ -87,7 +245,7 @@ struct StatusMenuTests { } @Test - func mergedMenuOpenDoesNotPersistResolvedProviderWhenSelectionIsNil() { + func `merged menu open does not persist resolved provider when selection is nil`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -96,16 +254,12 @@ struct StatusMenuTests { settings.selectedMenuProvider = nil let registry = ProviderRegistry.shared - var enabledProviders: [UsageProvider] = [] + let selectedProviders: Set = [.codex, .claude] for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = enabledProviders.count < 2 + let shouldEnable = selectedProviders.contains(provider) settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) - if shouldEnable { - enabledProviders.append(provider) - } } - #expect(enabledProviders.count == 2) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -116,6 +270,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let expectedResolved = store.enabledProviders().first ?? .codex #expect(store.enabledProviders().count > 1) @@ -128,13 +283,102 @@ struct StatusMenuTests { } @Test - func mergedMenuRefreshUsesResolvedEnabledProviderWhenPersistedSelectionIsDisabled() { + func `shortcut closes tracked menu instead of queueing another open`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + #expect(controller.openMenus[key] != nil) + + #expect(controller.closeOpenMenusFromShortcutIfNeeded() == true) + #expect(controller.openMenus.isEmpty) + #expect(controller.menuRefreshTasks.isEmpty) + #expect(controller.closeOpenMenusFromShortcutIfNeeded() == false) + } + + @Test + func `open menu defers store data refresh until next open`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + let openedVersion = controller.menuVersions[key] + + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")), + provider: .codex) + + for _ in 0..<50 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + + let staleVersion = controller.menuContentVersion + controller.refreshOpenMenusIfNeeded() + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + #expect(controller.menuVersions[key] == staleVersion) + } + + @Test + func `merged menu refresh uses resolved enabled provider when selection is cleared`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -166,6 +410,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let expectedResolved = store.enabledProviders().first ?? .codex #expect(store.enabledProviders().count > 1) @@ -185,7 +430,7 @@ struct StatusMenuTests { controller.menuWillOpen(menu) #expect(controller.lastMenuProvider == expectedResolved) - #expect(settings.selectedMenuProvider == .codex) + #expect(settings.selectedMenuProvider == nil) #expect(hasOpenAIWebSubmenus(menu) == false) controller.menuContentVersion &+= 1 @@ -195,7 +440,123 @@ struct StatusMenuTests { } @Test - func openMergedMenuRebuildsSwitcherWhenUsageBarsModeChanges() { + func `delayed menu refresh skips when refresh disabled during delay`() async { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + StatusItemController.setMenuOpenRefreshDelayForTesting(.milliseconds(50)) + defer { + StatusItemController.resetMenuOpenRefreshDelayForTesting() + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + var delayedRefreshWakeCount = 0 + + await withStatusItemControllerForTesting( + store: store, + settings: settings, + fetcher: fetcher, + statusBar: self.makeStatusBarForTesting()) + { controller in + controller.onDelayedMenuRefreshAttemptForTesting = { + delayedRefreshWakeCount += 1 + } + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuRefreshEnabledOverrideForTesting = false + try? await Task.sleep(for: .milliseconds(180)) + } + + #expect(delayedRefreshWakeCount == 0) + } + + @Test + func `login state callbacks do not attach menus after release`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + controller.releaseStatusItemsForTesting() + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + + controller.activeLoginProvider = .codex + let loginTask = Task {} + controller.loginTask = loginTask + loginTask.cancel() + controller.loginTask = nil + controller.activeLoginProvider = nil + + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + } + + @Test + func `display only dashboard does not show code review in status menu card`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let model = try #require(controller.menuCardModel(for: .codex)) + #expect(model.metrics.contains { $0.id == "code-review" } == false) + } + + @Test + func `display only dashboard does not show code review in providers pane`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let pane = ProvidersPane(settings: settings, store: store) + + let model = pane._test_menuCardModel(for: .codex) + #expect(model.metrics.contains { $0.id == "code-review" } == false) + } + + @Test + func `attached dashboard still shows code review in providers pane`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: true) + let pane = ProvidersPane(settings: settings, store: store) + + let model = pane._test_menuCardModel(for: .codex) + #expect(model.metrics.contains { $0.id == "code-review" && $0.percent == 88 }) + } + + @Test + func `open merged menu rebuilds switcher when usage bars mode changes`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -226,6 +587,8 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) + controller.openMenus[ObjectIdentifier(menu)] = menu + controller.menuRefreshEnabledOverrideForTesting = true let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(initialSwitcher != nil) @@ -233,6 +596,18 @@ struct StatusMenuTests { settings.usageBarsShowUsed = true controller.handleProviderConfigChange(reason: "usageBarsShowUsed") + for _ in 0..<20 { + await Task.yield() + } + + #expect(controller.parentMenuRebuildsDeferredDuringTracking.contains(ObjectIdentifier(menu))) + if let initialSwitcherID, let currentSwitcher = menu.items.first?.view as? ProviderSwitcherView { + #expect(initialSwitcherID == ObjectIdentifier(currentSwitcher)) + } + + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + defer { controller.menuDidClose(menu) } let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(updatedSwitcher != nil) @@ -242,7 +617,7 @@ struct StatusMenuTests { } @Test - func mergedSwitcherIncludesOverviewTabWhenMultipleProvidersEnabled() { + func `merged switcher includes overview tab when multiple providers enabled`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -272,13 +647,13 @@ struct StatusMenuTests { controller.menuWillOpen(menu) let buttons = self.switcherButtons(in: menu) - #expect(buttons.count == store.enabledProviders().count + 1) + #expect(buttons.count == store.enabledProvidersForDisplay().count + 1) #expect(buttons.contains(where: { $0.tag == 0 })) #expect(buttons.first(where: { $0.state == .on })?.tag == 2) } @Test - func mergedSwitcherOverviewSelectionPersistsWithoutOverwritingProviderSelection() { + func `merged switcher overview selection persists without overwriting provider selection`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -323,7 +698,7 @@ struct StatusMenuTests { } @Test - func openMenuRebuildsSwitcherWhenOverviewAvailabilityChanges() { + func `open menu rebuilds switcher when overview availability changes`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -361,6 +736,8 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) + controller.openMenus[ObjectIdentifier(menu)] = menu + controller.menuRefreshEnabledOverrideForTesting = true let initialButtons = self.switcherButtons(in: menu) #expect(initialButtons.count == activeProviders.count) @@ -370,14 +747,15 @@ struct StatusMenuTests { isSelected: true, activeProviders: activeProviders) controller.menuContentVersion &+= 1 - controller.refreshOpenMenusIfNeeded() + controller.menuDidClose(menu) + controller.menuWillOpen(menu) let updatedButtons = self.switcherButtons(in: menu) #expect(updatedButtons.count == activeProviders.count + 1) } @Test - func overviewTabOmitsContextualProviderActions() { + func `overview tab omits contextual provider actions`() throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -411,19 +789,76 @@ struct StatusMenuTests { #expect(!titles.contains("Switch Account...")) #expect(!titles.contains("Usage Dashboard")) #expect(!titles.contains("Status Page")) + #expect(titles.contains("Refresh")) #expect(titles.contains("Settings...")) #expect(titles.contains("About CodexBar")) #expect(titles.contains("Quit")) + + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(controller.isPersistentRefreshItem(refreshItem)) + #expect(refreshItem.keyEquivalent.isEmpty) + + let settingsItem = menu.items.first { $0.title == "Settings..." } + #expect(settingsItem != nil) + #expect(settingsItem?.keyEquivalent == ",") + #expect(settingsItem?.keyEquivalentModifierMask == [.command]) + + let quitItem = menu.items.first { $0.title == "Quit" } + #expect(quitItem != nil) + #expect(quitItem?.keyEquivalent == "q") + #expect(quitItem?.keyEquivalentModifierMask == [.command]) + } +} + +@MainActor +extension StatusMenuTests { + @Test + func `status blurb uses wrapped view-backed menu item`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = true + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let statusText = "An SSL error has occurred and a secure connection to the server cannot be made." + store.statuses[.codex] = ProviderStatus( + indicator: .critical, + description: statusText, + updatedAt: nil) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let statusItem = menu.items.first(where: { $0.toolTip == statusText }) + #expect(statusItem != nil) + #expect(statusItem?.view != nil) + #expect(statusItem?.title.isEmpty == true) + #expect(statusItem?.view?.frame.width == 310) } @Test - func providerToggleUpdatesStatusItemVisibility() { + func `provider toggle updates status item visibility`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = false - settings.providerDetectionCompleted = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -445,6 +880,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect(controller.statusItems[.claude]?.isVisible == true) @@ -452,11 +888,55 @@ struct StatusMenuTests { settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) } controller.handleProviderConfigChange(reason: "test") - #expect(controller.statusItems[.claude]?.isVisible == false) + #expect(controller.statusItems[.claude] == nil) } @Test - func hidesOpenAIWebSubmenusWhenNoHistory() { + func `provider config changes preserve status item instances`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let registry = ProviderRegistry.shared + try settings.setProviderEnabled(provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(registry.metadata[.claude]), + enabled: true) + try settings.setProviderEnabled( + provider: .gemini, + metadata: #require(registry.metadata[.gemini]), + enabled: false) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let codexItem = try #require(controller.statusItems[.codex]) + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(codexItem.autosaveName == "codexbar-codex") + + try settings.setProviderEnabled( + provider: .gemini, + metadata: #require(registry.metadata[.gemini]), + enabled: true) + controller.handleProviderConfigChange(reason: "test") + + #expect(controller.statusItems[.codex] === codexItem) + #expect(controller.statusItems[.codex]?.autosaveName == "codexbar-codex") + #expect(controller.statusItems[.gemini]?.autosaveName == "codexbar-gemini") + } + + @Test + func `hides open AI web submenus when no history`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -493,6 +973,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -502,7 +983,169 @@ struct StatusMenuTests { } @Test - func showsOpenAIWebSubmenusWhenHistoryExists() throws { + func `hides open AI web submenus when open AI web extras disabled`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let titles = Set(menu.items.map(\.title)) + #expect(!titles.contains("Credits history")) + #expect(!titles.contains("Usage breakdown")) + } + + @Test + func `hosted chart submenu matches widened parent menu width`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let parentMenu = NSMenu() + parentMenu.autoenablesItems = false + let wideItem = NSMenuItem(title: String(repeating: "W", count: 60), action: nil, keyEquivalent: "") + parentMenu.addItem(wideItem) + + let submenu = controller.makeHostedSubviewPlaceholderMenu(chartID: StatusItemController.usageBreakdownChartID) + let submenuItem = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") + submenuItem.submenu = submenu + parentMenu.addItem(submenuItem) + + let parentWidth = ceil(parentMenu.size.width) + #expect(parentWidth > 310) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + + let chartItem = submenu.items.first + #expect(chartItem?.representedObject as? String == StatusItemController.usageBreakdownChartID) + #expect(chartItem?.view != nil) + #expect(abs((chartItem?.view?.frame.width ?? 0) - parentWidth) <= 0.5) + } + + @Test + func `hosted storage submenu is height capped and scroll enabled`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.providerStorageFootprintsEnabled = true + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let root = "/Users/test/.claude" + store.providerStorageFootprints[.claude] = ProviderStorageFootprint( + provider: .claude, + totalBytes: 1_756_000_000, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 1_500_000_000), + .init(path: "\(root)/file-history", totalBytes: 103_000_000), + .init(path: "\(root)/telemetry", totalBytes: 51_000_000), + .init(path: "\(root)/plugins", totalBytes: 33_000_000), + .init(path: "\(root)/history.jsonl", totalBytes: 3_800_000), + .init(path: "\(root)/shell-snapshots", totalBytes: 1_500_000), + .init(path: "\(root)/plans", totalBytes: 1_100_000), + .init(path: "\(root)/paste-cache", totalBytes: 541_000), + .init(path: "\(root)/session-env", totalBytes: 208_000), + .init(path: "\(root)/todos", totalBytes: 6700), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let submenu = NSMenu() + let didAppend = controller.appendStorageBreakdownItem(to: submenu, provider: .claude, width: 310) + + #expect(didAppend) + let item = submenu.items.first + #expect(item?.isEnabled == true) + #expect((item?.view?.frame.height ?? 0) <= 620) + } + + @Test + func `shows open AI web submenus when history exists`() throws { self.disableMenuCardsForTesting() let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusMenuTests-history"), @@ -512,6 +1155,7 @@ struct StatusMenuTests { settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -546,6 +1190,8 @@ struct StatusMenuTests { usageBreakdown: breakdown, creditsPurchaseURL: nil, updatedAt: Date()) + store.openAIDashboardAttachmentAuthorized = true + store.openAIDashboardRequiresLogin = false let controller = StatusItemController( store: store, @@ -554,6 +1200,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -568,7 +1215,60 @@ struct StatusMenuTests { } @Test - func showsCreditsBeforeCostInCodexMenuCardSections() throws { + func `shows open AI API usage chart submenu without codex web history`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.selectedMenuProvider = .openai + + let registry = ProviderRegistry.shared + let metadata = try #require(registry.metadata[.openai]) + settings.setProviderEnabled(provider: .openai, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 9, + requests: 12, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .openai) + controller.menuWillOpen(menu) + let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } + + #expect(usageItem?.submenu?.items + .contains { ($0.representedObject as? String) == StatusItemController.costHistoryChartID } == true) + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardHeader" } == false) + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardExtraUsage" } == false) + } + + @Test + func `shows credits before cost in codex menu card sections`() throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -599,6 +1299,8 @@ struct StatusMenuTests { usageBreakdown: [], creditsPurchaseURL: nil, updatedAt: Date()) + store.openAIDashboardAttachmentAuthorized = true + store.openAIDashboardRequiresLogin = false store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( sessionTokens: 123, sessionCostUSD: 0.12, @@ -623,6 +1325,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -635,7 +1338,66 @@ struct StatusMenuTests { } @Test - func showsExtraUsageForClaudeWhenUsingMenuCardSections() { + func `hosted cost submenu preserves provider context after empty hydration`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + #expect(submenu.autoenablesItems == false) + #expect(submenu.items.first?.isEnabled == true) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.title == "No data available") + #expect(submenu.items.first?.toolTip == UsageProvider.codex.rawValue) + + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.title != "No data available") + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.isEnabled == true) + } + + @Test + func `shows extra usage for claude when using menu card sections`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -701,6 +1463,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -709,7 +1472,7 @@ struct StatusMenuTests { } @Test - func showsVertexCostWhenUsageErrorPresent() { + func `shows vertex cost when usage error present`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -756,6 +1519,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -766,7 +1530,7 @@ struct StatusMenuTests { extension StatusMenuTests { @Test - func overviewTabRendersOverviewRowsForAllActiveProvidersWhenThreeOrFewer() { + func `overview tab renders overview rows for all active providers when three or fewer`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -805,7 +1569,7 @@ extension StatusMenuTests { } @Test - func overviewTabHonorsStoredSubsetWhenThreeOrFewer() { + func `overview tab honors stored subset when three or fewer`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -848,7 +1612,7 @@ extension StatusMenuTests { } @Test - func overviewTabWithExplicitEmptySelectionIsHiddenAndShowsProviderDetail() { + func `overview tab with explicit empty selection is hidden and shows provider detail`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -883,7 +1647,7 @@ extension StatusMenuTests { let ids = self.representedIDs(in: menu) let switcherButtons = self.switcherButtons(in: menu) - #expect(switcherButtons.count == store.enabledProviders().count) + #expect(switcherButtons.count == store.enabledProvidersForDisplay().count) #expect(switcherButtons.contains(where: { $0.title == "Overview" }) == false) #expect(switcherButtons.contains(where: { $0.state == .on && $0.tag == 0 })) #expect(ids.contains("menuCard")) @@ -893,9 +1657,9 @@ extension StatusMenuTests { } @Test - func overviewRowsKeepMenuItemActionInRenderedMode() throws { + func `overview rows keep menu item action in rendered mode`() throws { StatusItemController.menuCardRenderingEnabled = true - StatusItemController.menuRefreshEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) defer { self.disableMenuCardsForTesting() } let settings = self.makeSettings() @@ -931,50 +1695,4 @@ extension StatusMenuTests { #expect(claudeRow.action != nil) #expect(claudeRow.target is StatusItemController) } - - @Test - func selectingOverviewRowSwitchesToProviderDetail() throws { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = true - settings.selectedMenuProvider = .codex - settings.mergedMenuLastSelectedWasOverview = true - - let registry = ProviderRegistry.shared - for provider in UsageProvider.allCases { - guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = provider == .codex || provider == .claude - settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) - } - - let fetcher = UsageFetcher() - let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let controller = StatusItemController( - store: store, - settings: settings, - account: fetcher.loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) - - let menu = controller.makeMenu() - controller.menuWillOpen(menu) - - let claudeRow = try #require(menu.items.first { - ($0.representedObject as? String) == "overviewRow-claude" - }) - let action = try #require(claudeRow.action) - let target = try #require(claudeRow.target as? StatusItemController) - _ = target.perform(action, with: claudeRow) - - #expect(settings.mergedMenuLastSelectedWasOverview == false) - #expect(settings.selectedMenuProvider == .claude) - - let ids = self.representedIDs(in: menu) - #expect(ids.contains("menuCard")) - #expect(ids.contains(where: { $0.hasPrefix("overviewRow-") }) == false) - #expect(self.switcherButtons(in: menu).first(where: { $0.state == .on })?.tag == 2) - } } diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift new file mode 100644 index 0000000000..fbe2bfb830 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -0,0 +1,532 @@ +import AppKit +import CodexBarCore +import Foundation +import XCTest +@testable import CodexBar + +@MainActor +final class StatusMenuTokenAccountSwitcherTests: XCTestCase { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeSettings() -> SettingsStore { + let settings = testSettingsStore( + suiteName: "StatusMenuTokenAccountSwitcherTests", + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + return settings + } + + private func enableOnlyClaude(_ settings: SettingsStore) { + self.enableOnly(.claude, settings) + } + + private func enableOnly(_ enabledProvider: UsageProvider, _ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == enabledProvider) + } + } + + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + private func installBlockingClaudeProvider(on store: UsageStore, blocker: BlockingTokenAccountFetchStrategy) { + let baseSpec = store.providerSpecs[.claude]! + store.providerSpecs[.claude] = Self.makeClaudeProviderSpec(baseSpec: baseSpec) { + try await blocker.awaitResult() + } + } + + private static func makeClaudeProviderSpec( + baseSpec: ProviderSpec, + loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec + { + let baseDescriptor = baseSpec.descriptor + let strategy = StatusMenuTokenAccountFetchStrategy(loader: loader) + let descriptor = ProviderDescriptor( + id: .claude, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli, .oauth], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli) + return ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + } + + private func snapshot(percent: Double = 12) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(300), + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "OAuth")) + } + + func test_tokenAccountMenuSelectionRefreshesProviderWhileGlobalRefreshIsActive() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let refreshTask = Task { @MainActor in + await store.refresh() + } + await blocker.waitUntilStarted(count: 1) + XCTAssertTrue(store.isRefreshing) + + let menu = controller.makeMenu() + defer { withExtendedLifetime(menu) {} } + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + XCTAssertEqual(settings.tokenAccountsData(for: .claude)?.clampedActiveIndex(), 1) + for _ in 0..<40 { + await Task.yield() + } + let startedBeforeDrain = await blocker.startedCallCount() + XCTAssertEqual(startedBeforeDrain, 1) + + await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await blocker.waitUntilStarted(count: 2) + await selectionTask.value + await refreshTask.value + let startedCallCount = await blocker.startedCallCount() + XCTAssertGreaterThanOrEqual(startedCallCount, 2) + } + + func test_multiAccountSegmentedLayoutShowsCopilotSwitcher() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnly(.copilot, settings) + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "gh_primary") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "gh_secondary") + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + _ = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard"]) + } + + func test_multiAccountStackedLayoutShowsCopilotCards() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnly(.copilot, settings) + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "gh_primary") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "gh_secondary") + let accounts = settings.tokenAccounts(for: .copilot) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.accountSnapshots[.copilot] = accounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(10 + index)), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard-0", "menuCard-1"]) + } + + func test_multiAccountStackedRefreshStartsAccountFetchesConcurrently() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + + let refreshTask = Task { @MainActor in + await store.refreshProvider(.claude) + } + + await blocker.waitUntilStarted(count: 2) + let startedBeforeResume = await blocker.startedCallCount() + XCTAssertEqual(startedBeforeResume, 2) + + await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await refreshTask.value + XCTAssertEqual(store.accountSnapshots[.claude]?.count, 2) + } + + func test_multiAccountStackedLayoutIgnoresStaleSnapshotsAndKeepsMenuCapped() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnly(.copilot, settings) + for index in 0..<8 { + settings.addTokenAccount(provider: .copilot, label: "Account \(index)", token: "gh_\(index)") + } + settings.setActiveTokenAccountIndex(7, for: .copilot) + let accounts = settings.tokenAccounts(for: .copilot) + let staleAccounts = (0..<2).map { index in + ProviderTokenAccount( + id: UUID(), + label: "Removed \(index)", + token: "stale_\(index)", + addedAt: TimeInterval(index), + lastUsed: nil) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let staleSnapshots = staleAccounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(70 + index)), + error: nil, + sourceLabel: "stale") + } + let currentSnapshots = accounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(10 + index)), + error: nil, + sourceLabel: "current") + } + store.accountSnapshots[.copilot] = staleSnapshots + currentSnapshots + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual( + self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, + ["menuCard-0", "menuCard-1", "menuCard-2", "menuCard-3", "menuCard-4", "menuCard-5"]) + } + + func test_tokenAccountSwitchDefersOpenMenuRebuildUntilAfterSwitcherAction() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.multiAccountMenuLayout = .segmented + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .claude || provider == .codex) + } + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + + XCTAssertEqual(rebuildCount, 0) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + XCTAssertEqual(rebuildCount, 1) + + await blocker.waitUntilStarted(count: 1) + await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await selectionTask.value + for _ in 0..<20 where rebuildCount < 2 { + await Task.yield() + } + XCTAssertEqual(rebuildCount, 2) + } + + func test_tokenAccountSwitchUsesSelectedAccountCacheWhileRefreshIsInFlight() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + let accounts = settings.tokenAccounts(for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.snapshots[.claude] = self.snapshot(percent: 11) + store.lastKnownResetSnapshots[.claude] = self.snapshot(percent: 11) + store.errors[.claude] = "primary-error" + store.lastSourceLabels[.claude] = "primary-cache" + store.accountSnapshots[.claude] = [ + TokenAccountUsageSnapshot( + account: accounts[0], + snapshot: self.snapshot(percent: 11), + error: nil, + sourceLabel: "primary-cache"), + TokenAccountUsageSnapshot( + account: accounts[1], + snapshot: self.snapshot(percent: 72), + error: nil, + sourceLabel: "secondary-cache"), + ] + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + + XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 72) + XCTAssertEqual(store.lastKnownResetSnapshots[.claude]?.primary?.usedPercent, 72) + XCTAssertNil(store.errors[.claude]) + XCTAssertEqual(store.sourceLabel(for: .claude), "secondary-cache") + + await blocker.waitUntilStarted(count: 1) + await blocker.resumeAll(with: .success(self.snapshot(percent: 45))) + await selectionTask.value + } + + func test_tokenAccountSwitchClearsPreviousAccountSnapshotWithoutSelectedCache() async throws { + self.disableMenuCardsForTesting() + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + let accounts = settings.tokenAccounts(for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.snapshots[.claude] = self.snapshot(percent: 11) + store.lastKnownResetSnapshots[.claude] = self.snapshot(percent: 11) + store.errors[.claude] = "primary-error" + store.lastSourceLabels[.claude] = "primary-cache" + store.accountSnapshots[.claude] = [ + TokenAccountUsageSnapshot( + account: accounts[0], + snapshot: self.snapshot(percent: 11), + error: nil, + sourceLabel: "primary-cache"), + ] + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: testStatusBar()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .claude) + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + + XCTAssertNil(store.snapshot(for: .claude)) + XCTAssertNil(store.lastKnownResetSnapshots[.claude]) + XCTAssertNil(store.errors[.claude]) + XCTAssertNil(store.lastSourceLabels[.claude]) + + await blocker.waitUntilStarted(count: 1) + await blocker.resumeAll(with: .success(self.snapshot(percent: 45))) + await selectionTask.value + } +} + +private struct StatusMenuTokenAccountFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable () async throws -> UsageSnapshot + + var id: String { + "status-menu-token-account-test" + } + + var kind: ProviderFetchKind { + .cli + } + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.loader() + return self.makeResult(usage: snapshot, sourceLabel: "status-menu-token-account-test") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private actor BlockingTokenAccountFetchStrategy { + private var waiters: [CheckedContinuation, Never>] = [] + private var startedWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var resolvedResult: Result? + private var startedCount = 0 + + func awaitResult() async throws -> UsageSnapshot { + if let resolvedResult { + self.startedCount += 1 + self.resumeStartedWaiters() + return try resolvedResult.get() + } + let result = await withCheckedContinuation { continuation in + self.waiters.append(continuation) + self.startedCount += 1 + self.resumeStartedWaiters() + } + return try result.get() + } + + func waitUntilStarted(count: Int) async { + if self.startedCount >= count { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append((count: count, continuation: continuation)) + } + } + + func startedCallCount() -> Int { + self.startedCount + } + + func resumeAll(with result: Result) { + self.resolvedResult = result + self.waiters.forEach { $0.resume(returning: result) } + self.waiters.removeAll() + } + + private func resumeStartedWaiters() { + let ready = self.startedWaiters.filter { self.startedCount >= $0.count } + self.startedWaiters.removeAll { self.startedCount >= $0.count } + ready.forEach { $0.continuation.resume() } + } +} diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index b999019e45..dde1026a4b 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -3,10 +3,9 @@ import Foundation import Testing @testable import CodexBar -@Suite struct StatusProbeTests { @Test - func parseCodexStatus() throws { + func `parse codex status`() throws { let sample = """ Model: gpt Credits: 980 credits @@ -20,20 +19,55 @@ struct StatusProbeTests { } @Test - func parseCodexStatusWithAnsiAndResets() throws { + func `parse codex status with ansi and resets`() throws { + let now = try #require( + Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 11, + day: 26, + hour: 8, + minute: 0))) let sample = """ \u{001B}[38;5;245mCredits:\u{001B}[0m 557 credits 5h limit: [█████ ] 50% left (resets 09:01) Weekly limit: [███████ ] 85% left (resets 04:01 on 27 Nov) """ - let snap = try CodexStatusProbe.parse(text: sample) + let snap = try CodexStatusProbe.parse(text: sample, now: now) #expect(snap.credits == 557) #expect(snap.fiveHourPercentLeft == 50) #expect(snap.weeklyPercentLeft == 85) + #expect(snap.fiveHourResetsAt == Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 11, + day: 26, + hour: 9, + minute: 1))) + #expect(snap.weeklyResetsAt == Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 11, + day: 27, + hour: 4, + minute: 1))) + } + + @Test + func `parse codex status with weekly only line`() throws { + let sample = """ + Model: gpt + Credits: 980 credits + Weekly limit: [##] 25% left + """ + let snap = try CodexStatusProbe.parse(text: sample) + #expect(snap.credits == 980) + #expect(snap.fiveHourPercentLeft == nil) + #expect(snap.weeklyPercentLeft == 25) } @Test - func parseClaudeStatus() throws { + func `parse claude status`() throws { let sample = """ Settings: Status Config Usage (tab to cycle) @@ -64,7 +98,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusWithANSI() throws { + func `parse claude status with ANSI`() throws { let sample = """ \u{001B}[35mCurrent session\u{001B}[0m 40% used (Resets 11am) @@ -86,7 +120,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusLegacyOpusLabel() throws { + func `parse claude status legacy opus label`() throws { let sample = """ Current session 12% used (Resets 11am) @@ -107,7 +141,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusRemainingKeyword() throws { + func `parse claude status remaining keyword`() throws { let sample = """ Current session 12% remaining (Resets 11am) @@ -120,7 +154,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusEnterpriseSessionOnly() throws { + func `parse claude status enterprise session only`() throws { let sample = """ Current session █ 2% used @@ -134,7 +168,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusResetMappings_withCRLineEndings() throws { + func `parse claude status reset mappings with CR line endings`() throws { let sample = "Current session\r" + "██████████████████████████████████████████████████ 17% used\r" + @@ -156,7 +190,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusResetMappings_doesNotPromoteWeeklyResetToSession() throws { + func `parse claude status reset mappings does not promote weekly reset to session`() throws { let sample = """ Current session ██████████████████████████████████████████████████ 17% used @@ -172,7 +206,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusWithPlanAndAnsiNoise() throws { + func `parse claude status with plan and ansi noise`() throws { let sample = """ Settings: Status Config Usage @@ -197,7 +231,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusWithExtraUsageSection() throws { + func `parse claude status with extra usage section`() throws { let sample = """ Settings: Status Config Usage (tab to cycle) @@ -225,7 +259,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatus_ignoresStatusBarContextPercent() throws { + func `parse claude status ignores status bar context percent`() throws { let sample = """ Claude Code v2.1.29 22:47 | | Opus 4.5 | default | ░░░░░░░░░░ 0% ◯ /ide for Visual Studio Code @@ -254,7 +288,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatus_loadingPanelDoesNotReportZeroPercent() { + func `parse claude status loading panel surfaces loading stall`() { let sample = """ Claude Code v2.1.29 22:47 | | Opus 4.5 | default | ░░░░░░░░░░ 0% ◯ /ide for Visual Studio Code @@ -267,7 +301,8 @@ struct StatusProbeTests { do { _ = try ClaudeStatusProbe.parse(text: sample) #expect(Bool(false), "Parsing should fail while /usage is still loading") - } catch ClaudeStatusProbeError.parseFailed { + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("loading")) return } catch ClaudeStatusProbeError.timedOut { return @@ -277,7 +312,34 @@ struct StatusProbeTests { } @Test - func parseClaudeStatus_statusOnlyOutputDoesNotFallbackToZero() { + func `parse claude retained usage panel classifies latest loading panel`() { + let sample = """ + Settings: Status Config Usage (tab to cycle) + Current session + ███████▌15%used + Resets 11:30pm (Asia/Calcutta) + + Current week (all models) + █▌ 3% used + Resets Feb 12 at 1:30pm (Asia/Calcutta) + + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + """ + + do { + _ = try ClaudeStatusProbe.parse(text: sample) + #expect(Bool(false), "Parsing should fail while the latest /usage panel is still loading") + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("loading")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func `parse claude status status only output does not fallback to zero`() { let sample = """ Claude Code v2.1.32 01:07 | | Opus 4.6 | default | ░░░░░░░░░░ 0% left @@ -298,7 +360,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatus_placeholderUsageWindowDoesNotUseStatusBarPercent() { + func `parse claude status placeholder usage window does not use status bar percent`() { let sample = """ Claude Code v2.1.32 01:07 | | Opus 4.6 | default | ░░░░░░░░░░ 0% left @@ -321,7 +383,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatus_compactMarkersStillParse() throws { + func `parse claude status compact markers still parse`() throws { let sample = """ Settings:StatusConfigUsage(←/→ortabtocycle) Loadingusagedata… @@ -340,10 +402,12 @@ struct StatusProbeTests { #expect(snap.sessionPercentLeft == 94) #expect(snap.weeklyPercentLeft == 96) #expect(snap.opusPercentLeft == 99) + #expect(snap.secondaryResetDescription == "ResetsFeb12at1:29pm(Asia/Calcutta)") + #expect(snap.opusResetDescription == "ResetsFeb12at1:29pm(Asia/Calcutta)") } @Test - func parseClaudeStatusWithBracketPlanNoiseNoEsc() throws { + func `parse claude status with bracket plan noise no esc`() throws { let sample = """ Login method: [22m Claude Max Account Account: user@example.com @@ -362,7 +426,7 @@ struct StatusProbeTests { } @Test - func surfacesClaudeTokenExpired() { + func `surfaces claude token expired`() { let sample = """ Settings: Status Config Usage @@ -385,7 +449,7 @@ struct StatusProbeTests { } @Test - func surfacesClaudeRateLimited_compactUsageError() { + func `surfaces claude rate limited compact usage error`() { let sample = """ Settings:StatusConfigUsage(←/→ortabtocycle) Error:Failedtoloadusagedata:{"error":{"message":"Ratelimited.Pleasetryagainlater.","type":"rate_limit_error"}} @@ -404,7 +468,7 @@ struct StatusProbeTests { } @Test - func surfacesClaudeFolderTrustPrompt() { + func `surfaces claude folder trust prompt`() { let sample = """ Do you trust the files in this folder? @@ -424,7 +488,7 @@ struct StatusProbeTests { } @Test - func surfacesClaudeFolderTrustPrompt_withCRLFAndSpaces() { + func `surfaces claude folder trust prompt with CRLF and spaces`() { let sample = "Do you trust the files in this folder?\r\n\r\n/Users/example/My Project\r\n" do { @@ -439,7 +503,7 @@ struct StatusProbeTests { } @Test - func surfacesClaudeFolderTrustPrompt_withoutFolderPath() { + func `surfaces claude folder trust prompt without folder path`() { let sample = """ Do you trust the files in this folder? """ @@ -457,7 +521,78 @@ struct StatusProbeTests { } @Test - func parsesClaudeResetTimeOnly() throws { + func `surfaces claude subscription notice without quota data`() { + let sample = """ + You are currently using your subscription to power your Claude Code usage + """ + + do { + _ = try ClaudeStatusProbe.parse(text: sample) + #expect(Bool(false), "Parsing should fail for subscription notice without quota data") + } catch let ClaudeStatusProbeError.parseFailed(message) { + let lower = message.lowercased() + #expect(lower.contains("subscription")) + #expect(!lower.contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func `parse claude status subscription notice is distinct from loading stall`() { + let subscriptionOnly = "You are currently using your subscription to power your Claude Code usage" + let loadingOnly = """ + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + """ + + do { + _ = try ClaudeStatusProbe.parse(text: subscriptionOnly) + #expect(Bool(false), "Subscription notice should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(subMessage) { + #expect(!subMessage.lowercased().contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error for subscription: \(error)") + } + + do { + _ = try ClaudeStatusProbe.parse(text: loadingOnly) + #expect(Bool(false), "Loading panel should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(loadMessage) { + #expect(loadMessage.lowercased().contains("loading")) + } catch { + #expect(Bool(false), "Unexpected error for loading: \(error)") + } + } + + @Test + func `parse claude status mixed loading and subscription notice surfaces subscription error`() { + // PTY capture containing both an intermediate "Loading usage data…" panel and the final + // Claude CLI 2.1.148 subscription notice. The subscription error must be surfaced, not + // the still-loading stall, so the UI shows the precise subscription message. + let mixedCapture = """ + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + + You are currently using your subscription to power your Claude Code usage + """ + + do { + _ = try ClaudeStatusProbe.parse(text: mixedCapture) + #expect(Bool(false), "Parsing should fail for mixed loading+subscription capture") + } catch let ClaudeStatusProbeError.parseFailed(message) { + let lower = message.lowercased() + #expect(lower.contains("subscription")) + #expect(!lower.contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error for mixed capture: \(error)") + } + } + + @Test + func `parses claude reset time only`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) let parsed = ClaudeStatusProbe.parseResetDate(from: "Resets 12:59pm (Europe/Helsinki)", now: now) let tz = try #require(TimeZone(identifier: "Europe/Helsinki")) @@ -471,7 +606,7 @@ struct StatusProbeTests { } @Test - func parsesClaudeResetDateAndTime() throws { + func `parses claude reset date and time`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) let parsed = ClaudeStatusProbe.parseResetDate(from: "Resets Dec 9, 8:59am (Europe/Helsinki)", now: now) var calendar = Calendar(identifier: .gregorian) @@ -487,7 +622,7 @@ struct StatusProbeTests { } @Test - func parsesClaudeResetWithDotSeparatedTime() throws { + func `parses claude reset with dot separated time`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) let parsed = ClaudeStatusProbe.parseResetDate(from: "Resets Dec 9 at 5.27am (UTC)", now: now) var calendar = Calendar(identifier: .gregorian) @@ -497,7 +632,7 @@ struct StatusProbeTests { } @Test - func parsesClaudeResetWithCompactTimes() throws { + func `parses claude reset with compact times`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) let parsedTimeOnly = ClaudeStatusProbe.parseResetDate(from: "Resets 1pm (UTC)", now: now) var calendar = Calendar(identifier: .gregorian) @@ -521,7 +656,17 @@ struct StatusProbeTests { } @Test - func liveCodexStatus() async throws { + func `parses claude reset with compact date and time no spaces`() throws { + let now = Date(timeIntervalSince1970: 1_773_097_200) // Mar 10, 2026 12:00:00 UTC + let parsed = ClaudeStatusProbe.parseResetDate(from: "ResetsMar13at12:30pm(Asia/Calcutta)", now: now) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Calcutta")) + let expected = calendar.date(from: DateComponents(year: 2026, month: 3, day: 13, hour: 12, minute: 30)) + #expect(parsed == expected) + } + + @Test + func `live codex status`() async throws { guard ProcessInfo.processInfo.environment["LIVE_CODEX_STATUS"] == "1" else { return } let probe = CodexStatusProbe() diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift new file mode 100644 index 0000000000..2c1ee8503a --- /dev/null +++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift @@ -0,0 +1,799 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct StepFunSettingsReaderTests { + @Test + func `reads STEPFUN_TOKEN`() { + let env = ["STEPFUN_TOKEN": "some-oasis-token-value"] + #expect(StepFunSettingsReader.token(environment: env) == "some-oasis-token-value") + } + + @Test + func `reads STEPFUN_USERNAME`() { + let env = ["STEPFUN_USERNAME": "user@example.com"] + #expect(StepFunSettingsReader.username(environment: env) == "user@example.com") + } + + @Test + func `reads STEPFUN_PASSWORD`() { + let env = ["STEPFUN_PASSWORD": "secret123"] + #expect(StepFunSettingsReader.password(environment: env) == "secret123") + } + + @Test + func `trims whitespace from token`() { + let env = ["STEPFUN_TOKEN": " some-token "] + #expect(StepFunSettingsReader.token(environment: env) == "some-token") + } + + @Test + func `strips double quotes from token`() { + let env = ["STEPFUN_TOKEN": "\"some-token\""] + #expect(StepFunSettingsReader.token(environment: env) == "some-token") + } + + @Test + func `strips single quotes from token`() { + let env = ["STEPFUN_TOKEN": "'some-token'"] + #expect(StepFunSettingsReader.token(environment: env) == "some-token") + } + + @Test + func `returns nil when no env vars present`() { + #expect(StepFunSettingsReader.token(environment: [:]) == nil) + #expect(StepFunSettingsReader.username(environment: [:]) == nil) + #expect(StepFunSettingsReader.password(environment: [:]) == nil) + } + + @Test + func `returns nil for empty values`() { + let env = ["STEPFUN_TOKEN": "", "STEPFUN_USERNAME": "", "STEPFUN_PASSWORD": ""] + #expect(StepFunSettingsReader.token(environment: env) == nil) + #expect(StepFunSettingsReader.username(environment: env) == nil) + #expect(StepFunSettingsReader.password(environment: env) == nil) + } + + @Test + func `returns nil for whitespace-only values`() { + let env = ["STEPFUN_TOKEN": " "] + #expect(StepFunSettingsReader.token(environment: env) == nil) + } +} + +struct StepFunProviderTokenResolverTests { + @Test + func `resolves token from environment`() { + let env = ["STEPFUN_TOKEN": "my-test-token"] + let resolution = ProviderTokenResolver.stepfunResolution(environment: env) + #expect(resolution?.token == "my-test-token") + #expect(resolution?.source == .environment) + } + + @Test + func `returns nil when token absent`() { + let resolution = ProviderTokenResolver.stepfunResolution(environment: [:]) + #expect(resolution == nil) + } +} + +struct StepFunUsageFetcherParsingTests { + @Test + func `parses real API response format with string timestamps and integer rates`() throws { + // This matches the actual StepFun API response format: + // - timestamps as strings (e.g. "1777528800") + // - rates can be integers (e.g. 1) or floats (e.g. 0.99781543) + let json = """ + { + "status": 1, + "desc": "", + "five_hour_usage_left_rate": 1, + "five_hour_usage_reset_time": "1777528800", + "weekly_usage_left_rate": 0.99781543, + "weekly_usage_reset_time": "1777899600" + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + + #expect(snapshot.fiveHourUsageLeftRate == 1.0) + #expect(snapshot.weeklyUsageLeftRate > 0.997 && snapshot.weeklyUsageLeftRate < 0.998) + } + + @Test + func `parses response with float rates and integer timestamps`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.75, + "weekly_usage_left_rate": 0.5, + "five_hour_usage_reset_time": 1746000000, + "weekly_usage_reset_time": 1746500000 + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + + #expect(snapshot.fiveHourUsageLeftRate == 0.75) + #expect(snapshot.weeklyUsageLeftRate == 0.5) + } + + @Test + func `throws on failed API status`() { + let json = """ + { + "status": 0, + "message": "Unauthorized", + "five_hour_usage_left_rate": 0.75, + "weekly_usage_left_rate": 0.5, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_reset_time": "1746500000" + } + """ + let data = Data(json.utf8) + #expect(throws: StepFunUsageError.self) { + try StepFunUsageFetcher._parseSnapshotForTesting(data) + } + } + + @Test + func `throws on missing fields`() { + let json = """ + { + "status": 1 + } + """ + let data = Data(json.utf8) + #expect(throws: StepFunUsageError.self) { + try StepFunUsageFetcher._parseSnapshotForTesting(data) + } + } + + @Test + func `throws on invalid JSON`() { + let data = Data("not json".utf8) + #expect(throws: StepFunUsageError.self) { + try StepFunUsageFetcher._parseSnapshotForTesting(data) + } + } + + @Test + func `snapshot maps to UsageSnapshot correctly`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.8, + "weekly_usage_left_rate": 0.6, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_reset_time": "1746500000" + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + let usage = snapshot.toUsageSnapshot() + + // Five-hour window: 20% used (1.0 - 0.8) + let primaryUsed = usage.primary?.usedPercent ?? 0 + #expect(primaryUsed > 19.9 && primaryUsed < 20.1) + + // Weekly window: 40% used (1.0 - 0.6) + let secondaryUsed = usage.secondary?.usedPercent ?? 0 + #expect(secondaryUsed > 39.9 && secondaryUsed < 40.1) + #expect(usage.secondary?.windowMinutes == 10080) + + // Identity + #expect(usage.identity?.providerID == .stepfun) + #expect(usage.identity?.loginMethod == "password") + } + + @Test + func `clamps used percent to 0-100 range`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.0, + "weekly_usage_left_rate": 1, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_reset_time": "1746500000" + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + let usage = snapshot.toUsageSnapshot() + + // 0% remaining → 100% used + #expect(usage.primary?.usedPercent == 100.0) + // 100% remaining → 0% used (integer 1 parsed as 1.0) + #expect(usage.secondary?.usedPercent == 0.0) + } +} + +struct StepFunTokenNormalizerTests { + @Test + func `extracts Oasis-Token from cookie header`() { + let input = "Oasis-Token=abc123...def456; Oasis-Webid=someid" + #expect(StepFunTokenNormalizer.normalize(input) == "abc123...def456") + } + + @Test + func `returns raw value when not a cookie header`() { + let input = "abc123...def456" + #expect(StepFunTokenNormalizer.normalize(input) == "abc123...def456") + } + + @Test + func `returns empty for empty string`() { + #expect(StepFunTokenNormalizer.normalize("").isEmpty) + } + + @Test + func `trims whitespace`() { + #expect(StepFunTokenNormalizer.normalize(" token123 ") == "token123") + } +} + +@Suite(.serialized) +struct StepFunTokenRefreshTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `refresh token returns combined token pair`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + #expect(request.url?.path.contains("RefreshToken") == true) + #expect(request.value(forHTTPHeaderField: "Oasis-Token") == "old-access...old-refresh") + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("old-access...old-refresh") == true) + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + let token = try await StepFunUsageFetcher.refreshToken(token: "old-access...old-refresh") + #expect(token == "new-access...new-refresh") + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `manual token auth failure refreshes token account and retries usage`() async throws { + let accountID = UUID() + let updateRecorder = StepFunTokenUpdateRecorder() + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("old-access...old-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("new-access...new-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + #expect(request.value(forHTTPHeaderField: "Oasis-Token") == "old-access...old-refresh") + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + selectedTokenAccountID: accountID, + tokenUpdater: { provider, updatedAccountID, token in + #expect(provider == .stepfun) + #expect(updatedAccountID == accountID) + await updateRecorder.record(token) + }) + + let result = try await StepFunWebFetchStrategy().fetch(context) + + #expect(result.usage.identity?.loginMethod == "Plus") + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + let updatedToken = await updateRecorder.recordedToken() + #expect(updatedToken == "new-access...new-refresh") + } + } + + @Test + func `manual token auth failure refreshes settings token and retries usage`() async throws { + let updateRecorder = StepFunTokenUpdateRecorder() + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("new-access...new-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + manualTokenUpdater: { provider, token in + #expect(provider == .stepfun) + await updateRecorder.record(token) + }) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + let updatedToken = await updateRecorder.recordedToken() + #expect(updatedToken == "new-access...new-refresh") + } + } + + @Test + func `stale cached token falls back to configured env token`() async throws { + CookieHeaderCache.store(provider: .stepfun, cookieHeader: "stale-access...stale-refresh", sourceLabel: "test") + defer { CookieHeaderCache.clear(provider: .stepfun) } + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("stale-access...stale-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("env-access...env-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings(cookieSource: .auto)) + let context = self.makeContext( + settings: settings, + env: ["STEPFUN_TOKEN": "env-access...env-refresh"]) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + #expect(CookieHeaderCache.load(provider: .stepfun) == nil) + } + } + + @Test + func `stale cached and env tokens fall back to env login credentials`() async throws { + CookieHeaderCache.store(provider: .stepfun, cookieHeader: "stale-access...stale-refresh", sourceLabel: "test") + defer { CookieHeaderCache.clear(provider: .stepfun) } + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.isEmpty || path == "/" { + return Self.jsonResponse( + for: request, + body: "{}", + headers: ["Set-Cookie": "INGRESSCOOKIE=ingress-cookie; Path=/"]) + } + + if path.contains("RegisterDevice") { + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "anon-access"}, + "refreshToken": {"raw": "anon-refresh"} + } + """) + } + + if path.contains("SignInByPassword") { + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "login-access"}, + "refreshToken": {"raw": "login-refresh"} + } + """) + } + + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("stale-access...stale-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + if call == 2 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("env-access...env-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("login-access...login-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings(cookieSource: .auto)) + let context = self.makeContext( + settings: settings, + env: [ + "STEPFUN_TOKEN": "env-access...env-refresh", + "STEPFUN_USERNAME": "user@example.com", + "STEPFUN_PASSWORD": "password", + ]) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 3) + #expect(recorder.refreshCallCount == 1) + #expect(CookieHeaderCache.load(provider: .stepfun)?.cookieHeader == "login-access...login-refresh") + } + } + + @Test + func `post refresh non auth usage failure is not rewritten as auth guidance`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + return Self.jsonResponse(for: request, statusCode: 500, body: #"{"error":"temporary"}"#) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext(settings: settings) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected post-refresh usage failure") + } catch let StepFunUsageError.apiError(message) { + #expect(message == "HTTP 500") + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `manual token refresh failure does not fall back to ambient env credentials`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + _ = recorder.recordUsageCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + Issue.record("Manual token recovery should not call login endpoint: \(path)") + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + env: [ + "STEPFUN_USERNAME": "someone@example.com", + "STEPFUN_PASSWORD": "secret", + ]) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected manual token auth failure") + } catch let StepFunUsageError.apiError(message) { + #expect(message.contains("Refresh the Oasis-Token")) + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 1) + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `non auth token wording does not trigger refresh recovery`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + _ = recorder.recordUsageCall() + return Self.jsonResponse( + for: request, + body: #"{"status":0,"message":"token plan status temporarily unavailable"}"#) + } + + Issue.record("Non-auth usage error should not call recovery endpoint: \(path)") + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext(settings: settings) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected provider API error") + } catch let StepFunUsageError.apiError(message) { + #expect(message == "token plan status temporarily unavailable") + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 1) + #expect(recorder.refreshCallCount == 0) + } + } + + private func makeContext( + settings: ProviderSettingsSnapshot?, + env: [String: String] = [:], + selectedTokenAccountID: UUID? = nil, + tokenUpdater: ProviderFetchContext.TokenAccountTokenUpdater? = nil, + manualTokenUpdater: ProviderFetchContext.ProviderManualTokenUpdater? = nil) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + selectedTokenAccountID: selectedTokenAccountID, + tokenAccountTokenUpdater: tokenUpdater, + providerManualTokenUpdater: manualTokenUpdater) + } + + private func withStubProtocol( + _ body: (StepFunRequestRecorder) async throws -> Void) async throws + { + let recorder = StepFunRequestRecorder() + let registered = URLProtocol.registerClass(StepFunStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(StepFunStubURLProtocol.self) + } + StepFunStubURLProtocol.handler = nil + } + try await body(recorder) + } + + private static func usageResponse(for request: URLRequest) -> (HTTPURLResponse, Data) { + self.jsonResponse( + for: request, + body: """ + { + "status": 1, + "five_hour_usage_left_rate": 0.8, + "weekly_usage_left_rate": 0.6, + "five_hour_usage_reset_time": "1777528800", + "weekly_usage_reset_time": "1777899600" + } + """) + } + + private static func jsonResponse( + for request: URLRequest, + statusCode: Int = 200, + body: String, + headers: [String: String] = ["Content-Type": "application/json"]) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: headers)! + return (response, Data(body.utf8)) + } +} + +private actor StepFunTokenUpdateRecorder { + private var token: String? + + func record(_ token: String) { + self.token = token + } + + func recordedToken() -> String? { + self.token + } +} + +private final class StepFunRequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var usageCalls = 0 + private var refreshCalls = 0 + + var usageCallCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.usageCalls + } + + var refreshCallCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.refreshCalls + } + + func recordUsageCall() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + self.usageCalls += 1 + return self.usageCalls + } + + func recordRefreshCall() { + self.lock.lock() + defer { self.lock.unlock() } + self.refreshCalls += 1 + } +} + +private final class StepFunStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "platform.stepfun.com" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift new file mode 100644 index 0000000000..3198b53a64 --- /dev/null +++ b/Tests/CodexBarTests/StorageBreakdownSegmentTests.swift @@ -0,0 +1,112 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct StorageBreakdownSegmentTests { + @Test @MainActor + func `folds overflow into eighth segment without losing paths`() { + let components = (1...10).map { index in + ProviderStorageFootprint.Component(path: "/tmp/item-\(index)", totalBytes: Int64(index)) + } + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + + #expect(view._segmentNamesForTesting == [ + "item-1", "item-2", "item-3", "item-4", "item-5", "item-6", "item-7", "Other (3 items)", + ]) + #expect(view._segmentBytesForTesting == [1, 2, 3, 4, 5, 6, 7, 27]) + #expect(view._overflowNamesForTesting == ["item-8", "item-9", "item-10"]) + #expect(view._overflowExpansionHeightForTesting == 68) + #expect(view.copyablePaths == components.map(\.path)) + } + + @Test @MainActor + func `segment widths fill bar and keep tiny values visible`() { + let components = [ + ProviderStorageFootprint.Component(path: "/tmp/large", totalBytes: 1_000_000), + ProviderStorageFootprint.Component(path: "/tmp/tiny", totalBytes: 1), + ProviderStorageFootprint.Component(path: "/tmp/zero", totalBytes: 0), + ] + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + let widths = view._segmentWidthsForTesting(barWidth: 100) + + #expect(widths.count == 3) + #expect(abs(widths.reduce(0, +) - 100) < 0.001) + #expect(widths.allSatisfy { $0 >= 2 }) + } + + @Test @MainActor + func `narrow bar divides width without overflow`() { + let components = (1...8).map { index in + ProviderStorageFootprint.Component(path: "/tmp/item-\(index)", totalBytes: 1) + } + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + let widths = view._segmentWidthsForTesting(barWidth: 8) + + #expect(widths == Array(repeating: 1, count: 8)) + #expect(widths.reduce(0, +) == 8) + } + + @Test @MainActor + func `zero byte components evenly fill bar`() { + let components = (1...4).map { index in + ProviderStorageFootprint.Component(path: "/tmp/item-\(index)", totalBytes: 0) + } + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + + #expect(view._segmentWidthsForTesting(barWidth: 100) == [25, 25, 25, 25]) + } + + @Test @MainActor + func `negative component sizes clamp to zero`() { + let components = [ + ProviderStorageFootprint.Component(path: "/tmp/negative", totalBytes: -10), + ProviderStorageFootprint.Component(path: "/tmp/positive", totalBytes: 10), + ] + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + + #expect(view._segmentBytesForTesting == [0, 10]) + #expect(view._segmentWidthsForTesting(barWidth: 100).allSatisfy { $0 >= 2 }) + } + + @Test @MainActor + func `extreme component sizes still fill exactly one bar`() { + let components = [ + ProviderStorageFootprint.Component(path: "/tmp/first", totalBytes: .max), + ProviderStorageFootprint.Component(path: "/tmp/second", totalBytes: .max), + ] + let view = StorageBreakdownMenuView( + footprint: Self.footprint(components: components), + width: 310) + let widths = view._segmentWidthsForTesting(barWidth: 100) + + #expect(abs(widths.reduce(0, +) - 100) < 0.001) + #expect(widths == [50, 50]) + } + + private static func footprint( + components: [ProviderStorageFootprint.Component]) -> ProviderStorageFootprint + { + ProviderStorageFootprint( + provider: .claude, + totalBytes: components.reduce(Int64(0)) { partial, component in + let (sum, overflowed) = partial.addingReportingOverflow(max(component.totalBytes, 0)) + return overflowed ? .max : sum + }, + paths: components.map(\.path), + missingPaths: [], + unreadablePaths: [], + components: components, + updatedAt: Date(timeIntervalSince1970: 0)) + } +} diff --git a/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift index 116d5da5d8..ae85d8bec2 100644 --- a/Tests/CodexBarTests/SubprocessRunnerTests.swift +++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift @@ -2,18 +2,332 @@ import Foundation import Testing @testable import CodexBarCore -@Suite +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + struct SubprocessRunnerTests { @Test - func readsLargeStdoutWithoutDeadlock() async throws { + func `reads large stdout without deadlock`() async throws { let result = try await SubprocessRunner.run( binary: "/usr/bin/python3", arguments: ["-c", "print('x' * 1_000_000)"], environment: ProcessInfo.processInfo.environment, - timeout: 5, + timeout: 15, label: "python large stdout") #expect(result.stdout.count >= 1_000_000) #expect(result.stderr.isEmpty) } + + @Test + func `bounds oversized stdout while continuing to drain`() async throws { + let result = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", "print('x' * 2_000_000)"], + environment: ProcessInfo.processInfo.environment, + timeout: 5, + label: "python oversized stdout") + + #expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes) + #expect(result.stderr.isEmpty) + } + + @Test + func `preserves captured prefix when limit splits three byte scalar`() async throws { + let asciiCount = ProcessPipeCapture.defaultMaxBytes - 1 + let script = "import sys; sys.stdout.buffer.write(b'x' * \(asciiCount) + bytes([0xe2, 0x82, 0xac]) + b'tail')" + let result = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: ProcessInfo.processInfo.environment, + timeout: 5, + label: "python split utf8 stdout") + + #expect(result.stdout.count == ProcessPipeCapture.defaultMaxBytes) + #expect(result.stdout.first == "x") + #expect(result.stdout.last == "\u{FFFD}") + #expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes + 2) + } + + @Test + func `bounds simultaneous oversized stdout and stderr while draining`() async throws { + let script = """ + import sys + chunk = 2048 + for _ in range(2000): + sys.stdout.write('o' * chunk) + sys.stdout.flush() + sys.stderr.write('e' * chunk) + sys.stderr.flush() + """ + let result = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: ProcessInfo.processInfo.environment, + timeout: 10, + label: "python simultaneous oversized output") + + #expect(result.stdout.utf8.count == ProcessPipeCapture.defaultMaxBytes) + #expect(result.stderr.utf8.count == ProcessPipeCapture.defaultMaxBytes) + } + + @Test + func `bounds oversized stderr on failure`() async throws { + do { + _ = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", "import sys; sys.stderr.write('e' * 2_000_000); sys.exit(7)"], + environment: ProcessInfo.processInfo.environment, + timeout: 5, + label: "python oversized stderr") + Issue.record("Expected non-zero exit") + } catch let error as SubprocessRunnerError { + guard case let .nonZeroExit(code, stderr) = error else { + Issue.record("Expected non-zero exit, got \(error)") + return + } + #expect(code == 7) + #expect(stderr.utf8.count == ProcessPipeCapture.defaultMaxBytes) + } catch { + Issue.record("Expected SubprocessRunnerError, got \(error)") + } + } + + @Test + func `returns partial output when detached child keeps pipes open`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-subprocess-drain-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path + let script = """ + import os + import subprocess + import sys + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(5)"], + start_new_session=True, + ) + with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle: + handle.write(str(child.pid)) + print("parent-output", flush=True) + """ + + let start = Date() + let result = try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: environment, + timeout: 5, + label: "detached-output-holder") + let elapsed = Date().timeIntervalSince(start) + + #expect(result.stdout.contains("parent-output")) + #expect(elapsed < 3, "Output drain should not wait for the detached child, took \(elapsed)s") + } + + /// Regression test for #474: a hung subprocess must be killed and throw `.timedOut` + /// instead of blocking indefinitely. + /// + /// This test was previously deleted (commit 3961770) because `waitUntilExit()` blocked + /// the cooperative thread pool, starving the timeout task. The fix moves blocking calls + /// to `DispatchQueue.global()`, making this test reliable. + @Test + func `throws timed out when process hangs`() async throws { + let start = Date() + do { + _ = try await SubprocessRunner.run( + binary: "/bin/sleep", + arguments: ["5"], + environment: ProcessInfo.processInfo.environment, + timeout: 1, + label: "hung-process-test") + Issue.record("Expected SubprocessRunnerError.timedOut but no error was thrown") + } catch let error as SubprocessRunnerError { + guard case let .timedOut(label) = error else { + Issue.record("Expected .timedOut, got \(error)") + return + } + #expect(label == "hung-process-test") + } catch { + Issue.record("Expected SubprocessRunnerError.timedOut, got unexpected error: \(error)") + } + + let elapsed = Date().timeIntervalSince(start) + // Must complete in well under 5s (the sleep duration). Allow generous bound for CI. + #expect(elapsed < 3, "Timeout should fire in ~1s, not wait for process to exit naturally") + } + + @Test + func `timeout kills descendants that escape the process group`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-subprocess-tree-\(UUID().uuidString)", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + var environment = ProcessInfo.processInfo.environment + environment["CODEXBAR_TEST_CHILD_PID_FILE"] = childPIDFile.path + let script = """ + import os + import subprocess + import sys + import time + + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + start_new_session=True, + ) + with open(os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], "w") as handle: + handle.write(str(child.pid)) + time.sleep(30) + """ + + await #expect(throws: SubprocessRunnerError.self) { + try await SubprocessRunner.run( + binary: "/usr/bin/python3", + arguments: ["-c", script], + environment: environment, + timeout: 0.5, + label: "escaped-descendant") + } + + let text = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines))) + defer { _ = kill(childPID, SIGKILL) } + + let deadline = Date().addingTimeInterval(1) + while kill(childPID, 0) == 0, Date() < deadline { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(kill(childPID, 0) == -1) + } + + /// Multiple concurrent hung subprocesses must all time out independently, proving that + /// one blocked subprocess does not starve the timeout mechanism of others. + /// This is the core scenario that caused the original permanent-refresh-stall bug. + @Test + func `concurrent hung processes all time out`() async { + let start = Date() + let count = 8 + + await withTaskGroup(of: Void.self) { group in + for i in 0.. UsageMenuCardView.Model + { + let identity = ProviderIdentitySnapshot( + providerID: .synthetic, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: providerCost, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.synthetic]) + return UsageMenuCardView.Model.make(.init( + provider: .synthetic, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + @Test + func `rolling regen text uses parsed tickPercent not hardcoded fallback`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let primary = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil, + nextRegenPercent: 2) + let model = try Self.makeModel(primary: primary, now: now) + let metric = try #require(model.metrics.first) + // 50% used / 2% per tick = 25 ticks to full. + #expect(metric.detailRightText == "Full in ~25 regens") + #expect(metric.detailLeftText == "52% after next regen") + } + + @Test + func `rolling regen omits Synthetic-specific text when tickPercent is missing`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let primary = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil, + nextRegenPercent: nil) + let model = try Self.makeModel(primary: primary, now: now) + let metric = try #require(model.metrics.first) + // Without nextRegenPercent we no longer assert a regen-specific label; + // the renderer must not fabricate ticks-to-full from a guessed rate. + #expect(metric.detailRightText?.contains("regen") != true) + } + + @Test + func `weekly regen text near full reports both labels consistently`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let secondary = RateWindow( + usedPercent: 1, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let cost = ProviderCostSnapshot( + used: 0.36, + limit: 36, + currencyCode: "USD", + period: "Weekly", + resetsAt: now.addingTimeInterval(3600), + nextRegenAmount: 0.72, + updatedAt: now) + let model = try Self.makeModel( + primary: nil, + secondary: secondary, + providerCost: cost, + now: now) + let weekly = try #require(model.metrics.first(where: { $0.id == "secondary" })) + // used=$0.36 / nextRegen=$0.72 = 0.5 ticks → between 0.1 and 1.5 → "Full in ~1 regen". + #expect(weekly.detailRightText == "Full in ~1 regen") + // remaining 99% + 2% next regen caps at 100% → "100% after next regen". + #expect(weekly.detailLeftText == "100% after next regen") + } +} diff --git a/Tests/CodexBarTests/SyntheticProviderTests.swift b/Tests/CodexBarTests/SyntheticProviderTests.swift index aad33712b7..abe9765b05 100644 --- a/Tests/CodexBarTests/SyntheticProviderTests.swift +++ b/Tests/CodexBarTests/SyntheticProviderTests.swift @@ -2,25 +2,23 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct SyntheticSettingsReaderTests { @Test - func apiKeyReadsFromEnvironment() { + func `api key reads from environment`() { let token = SyntheticSettingsReader.apiKey(environment: ["SYNTHETIC_API_KEY": "abc123"]) #expect(token == "abc123") } @Test - func apiKeyStripsQuotes() { + func `api key strips quotes`() { let token = SyntheticSettingsReader.apiKey(environment: ["SYNTHETIC_API_KEY": "\"token-xyz\""]) #expect(token == "token-xyz") } } -@Suite struct SyntheticUsageSnapshotTests { @Test - func mapsUsageSnapshotWindows() throws { + func `maps usage snapshot windows`() throws { let json = """ { "plan": "Starter", @@ -41,7 +39,7 @@ struct SyntheticUsageSnapshotTests { } @Test - func parsesSubscriptionQuota() throws { + func `parses subscription quota`() throws { let json = """ { "subscription": { @@ -63,4 +61,167 @@ struct SyntheticUsageSnapshotTests { #expect(usage.primary?.resetsAt == expectedReset) #expect(usage.loginMethod(for: .synthetic) == nil) } + + @Test + func `parses nested subscription pack quota`() throws { + let json = """ + { + "subscription": { + "packs": 2, + "rateLimit": { + "messages": 1000, + "requests": 250, + "period": "5hr", + "resetsAt": "2026-04-16T18:00:00Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let expectedReset = try #require(formatter.date(from: "2026-04-16T18:00:00Z")) + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == expectedReset) + } + + @Test + func `parses live root level rolling and weekly quotas`() throws { + let json = """ + { + "subscription": { + "limit": 750, + "requests": 0, + "renewsAt": "2026-04-17T08:35:49.493Z" + }, + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.05884722222223, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "tickPercent": 0.05, + "remaining": 750, + "max": 750, + "limited": false + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expectedPrimaryReset = try #require(formatter.date(from: "2026-04-17T03:44:11Z")) + let expectedSecondaryReset = try #require(formatter.date(from: "2026-04-17T05:19:30Z")) + let expectedTertiaryReset = try #require(fractionalFormatter.date(from: "2026-04-17T04:30:01.494Z")) + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetsAt == expectedPrimaryReset) + #expect(usage.primary?.resetDescription == nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 1.9411527777777715) < 0.001) + #expect(usage.secondary?.resetsAt == expectedSecondaryReset) + #expect(usage.secondary?.resetDescription == nil) + #expect(usage.tertiary?.usedPercent == 0.8) + #expect(usage.tertiary?.resetsAt == expectedTertiaryReset) + #expect(usage.providerCost?.limit == 36) + #expect(abs((usage.providerCost?.used ?? 0) - 0.7) < 0.0001) + #expect(usage.providerCost?.nextRegenAmount == 0.72) + } + + @Test + func `parses rolling lane tickPercent into primary nextRegenPercent`() throws { + let json = """ + { + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "tickPercent": 0.05, + "remaining": 750, + "max": 750, + "limited": false + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.nextRegenPercent == 5.0) + } + + @Test + func `omits nextRegenPercent when rolling lane lacks tickPercent`() throws { + let json = """ + { + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "remaining": 750, + "max": 750 + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.nextRegenPercent == nil) + } + + @Test + func `parses time string suffixes covering minutes hours and days`() { + #expect(SyntheticUsageParser.windowMinutes(fromText: "5min") == 5) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5m") == 5) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5hr") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5h") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5hours") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "2days") == 2880) + #expect(SyntheticUsageParser.windowMinutes(fromText: "2d") == 2880) + #expect(SyntheticUsageParser.windowMinutes(fromText: "1 hour") == 60) + #expect(SyntheticUsageParser.windowMinutes(fromText: "junk") == nil) + #expect(SyntheticUsageParser.windowMinutes(fromText: "") == nil) + } + + @Test + func `preserves slot identity when rolling lane is missing`() throws { + let json = """ + { + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.0, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 2.0) < 0.001) + #expect(usage.tertiary?.usedPercent == 0.8) + #expect(usage.providerCost?.limit == 36) + } } diff --git a/Tests/CodexBarTests/T3ChatUsageFetcherTests.swift b/Tests/CodexBarTests/T3ChatUsageFetcherTests.swift new file mode 100644 index 0000000000..83e69ef33c --- /dev/null +++ b/Tests/CodexBarTests/T3ChatUsageFetcherTests.swift @@ -0,0 +1,295 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct T3ChatUsageFetcherTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private static let now = Date(timeIntervalSince1970: 1_778_000_000) + // 2026-05-21T12:23:36Z, the usage-window reset that must not drive overage reset display. + private static let billingNextResetMilliseconds = 1_779_366_216_920 + // 2026-06-06T16:23:29Z, the subscription period end used for overage reset display. + private static let subscriptionPeriodEndSeconds = 1_780_763_009 + private static let subscriptionPeriodEndMilliseconds = Self.subscriptionPeriodEndSeconds * 1000 + + private static let sampleResponse = [ + #"{"json":{"0":[[0],[null,0,0]]}}"#, + #"{"json":[0,0,[[{"result":0}],["result",0,1]]]}"#, + #"{"json":[1,0,[[{"data":0}],["data",0,2]]]}"#, + #"{"json":[2,0,[[{"subTier":"pro","subscription":{"# + + #""productId":"pro","productName":"pro","status":"active","# + + #""currentPeriodStart":1778084609000,"currentPeriodEnd":1780763009000,"# + + #""canceledAt":null,"trialEndsAt":null},"lifetimeBalance":0,"usageBand":"max","# + + #""billingNextResetAt":1779366216920,"usageFourHourPercentage":12.5,"# + + #""usageMonthPercentage":34.25,"usageFourHourNextResetAt":1779366216920,"# + + #""usagePeriodPercentage":44,"usageWindowNextResetAt":1779366216920}]]]}"#, + ].joined(separator: "\n") + + @Test + func `parses customer data from json lines response`() throws { + let snapshot = try T3ChatUsageParser.parseJSONLines(Self.sampleResponse, now: Self.now) + + #expect(snapshot.customerData.subTier == "pro") + #expect(snapshot.customerData.usageBand == "max") + #expect(snapshot.customerData.usageFourHourPercentage == 12.5) + #expect(snapshot.customerData.usageMonthPercentage == 34.25) + #expect(snapshot.customerData.subscription?.status == "active") + } + + @Test + func `maps customer data to base and overage windows`() throws { + let usage = try T3ChatUsageParser.parseJSONLines(Self.sampleResponse, now: Self.now) + .toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 12.5) + #expect(usage.primary?.windowMinutes == 240) + #expect(usage.primary?.resetDescription == "Base - max") + #expect(usage.secondary?.usedPercent == 34.25) + #expect(usage.secondary?.resetDescription == "Overage") + #expect(usage.secondary?.resetsAt.map { Int($0.timeIntervalSince1970) } == Self.subscriptionPeriodEndSeconds) + #expect(usage.identity?.providerID == .t3chat) + #expect(usage.identity?.loginMethod == "Pro") + } + + @Test + func `falls back to usage period percentage when month percentage is absent`() throws { + let response = """ + {"json":[2,0,[[{"subTier":"free","usageFourHourPercentage":5,"usagePeriodPercentage":65}]]]} + """ + let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now) + .toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 5) + #expect(usage.secondary?.usedPercent == 65) + } + + @Test + func `overage reset ignores billing next reset`() throws { + let response = Self.customerDataResponse( + #"{"usageMonthPercentage":20,"billingNextResetAt":\#(Self.billingNextResetMilliseconds)}"#) + let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now) + .toUsageSnapshot() + + #expect(usage.secondary?.usedPercent == 20) + #expect(usage.secondary?.resetsAt == nil) + } + + @Test + func `overage reset uses subscription current period end`() throws { + let currentPeriodEnd = Self.subscriptionPeriodEndMilliseconds + let response = Self.customerDataResponse( + #"{"usageMonthPercentage":20,"subscription":{"currentPeriodEnd":\#(currentPeriodEnd)}}"#) + let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now) + .toUsageSnapshot() + + #expect(usage.secondary?.usedPercent == 20) + #expect(usage.secondary?.resetsAt.map { Int($0.timeIntervalSince1970) } == Self.subscriptionPeriodEndSeconds) + } + + @Test + func `fetch sends trpc headers and cookie`() async throws { + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.host == "t3.chat") + #expect(request.url?.path == "/api/trpc/getCustomerData") + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc") + #expect(request.value(forHTTPHeaderField: "trpc-accept") == "application/jsonl") + #expect(request.value(forHTTPHeaderField: "x-trpc-source") == "web-client") + #expect(request.value(forHTTPHeaderField: "Sec-Fetch-Site") == "same-origin") + #expect(request.url?.query?.contains("batch=1") == true) + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let snapshot = try await T3ChatUsageFetcher.fetchCustomerData( + cookieHeader: "session=abc", + now: Self.now, + transport: stub) + + #expect(snapshot.customerData.planName == "Pro") + } + + @Test + func `full curl capture forwards browser fingerprint headers`() async throws { + let curl = """ + curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\ + -H 'User-Agent: Mozilla/5.0 Firefox/151.0' \\ + --header "Referer: https://t3.chat/settings/customization" \\ + -H 'trpc-accept: application/jsonl' \\ + -H 'x-trpc-source: web-client' \\ + -H 'x-trpc-batch: true' \\ + -H 'X-Deployment-Id: dpl_test' \\ + -H 'x-client-context: eyJjbGllbnQiOnsidmVyc2lvbiI6IjEuMTIuNCJ9fQ==' \\ + -H 'Cookie: session=abc' + """ + let stub = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc") + #expect(request.value(forHTTPHeaderField: "User-Agent") == "Mozilla/5.0 Firefox/151.0") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://t3.chat/settings/customization") + #expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test") + #expect(request.value(forHTTPHeaderField: "x-client-context") == + "eyJjbGllbnQiOnsidmVyc2lvbiI6IjEuMTIuNCJ9fQ==") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + _ = try await fetcher.fetch( + cookieHeaderOverride: curl, + now: Self.now, + transport: stub) + } + + @Test + func `curl capture forwards ansi quoted and equals header forms`() async throws { + let curl = """ + curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\ + --header=$'User-Agent: Browser\\'s Agent' \\ + --header 'X-Deployment-Id: dpl_test' \\ + -H 'Cookie: session=abc' + """ + let stub = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc") + #expect(request.value(forHTTPHeaderField: "User-Agent") == "Browser's Agent") + #expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + _ = try await fetcher.fetch( + cookieHeaderOverride: curl, + now: Self.now, + transport: stub) + } + + @Test + func `full curl capture extracts cookie from long header form`() async throws { + let curl = """ + curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\ + --compressed \\ + --header "Referer: https://t3.chat/settings/customization" \\ + --header "Cookie: session=abc; cf_clearance=token" \\ + --header "X-Deployment-Id: dpl_test" + """ + let stub = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc; cf_clearance=token") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://t3.chat/settings/customization") + #expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + _ = try await fetcher.fetch( + cookieHeaderOverride: curl, + now: Self.now, + transport: stub) + } + + @Test + func `manual strategy accepts full curl capture`() async { + let curl = """ + curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\ + --header "Referer: https://t3.chat/settings/customization" \\ + --header "Cookie: session=abc; cf_clearance=token" \\ + --header "X-Deployment-Id: dpl_test" + """ + let settings = ProviderSettingsSnapshot.make( + t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings( + cookieSource: .manual, + manualCookieHeader: curl)) + + #expect(await T3ChatWebFetchStrategy().isAvailable(Self.makeContext(settings: settings))) + } + + @Test + func `unauthorized response is invalid credentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil)! + return (Data("unauthorized".utf8), response) + } + + await #expect { + _ = try await T3ChatUsageFetcher.fetchCustomerData( + cookieHeader: "session=abc", + now: Self.now, + transport: stub) + } throws: { error in + guard case T3ChatUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `vercel challenge response asks for full curl capture`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 429, + httpVersion: nil, + headerFields: ["x-vercel-mitigated": "challenge"])! + return (Data("checkpoint".utf8), response) + } + + await #expect { + _ = try await T3ChatUsageFetcher.fetchCustomerData( + cookieHeader: "session=abc", + now: Self.now, + transport: stub) + } throws: { error in + guard case T3ChatUsageError.vercelChallenge = error else { return false } + return true + } + } + + private static func customerDataResponse(_ customerDataJSON: String) -> String { + #"{"json":[2,0,[[\#(customerDataJSON)]]]}"# + "\n" + } + + private static func makeContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } +} diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index be4bc19584..fdd2a0ae1f 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -4,8 +4,27 @@ import Testing @Suite(.serialized) struct TTYCommandRunnerEnvTests { + private static let harnessPTYTimeout: TimeInterval = 10 + + private final class CallbackCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() + } + + func value() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.count + } + } + @Test - func shutdownFenceDrainsTrackedTTYProcesses() { + func `shutdown fence drains tracked TTY processes`() { TTYCommandRunner._test_resetTrackedProcesses() defer { TTYCommandRunner._test_resetTrackedProcesses() } @@ -19,7 +38,20 @@ struct TTYCommandRunnerEnvTests { } @Test - func trackedProcessHelpersIgnoreInvalidPID() { + func `cached CLI sessions share shutdown tracking`() { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } + + #expect(TTYCommandRunner.registerActiveProcessForAppShutdown(pid: 3001, binary: "codex")) + TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: 3001, processGroup: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 1) + + TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } + + @Test + func `tracked process helpers ignore invalid PID`() { TTYCommandRunner._test_resetTrackedProcesses() defer { TTYCommandRunner._test_resetTrackedProcesses() } @@ -28,7 +60,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func shutdownFenceRejectsNewRegistrations() { + func `shutdown fence rejects new registrations`() { TTYCommandRunner._test_resetTrackedProcesses() defer { TTYCommandRunner._test_resetTrackedProcesses() } @@ -41,7 +73,29 @@ struct TTYCommandRunnerEnvTests { } @Test - func shutdownResolverSkipsHostProcessGroupFallback() { + func `shutdown waits for launch cleanup before draining`() { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } + + #expect(TTYCommandRunner._test_beginTrackedProcessLaunch()) + let fenceSet = DispatchSemaphore(value: 0) + let completed = DispatchSemaphore(value: 0) + Thread.detachNewThread { + _ = TTYCommandRunner._test_drainTrackedProcessesForShutdown { + fenceSet.signal() + } + completed.signal() + } + + #expect(fenceSet.wait(timeout: .now() + 1) == .success) + #expect(completed.wait(timeout: .now() + 0.05) == .timedOut) + #expect(!TTYCommandRunner._test_registerTrackedProcess(pid: 2002, binary: "codex")) + TTYCommandRunner._test_endTrackedProcessLaunch() + #expect(completed.wait(timeout: .now() + 1) == .success) + } + + @Test + func `shutdown resolver skips host process group fallback`() { let hostGroup: pid_t = 4242 let targets: [(pid: pid_t, binary: String, processGroup: pid_t?)] = [ (pid: 100, binary: "codex", processGroup: nil), @@ -63,7 +117,44 @@ struct TTYCommandRunnerEnvTests { } @Test - func preservesEnvironmentAndSetsTerm() { + func `descendant resolver walks process tree once`() { + let children: [pid_t: [pid_t]] = [ + 100: [101, 102], + 101: [103], + 102: [103], + 103: [100], + ] + + let descendants = TTYProcessTreeTerminator.descendantPIDs(of: 100) { children[$0] ?? [] } + + #expect(Set(descendants) == Set([101, 102, 103])) + #expect(descendants.count == 3) + } + + @Test + func `process tree termination signals escaped descendants`() { + let children: [pid_t: [pid_t]] = [ + 100: [101, 102], + 102: [103], + ] + var signaled: [(pid: pid_t, signal: Int32)] = [] + + TTYProcessTreeTerminator.terminateProcessTree( + rootPID: 100, + processGroup: 200, + signal: 15, + childResolver: { children[$0] ?? [] }, + signalSender: { pid, signal in + signaled.append((pid: pid, signal: signal)) + }) + + #expect(Set(signaled.map(\.pid)) == Set([100, 101, 102, 103, -200])) + #expect(signaled.allSatisfy { $0.signal == 15 }) + #expect(signaled.last?.pid == 100) + } + + @Test + func `preserves environment and sets term`() { let baseEnv: [String: String] = [ "PATH": "/custom/bin", "HOME": "/Users/tester", @@ -83,7 +174,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func backfillsHomeWhenMissing() { + func `backfills home when missing`() { let merged = TTYCommandRunner.enrichedEnvironment( baseEnv: ["PATH": "/custom/bin"], loginPATH: nil, @@ -93,7 +184,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func preservesExistingTermAndCustomVars() { + func `preserves existing term and custom vars`() { let merged = TTYCommandRunner.enrichedEnvironment( baseEnv: [ "PATH": "/custom/bin", @@ -111,19 +202,89 @@ struct TTYCommandRunnerEnvTests { } @Test - func setsWorkingDirectoryWhenProvided() throws { + func `codex status probe uses non persistent thread storage`() { + let stateHome = URL(fileURLWithPath: "/tmp/codexbar status \"state\"", isDirectory: true) + let args = CodexStatusProbeIsolation.codexArguments(stateHome: stateHome) + + #expect(args.starts(with: ["-s", "read-only", "-a", "untrusted"])) + #expect(args.contains("history.persistence=\"none\"")) + #expect(args.contains("experimental_thread_store={type=\"in_memory\",id=\"codexbar-status\"}")) + #expect(args.contains("sqlite_home=\"/tmp/codexbar status \\\"state\\\"\"")) + } + + @Test + func `codex status probe avoids root working directory when home exists`() { + let home = "/Users/tester" + let workingDirectory = CodexStatusProbeIsolation.workingDirectory(environment: ["HOME": home]) + #expect(workingDirectory?.path == home) + } + + @Test + func `sets working directory when provided`() throws { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) let runner = TTYCommandRunner() - let result = try runner.run(binary: "/bin/pwd", send: "", options: .init(timeout: 3, workingDirectory: dir)) + let result = try runner.run( + binary: "/bin/pwd", + send: "", + options: .init(timeout: Self.harnessPTYTimeout, workingDirectory: dir)) let clean = result.text.replacingOccurrences(of: "\r", with: "") #expect(clean.contains(dir.path)) } @Test - func autoRespondsToTrustPrompt() throws { + func `claude runner keeps normal working directory by default`() throws { + let runner = TTYCommandRunner() + let fakeClaude = try Self.makeFakeClaudeCLI() + let result = try runner.run( + binary: fakeClaude.path, + send: "", + options: .init(timeout: Self.harnessPTYTimeout, stopOnSubstrings: ["deep-link-enabled"])) + let clean = result.text.replacingOccurrences(of: "\r", with: "") + + #expect(clean.contains("deep-link-enabled")) + } + + @Test + func `claude runner uses probe directory with deep link registration disabled when requested`() throws { + let runner = TTYCommandRunner() + let fakeClaude = try Self.makeFakeClaudeCLI() + let result = try runner.run( + binary: fakeClaude.path, + send: "", + options: .init( + timeout: Self.harnessPTYTimeout, + stopOnSubstrings: ["deep-link-disabled"], + useClaudeProbeWorkingDirectory: true)) + let clean = result.text.replacingOccurrences(of: "\r", with: "") + + #expect(clean.contains("deep-link-disabled")) + } + + @Test + func `claude runner uses probe directory for versioned CLI override`() throws { + let runner = TTYCommandRunner() + let fakeClaude = try Self.makeFakeClaudeCLI(fileName: "2.1.114") + var env = ProcessInfo.processInfo.environment + env["CLAUDE_CLI_PATH"] = fakeClaude.path + + let result = try runner.run( + binary: fakeClaude.path, + send: "", + options: .init( + timeout: Self.harnessPTYTimeout, + baseEnvironment: env, + stopOnSubstrings: ["deep-link-disabled"], + useClaudeProbeWorkingDirectory: true)) + let clean = result.text.replacingOccurrences(of: "\r", with: "") + + #expect(clean.contains("deep-link-disabled")) + } + + @Test + func `auto responds to trust prompt`() throws { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) @@ -150,7 +311,7 @@ struct TTYCommandRunnerEnvTests { binary: scriptURL.path, send: "", options: .init( - timeout: 6, + timeout: 15, // Use LF for portability: some PTY/termios setups do not translate CR → NL for shell reads. sendOnSubstrings: ["trust the files in this folder?": "y\n"], stopOnSubstrings: ["accepted", "rejected"], @@ -159,8 +320,118 @@ struct TTYCommandRunnerEnvTests { #expect(result.text.contains("accepted")) } + private static func makeFakeClaudeCLI(fileName: String = "claude") throws -> URL { + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + let scriptURL = dir.appendingPathComponent(fileName) + let script = """ + #!/bin/sh + settings="$PWD/.claude/settings.local.json" + if [ -f "$settings" ] \ + && grep -q '"disableDeepLinkRegistration"' "$settings" \ + && grep -q '"disable"' "$settings"; then + echo "deep-link-disabled" + else + echo "deep-link-enabled" + fi + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + return scriptURL + } + + @Test + func `post-exit drain processes trailing chunk through callback path`() { + let callbackCounter = CallbackCounter() + var reads: [TTYCommandRunner.DrainReadResult] = [ + .wouldBlock, + .wouldBlock, + .data(Data("https://example.com/auth".utf8)), + .closed, + ] + + TTYCommandRunner.drainRemainingOutput( + until: Date().addingTimeInterval(1), + readChunk: { + if reads.isEmpty { return .closed } + return reads.removeFirst() + }, + processChunk: { data in + if data.range(of: Data("https://".utf8)) != nil { + callbackCounter.increment() + } + }, + sleep: { _ in }) + + #expect(callbackCounter.value() == 1) + } + + @Test + func `post-exit drain keeps harvesting after late success marker`() { + var readCount = 0 + var processedChunks: [String] = [] + var reads: [TTYCommandRunner.DrainReadResult] = [ + .data(Data("accepted".utf8)), + .wouldBlock, + .data(Data(" trailing".utf8)), + .closed, + ] + + TTYCommandRunner.drainRemainingOutput( + until: Date().addingTimeInterval(1), + readChunk: { + readCount += 1 + if reads.isEmpty { return .closed } + return reads.removeFirst() + }, + processChunk: { data in + processedChunks.append(String(bytes: data, encoding: .utf8) ?? "") + }, + sleep: { _ in }) + + #expect(readCount == 4) + #expect(processedChunks == ["accepted", " trailing"]) + } + + @Test + func `post-exit drain stops once the PTY reports closure`() { + var readCount = 0 + + TTYCommandRunner.drainRemainingOutput( + until: Date().addingTimeInterval(1), + readChunk: { + readCount += 1 + return .closed + }, + processChunk: { _ in }, + sleep: { _ in }) + + #expect(readCount == 1) + } + + @Test + func `interrupted drain reads are treated as retryable`() { + let result = TTYCommandRunner.drainReadResult(for: Data(), terminalRead: -1, errno: EINTR) + if case .wouldBlock = result { + #expect(Bool(true)) + } else { + Issue.record("Expected interrupted read to remain retryable during drain") + } + } + + @Test + func `EOF beats stale would-block errno during drain classification`() { + let result = TTYCommandRunner.drainReadResult(for: Data(), terminalRead: 0, errno: EAGAIN) + if case .closed = result { + #expect(Bool(true)) + } else { + Issue.record("Expected EOF reads to stop draining even if errno still holds EAGAIN") + } + } + @Test - func stopsWhenOutputIsIdle() throws { + func `stops when output is idle`() throws { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) @@ -195,7 +466,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func rollingBufferDetectsNeedleAcrossBoundary() { + func `rolling buffer detects needle across boundary`() { var scanner = TTYCommandRunner.RollingBuffer(maxNeedle: 6) let needle = Data("hello".utf8) let first = scanner.append(Data("he".utf8)) @@ -205,7 +476,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func lowercasedASCIIOnlyTouchesAscii() { + func `lowercased ASCII only touches ascii`() { let data = Data("UpDaTe".utf8) let lowered = TTYCommandRunner.lowercasedASCII(data) #expect(String(data: lowered, encoding: .utf8) == "update") diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index adf59bccb8..a17e3c3f9b 100644 --- a/Tests/CodexBarTests/TTYIntegrationTests.swift +++ b/Tests/CodexBarTests/TTYIntegrationTests.swift @@ -6,7 +6,10 @@ import Testing @Suite(.serialized) struct TTYIntegrationTests { @Test - func codexRPCUsageLive() async throws { + func `codex RPC usage live`() async throws { + guard ProcessInfo.processInfo.environment["LIVE_CODEX_TTY"] == "1" else { + return + } let fetcher = UsageFetcher() do { let snapshot = try await fetcher.loadLatestUsage() @@ -23,7 +26,7 @@ struct TTYIntegrationTests { } @Test - func claudeTTYUsageProbeLive() async throws { + func `claude TTY usage probe live`() async throws { guard ProcessInfo.processInfo.environment["LIVE_CLAUDE_TTY"] == "1" else { return } @@ -55,4 +58,85 @@ struct TTYIntegrationTests { if !shouldAssert { return } } + + @Test + func `claude pty usage waits for values after session label`() async throws { + let cli = try Self.makeSlowUsageClaudeCLI() + defer { Task { await ClaudeCLISession.shared.reset() } } + + let snapshot = try await ClaudeCLISession.withIsolatedSessionForTesting { + try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 10).fetch() + } + + #expect(snapshot.sessionPercentLeft == 93) + #expect(snapshot.weeklyPercentLeft == 79) + } + + @Test + func `claude pty usage stops on subscription notice`() async throws { + let cli = try Self.makeSubscriptionNoticeClaudeCLI() + defer { Task { await ClaudeCLISession.shared.reset() } } + + do { + try await ClaudeCLISession.withIsolatedSessionForTesting { + _ = try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 3).fetch() + } + #expect(Bool(false), "Subscription notice should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("subscription")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + private static func makeSlowUsageClaudeCLI() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("claude") + let script = """ + #!/bin/sh + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf '%s\\n' 'Settings Status Config Usage' + printf '%s\\n' 'Current session' + sleep 2 + printf '%s\\n' '93% left' + printf '%s\\n' 'Current week (all models)' + printf '%s\\n' '79% left' + ;; + *"/status"*) + printf '%s\\n' 'Account: slow-usage@example.com' + ;; + esac + done + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } + + private static func makeSubscriptionNoticeClaudeCLI() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("claude") + let script = """ + #!/bin/sh + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf '%s\\n' 'You are currently using your subscription to power your Claude Code usage' + ;; + *"/status"*) + printf '%s\\n' 'Account: subscription@example.com' + ;; + esac + done + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } } diff --git a/Tests/CodexBarTests/TerminalAppTests.swift b/Tests/CodexBarTests/TerminalAppTests.swift new file mode 100644 index 0000000000..97a6b491ea --- /dev/null +++ b/Tests/CodexBarTests/TerminalAppTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import CodexBar + +@Suite("TerminalApp") +struct TerminalAppTests { + @Test + @MainActor + func `default is terminal`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(store.terminalApp == .terminal) + } + + @Test + @MainActor + func `setting terminal app persists it`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + store.terminalApp = .iTerm + #expect(store.terminalApp == .iTerm) + #expect(defaults.string(forKey: "terminalApp") == "iTerm") + } + + @Test + @MainActor + func `invalid stored value falls back to terminal`() throws { + let suite = "TerminalAppTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.set("nonexistent", forKey: "terminalApp") + let store = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(store.terminalApp == .terminal) + } + + @Test + func `only two cases exist`() { + #expect(TerminalApp.allCases.count == 2) + } + + @Test + func `all cases have unique bundle identifiers`() { + let ids = TerminalApp.allCases.map(\.bundleIdentifier) + #expect(Set(ids).count == TerminalApp.allCases.count) + } + + @Test + func `all cases have non-empty labels`() { + for app in TerminalApp.allCases { + #expect(!app.label.isEmpty) + } + } + + @Test + func `round-trip all cases through raw value`() { + for app in TerminalApp.allCases { + #expect(TerminalApp(rawValue: app.rawValue) == app) + } + } + + @Test + func `escapes commands embedded in AppleScript strings`() { + let escaped = TerminalApp.escapeForAppleScript(#"echo "C:\tmp""#) + + #expect(escaped == #"echo \"C:\\tmp\""#) + } + + @Test + func `builds terminal-specific launch scripts`() { + let command = #"echo "hello""# + let terminalScript = TerminalApp.terminal.appleScript(command: command) + let iTermScript = TerminalApp.iTerm.appleScript(command: command) + + #expect(terminalScript.contains(#"tell application "Terminal""#)) + #expect(terminalScript.contains(#"do script "echo \"hello\"""#)) + #expect(iTermScript.contains(#"tell application "iTerm""#)) + #expect(iTermScript.contains(#"write text "echo \"hello\"""#)) + } +} diff --git a/Tests/CodexBarTests/TestProcessCleanup.swift b/Tests/CodexBarTests/TestProcessCleanup.swift index dbc3a1b767..af9d6f0b0c 100644 --- a/Tests/CodexBarTests/TestProcessCleanup.swift +++ b/Tests/CodexBarTests/TestProcessCleanup.swift @@ -7,12 +7,19 @@ import Glibc #endif enum TestProcessCleanup { + static let codexTestStubCommandRegex = [ + #"codex-(stub|fallback-stub|plan-only-stub|credits-only-stub|hung-stub)-"#, + #"[[:xdigit:]]{8}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{4}-[[:xdigit:]]{12}"#, + #"[[:space:]]+app-server([[:space:]]|$)"#, + ].joined() + static func register() { atexit(_testProcessCleanupAtExit) } - fileprivate static func terminateLeakedCodexAppServers() { - let pids = Self.pids(matchingFullCommandRegex: "codex.*app-server") + fileprivate static func terminateLeakedCodexTestStubs() { + // Never target an installed Codex process. These UUID-named executables are created only by this test target. + let pids = Self.pids(matchingFullCommandRegex: Self.codexTestStubCommandRegex) .filter { $0 > 0 && $0 != getpid() } guard !pids.isEmpty else { return } @@ -65,5 +72,5 @@ private let _registerTestProcessCleanup: Void = TestProcessCleanup.register() @_cdecl("codexbar_test_cleanup_atexit") private func _testProcessCleanupAtExit() { - TestProcessCleanup.terminateLeakedCodexAppServers() + TestProcessCleanup.terminateLeakedCodexTestStubs() } diff --git a/Tests/CodexBarTests/TestProcessCleanupTests.swift b/Tests/CodexBarTests/TestProcessCleanupTests.swift new file mode 100644 index 0000000000..5fed8db627 --- /dev/null +++ b/Tests/CodexBarTests/TestProcessCleanupTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing + +struct TestProcessCleanupTests { + @Test + func `cleanup pattern matches only CodexBar test stub app servers`() throws { + let regex = try NSRegularExpression(pattern: TestProcessCleanup.codexTestStubCommandRegex) + let testStubCommands = [ + "/usr/bin/python3 -S /tmp/codex-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server", + "/bin/sh /tmp/codex-fallback-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server", + "/bin/sh /tmp/codex-plan-only-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server", + "/bin/sh /tmp/codex-credits-only-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server", + "/bin/sh /tmp/codex-hung-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server", + ] + let userCommands = [ + "/Applications/Codex.app/Contents/Resources/codex app-server --listen stdio://", + "/opt/homebrew/bin/codex app-server", + "node /Users/test/node_modules/.bin/codex app-server --listen stdio://", + "/tmp/codex-stub-cache app-server", + "/tmp/codex-stub-01234567-89AB-CDEF-0123 app-server", + "/tmp/codex-stub-01234567-89AB-CDEF-0123-456789ABCDEF app-server-helper", + ] + + for command in testStubCommands { + #expect(Self.matches(regex, command)) + } + for command in userCommands { + #expect(!Self.matches(regex, command)) + } + } + + private static func matches(_ regex: NSRegularExpression, _ command: String) -> Bool { + regex.firstMatch( + in: command, + range: NSRange(command.startIndex..., in: command)) != nil + } +} diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index 7d8e274915..c42b6f854b 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -1,6 +1,9 @@ import CodexBarCore import Foundation @testable import CodexBar +#if os(macOS) +import AppKit +#endif final class InMemoryCookieHeaderStore: CookieHeaderStoring, @unchecked Sendable { var value: String? @@ -132,3 +135,93 @@ func testConfigStore(suiteName: String, reset: Bool = true) -> CodexBarConfigSto } return CodexBarConfigStore(fileURL: url) } + +@MainActor +func testSettingsStore( + suiteName: String, + tokenAccountStore: any ProviderTokenAccountStoring = InMemoryTokenAccountStore()) -> SettingsStore +{ + let isolatedSuiteName = "\(suiteName)-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: isolatedSuiteName) else { + preconditionFailure("Could not create test defaults suite") + } + defaults.removePersistentDomain(forName: isolatedSuiteName) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: isolatedSuiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: tokenAccountStore) +} + +#if os(macOS) +@MainActor +func testStatusBar() -> NSStatusBar { + // Standalone NSStatusBar instances can crash during swiftpm-testing-helper teardown. + .system +} + +@MainActor +@discardableResult +func withStatusItemControllerForTesting( + store: UsageStore, + settings: SettingsStore, + fetcher: UsageFetcher, + statusBar: NSStatusBar = .system, + operation: (StatusItemController) throws -> T) rethrows -> T +{ + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: statusBar) + defer { controller.releaseStatusItemsForTesting() } + return try operation(controller) +} + +@MainActor +@discardableResult +func withStatusItemControllerForTesting( + store: UsageStore, + settings: SettingsStore, + fetcher: UsageFetcher, + statusBar: NSStatusBar = .system, + operation: (StatusItemController) async throws -> T) async rethrows -> T +{ + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: statusBar) + defer { controller.releaseStatusItemsForTesting() } + return try await operation(controller) +} +#endif + +func testPlanUtilizationHistoryStore(suiteName: String, reset: Bool = true) -> PlanUtilizationHistoryStore { + let sanitized = suiteName.replacingOccurrences(of: "/", with: "-") + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(sanitized, isDirectory: true) + let url = base.appendingPathComponent("history", isDirectory: true) + if reset { + try? FileManager.default.removeItem(at: url) + } + return PlanUtilizationHistoryStore(directoryURL: url) +} diff --git a/Tests/CodexBarTests/TextParsingTests.swift b/Tests/CodexBarTests/TextParsingTests.swift index 812adbe026..ad4d07d25e 100644 --- a/Tests/CodexBarTests/TextParsingTests.swift +++ b/Tests/CodexBarTests/TextParsingTests.swift @@ -1,17 +1,16 @@ import CodexBarCore import Testing -@Suite struct TextParsingTests { @Test - func stripANSICodesRemovesCursorVisibilityCSI() { + func `strip ANSI codes removes cursor visibility CSI`() { let input = "\u{001B}[?25hhello\u{001B}[0m" let stripped = TextParsing.stripANSICodes(input) #expect(stripped == "hello") } @Test - func firstNumberParsesDecimalSeparators() { + func `first number parses decimal separators`() { let dotDecimal = TextParsing.firstNumber(pattern: #"Credits:\s*([0-9][0-9., ]*)"#, text: "Credits: 54.72") #expect(dotDecimal == 54.72) diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index cb36b24ae8..12172c24f2 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -4,11 +4,11 @@ import Testing @testable import CodexBar @testable import CodexBarCLI +@Suite(.serialized) @MainActor -@Suite struct TokenAccountEnvironmentPrecedenceTests { @Test - func tokenAccountEnvironmentOverridesConfigAPIKey_inAppEnvironmentBuilder() { + func `token account environment overrides config API key in app environment builder`() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-app") settings.zaiAPIToken = "config-token" settings.addTokenAccount(provider: .zai, label: "Account 1", token: "account-token") @@ -25,7 +25,22 @@ struct TokenAccountEnvironmentPrecedenceTests { } @Test - func tokenAccountEnvironmentOverridesConfigAPIKey_inCLIEnvironmentBuilder() throws { + func `deepseek token account injects environment in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-deepseek-app") + settings.addTokenAccount(provider: .deepseek, label: "Account 1", token: "account-token") + + let env = ProviderRegistry.makeEnvironment( + base: ["FOO": "bar"], + provider: .deepseek, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == "account-token") + } + + @Test + func `token account environment overrides config API key in CLI environment builder`() throws { let config = CodexBarConfig( providers: [ ProviderConfig(id: .zai, apiKey: "config-token"), @@ -46,7 +61,24 @@ struct TokenAccountEnvironmentPrecedenceTests { } @Test - func ollamaTokenAccountSelectionForcesManualCookieSourceInCLISettingsSnapshot() throws { + func `deepseek token account injects environment in CLI environment builder`() throws { + let config = CodexBarConfig(providers: []) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = ProviderTokenAccount( + id: UUID(), + label: "Account 1", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + + let env = tokenContext.environment(base: [:], provider: .deepseek, account: account) + + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == "account-token") + } + + @Test + func `ollama token account selection forces manual cookie source in CLI settings snapshot`() throws { let accounts = ProviderTokenAccountData( version: 1, accounts: [ @@ -76,7 +108,620 @@ struct TokenAccountEnvironmentPrecedenceTests { } @Test - func applyAccountLabelInAppPreservesSnapshotFields() { + func `command code config cookie is carried into CLI settings snapshot`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .commandcode, + cookieHeader: "better-auth.session_token=manual-token", + cookieSource: .manual), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .commandcode, account: nil)) + let commandCodeSettings = try #require(snapshot.commandcode) + + #expect(commandCodeSettings.cookieSource == .manual) + #expect(commandCodeSettings.manualCookieHeader == "better-auth.session_token=manual-token") + } + + @Test + func `app snapshot override resolves cookie account without mutating stored selection`() throws { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-cookie-override-app") + settings.cursorCookieSource = .auto + settings.cursorCookieHeader = "configured=true" + let account = ProviderTokenAccount( + id: UUID(), + label: "Override", + token: "account=true", + addedAt: 0, + lastUsed: nil) + + let snapshot = ProviderRegistry.makeSettingsSnapshot( + settings: settings, + tokenOverride: TokenAccountOverride(provider: .cursor, account: account)) + let cursorSettings = try #require(snapshot.cursor) + + #expect(cursorSettings.cookieSource == .manual) + #expect(cursorSettings.manualCookieHeader == "account=true") + #expect(settings.tokenAccounts(for: .cursor).isEmpty) + } + + @Test + func `stepfun CLI snapshot reads manual token from region field`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .stepfun, + region: "Oasis-Token=manual-token; Oasis-Webid=web"), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .stepfun, account: nil)) + let stepfunSettings = try #require(snapshot.stepfun) + + #expect(stepfunSettings.cookieSource == .manual) + #expect(stepfunSettings.manualToken == "Oasis-Token=manual-token; Oasis-Webid=web") + } + + @Test + func `stepfun CLI token account overrides region manual token`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "StepFun", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .stepfun, + region: "manual-token", + tokenAccounts: ProviderTokenAccountData( + version: 1, + accounts: [account], + activeIndex: 0)), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let resolvedAccount = try #require(tokenContext.resolvedAccounts(for: .stepfun).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .stepfun, account: resolvedAccount)) + let stepfunSettings = try #require(snapshot.stepfun) + + #expect(stepfunSettings.cookieSource == .manual) + #expect(stepfunSettings.manualToken == "account-token") + } + + @Test + func `claude OAuth token account overrides environment in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-app") + settings.addTokenAccount(provider: .claude, label: "OAuth", token: "Bearer sk-ant-oat-account-token") + + let env = ProviderRegistry.makeEnvironment( + base: ["FOO": "bar"], + provider: .claude, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account-token") + } + + @Test + func `claude session account strips ambient admin api credentials in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-admin-strip-app") + settings.claudeAdminAPIKey = "sk-ant-admin-config" + settings.addTokenAccount(provider: .claude, label: "Session", token: "sk-ant-session-token") + + let env = ProviderRegistry.makeEnvironment( + base: [ + "FOO": "bar", + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "sk-ant-admin-base", + ClaudeOAuthCredentialsStore.environmentTokenKey: "sk-ant-oat-base", + ], + provider: .claude, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == nil) + } + + @Test + func `claude session key selection carries organization id in app settings snapshot`() throws { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-org-app") + settings.addTokenAccount( + provider: .claude, + label: "Team", + token: "sk-ant-session-token", + organizationID: " org-team ") + + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token") + #expect(claudeSettings.organizationID == "org-team") + } + + @Test + func `claude OAuth token selection forces OAuth in CLI settings snapshot`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieSource: .auto, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.usageDataSource == .oauth) + #expect(claudeSettings.cookieSource == .off) + #expect(claudeSettings.manualCookieHeader == nil) + } + + @Test + func `claude OAuth token selection injects environment override in CLI`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig(id: .claude, tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + + let env = tokenContext.environment(base: ["FOO": "bar"], provider: .claude, account: account) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account-token") + } + + @Test + func `claude session account strips ambient admin api credentials in CLI environment builder`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + apiKey: "sk-ant-admin-config", + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + + let env = tokenContext.environment( + base: [ + "FOO": "bar", + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "sk-ant-admin-base", + ClaudeOAuthCredentialsStore.environmentTokenKey: "sk-ant-oat-base", + ], + provider: .claude, + account: account) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == nil) + } + + @Test + func `claude OAuth token selection promotes auto source mode in CLI`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .auto, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .oauth) + } + + @Test + func `claude OAuth token selection reroutes explicit CLI source to OAuth in CLI`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .oauth) + } + + @Test + func `claude session key selection reroutes explicit CLI source to Web in CLI`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .web) + } + + @Test + func `claude all accounts reroutes explicit CLI source per selected credential in CLI`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "OAuth", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil), + ProviderTokenAccount( + id: UUID(), + label: "Session", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig(id: .claude, tokenAccounts: accounts), + ]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: true), + config: config, + verbose: false) + + let resolved = try tokenContext.resolvedAccounts(for: .claude) + #expect(resolved.map(\.label) == ["OAuth", "Session"]) + + let oauth = try #require(resolved.first) + let oauthSnapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: oauth)?.claude) + #expect(tokenContext.effectiveSourceMode(base: .cli, provider: .claude, account: oauth) == .oauth) + #expect(oauthSnapshot.usageDataSource == .oauth) + #expect(tokenContext.environment(base: [:], provider: .claude, account: oauth)[ + ClaudeOAuthCredentialsStore.environmentTokenKey, + ] == "sk-ant-oat-account-token") + + let session = try #require(resolved.dropFirst().first) + let sessionSnapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: session)?.claude) + #expect(tokenContext.effectiveSourceMode(base: .cli, provider: .claude, account: session) == .web) + #expect(sessionSnapshot.cookieSource == .manual) + #expect(sessionSnapshot.manualCookieHeader == "sessionKey=sk-ant-session-token") + } + + @Test + func `codex all accounts selection exposes configured accounts and scopes CLI homes`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-cli-all-accounts-\(UUID().uuidString)", isDirectory: true) + let ambientHome = root.appendingPathComponent("ambient", isDirectory: true) + let firstHome = root.appendingPathComponent("first", isDirectory: true) + let secondHome = root.appendingPathComponent("second", isDirectory: true) + let profileHome = root.appendingPathComponent("profile", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: ambientHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: firstHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondHome, withIntermediateDirectories: true) + try Self.writeCodexAuthFile( + homeURL: profileHome, + email: "profile@example.com", + accountID: "acct_profile") + let storeURL = root.appendingPathComponent("managed-codex-accounts.json") + let firstID = UUID() + let secondID = UUID() + let accounts = ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: [ + ManagedCodexAccount( + id: firstID, + email: "FIRST@EXAMPLE.COM", + workspaceLabel: "Team", + managedHomePath: firstHome.path, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: nil), + ManagedCodexAccount( + id: secondID, + email: "second@example.com", + workspaceLabel: "Personal", + managedHomePath: secondHome.path, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: nil), + ]) + try FileManagedCodexAccountStore(fileURL: storeURL).storeAccounts(accounts) + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .codex, + codexActiveSource: .managedAccount(id: secondID), + codexProfileHomePaths: [profileHome.path]), + ]) + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: true), + config: config, + verbose: false, + baseEnvironment: ["CODEX_HOME": ambientHome.path], + managedCodexAccountStoreURL: storeURL) + + let projection = context.visibleCodexAccounts() + #expect(projection.visibleAccounts.map(\.menuDisplayName) == [ + "first@example.com — Team", + "profile@example.com", + "second@example.com", + ]) + #expect(projection.visibleAccounts.map(\.selectionSource) == [ + .managedAccount(id: firstID), + .profileHome(path: profileHome.path), + .managedAccount(id: secondID), + ]) + #expect(projection.visibleAccounts.first { $0.email == "second@example.com" }?.isActive == true) + + let firstEnv = context.environment( + base: ["CODEX_HOME": ambientHome.path], + provider: .codex, + account: nil, + codexActiveSourceOverride: .managedAccount(id: firstID)) + #expect(firstEnv["CODEX_HOME"] == firstHome.path) + + let profileEnv = context.environment( + base: ["CODEX_HOME": ambientHome.path], + provider: .codex, + account: nil, + codexActiveSourceOverride: .profileHome(path: profileHome.path)) + #expect(profileEnv["CODEX_HOME"] == profileHome.path) + #expect(context.settingsSnapshot( + for: .codex, + account: nil, + codexActiveSourceOverride: .profileHome(path: profileHome.path))?.codex?.openAIWebCacheScope + == .profileHome(profileHome.path)) + + let liveEnv = context.environment( + base: ["CODEX_HOME": ambientHome.path], + provider: .codex, + account: nil, + codexActiveSourceOverride: .liveSystem) + #expect(liveEnv["CODEX_HOME"] == ambientHome.path) + + let firstFetcher = context.fetcher( + base: UsageFetcher(environment: ["CODEX_HOME": ambientHome.path]), + provider: .codex, + env: firstEnv) + #expect(Self.codexHomePath(from: firstFetcher) == firstHome.path) + + let nonCodexBaseFetcher = UsageFetcher(environment: ["CODEX_HOME": ambientHome.path]) + let nonCodexFetcher = context.fetcher(base: nonCodexBaseFetcher, provider: .claude, env: firstEnv) + #expect(Self.codexHomePath(from: nonCodexFetcher) == ambientHome.path) + + let labeled = try context.applyCodexVisibleAccountLabel( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), + account: #require(projection.visibleAccounts.first)) + let identity = try #require(labeled.identity(for: .codex)) + #expect(identity.accountEmail == "first@example.com") + #expect(identity.accountOrganization == "Team") + } + + @Test + func `codex CLI ignores relative profile homes`() throws { + let config = CodexBarConfig(providers: [ + ProviderConfig( + id: .codex, + codexActiveSource: .profileHome(path: "relative-codex-home"), + codexProfileHomePaths: ["relative-codex-home"]), + ]) + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false, + baseEnvironment: ["CODEX_HOME": "/tmp/ambient-codex"]) + + let environment = context.environment( + base: ["CODEX_HOME": "/tmp/ambient-codex"], + provider: .codex, + account: nil, + codexActiveSourceOverride: .profileHome(path: "relative-codex-home")) + + #expect(context.visibleCodexAccounts().visibleAccounts.isEmpty) + #expect(environment["CODEX_HOME"] == "/tmp/ambient-codex") + } + + @Test + func `claude ambient explicit CLI source remains CLI in CLI`() throws { + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: nil) + + #expect(effectiveSourceMode == .cli) + } + + @Test + func `claude session key selection stays in manual cookie mode in CLI settings snapshot`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieSource: .auto, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.usageDataSource == .auto) + #expect(claudeSettings.cookieSource == .manual) + #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token") + } + + @Test + func `claude session key selection carries organization id in CLI settings snapshot`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Team", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil, + organizationID: " org-team "), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.organizationID == "org-team") + } + + @Test + func `claude token account organization id uses organizationId JSON key`() throws { + let json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "label": "Team", + "token": "sk-ant-session-token", + "addedAt": 0, + "lastUsed": null, + "organizationId": "org-team" + } + """ + let account = try JSONDecoder().decode(ProviderTokenAccount.self, from: Data(json.utf8)) + let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(account)) as? [String: Any] + + #expect(account.organizationID == "org-team") + #expect(encoded?["organizationId"] as? String == "org-team") + #expect(encoded?["organizationID"] == nil) + } + + @Test + func `claude config manual cookie uses shared route in CLI settings snapshot`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieHeader: "Cookie: sessionKey=sk-ant-session-token; foo=bar"), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: nil)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.usageDataSource == .auto) + #expect(claudeSettings.cookieSource == .manual) + #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token; foo=bar") + } + + @Test + func `claude config manual cookie does not promote auto source mode in CLI`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieHeader: "Cookie: sessionKey=sk-ant-session-token"), + ]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .auto, + provider: .claude, + account: nil) + + #expect(effectiveSourceMode == .auto) + } + + @Test + func `apply account label in app preserves snapshot fields`() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-apply-app") let store = Self.makeUsageStore(settings: settings) let snapshot = Self.makeSnapshotWithAllFields(provider: .zai) @@ -95,7 +740,7 @@ struct TokenAccountEnvironmentPrecedenceTests { } @Test - func applyAccountLabelInCLIPreservesSnapshotFields() throws { + func `apply account label in CLI preserves snapshot fields`() throws { let context = try TokenAccountCLIContext( selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), config: CodexBarConfig(providers: []), @@ -115,7 +760,139 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(labeled.identity?.accountEmail == "CLI Account") } - private static func makeSettingsStore(suite: String) -> SettingsStore { + @Test + func `codex known owners match between app and CLI for live system only`() throws { + let ambientHome = Self.makeTempCodexHome( + email: "live@example.com", + plan: "pro", + accountId: "acct-live") + defer { try? FileManager.default.removeItem(at: ambientHome) } + + let appSettings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-codex-live-only") + appSettings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: ambientHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-live")) + defer { appSettings._test_liveSystemCodexAccount = nil } + let appStore = Self.makeUsageStore(settings: appSettings) + + try Self.withCLIKnownOwnerFixtures( + ambientHome: ambientHome, + managedAccounts: []) + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) + let cliOwners = try #require(rawCLIOwners) + let appOwners = appStore.codexDashboardKnownOwnerCandidates() + + #expect(Self.knownOwnerMultiset(appOwners) == Self.knownOwnerMultiset(cliOwners)) + } + } + + @Test + func `codex known owners match between app and CLI when managed and live identities are the same`() throws { + let ambientHome = Self.makeTempCodexHome( + email: "shared@example.com", + plan: "pro", + accountId: "acct-shared") + let managedHome = Self.makeTempCodexHome( + email: "shared@example.com", + plan: "pro", + accountId: "acct-shared") + defer { + try? FileManager.default.removeItem(at: ambientHome) + try? FileManager.default.removeItem(at: managedHome) + } + + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "shared@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let appSettings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-codex-same-identity") + appSettings._test_activeManagedCodexAccount = managedAccount + appSettings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "shared@example.com", + codexHomePath: ambientHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-shared")) + defer { + appSettings._test_activeManagedCodexAccount = nil + appSettings._test_liveSystemCodexAccount = nil + } + let appStore = Self.makeUsageStore(settings: appSettings) + + try Self.withCLIKnownOwnerFixtures( + ambientHome: ambientHome, + managedAccounts: [managedAccount]) + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) + let cliOwners = try #require(rawCLIOwners) + let appOwners = appStore.codexDashboardKnownOwnerCandidates() + + #expect(Self.knownOwnerMultiset(appOwners) == Self.knownOwnerMultiset(cliOwners)) + } + } + + @Test + func `codex known owners match between app and CLI when managed and live identities differ`() throws { + let ambientHome = Self.makeTempCodexHome( + email: "live@example.com", + plan: "pro", + accountId: "acct-live") + let managedHome = Self.makeTempCodexHome( + email: "managed@example.com", + plan: "pro", + accountId: "acct-managed") + defer { + try? FileManager.default.removeItem(at: ambientHome) + try? FileManager.default.removeItem(at: managedHome) + } + + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let appSettings = Self + .makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-codex-different-identities") + appSettings._test_activeManagedCodexAccount = managedAccount + appSettings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: ambientHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-live")) + defer { + appSettings._test_activeManagedCodexAccount = nil + appSettings._test_liveSystemCodexAccount = nil + } + let appStore = Self.makeUsageStore(settings: appSettings) + + try Self.withCLIKnownOwnerFixtures( + ambientHome: ambientHome, + managedAccounts: [managedAccount]) + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) + let cliOwners = try #require(rawCLIOwners) + let appOwners = appStore.codexDashboardKnownOwnerCandidates() + + #expect(Self.knownOwnerMultiset(appOwners) == Self.knownOwnerMultiset(cliOwners)) + } + } +} + +extension TokenAccountEnvironmentPrecedenceTests { + fileprivate static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) @@ -140,14 +917,112 @@ struct TokenAccountEnvironmentPrecedenceTests { tokenAccountStore: InMemoryTokenAccountStore()) } - private static func makeUsageStore(settings: SettingsStore) -> UsageStore { + fileprivate static func makeUsageStore(settings: SettingsStore) -> UsageStore { UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) } - private static func makeSnapshotWithAllFields(provider: UsageProvider) -> UsageSnapshot { + fileprivate static func codexCLIKnownOwners( + ambientHome: URL, + managedStoreURL: URL) throws -> [CodexDashboardKnownOwnerCandidate]? + { + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: CodexBarConfig(providers: [ProviderConfig(id: .codex)]), + verbose: false, + baseEnvironment: ["CODEX_HOME": ambientHome.path], + managedCodexAccountStoreURL: managedStoreURL) + return context.settingsSnapshot(for: .codex, account: nil)?.codex?.dashboardAuthorityKnownOwners + } + + fileprivate static func codexHomePath(from fetcher: UsageFetcher) -> String? { + guard let environment = Mirror(reflecting: fetcher).children.first(where: { $0.label == "environment" })? + .value as? [String: String] + else { + return nil + } + return environment["CODEX_HOME"] + } + + fileprivate static func writeCodexAuthFile(homeURL: URL, email: String, accountID: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let auth: [String: Any] = [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": self.fakeJWT(email: email, plan: "pro", accountId: accountID), + "account_id": accountID, + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + fileprivate static func knownOwnerMultiset( + _ owners: [CodexDashboardKnownOwnerCandidate]) -> [CodexDashboardKnownOwnerCandidate: Int] + { + owners.reduce(into: [:]) { counts, owner in + counts[owner, default: 0] += 1 + } + } + + fileprivate static func makeTempCodexHome(email: String, plan: String, accountId: String) -> URL { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-known-owner-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + let credentials = CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: self.fakeJWT(email: email, plan: plan, accountId: accountId), + accountId: accountId, + lastRefresh: Date()) + try? CodexOAuthCredentialsStore.save(credentials, env: ["CODEX_HOME": home.path]) + return home + } + + fileprivate static func fakeJWT(email: String, plan: String, accountId: String) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": [ + "chatgpt_plan_type": plan, + "chatgpt_account_id": accountId, + ], + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } + + fileprivate static func withCLIKnownOwnerFixtures( + ambientHome: URL, + managedAccounts: [ManagedCodexAccount], + operation: (URL) throws -> T) throws -> T + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-known-owner-store-\(UUID().uuidString)", isDirectory: true) + let managedStoreURL = root.appendingPathComponent("managed-codex-accounts.json", isDirectory: false) + let fileManager = FileManager.default + defer { try? fileManager.removeItem(at: root) } + + let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL) + try managedStore.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: managedAccounts)) + + return try operation(managedStoreURL) + } + + fileprivate static func makeSnapshotWithAllFields(provider: UsageProvider) -> UsageSnapshot { let now = Date(timeIntervalSince1970: 1_700_000_000) let reset = Date(timeIntervalSince1970: 1_700_003_600) let tokenLimit = ZaiLimitEntry( @@ -199,11 +1074,13 @@ struct TokenAccountEnvironmentPrecedenceTests { rateLimit: nil, updatedAt: now), cursorRequests: CursorRequestUsage(used: 7, limit: 70), + subscriptionExpiresAt: reset.addingTimeInterval(86400), + subscriptionRenewsAt: reset.addingTimeInterval(43200), updatedAt: now, identity: identity) } - private static func expectSnapshotFieldsPreserved(before: UsageSnapshot, after: UsageSnapshot) { + fileprivate static func expectSnapshotFieldsPreserved(before: UsageSnapshot, after: UsageSnapshot) { #expect(after.primary?.usedPercent == before.primary?.usedPercent) #expect(after.secondary?.usedPercent == before.secondary?.usedPercent) #expect(after.tertiary?.usedPercent == before.tertiary?.usedPercent) @@ -218,6 +1095,8 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(after.openRouterUsage?.rateLimit?.requests == before.openRouterUsage?.rateLimit?.requests) #expect(after.cursorRequests?.used == before.cursorRequests?.used) #expect(after.cursorRequests?.limit == before.cursorRequests?.limit) + #expect(after.subscriptionExpiresAt == before.subscriptionExpiresAt) + #expect(after.subscriptionRenewsAt == before.subscriptionRenewsAt) #expect(after.updatedAt == before.updatedAt) } } diff --git a/Tests/CodexBarTests/TokenAccountStoreTests.swift b/Tests/CodexBarTests/TokenAccountStoreTests.swift index e1cbbce240..9a252219fa 100644 --- a/Tests/CodexBarTests/TokenAccountStoreTests.swift +++ b/Tests/CodexBarTests/TokenAccountStoreTests.swift @@ -3,8 +3,8 @@ import Foundation import Testing @testable import CodexBar -@Test("ProviderTokenAccountData encoding") -func providerTokenAccountDataEncoding() throws { +@Test +func `ProviderTokenAccountData encoding`() throws { let now = Date().timeIntervalSince1970 let account = ProviderTokenAccount( id: UUID(), @@ -26,8 +26,8 @@ func providerTokenAccountDataEncoding() throws { #expect(decoded.activeIndex == 0) } -@Test("FileTokenAccountStore round trip") -func fileTokenAccountStoreRoundTrip() throws { +@Test +func `FileTokenAccountStore round trip`() throws { let tempDir = FileManager.default.temporaryDirectory let fileURL = tempDir.appendingPathComponent("codexbar-token-accounts-test.json") defer { try? FileManager.default.removeItem(at: fileURL) } diff --git a/Tests/CodexBarTests/UpdateChannelTests.swift b/Tests/CodexBarTests/UpdateChannelTests.swift index c5ee1f9c9c..c93ed23985 100644 --- a/Tests/CodexBarTests/UpdateChannelTests.swift +++ b/Tests/CodexBarTests/UpdateChannelTests.swift @@ -1,22 +1,21 @@ import Testing @testable import CodexBar -@Suite struct UpdateChannelTests { @Test - func defaultChannelFromStableVersion() { + func `default channel from stable version`() { #expect(UpdateChannel.defaultChannel(for: "1.2.3") == .stable) } @Test - func defaultChannelFromPrereleaseVersion() { + func `default channel from prerelease version`() { #expect(UpdateChannel.defaultChannel(for: "1.2.3-beta.1") == .beta) #expect(UpdateChannel.defaultChannel(for: "1.2.3-rc.1") == .beta) #expect(UpdateChannel.defaultChannel(for: "1.2.3-alpha") == .beta) } @Test - func allowedSparkleChannels() { + func `allowed sparkle channels`() { #expect(UpdateChannel.stable.allowedSparkleChannels == [""]) #expect(UpdateChannel.beta.allowedSparkleChannels == ["", UpdateChannel.sparkleBetaChannel]) } diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 072b299df6..e973891236 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -3,32 +3,152 @@ import Foundation import Testing @testable import CodexBar -@Suite +@Suite(.serialized) struct UsageFormatterTests { + private static let usageFormatterLocalizationKeys: [String] = [ + "%@ left", + "Resets %@", + "Resets in %@", + "Resets now", + "reset_tomorrow_format", + "Updated %@", + "Updated %@h ago", + "Updated %@m ago", + "Updated just now", + "usage_percent_suffix_left", + "usage_percent_suffix_used", + "byte_unit_byte", + "byte_unit_bytes", + "byte_unit_kilobyte", + "byte_unit_kilobytes", + "byte_unit_megabyte", + "byte_unit_megabytes", + "byte_unit_gigabyte", + "byte_unit_gigabytes", + ] + @Test - func formatsUsageLine() { + func `formats usage line`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() let line = UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: false) #expect(line == "25% left") } @Test - func formatsUsageLineShowUsed() { + func `formats usage line show used`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() let line = UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: true) #expect(line == "75% used") } @Test - func relativeUpdatedRecent() { + func `usage line respects injected localization provider`() { + UsageFormatter.setLocalizationProvider { key in + switch key { + case "usage_percent_suffix_left": "剩余" + case "usage_percent_suffix_used": "已使用" + default: key + } + } + defer { UsageFormatter.clearLocalizationProvider() } + + #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "22% 剩余") + #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "78% 已使用") + } + + @Test + func `default locale fallback matches stable en US POSIX behavior`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + + let defaultOutput = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.setLocaleProvider { Locale(identifier: "en_US_POSIX") } + let injectedStableOutput = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.clearLocaleProvider() + + #expect(defaultOutput == injectedStableOutput) + } + + @Test + func `injected zh Hans locale applies app language formatting`() { + UsageFormatter.setLocalizationProvider { key in + switch key { + case "Updated %@": + "更新于 %@" + default: + key + } + } + UsageFormatter.setLocaleProvider { Locale(identifier: "zh-Hans") } + defer { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + } + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + let output = UsageFormatter.updatedString(from: old, now: now) + + #expect(output.hasPrefix("更新于 ")) + } + + @Test + func `clearing locale provider returns to stable default behavior`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + let baseline = UsageFormatter.updatedString(from: old, now: now) + + UsageFormatter.setLocaleProvider { Locale(identifier: "fr_FR") } + _ = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.clearLocaleProvider() + + let restored = UsageFormatter.updatedString(from: old, now: now) + #expect(restored == baseline) + } + + @Test + func `tomorrow reset description uses localized format`() throws { + UsageFormatter.setLocalizationProvider { key in + key == "reset_tomorrow_format" ? "明日 %@" : key + } + UsageFormatter.setLocaleProvider { Locale(identifier: "ja_JP") } + defer { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + } + + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date(timeIntervalSince1970: 1_750_000_000)) + let now = try #require(calendar.date(byAdding: .hour, value: 12, to: today)) + let tomorrow = try #require(calendar.date(byAdding: .day, value: 1, to: today)) + let reset = try #require(calendar.date(byAdding: .minute, value: 10 * 60 + 50, to: tomorrow)) + + let output = UsageFormatter.resetDescription(from: reset, now: now) + #expect(output.hasPrefix("明日 ")) + #expect(!output.contains("tomorrow")) + #expect(!output.contains("%@")) + } + + @Test + func `relative updated recent`() { let now = Date() let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) let text = UsageFormatter.updatedString(from: fiveHoursAgo, now: now) - #expect(text.contains("Updated")) - // Check for relative time format (varies by locale: "ago" in English, "전" in Korean, etc.) - #expect(text.contains("5") || text.lowercased().contains("hour") || text.contains("시간")) + #expect(text.hasPrefix("Updated ") || text.hasPrefix("更新")) + #expect(text.contains("5")) + #expect(text.lowercased().contains("ago") || text.contains("前")) } @Test - func absoluteUpdatedOld() { + func `absolute updated old`() { let now = Date() let dayAgo = now.addingTimeInterval(-26 * 3600) let text = UsageFormatter.updatedString(from: dayAgo, now: now) @@ -37,42 +157,42 @@ struct UsageFormatterTests { } @Test - func resetCountdown_minutes() { + func `reset countdown minutes`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(10 * 60 + 1) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 11m") } @Test - func resetCountdown_hoursAndMinutes() { + func `reset countdown hours and minutes`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(3 * 3600 + 31 * 60) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 3h 31m") } @Test - func resetCountdown_daysAndHours() { + func `reset countdown days and hours`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval((26 * 3600) + 10) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 2h") } @Test - func resetCountdown_exactHour() { + func `reset countdown exact hour`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(60 * 60) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1h") } @Test - func resetCountdown_pastDate() { + func `reset countdown past date`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(-10) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "now") } @Test - func resetLineUsesCountdownWhenResetsAtIsAvailable() { + func `reset line uses countdown when resets at is available`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(10 * 60 + 1) let window = RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: reset, resetDescription: "Resets soon") @@ -81,7 +201,7 @@ struct UsageFormatterTests { } @Test - func resetLineFallsBackToProvidedDescription() { + func `reset line falls back to provided description`() { let window = RateWindow( usedPercent: 0, windowMinutes: nil, @@ -94,22 +214,46 @@ struct UsageFormatterTests { } @Test - func modelDisplayNameStripsTrailingDates() { + func `model display name strips trailing dates`() { #expect(UsageFormatter.modelDisplayName("claude-opus-4-5-20251101") == "claude-opus-4-5") #expect(UsageFormatter.modelDisplayName("gpt-4o-2024-08-06") == "gpt-4o") #expect(UsageFormatter.modelDisplayName("Claude Opus 4.5 2025 1101") == "Claude Opus 4.5") #expect(UsageFormatter.modelDisplayName("claude-sonnet-4-5") == "claude-sonnet-4-5") + #expect(UsageFormatter.modelDisplayName("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") } @Test - func cleanPlanMapsOAuthToOllama() { + func `model cost detail uses research preview label`() { + #expect( + UsageFormatter.modelCostDetail("gpt-5.3-codex-spark", costUSD: 0, totalTokens: nil) == "Research Preview") + #expect(UsageFormatter.modelCostDetail("gpt-5.2-codex", costUSD: 0.42, totalTokens: nil) == "$0.42") + } + + @Test + func `model cost detail includes token counts when present`() { + #expect(UsageFormatter.modelCostDetail("gpt-5.2-codex", costUSD: 0.42, totalTokens: 1200) == "$0.42 · 1.2K") + #expect( + UsageFormatter.modelCostDetail("gpt-5.3-codex-spark", costUSD: 0, totalTokens: 1500) + == "Research Preview · 1.5K") + #expect(UsageFormatter.modelCostDetail("custom-model", costUSD: nil, totalTokens: 987) == "987") + } + + @Test + func `token count string formats small values without grouping`() { + #expect(UsageFormatter.tokenCountString(0) == "0") + #expect(UsageFormatter.tokenCountString(987) == "987") + #expect(UsageFormatter.tokenCountString(-42) == "-42") + } + + @Test + func `clean plan maps O auth to ollama`() { #expect(UsageFormatter.cleanPlanName("oauth") == "Ollama") } // MARK: - Currency Formatting @Test - func currencyStringFormatsUSDCorrectly() { + func `currency string formats USD correctly`() { // Should produce "$54.72" without space after symbol let result = UsageFormatter.currencyString(54.72, currencyCode: "USD") #expect(result == "$54.72") @@ -117,7 +261,7 @@ struct UsageFormatterTests { } @Test - func currencyStringHandlesLargeValues() { + func `currency string handles large values`() { let result = UsageFormatter.currencyString(1234.56, currencyCode: "USD") // For USD, we use direct string formatting with thousand separators #expect(result == "$1,234.56") @@ -125,26 +269,26 @@ struct UsageFormatterTests { } @Test - func currencyStringHandlesVeryLargeValues() { + func `currency string handles very large values`() { let result = UsageFormatter.currencyString(1_234_567.89, currencyCode: "USD") #expect(result == "$1,234,567.89") } @Test - func currencyStringHandlesNegativeValues() { + func `currency string handles negative values`() { // Negative sign should come before the dollar sign: -$54.72 (not $-54.72) let result = UsageFormatter.currencyString(-54.72, currencyCode: "USD") #expect(result == "-$54.72") } @Test - func currencyStringHandlesNegativeLargeValues() { + func `currency string handles negative large values`() { let result = UsageFormatter.currencyString(-1234.56, currencyCode: "USD") #expect(result == "-$1,234.56") } @Test - func usdStringMatchesCurrencyString() { + func `usd string matches currency string`() { // usdString should produce identical output to currencyString for USD #expect(UsageFormatter.usdString(54.72) == UsageFormatter.currencyString(54.72, currencyCode: "USD")) #expect(UsageFormatter.usdString(-1234.56) == UsageFormatter.currencyString(-1234.56, currencyCode: "USD")) @@ -152,13 +296,13 @@ struct UsageFormatterTests { } @Test - func currencyStringHandlesZero() { + func `currency string handles zero`() { let result = UsageFormatter.currencyString(0, currencyCode: "USD") #expect(result == "$0.00") } @Test - func currencyStringHandlesNonUSDCurrencies() { + func `currency string handles non USD currencies`() { // FormatStyle handles all currencies with proper symbols let eur = UsageFormatter.currencyString(54.72, currencyCode: "EUR") #expect(eur == "€54.72") @@ -172,7 +316,7 @@ struct UsageFormatterTests { } @Test - func currencyStringHandlesSmallValues() { + func `currency string handles small values`() { // Values smaller than 0.01 should round to $0.00 let tiny = UsageFormatter.currencyString(0.001, currencyCode: "USD") #expect(tiny == "$0.00") @@ -187,7 +331,7 @@ struct UsageFormatterTests { } @Test - func currencyStringHandlesBoundaryValues() { + func `currency string handles boundary values`() { // Just under 1000 (no comma) let under1k = UsageFormatter.currencyString(999.99, currencyCode: "USD") #expect(under1k == "$999.99") @@ -202,8 +346,77 @@ struct UsageFormatterTests { } @Test - func creditsStringFormatsCorrectly() { + func `credits string formats correctly`() { let result = UsageFormatter.creditsString(from: 42.5) #expect(result == "42.5 left") } + + @Test + func `byte count string formats binary units`() { + #expect(UsageFormatter.byteCountString(0) == "0 B") + #expect(UsageFormatter.byteCountString(512) == "512 B") + #expect(UsageFormatter.byteCountString(1536) == "1.5 KB") + #expect(UsageFormatter.byteCountString(10 * 1024) == "10 KB") + #expect(UsageFormatter.byteCountString(5 * 1024 * 1024) == "5 MB") + #expect(UsageFormatter.byteCountString(Int64(1536 * 1024 * 1024)) == "1.5 GB") + #expect(UsageFormatter.byteCountString(.min) == "-8589934592 GB") + } + + @Test + func `long byte count string localizes units and handles boundaries`() { + UsageFormatter.clearLocalizationProvider() + #expect(UsageFormatter.byteCountStringLong(1024 * 1024) == "1 megabyte") + + UsageFormatter.setLocalizationProvider { "[\($0)]" } + defer { UsageFormatter.clearLocalizationProvider() } + + #expect(UsageFormatter.byteCountStringLong(1) == "1 [byte_unit_byte]") + #expect(UsageFormatter.byteCountStringLong(2) == "2 [byte_unit_bytes]") + #expect(UsageFormatter.byteCountStringLong(1536) == "1.5 [byte_unit_kilobytes]") + #expect(UsageFormatter.byteCountStringLong(1024 * 1024) == "1 [byte_unit_megabyte]") + #expect(UsageFormatter.byteCountStringLong(1024 * 1024 + 1) == "1.0 [byte_unit_megabyte]") + #expect(UsageFormatter.byteCountStringLong(.min) == "-8589934592 [byte_unit_gigabytes]") + } + + @Test + func `usage formatter localization keys exist in en and zh Hans with matching placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let enURL = root.appendingPathComponent("Sources/CodexBar/Resources/en.lproj/Localizable.strings") + let zhURL = root.appendingPathComponent("Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings") + + let en = try Self.readStringsTable(at: enURL) + let zh = try Self.readStringsTable(at: zhURL) + + for key in Self.usageFormatterLocalizationKeys { + let enValue = try #require(en[key], "Missing en key: \(key)") + let zhValue = try #require(zh[key], "Missing zh-Hans key: \(key)") + #expect( + Self.placeholderTokens(in: enValue) == Self.placeholderTokens(in: zhValue), + "Placeholder mismatch for key '\(key)': en='\(enValue)' zh='\(zhValue)'") + } + } + + private static func readStringsTable(at url: URL) throws -> [String: String] { + guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { + throw NSError( + domain: "UsageFormatterTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse strings file at \(url.path)"]) + } + return dict + } + + private static func placeholderTokens(in value: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: "%(?:\\d+\\$)?[@dDuUxXfFeEgGcCsSpaA]") else { + return [] + } + let nsRange = NSRange(value.startIndex.. 0) + #expect(abs(cardSize.height - headerSize.height) < Self.heightTolerance) + } + + @Test + func `full provider card matches overview height`() { + let model = Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: "24% in reserve", + detailRightText: "Lasts until reset", + pacePercent: nil, + paceOnTop: true), + ]) + let width: CGFloat = 296 + + let fullCardSize = NSHostingController(rootView: UsageMenuCardView(model: model, width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + let overviewStyleSize = NSHostingController(rootView: UsageMenuCardHeaderAndUsageSectionView( + model: model, + layoutModel: model, + bottomPadding: UsageMenuCardLayout.sectionBottomPadding, + width: width)) + .sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + + #expect(UsageMenuCardLayout.postHeaderDividerContentSpacing == 16) + #expect(UsageMenuCardLayout.headerOnlyVerticalPadding == 6) + #expect(UsageMenuCardLayout.sectionTopPadding == 6) + #expect(UsageMenuCardLayout.sectionBottomPadding == 6) + + #expect(abs(fullCardSize.height - overviewStyleSize.height) < Self.heightTolerance) + } + + @Test + func `detail card keeps compact divider gap without usage section`() { + let metricsModel = Self.model(metrics: [ + UsageMenuCardView.Model.Metric( + id: "session", + title: "Session", + percent: 37, + percentStyle: .left, + resetText: "Resets in 41m", + detailText: nil, + detailLeftText: "24% in reserve", + detailRightText: "Lasts until reset", + pacePercent: nil, + paceOnTop: true), + ]) + + #expect(UsageMenuCardView.dividerBottomPadding(for: metricsModel) == + UsageMenuCardLayout.postHeaderDividerContentSpacing) + #expect(UsageMenuCardView.dividerBottomPadding(for: Self.model(creditsText: "$12.34 remaining")) == + UsageMenuCardLayout.sectionBottomPadding) + #expect(UsageMenuCardView.dividerBottomPadding(for: Self.model(usageNotes: ["Waiting for data"])) == + UsageMenuCardLayout.sectionBottomPadding) + #expect(UsageMenuCardView.dividerBottomPadding(for: Self.model(placeholder: "No usage yet")) == + UsageMenuCardLayout.sectionBottomPadding) + } + + private static func model( + metrics: [UsageMenuCardView.Model.Metric] = [], + usageNotes: [String] = [], + creditsText: String? = nil, + placeholder: String? = nil) -> UsageMenuCardView.Model + { + UsageMenuCardView.Model( + provider: .codex, + providerName: "Codex", + email: "steipete@gmail.com", + subtitleText: "Not fetched yet", + subtitleStyle: .info, + planText: "Pro 20x", + metrics: metrics, + usageNotes: usageNotes, + openAIAPIUsage: nil, + inlineUsageDashboard: nil, + creditsText: creditsText, + creditsRemaining: nil, + creditsHintText: nil, + creditsHintCopyText: nil, + providerCost: nil, + tokenUsage: nil, + placeholder: placeholder, + progressColor: .blue) + } +} diff --git a/Tests/CodexBarTests/UsagePaceTests.swift b/Tests/CodexBarTests/UsagePaceTests.swift index b00ed51923..c162bf868c 100644 --- a/Tests/CodexBarTests/UsagePaceTests.swift +++ b/Tests/CodexBarTests/UsagePaceTests.swift @@ -2,10 +2,9 @@ import CodexBarCore import Foundation import Testing -@Suite struct UsagePaceTests { @Test - func weeklyPace_computesDeltaAndEta() { + func `weekly pace computes delta and eta`() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 50, @@ -27,7 +26,7 @@ struct UsagePaceTests { } @Test - func weeklyPace_marksLastsToResetWhenUsageIsLow() { + func `weekly pace marks lasts to reset when usage is low`() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 5, @@ -46,7 +45,7 @@ struct UsagePaceTests { } @Test - func weeklyPace_hidesWhenResetMissingOrOutsideWindow() { + func `weekly pace hides when reset missing or outside window`() { let now = Date(timeIntervalSince1970: 0) let missing = RateWindow( usedPercent: 10, @@ -64,7 +63,7 @@ struct UsagePaceTests { } @Test - func weeklyPace_hidesWhenUsageExistsButNoElapsed() { + func `weekly pace hides when usage exists but no elapsed`() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 12, @@ -76,4 +75,488 @@ struct UsagePaceTests { #expect(pace == nil) } + + // MARK: - Workday-aware pace + + @Test + func `workday aware pace shows on track for five day user on friday`() throws { + // Window: Sun Jun 7 00:00 → Sun Jun 14 00:00 (7 days). + // "now" is Friday Jun 12 18:00 → elapsed = 5.75 days. + // 7-day linear: expected ≈ 82.1%, actual = 100% → ~18% deficit. + // 5-day workday: Mon-Thu plus 18 hours Friday → expected = 95%. + let calendar = Self.utcCalendar + + // Reset on Sunday Jun 14 00:00 + var resetComponents = DateComponents() + resetComponents.calendar = calendar + resetComponents.timeZone = calendar.timeZone + resetComponents.year = 2026 + resetComponents.month = 6 + resetComponents.day = 14 // Sunday + resetComponents.hour = 0 + resetComponents.minute = 0 + let resetsAt = try #require(calendar.date(from: resetComponents)) + + // "now" is Friday Jun 12 18:00 (30 hours before reset) + let now = resetsAt.addingTimeInterval(-30 * 3600) + + let window = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace7 = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + let pace5 = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + // 7-day linear: expected ≈ 82%, actual = 100% → ~18% deficit + #expect(pace7.deltaPercent > 15) + + // 5-day workday: expected = 95%, so 100% actual remains within the on-pace threshold. + #expect(abs(pace5.expectedUsedPercent - 95) < 0.01) + #expect(abs(pace5.deltaPercent) <= 5) + } + + @Test + func `workday aware pace shows on track midweek`() throws { + // Window: Sun Jun 7 00:00 → Sun Jun 14 00:00. + // "now" is Thu Jun 11 00:00 → 3 full workdays (Mon-Wed) elapsed of 5. + // 5-day model: expected ≈ 60%. + let calendar = Self.utcCalendar + + // Reset on Sunday Jun 14 00:00 + var resetComponents = DateComponents() + resetComponents.calendar = calendar + resetComponents.timeZone = calendar.timeZone + resetComponents.year = 2026 + resetComponents.month = 6 + resetComponents.day = 14 // Sunday + resetComponents.hour = 0 + resetComponents.minute = 0 + let resetsAt = try #require(calendar.date(from: resetComponents)) + + // Thu Jun 11 00:00 (3 days before reset). + let now = resetsAt.addingTimeInterval(-72 * 3600) + + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace5 = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + // 3 full workdays elapsed out of 5 → expected ≈ 60% + #expect(abs(pace5.expectedUsedPercent - 60) < 0.01) + #expect(abs(pace5.deltaPercent) < 0.01) + } + + @Test + func `workday aware exhausted quota does not last through weekend`() throws { + let calendar = Self.utcCalendar + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 13, + hour: 12))) + let window = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == 0) + } + + @Test + func `workday aware eta excludes non workday elapsed time`() throws { + let calendar = Self.utcCalendar + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 8, + hour: 12))) + let window = RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(pace.willLastToReset == false) + #expect(abs((pace.etaSeconds ?? 0) - (48 * 3600)) < 1) + } + + @Test + func `workday aware eta maps work time across a weekend`() throws { + let calendar = Self.utcCalendar + let resetsAt = try Self.date( + year: 2026, + month: 6, + day: 17, + hour: 0, + calendar: calendar) + let now = try Self.date( + year: 2026, + month: 6, + day: 12, + hour: 12, + calendar: calendar) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + // 40 work hours remain at the observed rate: 12 hours Friday, all Monday, then 4 hours Tuesday. + #expect(pace.willLastToReset == false) + #expect(abs((pace.etaSeconds ?? 0) - (88 * 3600)) < 1) + } + + @Test + func `workday aware pace stays flat on non workdays`() throws { + let calendar = Self.utcCalendar + let resetsAt = try Self.date( + year: 2026, + month: 6, + day: 17, + hour: 0, + calendar: calendar) + let saturday = try Self.date( + year: 2026, + month: 6, + day: 13, + hour: 12, + calendar: calendar) + let sunday = try Self.date( + year: 2026, + month: 6, + day: 14, + hour: 12, + calendar: calendar) + let window = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let saturdayPace = try #require(UsagePace.weekly( + window: window, + now: saturday, + workDays: 5, + calendar: calendar)) + let sundayPace = try #require(UsagePace.weekly( + window: window, + now: sunday, + workDays: 5, + calendar: calendar)) + + #expect(abs(saturdayPace.expectedUsedPercent - 60) < 0.01) + #expect(sundayPace.expectedUsedPercent == saturdayPace.expectedUsedPercent) + } + + @Test + func `zero usage becomes safe only after the first configured workday begins`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let resetsAt = try Self.date( + year: 2026, + month: 6, + day: 14, + hour: 0, + calendar: calendar) + let firstWorkday = try Self.date( + year: 2026, + month: 6, + day: 8, + hour: 0, + calendar: calendar) + let window = RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let before = try #require(UsagePace.weekly( + window: window, + now: firstWorkday.addingTimeInterval(-1), + workDays: 5, + calendar: calendar)) + let boundary = try #require(UsagePace.weekly( + window: window, + now: firstWorkday, + workDays: 5, + calendar: calendar)) + let after = try #require(UsagePace.weekly( + window: window, + now: firstWorkday.addingTimeInterval(3600), + workDays: 5, + calendar: calendar)) + + #expect(before.expectedUsedPercent == 0) + #expect(before.willLastToReset == false) + #expect(boundary.expectedUsedPercent == 0) + #expect(boundary.willLastToReset == false) + #expect(after.expectedUsedPercent > 0) + #expect(after.willLastToReset == true) + } + + @Test + func `workday aware pace does not declare zero usage safe before first workday`() throws { + let calendar = Self.utcCalendar + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 7, + hour: 12))) + let window = RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(pace.expectedUsedPercent == 0) + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == nil) + } + + @Test + func `workday aware exhausted quota stays exhausted before first workday`() throws { + let calendar = Self.utcCalendar + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 7, + hour: 12))) + let window = RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + #expect(pace.willLastToReset == false) + #expect(pace.etaSeconds == 0) + } + + @Test + func `workday aware pace splits a non midnight reset at local day boundaries`() throws { + let calendar = Self.utcCalendar + + let resetsAt = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 14, + hour: 20))) + let now = try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: 2026, + month: 6, + day: 8, + hour: 12))) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: resetsAt, + resetDescription: nil) + + let pace = try #require(UsagePace.weekly( + window: window, + now: now, + workDays: 5, + calendar: calendar)) + + // The weekly window starts Sunday at 20:00. Monday 00:00-12:00 is 12 of + // the week's 120 work hours, so it must contribute 10% despite the reset offset. + #expect(abs(pace.expectedUsedPercent - 10) < 0.01) + #expect(abs(pace.deltaPercent) < 0.01) + } + + @Test + func `workday aware pace falls back to linear when workDays is nil or 7`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let paceNil = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + let pace7 = try #require(UsagePace.weekly(window: window, now: now, workDays: 7)) + let paceDefault = try #require(UsagePace.weekly(window: window, now: now)) + + // All should produce identical expected values (linear) + #expect(abs(paceNil.expectedUsedPercent - paceDefault.expectedUsedPercent) < 0.01) + #expect(abs(pace7.expectedUsedPercent - paceDefault.expectedUsedPercent) < 0.01) + } + + @Test + func `workdays off linear weekly pace keeps deficit sign`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 88, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600 + 19 * 3600), + resetDescription: nil) + + let pace = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + + #expect(abs(pace.expectedUsedPercent - (101.0 / 168.0 * 100.0)) < 0.01) + #expect(pace.deltaPercent > 25) + #expect(pace.stage == .farAhead) + #expect(pace.willLastToReset == false) + } + + @Test + func `workday aware pace ignores non weekly windows`() throws { + let now = Date(timeIntervalSince1970: 0) + // 300-minute session window — workDays should have no effect + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let paceNoWork = try #require( + UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300, workDays: nil)) + let paceWork5 = try #require( + UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300, workDays: 5)) + + #expect(abs(paceNoWork.expectedUsedPercent - paceWork5.expectedUsedPercent) < 0.01) + } + + @Test + func `session pace computes delta and eta for five hour window`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300) + + #expect(pace != nil) + guard let pace else { return } + #expect(abs(pace.expectedUsedPercent - 60.0) < 0.01) + #expect(abs(pace.deltaPercent - -10.0) < 0.01) + #expect(pace.stage == .behind) + #expect(pace.willLastToReset == true) + } + + @Test + func `one work day falls back to linear pace`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let paceOne = try #require(UsagePace.weekly(window: window, now: now, workDays: 1)) + let paceNil = try #require(UsagePace.weekly(window: window, now: now)) + + // workDays == 1 should fall back to linear pace, identical to workDays: nil + #expect(abs(paceOne.expectedUsedPercent - paceNil.expectedUsedPercent) < 0.01) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func date( + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int = 0, + calendar: Calendar) throws -> Date + { + try #require(calendar.date(from: DateComponents( + calendar: calendar, + timeZone: calendar.timeZone, + year: year, + month: month, + day: day, + hour: hour, + minute: minute))) + } } diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 6f6fddac3d..8eec85007b 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -3,10 +3,24 @@ import Foundation import Testing @testable import CodexBar -@Suite struct UsagePaceTextTests { + private static let localizedKeys: [String] = [ + "Pace: %@", + "Pace: %@ · %@", + "On pace", + "%d%% in deficit", + "%d%% in reserve", + "Lasts until reset", + "Projected empty now", + "Projected empty in %@", + "Runs out now", + "Runs out in %@", + "≈ %d%% run-out risk", + "%@ · %@", + ] + @Test - func weeklyPaceDetail_providesLeftRightLabels() throws { + func `weekly pace detail provides left right labels`() throws { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 50, @@ -22,7 +36,7 @@ struct UsagePaceTextTests { } @Test - func weeklyPaceDetail_reportsLastsUntilReset() throws { + func `weekly pace detail reports lasts until reset`() throws { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 10, @@ -38,7 +52,7 @@ struct UsagePaceTextTests { } @Test - func weeklyPaceSummary_formatsSingleLineText() throws { + func `weekly pace summary formats single line text`() throws { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 50, @@ -53,7 +67,24 @@ struct UsagePaceTextTests { } @Test - func weeklyPaceDetail_formatsRoundedRiskWhenAvailable() { + func `reported weekly state renders deficit and run out headline`() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 88, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval((2 * 24 + 19) * 3600), + resetDescription: nil) + let pace = try #require(UsagePace.weekly(window: window, now: now, workDays: nil)) + + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + + #expect(detail.leftLabel == "28% in deficit") + #expect(detail.rightLabel == "Runs out in 13h 47m") + #expect(detail.rightLabel?.contains("Lasts until reset") == false) + } + + @Test + func `weekly pace detail formats rounded risk when available`() { let now = Date(timeIntervalSince1970: 0) let pace = UsagePace( stage: .ahead, @@ -68,4 +99,208 @@ struct UsagePaceTextTests { #expect(detail.rightLabel == "Runs out in 2d · ≈ 70% run-out risk") } + + @Test + func `weekly pace detail does not combine lasts until reset with run out risk`() { + let now = Date(timeIntervalSince1970: 0) + let pace = UsagePace( + stage: .slightlyBehind, + deltaPercent: -9, + expectedUsedPercent: 21, + actualUsedPercent: 12, + etaSeconds: nil, + willLastToReset: true, + runOutProbability: 0.45) + + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + + #expect(detail.leftLabel == "9% in reserve") + #expect(detail.rightLabel == "≈ 45% run-out risk") + } + + @Test + func `weekly pace detail keeps lasts until reset only when rounded risk is zero`() { + let now = Date(timeIntervalSince1970: 0) + let pace = UsagePace( + stage: .farBehind, + deltaPercent: -30, + expectedUsedPercent: 40, + actualUsedPercent: 10, + etaSeconds: nil, + willLastToReset: true, + runOutProbability: 0.02) + + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + + #expect(detail.rightLabel == "Lasts until reset · ≈ 0% run-out risk") + } + + @Test + func `weekly pace detail prefers risk over lasts until reset when rounded risk is material`() { + let now = Date(timeIntervalSince1970: 0) + let pace = UsagePace( + stage: .slightlyBehind, + deltaPercent: -9, + expectedUsedPercent: 21, + actualUsedPercent: 12, + etaSeconds: nil, + willLastToReset: true, + runOutProbability: 0.03) + + let detail = UsagePaceText.weeklyDetail(pace: pace, now: now) + + #expect(detail.rightLabel == "≈ 5% run-out risk") + } + + // MARK: - Session pace (5-hour window) + + @Test + func `session pace detail provides left right labels`() { + let now = Date(timeIntervalSince1970: 0) + // 300-minute window, 2h remaining => 3h elapsed out of 5h + // expected = 60%, actual = 80% => 20% ahead (in deficit) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now) + + #expect(detail != nil) + #expect(detail?.leftLabel == "20% in deficit") + #expect(detail?.rightLabel == "Projected empty in 45m") + #expect(detail?.stage == .farAhead) + } + + @Test + func `session pace detail reports lasts until reset`() { + let now = Date(timeIntervalSince1970: 0) + // 300-minute window, 2h remaining => 3h elapsed + // expected = 60%, actual = 10% => far behind (in reserve) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now) + + #expect(detail != nil) + #expect(detail?.leftLabel == "50% in reserve") + #expect(detail?.rightLabel == "Lasts until reset") + } + + @Test + func `session pace summary formats single line text`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let summary = UsagePaceText.sessionSummary(provider: .claude, window: window, now: now) + + #expect(summary == "Pace: 20% in deficit · Projected empty in 45m") + } + + @Test + func `session pace detail supports Ollama five hour window`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .ollama, window: window, now: now) + + #expect(detail?.leftLabel == "20% in deficit") + #expect(detail?.rightLabel == "Projected empty in 45m") + } + + @Test + func `session pace detail hides Ollama window without explicit duration`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .ollama, window: window, now: now) + + #expect(detail == nil) + } + + @Test + func `session pace detail hides for unsupported provider`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .zai, window: window, now: now) + + #expect(detail == nil) + } + + @Test + func `session pace detail hides when reset is missing`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now) + + #expect(detail == nil) + } + + @Test + func `usage pace text localization keys exist in en and zh Hans with matching placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let enURL = root.appendingPathComponent("Sources/CodexBar/Resources/en.lproj/Localizable.strings") + let zhURL = root.appendingPathComponent("Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings") + + let en = try Self.readStringsTable(at: enURL) + let zh = try Self.readStringsTable(at: zhURL) + + for key in Self.localizedKeys { + let enValue = try #require(en[key], "Missing en key: \(key)") + let zhValue = try #require(zh[key], "Missing zh-Hans key: \(key)") + #expect( + Self.placeholderTokens(in: enValue) == Self.placeholderTokens(in: zhValue), + "Placeholder mismatch for key '\(key)': en='\(enValue)' zh='\(zhValue)'") + } + } + + private static func readStringsTable(at url: URL) throws -> [String: String] { + guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { + throw NSError( + domain: "UsagePaceTextTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse strings file at \(url.path)"]) + } + return dict + } + + private static func placeholderTokens(in value: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: "%(?:\\d+\\$)?[@dDuUxXfFeEgGcCsSpaA]") else { + return [] + } + let nsRange = NSRange(value.startIndex.. SettingsStore { + let suite = "UsageStoreCachedTokenHydrationTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.costUsageHistoryDays = historyDays + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + settings.providerDetectionCompleted = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + return settings + } + + private static func writeCodexSessionFile( + homeRoot: URL, + env: CostUsageTestEnvironment, + day: Date, + filename: String, + tokens: Int) throws + { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) + let dir = homeRoot + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let model = "openai/gpt-5.4" + let url = dir.appendingPathComponent(filename, isDirectory: false) + try env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ]).write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 037a7180cb..680aab05af 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -1,13 +1,30 @@ import CodexBarCore import Foundation +import Observation import Testing @testable import CodexBar @MainActor -@Suite struct UsageStoreCoverageTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + @Test - func providerWithHighestUsageAndIconStyle() throws { + func `provider with highest usage and icon style`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-highest") let store = Self.makeUsageStore(settings: settings) let metadata = ProviderRegistry.shared.metadata @@ -50,7 +67,7 @@ struct UsageStoreCoverageTests { } @Test - func sourceLabelAddsOpenAIWeb() { + func `source label adds open AI web`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-source") settings.debugDisableKeychainAccess = false settings.codexUsageDataSource = .oauth @@ -72,7 +89,66 @@ struct UsageStoreCoverageTests { } @Test - func sourceLabelUsesConfiguredKiloSource() { + func `amp balances are rendered in provider cards`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-amp-credits") + let store = Self.makeUsageStore(settings: settings) + let now = Date() + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 51.4, + windowMinutes: 1440, + resetsAt: now.addingTimeInterval(12 * 3600), + resetDescription: nil), + secondary: nil, + ampUsage: AmpUsageDetails( + individualCredits: 25.64, + workspaceBalances: [AmpWorkspaceBalance(name: "billing@example.test", remaining: 10.22)]), + updatedAt: now), + provider: .amp) + let model = ProvidersPane(settings: settings, store: store)._test_menuCardModel(for: .amp) + + #expect(model.creditsText == "Individual credits: $25.64\nWorkspace billing@example.test: $10.22") + #expect(model.creditsRemaining == nil) + + settings.hidePersonalInfo = true + let redactedModel = ProvidersPane(settings: settings, store: store)._test_menuCardModel(for: .amp) + #expect(redactedModel.creditsText == "Individual credits: $25.64\nWorkspace Hidden: $10.22") + } + + @Test + func `account info caches codex auth parsing until config revision changes`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-account-info-cache") + let home = FileManager.default.temporaryDirectory.appendingPathComponent( + "usage-store-account-info-\(UUID().uuidString)", + isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + + try Self.writeCodexAuthFile(homeURL: home, email: "first@example.com", plan: "plus") + let env = ["CODEX_HOME": home.path] + settings._test_codexReconciliationEnvironment = env + defer { settings._test_codexReconciliationEnvironment = nil } + let store = UsageStore( + fetcher: UsageFetcher(environment: env), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: env) + + let first = store.accountInfo(for: .codex) + try Self.writeCodexAuthFile(homeURL: home, email: "second@example.com", plan: "pro") + let cached = store.accountInfo(for: .codex) + settings.configRevision &+= 1 + let refreshed = store.accountInfo(for: .codex) + + #expect(first.email == "first@example.com") + #expect(cached.email == "first@example.com") + #expect(refreshed.email == "second@example.com") + } + + @Test + func `source label uses configured kilo source`() { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-kilo-source") settings.kiloUsageDataSource = .api @@ -81,7 +157,55 @@ struct UsageStoreCoverageTests { } @Test - func providerWithHighestUsagePrefersKimiRateLimitWindow() throws { + func `clearing copilot budget extras syncs reset baseline`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-budget-clear") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSnapshot(usedPercent: 20, extraRateWindows: [Self.makeCopilotBudgetWindow()]) + let resetBaseline = Self.makeCopilotSnapshot(usedPercent: 10, extraRateWindows: nil) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = resetBaseline + + store.clearCopilotBudgetExtras() + + #expect(store.snapshot(for: .copilot)?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.primary?.usedPercent == 20) + } + + @Test + func `clearing copilot budget extras also clears stale reset baseline`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-copilot-budget-reset-clear") + let store = Self.makeUsageStore(settings: settings) + let live = Self.makeCopilotSnapshot(usedPercent: 20, extraRateWindows: nil) + let resetBaseline = Self.makeCopilotSnapshot( + usedPercent: 10, + extraRateWindows: [Self.makeCopilotBudgetWindow()]) + store._setSnapshotForTesting(live, provider: .copilot) + store.lastKnownResetSnapshots[.copilot] = resetBaseline + + store.clearCopilotBudgetExtras() + + #expect(store.snapshot(for: .copilot)?.extraRateWindows == nil) + #expect(store.snapshot(for: .copilot)?.primary?.usedPercent == 20) + #expect(store.lastKnownResetSnapshots[.copilot]?.extraRateWindows == nil) + #expect(store.lastKnownResetSnapshots[.copilot]?.primary?.usedPercent == 10) + } + + @Test + func `permission prompt errors are detected for notifications`() { + let errors: [LocalizedTestError] = [ + LocalizedTestError("Waiting for folder trust prompt"), + LocalizedTestError("Permission prompt is waiting in the CLI"), + ] + + for error in errors { + #expect(UsageStore.isPermissionPromptWaiting(error)) + } + #expect(!UsageStore.isPermissionPromptWaiting(LocalizedTestError("network timeout"))) + } + + @Test + func `provider with highest usage prefers kimi rate limit window`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-kimi-highest") let store = Self.makeUsageStore(settings: settings) let metadata = ProviderRegistry.shared.metadata @@ -109,7 +233,7 @@ struct UsageStoreCoverageTests { } @Test - func providerAvailabilityAndSubscriptionDetection() { + func `provider availability and subscription detection`() { let zaiStore = InMemoryZaiTokenStore(value: "zai-token") let syntheticStore = InMemorySyntheticTokenStore(value: "synthetic-token") let settings = Self.makeSettingsStore( @@ -135,10 +259,288 @@ struct UsageStoreCoverageTests { } @Test - func statusIndicatorsAndFailureGate() { + func `background refresh only tracks enabled providers`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-refresh") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let staleSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(staleSnapshot, provider: .claude) + store._setErrorForTesting("stale", provider: .claude) + store.statuses[.claude] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + + #expect(store.enabledProviders() == [.codex]) + + store.clearDisabledProviderState(enabledProviders: Set(store.enabledProvidersForDisplay())) + + #expect(store.snapshot(for: .claude) == nil) + #expect(store.errors[.claude] == nil) + #expect(store.statuses[.claude] == nil) + } + + @Test + func `cleanup preserves enabled but unavailable provider state`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-preserve-unavailable") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let staleSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(staleSnapshot, provider: .synthetic) + store._setErrorForTesting("stale", provider: .synthetic) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + + store.clearDisabledProviderState(enabledProviders: Set(store.enabledProvidersForDisplay())) + + #expect(store.snapshot(for: .synthetic) != nil) + #expect(store.errors[.synthetic] == "stale") + #expect(store.statuses[.synthetic]?.indicator == .major) + } + + @Test + func `background work excludes enabled but unavailable providers`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-unavailable") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func `visible unavailable provider gets explicit user facing state`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-unavailable-message") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + + #expect(store.errors[.synthetic] == nil) + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.isProviderAvailable(.synthetic) == false) + #expect(store.userFacingError(for: .synthetic) == SyntheticSettingsError.missingToken.errorDescription) + #expect(store.unavailableMessage(for: .synthetic) == SyntheticSettingsError.missingToken.errorDescription) + } + + @Test + func `refresh clears enabled but unavailable cached state`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + let cachedSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(cachedSnapshot, provider: .synthetic) + let account = ProviderTokenAccount(id: UUID(), label: "Account", token: "token", addedAt: 0, lastUsed: nil) + store.accountSnapshots[.synthetic] = [ + TokenAccountUsageSnapshot(account: account, snapshot: cachedSnapshot, error: nil, sourceLabel: "api"), + ] + store._setTokenSnapshotForTesting( + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: 1.23, + last30DaysTokens: 100, + last30DaysCostUSD: 4.56, + daily: [], + updatedAt: Date()), + provider: .synthetic) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + + await store.refresh() + #expect(store.snapshot(for: .synthetic) == nil) + #expect((store.accountSnapshots[.synthetic] ?? []).isEmpty) + #expect(store.tokenSnapshots[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func `refresh clears enabled but unavailable failure state`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-failure-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + store._setErrorForTesting("stale", provider: .synthetic) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + store.tokenErrors[.synthetic] = "token stale" + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + + await store.refresh() + + #expect(store.errors[.synthetic] == nil) + #expect(store.tokenErrors[.synthetic] == nil) + #expect(store.statuses[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func `widget snapshot projects provider derived token usage`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-widget-provider-cost") + let store = Self.makeUsageStore(settings: settings) + let day = MistralDailyUsageBucket( + day: "2026-05-26", + cost: 1.2, + inputTokens: 10, + cachedTokens: 0, + outputTokens: 5, + models: []) + store._setSnapshotForTesting(MistralUsageSnapshot( + totalCost: 9, + currency: "eur", + currencySymbol: "€", + totalInputTokens: 10, + totalOutputTokens: 5, + totalCachedTokens: 0, + modelCount: 1, + daily: [day], + startDate: nil, + endDate: nil, + updatedAt: Date()).toUsageSnapshot(), provider: .mistral) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "provider-cost") + await store.widgetSnapshotPersistTask?.value + + let mistralEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .mistral }) + #expect(mistralEntry.tokenUsage?.currencyCode == "EUR") + #expect(mistralEntry.tokenUsage?.sessionLabel == "Latest billing day") + #expect(mistralEntry.tokenUsage?.last30DaysLabel == "This month") + #expect(mistralEntry.tokenUsage?.last30DaysCostUSD == 9) + } + + @Test + func `unavailable provider with only cached status gets single cleanup pass`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-status-cleanup") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + + let metadata = ProviderRegistry.shared.metadata + + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: false) + } + try settings.setProviderEnabled( + provider: .synthetic, + metadata: #require(metadata[.synthetic]), + enabled: true) + + let store = Self.makeUsageStore(settings: settings) + store.statuses[.synthetic] = ProviderStatus(indicator: .major, description: "Outage", updatedAt: Date()) + + #expect(store.enabledProvidersForDisplay() == [.synthetic]) + #expect(store.enabledProviders().isEmpty) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + + await store.refresh() + + #expect(store.statuses[.synthetic] == nil) + #expect(store.enabledProvidersForBackgroundWork().isEmpty) + } + + @Test + func `status indicators and failure gate`() { #expect(!ProviderStatusIndicator.none.hasIssue) #expect(ProviderStatusIndicator.maintenance.hasIssue) - #expect(ProviderStatusIndicator.unknown.label == "Status unknown") + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(ProviderStatusIndicator.unknown.label == "Status unknown") + } var gate = ConsecutiveFailureGate() let first = gate.shouldSurfaceError(onFailureWithPriorData: true) @@ -152,6 +554,151 @@ struct UsageStoreCoverageTests { #expect(gate.streak == 0) } + @Test + func `token account error message ignores cancellation`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-token-account-cancel") + let store = Self.makeUsageStore(settings: settings) + + #expect(store.tokenAccountErrorMessage(CancellationError()) == nil) + #expect(store.tokenAccountErrorMessage(ProviderFetchError.noAvailableStrategy(.copilot)) != nil) + } + + @Test + func `isPreservableNetworkTransportError classifies transport failures correctly`() { + #expect(UsageStore.isPreservableNetworkTransportError( + NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotFindHost))) + #expect(UsageStore.isPreservableNetworkTransportError( + NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost))) + #expect(UsageStore.isPreservableNetworkTransportError( + NSError(domain: NSURLErrorDomain, code: NSURLErrorDNSLookupFailed))) + #expect(UsageStore.isPreservableNetworkTransportError( + NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))) + #expect(UsageStore.isPreservableNetworkTransportError( + NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet))) + #expect(UsageStore.isPreservableNetworkTransportError( + NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost))) + #expect(UsageStore.isPreservableNetworkTransportError( + NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))) + #expect(!UsageStore.isPreservableNetworkTransportError( + NSError(domain: NSCocoaErrorDomain, code: 0))) + } + + @Test + func `background work settings observation ignores menu provider selection churn`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-switcher-selection-observation") + settings.refreshFrequency = .manual + settings.mergeIcons = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + didChange.set() + } + + settings.selectedMenuProvider = .codex + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(didChange.get() == false) + + let refreshDidChange = ObservationFlag() + withObservationTracking { + _ = store.backgroundWorkSettingsObservationToken + } onChange: { + refreshDidChange.set() + } + + settings.refreshFrequency = .oneMinute + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(refreshDidChange.get() == true) + } + + @Test + func `startup status network failure schedules bounded retry`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-startup-status-retry") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_providerStatusFetchOverride = { _ in + throw URLError(.notConnectedToInternet) + } + defer { store._test_providerStatusFetchOverride = nil } + + var scheduled: [(attempt: Int, delay: TimeInterval)] = [] + store._test_startupConnectivityRetryScheduled = { attempt, delay in + scheduled.append((attempt, delay)) + } + defer { store._test_startupConnectivityRetryScheduled = nil } + + await store.refresh() + defer { + store.startupConnectivityRetryTask?.cancel() + store.startupConnectivityRetryTask = nil + } + + #expect(scheduled.map(\.attempt) == [1]) + #expect(scheduled.map(\.delay) == [15]) + #expect(store.statuses[.codex]?.indicator == .unknown) + #expect(store.statuses[.codex]?.description?.isEmpty == false) + } + + @Test + func `startup connectivity retry refreshes status and clears retry task after recovery`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-startup-status-recovery") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + + var statusAttempts = 0 + store._test_providerStatusFetchOverride = { _ in + statusAttempts += 1 + if statusAttempts == 1 { + throw URLError(.cannotFindHost) + } + return ProviderStatus(indicator: .none, description: "Operational", updatedAt: Date()) + } + defer { store._test_providerStatusFetchOverride = nil } + + let sleepGate = StartupConnectivityRetrySleepGate() + store._test_startupConnectivityRetrySleepOverride = { delay in + try await sleepGate.sleep(delay) + } + defer { store._test_startupConnectivityRetrySleepOverride = nil } + + await store.refresh() + await sleepGate.waitUntilSleeping() + let retryTask = try #require(store.startupConnectivityRetryTask) + + await sleepGate.resume() + await retryTask.value + + #expect(statusAttempts == 2) + #expect(store.statuses[.codex]?.indicator == ProviderStatusIndicator.none) + #expect(store.statuses[.codex]?.description == "Operational") + #expect(store.startupConnectivityRetryTask == nil) + } + + @Test + func `startup connectivity retry classification is bounded and excludes cancellation`() { + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 1) == 15) + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 4) == 300) + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 5) == nil) + #expect(UsageStore.isStartupConnectivityRetryableError(URLError(.timedOut))) + #expect(UsageStore.isStartupConnectivityRetryableError(URLError(.notConnectedToInternet))) + #expect(!UsageStore.isStartupConnectivityRetryableError(URLError(.cancelled))) + #expect(!UsageStore.isStartupConnectivityRetryableError(CancellationError())) + } + private static func makeSettingsStore( suite: String, zaiTokenStore: any ZaiTokenStoring = NoopZaiTokenStore(), @@ -162,7 +709,7 @@ struct UsageStoreCoverageTests { defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) - return SettingsStore( + let settings = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: zaiTokenStore, @@ -180,13 +727,109 @@ struct UsageStoreCoverageTests { ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + return settings } private static func makeUsageStore(settings: SettingsStore) -> UsageStore { UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + settings: settings, + environmentBase: [:]) + } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let auth = try [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeCodexJWT(email: email, plan: plan), + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json"), options: .atomic) + } + + private static func fakeCodexJWT(email: String, plan: String) throws -> String { + let header = try JSONSerialization.data(withJSONObject: ["alg": "none"]) + let payload = try JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": [ + "chatgpt_plan_type": plan, + ], + ]) + return "\(Self.base64URL(header)).\(Self.base64URL(payload))." + } + + private static func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + private static func makeCopilotSnapshot( + usedPercent: Double, + extraRateWindows: [NamedRateWindow]?) -> UsageSnapshot + { + UsageSnapshot( + primary: RateWindow(usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + extraRateWindows: extraRateWindows, + updatedAt: Date(timeIntervalSince1970: 1_780_358_400)) + } + + private static func makeCopilotBudgetWindow() -> NamedRateWindow { + NamedRateWindow( + id: "copilot-budget-test", + title: "Budget - Copilot", + window: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil)) + } + + private static func enableOnly(_ enabledProvider: UsageProvider, settings: SettingsStore) throws { + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: provider == enabledProvider) + } + } +} + +private actor StartupConnectivityRetrySleepGate { + private var continuation: CheckedContinuation? + private var waiters: [CheckedContinuation] = [] + + func sleep(_ delay: TimeInterval) async throws { + #expect(delay == 15) + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + self.resumeWaiters() + } + } + + func waitUntilSleeping() async { + if self.continuation != nil { return } + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } + + private func resumeWaiters() { + let waiters = self.waiters + self.waiters.removeAll() + for waiter in waiters { + waiter.resume() + } } } @@ -221,3 +864,15 @@ private final class InMemorySyntheticTokenStore: SyntheticTokenStoring, @uncheck self.value = token } } + +private struct LocalizedTestError: LocalizedError { + let message: String + + init(_ message: String) { + self.message = message + } + + var errorDescription: String? { + self.message + } +} diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index c75944d635..3c781ca022 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct UsageStoreHighestUsageTests { @Test - func selectsHighestUsageAmongEnabledProviders() { + func `selects highest usage among enabled providers`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-selects"), zaiTokenStore: NoopZaiTokenStore(), @@ -44,7 +43,7 @@ struct UsageStoreHighestUsageTests { } @Test - func skipsFullyUsedProviders() { + func `skips fully used providers`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-skips"), zaiTokenStore: NoopZaiTokenStore(), @@ -81,7 +80,7 @@ struct UsageStoreHighestUsageTests { } @Test - func automaticMetricUsesSecondaryForKimiWhenRankingHighestUsage() { + func `automatic metric uses secondary for kimi when ranking highest usage`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-kimi-automatic"), zaiTokenStore: NoopZaiTokenStore(), @@ -119,7 +118,377 @@ struct UsageStoreHighestUsageTests { } @Test - func automaticMetricKeepsCopilotMostConstrainedRanking() { + func `automatic metric ignores antigravity tertiary when compact icon has no quota summary`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-tertiary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: RateWindow(usedPercent: 85, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 70) + } + + @Test + func `automatic metric ignores unclassified antigravity compact fallback`() throws { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-unclassified"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = try AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Experimental Model", + modelId: "MODEL_PLACEHOLDER_NEW", + remainingFraction: 0.36, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + .toUsageSnapshot() + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 50) + } + + @Test + func `automatic metric ignores legacy antigravity family lanes without quota summary`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-constrained-gemini"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Claude"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Pro"), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: "Gemini Flash"), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 70) + } + + @Test + func `automatic metric ranks antigravity by rendered gemini quota summary lanes`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-rendered-gemini"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + store._setSnapshotForTesting( + self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 10, + geminiWeeklyUsed: 20, + otherSessionUsed: 95, + otherWeeklyUsed: 90), + provider: .antigravity) + + var highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + + store._setSnapshotForTesting( + self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 95, + geminiWeeklyUsed: 20, + otherSessionUsed: 10, + otherWeeklyUsed: 10), + provider: .antigravity) + highest = store.providerWithHighestUsage() + #expect(highest?.provider == .antigravity) + #expect(highest?.usedPercent == 95) + + store._setSnapshotForTesting( + self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 100, + geminiWeeklyUsed: 100, + otherSessionUsed: 50, + otherWeeklyUsed: 50), + provider: .antigravity) + highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + } + + @Test + func `automatic metric keeps antigravity when one rendered lane has quota`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-all-100"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 100, + geminiWeeklyUsed: 40, + otherSessionUsed: 100, + otherWeeklyUsed: 100) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .antigravity) + #expect(highest?.usedPercent == 100) + } + + @Test + func `automatic metric ignores antigravity legacy detail rows`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-fallback-detail"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-compact-fallback-model-a", + title: "Model A", + window: RateWindow( + usedPercent: 100, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "model-b", + title: "Model B", + window: RateWindow( + usedPercent: 50, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()), + provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + } + + @Test + func `automatic metric skips antigravity with no quota lanes`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-empty"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + } + + @Test + func `automatic metric uses zai 5-hour token lane when ranking highest usage`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-zai-automatic-tertiary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .zai) + settings.addTokenAccount(provider: .zai, label: "Primary", token: "zai-token") + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let zaiMeta = registry.metadata[.zai] { + settings.setProviderEnabled(provider: .zai, metadata: zaiMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let zaiSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 15, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 90, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(zaiSnapshot, provider: .zai) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .zai) + #expect(highest?.usedPercent == 90) + } + + @Test + func `automatic metric keeps copilot most constrained ranking`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-copilot-automatic"), zaiTokenStore: NoopZaiTokenStore(), @@ -157,7 +526,7 @@ struct UsageStoreHighestUsageTests { } @Test - func automaticMetricDoesNotExcludePartiallyAvailableCopilotAtHundredPercent() { + func `automatic metric does not exclude partially available copilot at hundred percent`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-copilot-partial-100"), zaiTokenStore: NoopZaiTokenStore(), @@ -195,7 +564,7 @@ struct UsageStoreHighestUsageTests { } @Test - func automaticMetricExcludesCopilotWhenBothLanesAreExhausted() { + func `automatic metric excludes copilot when both lanes are exhausted`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-copilot-both-100"), zaiTokenStore: NoopZaiTokenStore(), @@ -231,4 +600,377 @@ struct UsageStoreHighestUsageTests { #expect(highest?.provider == .codex) #expect(highest?.usedPercent == 80) } + + @Test + func `automatic metric uses tertiary when it is most constrained for cursor`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-cursor-tertiary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .cursor) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let cursorSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 95, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(cursorSnapshot, provider: .cursor) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .cursor) + #expect(highest?.usedPercent == 95) + } + + @Test + func `automatic metric keeps perplexity in highest usage when purchased credits remain`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-purchased"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 15, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let perplexitySnapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .perplexity) + #expect(highest?.usedPercent == 45) + } + + @Test + func `automatic metric ignores exhausted recurring perplexity lane when fallback remains`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-recurring-exhausted"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let perplexitySnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .perplexity) + #expect(highest?.usedPercent == 40) + } + + @Test + func `automatic metric prefers purchased perplexity credits before bonus in highest usage`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-purchased-before-bonus"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let perplexitySnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .perplexity) + #expect(highest?.usedPercent == 45) + } + + @Test + func `primary metric keeps exhausted recurring perplexity lane in highest usage selection`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-primary-exhausted"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.primary, for: .perplexity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let perplexitySnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 25) + } + + @Test + func `automatic metric excludes cursor when all opus lanes are exhausted`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-cursor-all-100"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .cursor) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let cursorSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(cursorSnapshot, provider: .cursor) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + } + + @Test + func `cursor highest usage keeps provider when saved tertiary falls back to automatic`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-cursor-missing-tertiary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.tertiary, for: .cursor) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let cursorSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(cursorSnapshot, provider: .cursor) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .cursor) + #expect(highest?.usedPercent == 100) + } + + private func antigravityQuotaSummarySnapshot( + geminiSessionUsed: Double, + geminiWeeklyUsed: Double, + otherSessionUsed: Double, + otherWeeklyUsed: Double) -> UsageSnapshot + { + UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Session", + window: RateWindow( + usedPercent: geminiSessionUsed, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Weekly", + window: RateWindow( + usedPercent: geminiWeeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude + GPT Session", + window: RateWindow( + usedPercent: otherSessionUsed, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude + GPT Weekly", + window: RateWindow( + usedPercent: otherWeeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()) + } +} + +extension UsageStoreHighestUsageTests { + @Test + func `explicit antigravity metric remains authoritative for highest usage`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-explicit"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.secondary, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + let antigravity = self.antigravityQuotaSummarySnapshot( + geminiSessionUsed: 10, + geminiWeeklyUsed: 20, + otherSessionUsed: 95, + otherWeeklyUsed: 90) + .with( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 95, windowMinutes: nil, resetsAt: nil, resetDescription: nil)) + store._setSnapshotForTesting(antigravity, provider: .antigravity) + + var highest = store.providerWithHighestUsage() + #expect(highest?.provider == .antigravity) + #expect(highest?.usedPercent == 95) + + store._setSnapshotForTesting( + antigravity.with( + primary: antigravity.primary, + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil)), + provider: .antigravity) + highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + } } diff --git a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift new file mode 100644 index 0000000000..2268cc3431 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift @@ -0,0 +1,308 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +private actor TokenRefreshGate { + private var didStart = false + private var didFinish = false + private var released = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + private var finishWaiters: [CheckedContinuation] = [] + private(set) var calls: [(provider: UsageProvider, force: Bool)] = [] + + func start(provider: UsageProvider, force: Bool) { + self.didStart = true + self.calls.append((provider, force)) + let waiters = self.startWaiters + self.startWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func waitForStart() async { + if self.didStart { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func waitForRelease() async { + if self.released { return } + await withCheckedContinuation { continuation in + self.releaseWaiters.append(continuation) + } + } + + func release() { + self.released = true + let waiters = self.releaseWaiters + self.releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func finish() { + self.didFinish = true + let waiters = self.finishWaiters + self.finishWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func hasFinished() -> Bool { + self.didFinish + } + + func waitForFinish() async { + if self.didFinish { return } + await withCheckedContinuation { continuation in + self.finishWaiters.append(continuation) + } + } +} + +private actor CompletionFlag { + private var completed = false + + func markCompleted() { + self.completed = true + } + + func isCompleted() -> Bool { + self.completed + } +} + +private actor TokenRefreshRecorder { + private(set) var calls: [(provider: UsageProvider, force: Bool)] = [] + + func record(provider: UsageProvider, force: Bool) { + self.calls.append((provider, force)) + } +} + +@MainActor +@Suite(.serialized) +struct UsageStoreManualTokenRefreshTests { + @Test + func `manual refresh waits for token-cost refresh before completing`() async { + let store = Self.makeStore() + let gate = TokenRefreshGate() + let completion = CompletionFlag() + store._test_providerRefreshOverride = { _ in } + store._test_tokenUsageRefreshOverride = { provider, force in + await gate.start(provider: provider, force: force) + await gate.waitForRelease() + await gate.finish() + } + + let task = Task { @MainActor in + await store.refresh(forceTokenUsage: true) + await completion.markCompleted() + } + + await gate.waitForStart() + #expect(await completion.isCompleted() == false) + #expect(await gate.hasFinished() == false) + + await gate.release() + await task.value + + #expect(await completion.isCompleted()) + #expect(await gate.hasFinished()) + #expect(await gate.calls.map(\.provider) == [.codex]) + #expect(await gate.calls.map(\.force) == [true]) + } + + @Test + func `manual refresh drains scheduled token-cost refresh before forced pass`() async { + let store = Self.makeStore() + let scheduledGate = TokenRefreshGate() + let forcedGate = TokenRefreshGate() + let recorder = TokenRefreshRecorder() + let completion = CompletionFlag() + store._test_providerRefreshOverride = { _ in } + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + if force { + await forcedGate.start(provider: provider, force: force) + await forcedGate.waitForRelease() + await forcedGate.finish() + } else { + await scheduledGate.start(provider: provider, force: force) + await scheduledGate.waitForRelease() + await scheduledGate.finish() + } + } + + await store.refresh(forceTokenUsage: false) + await scheduledGate.waitForStart() + + let task = Task { @MainActor in + await store.refresh(forceTokenUsage: true) + await completion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await completion.isCompleted() == false) + + await scheduledGate.release() + await forcedGate.waitForStart() + #expect(await completion.isCompleted() == false) + + await forcedGate.release() + await task.value + + #expect(await completion.isCompleted()) + #expect(await scheduledGate.hasFinished()) + #expect(await forcedGate.hasFinished()) + #expect(await recorder.calls.map(\.provider) == [.codex, .codex]) + #expect(await recorder.calls.map(\.force) == [false, true]) + } + + @Test + func `scoped manual refresh drains scheduled token-cost refresh before forced pass`() async { + let store = Self.makeStore() + let scheduledGate = TokenRefreshGate() + let forcedGate = TokenRefreshGate() + let recorder = TokenRefreshRecorder() + let completion = CompletionFlag() + store._test_providerRefreshOverride = { _ in } + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + if force { + await forcedGate.start(provider: provider, force: force) + await forcedGate.waitForRelease() + await forcedGate.finish() + } else { + await scheduledGate.start(provider: provider, force: force) + await scheduledGate.waitForRelease() + await scheduledGate.finish() + } + } + + await store.refresh(forceTokenUsage: false) + await scheduledGate.waitForStart() + + let task = Task { @MainActor in + await store.refreshTokenUsageNow(for: .codex, force: true) + await completion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await completion.isCompleted() == false) + + await scheduledGate.release() + await forcedGate.waitForStart() + #expect(await completion.isCompleted() == false) + + await forcedGate.release() + await task.value + + #expect(await completion.isCompleted()) + #expect(await scheduledGate.hasFinished()) + #expect(await forcedGate.hasFinished()) + #expect(await recorder.calls.map(\.provider) == [.codex, .codex]) + #expect(await recorder.calls.map(\.force) == [false, true]) + } + + @Test + func `scoped manual refresh leaves unrelated scheduled token-cost refresh running`() async { + let store = Self.makeStore(enabledProviders: [.claude, .codex]) + let scheduledGate = TokenRefreshGate() + let forcedGate = TokenRefreshGate() + let recorder = TokenRefreshRecorder() + let completion = CompletionFlag() + store._test_providerRefreshOverride = { _ in } + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + if force { + await forcedGate.start(provider: provider, force: force) + await forcedGate.waitForRelease() + await forcedGate.finish() + } else { + await scheduledGate.start(provider: provider, force: force) + await scheduledGate.waitForRelease() + await scheduledGate.finish() + } + } + + await store.refresh(forceTokenUsage: false) + await scheduledGate.waitForStart() + + let task = Task { @MainActor in + await store.refreshTokenUsageNow(for: .claude, force: true) + await completion.markCompleted() + } + + await forcedGate.waitForStart() + #expect(await scheduledGate.hasFinished() == false) + #expect(await completion.isCompleted() == false) + #expect(await recorder.calls.map(\.provider) == [.codex, .claude]) + #expect(await recorder.calls.map(\.force) == [false, true]) + + await forcedGate.release() + await task.value + #expect(await completion.isCompleted()) + #expect(await scheduledGate.hasFinished() == false) + + await scheduledGate.release() + await scheduledGate.waitForFinish() + } + + @Test + func `regular refresh schedules token-cost refresh without waiting`() async { + let store = Self.makeStore() + let gate = TokenRefreshGate() + store._test_providerRefreshOverride = { _ in } + store._test_tokenUsageRefreshOverride = { provider, force in + await gate.start(provider: provider, force: force) + await gate.waitForRelease() + await gate.finish() + } + + await store.refresh(forceTokenUsage: false) + #expect(await gate.hasFinished() == false) + + await gate.release() + try? await Task.sleep(for: .milliseconds(50)) + let calls = await gate.calls + if !calls.isEmpty { + #expect(calls.map(\.provider) == [.codex]) + #expect(calls.map(\.force) == [false]) + #expect(await gate.hasFinished()) + } + } + + private static func makeStore(enabledProviders: Set = [.codex]) -> UsageStore { + let suite = "UsageStoreManualTokenRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + settings.providerDetectionCompleted = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: enabledProviders.contains(provider)) + } + + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + } +} diff --git a/Tests/CodexBarTests/UsageStorePathDebugTests.swift b/Tests/CodexBarTests/UsageStorePathDebugTests.swift index a73a8b3ebf..856fc178cb 100644 --- a/Tests/CodexBarTests/UsageStorePathDebugTests.swift +++ b/Tests/CodexBarTests/UsageStorePathDebugTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct UsageStorePathDebugTests { @Test - func refreshPathDebugInfoPopulatesSnapshot() async throws { + func `refresh path debug info populates snapshot`() async throws { let suite = "UsageStorePathDebugTests-path" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -30,4 +29,27 @@ struct UsageStorePathDebugTests { #expect(store.pathDebugInfo != .empty) #expect(store.pathDebugInfo.effectivePATH.isEmpty == false) } + + @Test + func `deepseek debug log includes selected token account`() async throws { + let suite = "UsageStorePathDebugTests-deepseek-debug-token-account" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.addTokenAccount(provider: .deepseek, label: "Primary", token: "sk-deepseek-test") + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + let debugLog = await store.debugLog(for: UsageProvider.deepseek) + + #expect(debugLog == "DEEPSEEK_API_KEY=present source=settings-token-account") + } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift new file mode 100644 index 0000000000..2ccc89a236 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift @@ -0,0 +1,244 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationClaudeIdentityTests { + @MainActor + @Test + func `selected token account chooses matching bucket`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + + store.settings.setActiveTokenAccountIndex(0, for: .claude) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [ + aliceKey: [planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ])], + bobKey: [planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 50), + ])], + ]) + + #expect(store.planUtilizationHistory(for: .claude) == [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]), + ]) + + store.settings.setActiveTokenAccountIndex(1, for: .claude) + #expect(store.planUtilizationHistory(for: .claude) == [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 50), + ]), + ]) + } + + @MainActor + @Test + func `fetched non selected accounts persist into separate claude buckets`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "bob@example.com", + accountOrganization: nil, + loginMethod: "max")) + + await store.recordFetchedTokenAccountPlanUtilizationHistory( + provider: .claude, + samples: [(account: bob, snapshot: snapshot)], + selectedAccount: alice) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + let histories = try #require(buckets.accounts[bobKey]) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) + #expect(findSeries(histories, name: .opus, windowMinutes: 10080)?.entries.last?.usedPercent == 30) + } + + @MainActor + @Test + func `first resolved claude token account adopts unscoped history`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + let alice = try #require(store.settings.tokenAccounts(for: .claude).first) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 15), + ]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [bootstrap]) + store.settings.setActiveTokenAccountIndex(0, for: .claude) + + let history = store.planUtilizationHistory(for: .claude) + let buckets = try #require(store.planUtilizationHistory[.claude]) + + #expect(history == [bootstrap]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[aliceKey] == [bootstrap]) + } + + @MainActor + @Test + func `claude history without identity falls back to last resolved account`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + store._setSnapshotForTesting(snapshot, provider: .claude) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let identitylessSnapshot = UsageSnapshot( + primary: snapshot.primary, + secondary: snapshot.secondary, + updatedAt: snapshot.updatedAt) + store._setSnapshotForTesting(identitylessSnapshot, provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) + #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) + } + + @Test + func `same claude email separates team and personal plan history keys`() throws { + let team = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: "Team Org", + loginMethod: "Claude Team")) + let max = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: "Claude Max")) + + let teamKey = try #require(UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: team)) + let maxKey = try #require(UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: max)) + + #expect(teamKey != maxKey) + } + + @Test + func `claude email only identity keeps legacy history key`() throws { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let identityKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: snapshot)) + let legacyKey = try #require( + UsageStore._legacyClaudePlanUtilizationEmailAccountKeyForTesting(snapshot: snapshot)) + + #expect(identityKey == legacyKey) + } + + @Test + func `claude compact and branded plan labels share history key`() throws { + let compact = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: "Max")) + let branded = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: "Claude Max")) + + let compactKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: compact)) + let brandedKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: branded)) + + #expect(compactKey == brandedKey) + } + + @MainActor + @Test + func `new claude email discriminator adopts legacy email history`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: "Team Org", + loginMethod: "Claude Team")) + let legacyKey = try #require( + UsageStore._legacyClaudePlanUtilizationEmailAccountKeyForTesting(snapshot: snapshot)) + let accountKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: snapshot)) + let legacyWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 42), + ]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + preferredAccountKey: legacyKey, + accounts: [ + legacyKey: [legacyWeekly], + ]) + store._setSnapshotForTesting(snapshot, provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + let buckets = try #require(store.planUtilizationHistory[.claude]) + + #expect(history == [legacyWeekly]) + #expect(buckets.accounts[legacyKey] == nil) + #expect(buckets.accounts[accountKey] == [legacyWeekly]) + #expect(buckets.preferredAccountKey == accountKey) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationCodexOwnershipTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationCodexOwnershipTests.swift new file mode 100644 index 0000000000..cdd97b835a --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationCodexOwnershipTests.swift @@ -0,0 +1,582 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationCodexOwnershipTests { + @MainActor + @Test + func `codex plan history aliases pre-upgrade codex email hash bucket into canonical email hash`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: "alice@example.com") + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "alice@example.com") + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + legacyEmailHash: [weekly], + ]) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(buckets.preferredAccountKey == canonicalKey) + #expect(history == [weekly]) + #expect(buckets.accounts[canonicalKey] == [weekly]) + #expect(buckets.accounts[legacyEmailHash] == nil) + } + + @MainActor + @Test + func `codex strict continuity adopts unscoped only when there is one owner`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: "alice@example.com") + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "alice@example.com") + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 15), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [bootstrap], + accounts: [ + legacyEmailHash: [weekly], + ]) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(buckets.preferredAccountKey == canonicalKey) + #expect(history == [bootstrap, weekly]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[canonicalKey] == [bootstrap, weekly]) + #expect(buckets.accounts[legacyEmailHash] == nil) + } + + @MainActor + @Test + func `codex strict continuity ignores later unrelated owners outside the unscoped period`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: "alice@example.com") + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "alice@example.com") + let laterOtherKey = "codex:v1:provider-account:acct-later" + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 15), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + let laterWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_701_900_000), usedPercent: 35), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [bootstrap], + accounts: [ + legacyEmailHash: [weekly], + laterOtherKey: [laterWeekly], + ]) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(buckets.preferredAccountKey == canonicalKey) + #expect(history == [bootstrap, weekly]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[canonicalKey] == [bootstrap, weekly]) + #expect(buckets.accounts[laterOtherKey] == [laterWeekly]) + } + + @MainActor + @Test + func `codex real fixture carries local opaque weekly continuity into provider account`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "ratulsarna@gmail.com") + let canonicalEmailHashKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "ratulsarna@gmail.com") + let opaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let weeklyResetAt = try #require(ISO8601DateFormatter().date(from: "2026-04-08T07:57:12Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + store.planUtilizationHistory[.codex] = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + let session = try #require(findSeries(history, name: .session, windowMinutes: 300)) + let expectedOldestWeekly = try #require(formatter.date(from: "2026-03-23T09:55:44Z")) + let expectedNewestWeekly = try #require(formatter.date(from: "2026-04-01T20:33:41Z")) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedOldestWeekly) + #expect(weekly.entries.last?.capturedAt == expectedNewestWeekly) + #expect(session.entries.first?.capturedAt == expectedOldestWeekly) + #expect(buckets.accounts[providerAccountKey] == history) + #expect(buckets.accounts[opaqueKey] == nil) + #expect(buckets.accounts[legacyEmailHash] == nil) + #expect(buckets.accounts[canonicalEmailHashKey] == nil) + } + + @MainActor + @Test + func `codex real fixture keeps opaque history separate when multiple opaque candidates could match`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let originalOpaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let duplicateOpaqueKey = "legacy-codex-opaque-duplicate" + let weeklyResetAt = try #require(ISO8601DateFormatter().date(from: "2026-04-08T07:57:12Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + var fixture = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + fixture.accounts[duplicateOpaqueKey] = fixture.accounts[originalOpaqueKey] + store.planUtilizationHistory[.codex] = fixture + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + let expectedNonOpaqueStart = try #require(formatter.date(from: "2026-03-28T06:50:16Z")) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedNonOpaqueStart) + #expect(buckets.accounts[originalOpaqueKey] != nil) + #expect(buckets.accounts[duplicateOpaqueKey] != nil) + } + + @MainActor + @Test + func `codex real fixture keeps opaque history separate when overlapping non target owner exists`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let opaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let overlappingOtherKey = "codex:v1:provider-account:acct-other" + let weeklyResetAt = try #require(formatter.date(from: "2026-04-08T07:57:12Z")) + let expectedNonOpaqueStart = try #require(formatter.date(from: "2026-03-28T06:50:16Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + let overlappingCapturedAt = try #require(formatter.date(from: "2026-03-30T12:00:00Z")) + var fixture = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + fixture.accounts[overlappingOtherKey] = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry( + at: overlappingCapturedAt, + usedPercent: 35, + resetsAt: weeklyResetAt), + ]), + ] + store.planUtilizationHistory[.codex] = fixture + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedNonOpaqueStart) + #expect(buckets.accounts[opaqueKey] != nil) + #expect(buckets.accounts[overlappingOtherKey] != nil) + } + + @MainActor + @Test + func `codex opaque recovery uses normalized dashboard weekly reset when snapshot has only session window`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let opaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let weeklyResetAt = try #require(formatter.date(from: "2026-04-08T07:57:12Z")) + let expectedOpaqueStart = try #require(formatter.date(from: "2026-03-23T09:55:44Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + store.planUtilizationHistory[.codex] = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "ratulsarna@gmail.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: "plus", + updatedAt: weeklyResetAt) + store.openAIDashboardAttachmentAuthorized = true + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedOpaqueStart) + #expect(buckets.accounts[opaqueKey] == nil) + } + + @MainActor + @Test + func `codex display only dashboard does not drive opaque recovery when snapshot has only session window`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let opaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let weeklyResetAt = try #require(formatter.date(from: "2026-04-08T07:57:12Z")) + let expectedNonOpaqueStart = try #require(formatter.date(from: "2026-03-28T06:50:16Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + store.planUtilizationHistory[.codex] = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "ratulsarna@gmail.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: "plus", + updatedAt: weeklyResetAt) + store.openAIDashboardAttachmentAuthorized = false + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedNonOpaqueStart) + #expect(buckets.accounts[opaqueKey] != nil) + } + + @MainActor + @Test + func `codex adjacent managed and live accounts veto unscoped adoption`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: managedAccount.email) + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: managedAccount.email) + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 15), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [bootstrap], + accounts: [ + legacyEmailHash: [weekly], + ]) + store.settings._test_activeManagedCodexAccount = managedAccount + store.settings.codexActiveSource = .managedAccount(id: managedAccount.id) + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "live-acct")) + defer { + store.settings._test_activeManagedCodexAccount = nil + store.settings._test_liveSystemCodexAccount = nil + store.settings.codexActiveSource = .liveSystem + } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(history == [weekly]) + #expect(buckets.unscoped == [bootstrap]) + #expect(buckets.accounts[canonicalKey] == [weekly]) + } + + @MainActor + @Test + func `codex extra saved managed accounts do not veto active account adoption`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let activeManagedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let inactiveManagedAccount = ManagedCodexAccount( + id: UUID(), + email: "other@example.com", + managedHomePath: "/tmp/other-codex-home", + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2) + let managedStoreURL = try #require(store.settings._test_managedCodexAccountStoreURL) + let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL) + try managedStore.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [activeManagedAccount, inactiveManagedAccount])) + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: activeManagedAccount.email) + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: activeManagedAccount.email) + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 15), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [bootstrap], + accounts: [ + legacyEmailHash: [weekly], + ]) + store.settings._test_activeManagedCodexAccount = activeManagedAccount + store.settings.codexActiveSource = .managedAccount(id: activeManagedAccount.id) + defer { + store.settings._test_activeManagedCodexAccount = nil + store.settings.codexActiveSource = .liveSystem + } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(history == [bootstrap, weekly]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[canonicalKey] == [bootstrap, weekly]) + } + + @MainActor + @Test + func `codex inactive managed accounts do not veto live opaque recovery`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let opaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let weeklyResetAt = try #require(formatter.date(from: "2026-04-08T07:57:12Z")) + let expectedOpaqueStart = try #require(formatter.date(from: "2026-03-23T09:55:44Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + store.planUtilizationHistory[.codex] = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + store.settings._test_activeManagedCodexAccount = managedAccount + store.settings.codexActiveSource = .liveSystem + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { + store.settings._test_activeManagedCodexAccount = nil + store.settings._test_liveSystemCodexAccount = nil + } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedOpaqueStart) + #expect(buckets.accounts[opaqueKey] == nil) + } + + @MainActor + @Test + func `codex provider account continuity absorbs matching email scoped history`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let normalizedEmail = "alice@example.com" + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: normalizedEmail) + let providerAccountKey = try #require( + CodexHistoryOwnership.canonicalKey(for: .providerAccount(id: "live-acct"))) + let canonicalEmailHashKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: normalizedEmail) + let session = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 15), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + canonicalEmailHashKey: [session], + legacyEmailHash: [weekly], + ]) + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: normalizedEmail, + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "live-acct")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(history == [session, weekly]) + #expect(buckets.accounts[providerAccountKey] == [session, weekly]) + #expect(buckets.accounts[canonicalEmailHashKey] == nil) + #expect(buckets.accounts[legacyEmailHash] == nil) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift new file mode 100644 index 0000000000..96327c78a2 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift @@ -0,0 +1,55 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationDerivedChartTests { + @MainActor + @Test + func `chart uses requested native series without cross series selection`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: firstBoundary), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary) + + #expect(model.selectedSeries == "weekly:10080") + #expect(model.usedPercents == [62, 48]) + } + + @MainActor + @Test + func `chart exposes claude opus as separate native tab`() { + let boundary = Date(timeIntervalSince1970: 1_710_000_000) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 10, resetsAt: boundary), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: boundary), + ]), + planSeries(name: .opus, windowMinutes: 10080, entries: [ + planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 30, resetsAt: boundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .claude, + referenceDate: boundary) + + #expect(model.visibleSeries == ["session:300", "weekly:10080", "opus:10080"]) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift new file mode 100644 index 0000000000..ac6d69f3f3 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift @@ -0,0 +1,227 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationExactFitResetTests { + @MainActor + @Test + func `weekly chart uses reset date as bar date`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let thirdBoundary = secondBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + planEntry(at: thirdBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: thirdBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: thirdBoundary) + + #expect(model.usedPercents == [62, 48, 20]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary), + formattedBoundary(secondBoundary), + formattedBoundary(thirdBoundary), + ]) + } + + @MainActor + @Test + func `chart keeps maximum usage for each effective period`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(5 * 60 * 60) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-50 * 60), usedPercent: 22, resetsAt: firstBoundary), + planEntry( + at: firstBoundary.addingTimeInterval(-20 * 60), + usedPercent: 61, + resetsAt: firstBoundary.addingTimeInterval(75)), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 18, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: secondBoundary) + + #expect(model.usedPercents == [61, 18]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary.addingTimeInterval(75)), + formattedBoundary(secondBoundary), + ]) + } + + @MainActor + @Test + func `chart prefers reset backed entry when usage ties within period`() { + let boundary = Date(timeIntervalSince1970: 1_710_000_000) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: boundary.addingTimeInterval(-55 * 60), usedPercent: 48), + planEntry(at: boundary.addingTimeInterval(-20 * 60), usedPercent: 48, resetsAt: boundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: boundary) + + #expect(model.usedPercents == [48]) + #expect(model.pointDates == [formattedBoundary(boundary)]) + } + + @MainActor + @Test + func `chart adds synthetic current bar when current period has no observation`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let currentBoundary = firstBoundary.addingTimeInterval(10 * 60 * 60) + let referenceDate = currentBoundary.addingTimeInterval(-30 * 60) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: referenceDate) + + #expect(model.usedPercents == [62, 0, 0]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary), + formattedBoundary(firstBoundary.addingTimeInterval(5 * 60 * 60)), + formattedBoundary(currentBoundary), + ]) + } + + @MainActor + @Test + func `weekly chart shows zero bars for missing reset periods`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let fourthBoundary = secondBoundary.addingTimeInterval(14 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + planEntry(at: fourthBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: fourthBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: fourthBoundary) + + #expect(model.usedPercents == [62, 48, 0, 20]) + } + + @MainActor + @Test + func `weekly chart starts axis labels from first bar`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let thirdBoundary = secondBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let fourthBoundary = thirdBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + planEntry(at: thirdBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: thirdBoundary), + planEntry(at: fourthBoundary.addingTimeInterval(-30 * 60), usedPercent: 15, resetsAt: fourthBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: fourthBoundary) + + #expect(model.axisIndexes == [0]) + } + + @MainActor + @Test + func `weekly chart keeps observed current boundary when reset times drift slightly`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_055) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60 + 88) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 33, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary.addingTimeInterval(-60)) + + #expect(model.usedPercents == [62, 33]) + } + + @MainActor + @Test + func `weekly chart prefers reset backed history over legacy synthetic points`() { + let legacyCapturedAt = Date(timeIntervalSince1970: 1_742_100_000) + let firstBoundary = Date(timeIntervalSince1970: 1_742_356_855) // 2026-03-18T17:00:55Z + let secondBoundary = Date(timeIntervalSince1970: 1_742_961_343) // 2026-03-25T17:02:23Z + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: legacyCapturedAt, usedPercent: 57), + planEntry(at: firstBoundary.addingTimeInterval(-60 * 60), usedPercent: 73, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-60 * 60), usedPercent: 35, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary.addingTimeInterval(-60)) + + #expect(model.usedPercents == [73, 35]) + } + + @MainActor + @Test + func `chart keeps legacy history before first reset backed boundary`() { + let firstLegacyCapturedAt = Date(timeIntervalSince1970: 1_739_692_800) // 2026-02-23T07:00:00Z + let secondLegacyCapturedAt = firstLegacyCapturedAt.addingTimeInterval(7 * 24 * 60 * 60) + let firstBoundary = secondLegacyCapturedAt.addingTimeInterval(7 * 24 * 60 * 60 + 55) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstLegacyCapturedAt, usedPercent: 20), + planEntry(at: secondLegacyCapturedAt, usedPercent: 40), + planEntry(at: firstBoundary.addingTimeInterval(-60 * 60), usedPercent: 73, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-60 * 60), usedPercent: 35, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .claude, + referenceDate: secondBoundary.addingTimeInterval(-60)) + + #expect(model.usedPercents == [20, 40, 73, 35]) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift new file mode 100644 index 0000000000..076f20b5f0 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift @@ -0,0 +1,279 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationResetCoalescingTests { + @Test + func `near canonical codex windows merge into canonical history series`() throws { + let base = Date(timeIntervalSince1970: 1_700_000_000) + let existing = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: base, usedPercent: 20), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: base, usedPercent: 40), + ]), + ] + let incoming = [ + planSeries(name: .session, windowMinutes: 299, entries: [ + planEntry(at: base.addingTimeInterval(3600), usedPercent: 30), + ]), + planSeries(name: .weekly, windowMinutes: 10079, entries: [ + planEntry(at: base.addingTimeInterval(3600), usedPercent: 50), + ]), + ] + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoriesForTesting( + existingHistories: existing, + samples: incoming)) + + #expect(updated.map { "\($0.name.rawValue):\($0.windowMinutes)" } == ["session:300", "weekly:10080"]) + #expect(findSeries(updated, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [20, 30]) + #expect(findSeries(updated, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [40, 50]) + } + + @Test + func `same hour entry backfills missing reset metadata`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let existing = planEntry( + at: hourStart.addingTimeInterval(10 * 60), + usedPercent: 20) + let incoming = planEntry( + at: hourStart.addingTimeInterval(45 * 60), + usedPercent: 30, + resetsAt: hourStart.addingTimeInterval(30 * 60)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == incoming.capturedAt) + #expect(updated[0].usedPercent == 30) + #expect(updated[0].resetsAt == incoming.resetsAt) + } + + @Test + func `same hour later higher usage without reset metadata keeps promoted reset boundary`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let first = planEntry( + at: hourStart.addingTimeInterval(10 * 60), + usedPercent: 40) + let second = planEntry( + at: hourStart.addingTimeInterval(25 * 60), + usedPercent: 8, + resetsAt: hourStart.addingTimeInterval(30 * 60)) + let third = planEntry( + at: hourStart.addingTimeInterval(50 * 60), + usedPercent: 22) + + let initial = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let promoted = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: promoted, + entry: third)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == third.capturedAt) + #expect(updated[0].usedPercent == third.usedPercent) + #expect(updated[0].resetsAt == second.resetsAt) + } + + @Test + func `same hour zero usage with drifting reset coalesces to latest entry`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 20, + hour: 0))) + let existing = planEntry( + at: hourStart.addingTimeInterval(14 * 60), + usedPercent: 0, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60 + 14 * 60 + 2)) + let incoming = planEntry( + at: hourStart.addingTimeInterval(23 * 60), + usedPercent: 0, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60 + 14 * 60 + 3)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0] == incoming) + } + + @Test + func `same hour reset times within two minutes still keep single hourly peak`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 20, + hour: 0))) + let existing = planEntry( + at: hourStart.addingTimeInterval(21 * 60), + usedPercent: 10, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)) + let incoming = planEntry( + at: hourStart.addingTimeInterval(55 * 60), + usedPercent: 10, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60 + 1)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == incoming.capturedAt) + #expect(updated[0].usedPercent == 10) + #expect(updated[0].resetsAt == incoming.resetsAt) + } + + @Test + func `same hour usage drop without meaningful reset still keeps single hourly peak`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 20, + hour: 0))) + let existing = planEntry( + at: hourStart.addingTimeInterval(15 * 60), + usedPercent: 40, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)) + let incoming = planEntry( + at: hourStart.addingTimeInterval(45 * 60), + usedPercent: 5, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60 + 30)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == existing.capturedAt) + #expect(updated[0].usedPercent == existing.usedPercent) + #expect(updated[0].resetsAt == incoming.resetsAt) + } + + @Test + func `same hour reset keeps peak before reset and latest peak after reset`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 20, + hour: 0))) + let initial = [ + planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 40, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)), + planEntry( + at: hourStart.addingTimeInterval(20 * 60), + usedPercent: 12, + resetsAt: hourStart.addingTimeInterval(8 * 60 * 60)), + ] + let incoming = planEntry( + at: hourStart.addingTimeInterval(45 * 60), + usedPercent: 18, + resetsAt: hourStart.addingTimeInterval(8 * 60 * 60)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: incoming)) + + #expect(updated.count == 2) + #expect(updated[0].usedPercent == 40) + #expect(updated[1].usedPercent == 18) + #expect(updated[1].resetsAt == incoming.resetsAt) + } + + @Test + func `newer reset within hour replaces earlier post reset record`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 20, + hour: 0))) + let initial = [ + planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 40, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)), + planEntry( + at: hourStart.addingTimeInterval(20 * 60), + usedPercent: 12, + resetsAt: hourStart.addingTimeInterval(8 * 60 * 60)), + ] + let incoming = planEntry( + at: hourStart.addingTimeInterval(50 * 60), + usedPercent: 3, + resetsAt: hourStart.addingTimeInterval(10 * 60 * 60)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: incoming)) + + #expect(updated.count == 2) + #expect(updated[0].usedPercent == 40) + #expect(updated[1] == incoming) + } + + @Test + func `merged histories keep series separated by stable name`() throws { + let existing = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]), + ] + let incoming = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 40), + ]), + ] + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoriesForTesting( + existingHistories: existing, + samples: incoming)) + + #expect(findSeries(updated, name: .session, windowMinutes: 300) != nil) + #expect(findSeries(updated, name: .weekly, windowMinutes: 10080) != nil) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift new file mode 100644 index 0000000000..3e88039c00 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -0,0 +1,1532 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +// swiftlint:disable:next type_body_length +struct UsageStorePlanUtilizationTests { + @Test + func `coalesces changed usage within hour into single entry`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let first = planEntry(at: hourStart, usedPercent: 10) + let second = planEntry(at: hourStart.addingTimeInterval(25 * 60), usedPercent: 35) + + let initial = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + + #expect(updated.count == 1) + #expect(updated.last == second) + } + + @Test + func `changed reset boundary within hour appends new entry`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let first = planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 82, + resetsAt: hourStart.addingTimeInterval(30 * 60)) + let second = planEntry( + at: hourStart.addingTimeInterval(35 * 60), + usedPercent: 4, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60)) + + let initial = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + + #expect(updated.count == 2) + #expect(updated[0] == first) + #expect(updated[1] == second) + } + + @Test + func `first known reset boundary within hour replaces earlier provisional peak even when usage drops`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let first = planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 82, + resetsAt: nil) + let second = planEntry( + at: hourStart.addingTimeInterval(35 * 60), + usedPercent: 4, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60)) + + let initial = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + + #expect(updated.count == 1) + #expect(updated[0] == second) + } + + @Test + func `trims entry history to retention limit`() throws { + let maxSamples = UsageStore._planUtilizationMaxSamplesForTesting + let base = Date(timeIntervalSince1970: 1_700_000_000) + var entries: [PlanUtilizationHistoryEntry] = [] + + for offset in 0.. UsageSnapshot + { + UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow( + usedPercent: geminiWeeklyUsed, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude and GPT models Weekly Limit", + window: RateWindow( + usedPercent: thirdPartyWeeklyUsed, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: updatedAt) + } + + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let before = snapshot( + primary: RateWindow( + usedPercent: 80, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + geminiWeeklyUsed: 80, + thirdPartyWeeklyUsed: 0, + updatedAt: firstDate) + let representativeChanged = snapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + geminiWeeklyUsed: 0, + thirdPartyWeeklyUsed: 80, + updatedAt: firstDate.addingTimeInterval(3600)) + let reset = snapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + geminiWeeklyUsed: 0, + thirdPartyWeeklyUsed: 0, + updatedAt: firstDate.addingTimeInterval(7200)) + + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: before, + now: before.updatedAt) + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: representativeChanged, + now: representativeChanged.updatedAt) + #expect(recorder.events.isEmpty) + + await store.recordPlanUtilizationHistorySample( + provider: .antigravity, + snapshot: reset, + now: reset.updatedAt) + #expect(recorder.events.count == 1) + #expect(recorder.events.first?.usedPercent == 0) + } + + @MainActor + @Test + func `weekly quota celebration fires once across repeated low samples`() async { + let store = Self.makeStore() + let accountLabel = "repeated-low@example.com" + let recorder = WeeklyLimitResetEventRecorder(provider: .claude, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + let firstLow = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 1, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_001_800), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + let secondLow = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_002_100), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: accountLabel, + accountOrganization: nil, + loginMethod: "max")) + + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: firstLow, now: firstLow.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .claude, snapshot: secondLow, now: secondLow.updatedAt) + + let events = recorder.events + #expect(events.count == 1) + #expect(events[0].usedPercent == 1) + } + + @MainActor + @Test + func `weekly quota celebration posts for generic provider weekly lane`() async { + let store = Self.makeStore() + let accountLabel = "zai-reset-org" + let recorder = WeeklyLimitResetEventRecorder(provider: .zai, accountLabel: accountLabel) + defer { recorder.invalidate() } + + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 92, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: accountLabel, + loginMethod: "pro")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: accountLabel, + loginMethod: "pro")) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + + let events = recorder.events + #expect(events.count == 1) + #expect(events[0].provider == .zai) + #expect(events[0].accountLabel == accountLabel) + #expect(events[0].usedPercent == 0) + } + + @MainActor + @Test + func `generic provider weekly lane is persisted to provider history json`() async throws { + let store = Self.makeStore() + store.settings.historicalTrackingEnabled = true + let accountLabel = "zai-history-org" + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: firstDate, + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: accountLabel, + loginMethod: "pro")) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 58, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: firstDate.addingTimeInterval(3600), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: nil, + accountOrganization: accountLabel, + loginMethod: "pro")) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + + let histories = store.planUtilizationHistory(for: .zai) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [42, 58]) + + let providerURL = try #require(store.planUtilizationHistoryStore.directoryURL? + .appendingPathComponent("zai.json", isDirectory: false)) + var persistedBuckets: PlanUtilizationHistoryBuckets? + for _ in 0..<20 { + persistedBuckets = store.planUtilizationHistoryStore.load()[.zai] + if persistedBuckets?.histories(for: persistedBuckets?.preferredAccountKey).flatMap(\.entries).count == 2 { + break + } + try await Task.sleep(nanoseconds: 50_000_000) + } + #expect(FileManager.default.fileExists(atPath: providerURL.path)) + let persisted = try #require(persistedBuckets) + #expect(persisted.histories(for: persisted.preferredAccountKey) + .flatMap(\.entries) + .map(\.usedPercent) == [42, 58]) + } + + @MainActor + @Test + func `generic history opt in controls recording while saved history stays visible`() async throws { + let store = Self.makeStore() + let firstDate = Date(timeIntervalSince1970: 1_700_000_000) + let before = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: firstDate) + store._setSnapshotForTesting(before, provider: .zai) + let providerURL = try #require(store.planUtilizationHistoryStore.directoryURL? + .appendingPathComponent("zai.json", isDirectory: false)) + + #expect(store.settings.historicalTrackingEnabled == false) + #expect(store.supportsPlanUtilizationHistory(for: .zai) == false) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + #expect(store.planUtilizationHistory(for: .zai).isEmpty) + #expect(FileManager.default.fileExists(atPath: providerURL.path) == false) + + store.settings.historicalTrackingEnabled = true + #expect(store.supportsPlanUtilizationHistory(for: .zai)) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt) + #expect(findSeries(store.planUtilizationHistory(for: .zai), name: .weekly, windowMinutes: 10080)? + .entries.map(\.usedPercent) == [42]) + + store.settings.historicalTrackingEnabled = false + #expect(store.supportsPlanUtilizationHistory(for: .zai)) + let after = UsageSnapshot( + primary: RateWindow(usedPercent: 58, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: firstDate.addingTimeInterval(3600)) + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt) + #expect(findSeries(store.planUtilizationHistory(for: .zai), name: .weekly, windowMinutes: 10080)? + .entries.map(\.usedPercent) == [42]) + + for _ in 0..<20 where !FileManager.default.fileExists(atPath: providerURL.path) { + try await Task.sleep(nanoseconds: 50_000_000) + } + #expect(FileManager.default.fileExists(atPath: providerURL.path)) + } + + @MainActor + @Test + func `generic provider persists weekly extra window`() async { + let store = Self.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "weekly-budget", + title: "Weekly budget", + window: RateWindow( + usedPercent: 42, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: now) + + await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: snapshot, now: now) + + #expect(findSeries(store.planUtilizationHistory(for: .zai), name: .weekly, windowMinutes: 10080)? + .entries.map(\.usedPercent) == [42]) + } + + @MainActor + @Test + func `generic provider ignores unknown weekly extra window`() async { + let store = Self.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "weekly-reset-only", + title: "Weekly reset", + window: RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + usageKnown: false), + ], + updatedAt: now) + + await store.recordPlanUtilizationHistorySample(provider: .zed, snapshot: snapshot, now: now) + + #expect(store.planUtilizationHistory(for: .zed).isEmpty) + } + + @MainActor + @Test + func `generic provider prefers standard weekly window over extra window`() async { + let store = Self.makeStore() + store.settings.historicalTrackingEnabled = true + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "extra-weekly-budget", + title: "Extra weekly budget", + window: RateWindow( + usedPercent: 84, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: now) + + await store.recordPlanUtilizationHistorySample(provider: .factory, snapshot: snapshot, now: now) + + #expect(findSeries(store.planUtilizationHistory(for: .factory), name: .weekly, windowMinutes: 10080)? + .entries.map(\.usedPercent) == [42]) + } + + @MainActor + @Test + func `concurrent plan history writes coalesce within single hour bucket per series`() async throws { + let store = Self.makeStore() + let snapshot = Self.makeSnapshot(provider: .codex, email: "alice@example.com") + store._setSnapshotForTesting(snapshot, provider: .codex) + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let writeTimes = [ + hourStart.addingTimeInterval(5 * 60), + hourStart.addingTimeInterval(25 * 60), + hourStart.addingTimeInterval(45 * 60), + ] + + await withTaskGroup(of: Void.self) { group in + for writeTime in writeTimes { + group.addTask { + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + now: writeTime) + } + } + } + + let histories = try #require(store.planUtilizationHistory[.codex]?.accounts.values.first) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.count == 1) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.count == 1) + } + + @Test + func `runtime does not load unsupported plan history file`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let directoryURL = root + .appendingPathComponent("com.steipete.codexbar", isDirectory: true) + .appendingPathComponent("history", isDirectory: true) + let providerURL = directoryURL.appendingPathComponent("codex.json") + let store = PlanUtilizationHistoryStore(directoryURL: directoryURL) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true) + + let unsupportedJSON = """ + { + "version": 999, + "unscoped": [], + "accounts": {} + } + """ + try Data(unsupportedJSON.utf8).write(to: providerURL, options: Data.WritingOptions.atomic) + + let loaded = store.load() + #expect(loaded.isEmpty) + } + + @Test + func `store drops invalid zero minute and empty histories when loading and saving`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let directoryURL = root + .appendingPathComponent("com.steipete.codexbar", isDirectory: true) + .appendingPathComponent("history", isDirectory: true) + let providerURL = directoryURL.appendingPathComponent("codex.json") + let store = PlanUtilizationHistoryStore(directoryURL: directoryURL) + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true) + + let validUnscoped = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 12), + ]) + let validAccount = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 64), + ]) + let document = PersistedFixtureDocument( + version: 1, + preferredAccountKey: "alice", + unscoped: [ + planSeries(name: .session, windowMinutes: 0, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 99), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: []), + validUnscoped, + ], + accounts: [ + "alice": [ + planSeries(name: .session, windowMinutes: 0, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 88), + ]), + validAccount, + ], + "empty": [ + planSeries(name: .weekly, windowMinutes: 10080, entries: []), + ], + ]) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(document).write(to: providerURL, options: Data.WritingOptions.atomic) + + let loaded = store.load() + let loadedBuckets = try #require(loaded[.codex]) + #expect(loadedBuckets.unscoped == [validUnscoped]) + #expect(loadedBuckets.accounts == ["alice": [validAccount]]) + + store.save(loaded) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let rewritten = try decoder.decode(PersistedFixtureDocument.self, from: Data(contentsOf: providerURL)) + #expect(rewritten.unscoped == [validUnscoped]) + #expect(rewritten.accounts == ["alice": [validAccount]]) + } + + @Test + func `store round trips account buckets with series entries`() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let directoryURL = root + .appendingPathComponent("com.steipete.codexbar", isDirectory: true) + .appendingPathComponent("history", isDirectory: true) + let store = PlanUtilizationHistoryStore(directoryURL: directoryURL) + let buckets = PlanUtilizationHistoryBuckets( + preferredAccountKey: "alice", + unscoped: [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 50), + ]), + ], + accounts: [ + "alice": [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 10), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]), + ], + ]) + + store.save([.codex: buckets]) + let loaded = store.load() + + #expect(loaded == [.codex: buckets]) + } +} + +extension UsageStorePlanUtilizationTests { + private struct PersistedFixtureDocument: Codable { + let version: Int + let preferredAccountKey: String? + let unscoped: [PlanUtilizationSeriesHistory] + let accounts: [String: [PlanUtilizationSeriesHistory]] + } + + private struct FixtureDocument: Decodable { + let preferredAccountKey: String? + let unscoped: [PlanUtilizationSeriesHistory] + let accounts: [String: [PlanUtilizationSeriesHistory]] + } + + @MainActor + static func makeStore() -> UsageStore { + let suiteName = "UsageStorePlanUtilizationTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create isolated UserDefaults suite for tests") + } + defaults.removePersistentDomain(forName: suiteName) + let configStore = testConfigStore(suiteName: suiteName) + let planHistoryStore = testPlanUtilizationHistoryStore(suiteName: suiteName) + let temporaryRoot = FileManager.default.temporaryDirectory.standardizedFileURL.path + let managedStoreURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(suiteName)-managed-codex-accounts.json") + precondition(configStore.fileURL.standardizedFileURL.path.hasPrefix(temporaryRoot)) + precondition(configStore.fileURL.standardizedFileURL != CodexBarConfigStore.defaultURL().standardizedFileURL) + if let historyURL = planHistoryStore.directoryURL?.standardizedFileURL { + precondition(historyURL.path.hasPrefix(temporaryRoot)) + } + let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL) + try? FileManager.default.removeItem(at: managedStoreURL) + do { + try managedStore.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [])) + } catch { + fatalError("Failed to seed isolated managed Codex account store: \(error)") + } + let isolatedSettings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + tokenAccountStore: InMemoryTokenAccountStore()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: isolatedSettings, + planUtilizationHistoryStore: planHistoryStore, + startupBehavior: .testing) + isolatedSettings._test_managedCodexAccountStoreURL = managedStoreURL + isolatedSettings.codexActiveSource = .liveSystem + store.planUtilizationHistory = [:] + return store + } + + static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "plus")) + } + + static func loadPlanUtilizationFixture(named name: String) throws -> PlanUtilizationHistoryBuckets { + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures", isDirectory: true) + .appendingPathComponent(name, isDirectory: false) + let data = try Data(contentsOf: fixtureURL) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let document = try decoder.decode(FixtureDocument.self, from: data) + return PlanUtilizationHistoryBuckets( + preferredAccountKey: document.preferredAccountKey, + unscoped: document.unscoped, + accounts: document.accounts) + } +} + +func planEntry(at capturedAt: Date, usedPercent: Double, resetsAt: Date? = nil) -> PlanUtilizationHistoryEntry { + PlanUtilizationHistoryEntry(capturedAt: capturedAt, usedPercent: usedPercent, resetsAt: resetsAt) +} + +func planSeries( + name: PlanUtilizationSeriesName, + windowMinutes: Int, + entries: [PlanUtilizationHistoryEntry]) -> PlanUtilizationSeriesHistory +{ + PlanUtilizationSeriesHistory(name: name, windowMinutes: windowMinutes, entries: entries) +} + +func findSeries( + _ histories: [PlanUtilizationSeriesHistory], + name: PlanUtilizationSeriesName, + windowMinutes: Int) -> PlanUtilizationSeriesHistory? +{ + histories.first { $0.name == name && $0.windowMinutes == windowMinutes } +} + +private final class WeeklyLimitResetEventRecorder: @unchecked Sendable { + struct Event { + let provider: UsageProvider + let accountLabel: String? + let usedPercent: Double + } + + private let provider: UsageProvider + private let accountLabel: String? + private let lock = NSLock() + private var observedEvents: [Event] = [] + private var token: NSObjectProtocol? + + init(provider: UsageProvider, accountLabel: String?) { + self.provider = provider + self.accountLabel = accountLabel + self.token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { [weak self] notification in + guard let self, + let event = notification.object as? WeeklyLimitResetEvent + else { + return + } + + let recorded = MainActor.assumeIsolated { () -> Event? in + guard event.provider == self.provider, + event.accountLabel == self.accountLabel + else { + return nil + } + return Event( + provider: event.provider, + accountLabel: event.accountLabel, + usedPercent: event.usedPercent) + } + guard let recorded else { return } + + self.lock.lock() + self.observedEvents.append(recorded) + self.lock.unlock() + } + } + + var events: [Event] { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents + } + + var count: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents.count + } + + func invalidate() { + guard let token else { return } + NotificationCenter.default.removeObserver(token) + self.token = nil + } + + deinit { + self.invalidate() + } +} + +func formattedBoundary(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: date) +} diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 73af6fca3c..f6edec7618 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -1,26 +1,40 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore @MainActor -@Suite struct UsageStoreSessionQuotaTransitionTests { + private func makeSettings(suiteName: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + @MainActor final class SessionQuotaNotifierSpy: SessionQuotaNotifying { private(set) var posts: [(transition: SessionQuotaTransition, provider: UsageProvider)] = [] + private(set) var quotaWarningPosts: [( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool)] = [] func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) { self.posts.append((transition: transition, provider: provider)) } + + func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) { + self.quotaWarningPosts.append((event: event, provider: provider, soundEnabled: soundEnabled)) + } } @Test - func copilotSwitchFromPrimaryToSecondaryResetsBaseline() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-primary-secondary"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + func `copilot switch from primary to secondary resets baseline`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-primary-secondary") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.sessionQuotaNotificationsEnabled = true @@ -48,11 +62,8 @@ struct UsageStoreSessionQuotaTransitionTests { } @Test - func copilotSwitchFromSecondaryToPrimaryResetsBaseline() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-secondary-primary"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + func `copilot switch from secondary to primary resets baseline`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-secondary-primary") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.sessionQuotaNotificationsEnabled = true @@ -78,4 +89,775 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.isEmpty) } + + @Test + func `claude weekly primary fallback does not emit session quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-weekly") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.isEmpty) + } + + @Test + func `claude spend limit fallback does not emit session or quota warning notifications`() throws { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-spend-limit") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + let json = """ + { + "extra_usage": { + "is_enabled": true, + "monthly_limit": 600, + "used_credits": 434.43, + "utilization": 72, + "currency": "USD" + } + } + """ + let claude = try ClaudeUsageFetcher._mapOAuthUsageForTesting( + Data(json.utf8), + subscriptionType: "enterprise") + let snapshot = ClaudeOAuthFetchStrategy._snapshotForTesting(from: claude) + + store.handleSessionQuotaTransition(provider: .claude, snapshot: snapshot) + store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(snapshot.providerCost?.period == "Spend limit") + #expect(notifier.posts.isEmpty) + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `mimo balance and monthly credits do not emit quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-mimo-balance") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + let balanceSnapshot = MiMoUsageSnapshot( + balance: 0, + currency: "USD", + updatedAt: Date()) + .toUsageSnapshot() + let tokenPlanSnapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + planCode: "standard", + tokenUsed: 100, + tokenLimit: 100, + tokenPercent: 1, + updatedAt: Date()) + .toUsageSnapshot() + + for snapshot in [balanceSnapshot, tokenPlanSnapshot] { + store.handleSessionQuotaTransition(provider: .mimo, snapshot: snapshot) + store.handleQuotaWarningTransitions(provider: .mimo, snapshot: snapshot) + } + + #expect(notifier.posts.isEmpty) + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `claude five hour primary still emits session quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-session") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 5 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 5 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.map(\.provider) == [.claude]) + } + + @Test + func `antigravity session notification uses quota summary duration instead of family representative`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-antigravity-session") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 20, weeklyUsed: 100)) + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 100, weeklyUsed: 100)) + + #expect(notifier.posts.map(\.provider) == [.antigravity]) + #expect(notifier.posts.map(\.transition) == [.depleted]) + } + + @Test + func `antigravity preserves session notifications for durationless legacy family lanes`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-antigravity-legacy-session") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 20, claudeUsed: 20)) + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 20, claudeUsed: 100)) + + #expect(notifier.posts.map(\.provider) == [.antigravity]) + #expect(notifier.posts.map(\.transition) == [.depleted]) + } + + @Test + func `antigravity snapshot mode change resets session notification baseline`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-antigravity-mode-change") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 20, weeklyUsed: 20)) + store.handleSessionQuotaTransition( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 100, claudeUsed: 100)) + + #expect(notifier.posts.isEmpty) + } + + @Test + func `quota warning disabled does not post`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-disabled") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = false + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleQuotaWarningTransitions(provider: .codex, snapshot: snapshot) + + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `quota warning posts once per downward threshold crossing`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-once") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil))) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil))) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil))) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.window == .session) + #expect(notifier.quotaWarningPosts.first?.event.threshold == 50) + #expect(notifier.quotaWarningPosts.first?.event.accountDisplayName == "person@example.com") + } + + @Test + func `quota warning omits account when personal info is hidden`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-account-hidden") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.hidePersonalInfo = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: identity)) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: identity)) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.accountDisplayName == nil) + } + + @Test + func `hidden quota warning markers do not disable warning notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-markers-hidden") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningMarkersVisible = false + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.threshold == 50) + } + + @Test + func `quota warning crossing multiple thresholds posts most severe only`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-severe") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 85, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [20]) + } + + @Test + func `quota warning recovers and can fire again`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-recover") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + for used in [40, 55, 10, 55] { + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: Double(used), + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date())) + } + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + + @Test + func `quota warning provider override beats global thresholds`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-override") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + settings.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: [10]) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 95, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [10]) + } + + @Test + func `quota warning session only config ignores weekly crossings`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-session-only") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: false) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session]) + } + + @Test + func `quota warning weekly only config ignores session crossings`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-weekly-only") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: false) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.weekly]) + } + + @Test + func `minimax quota warning posts for session and weekly windows`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-minimax") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .minimax, + snapshot: self.minimaxSnapshot(sessionUsed: 40, weeklyUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .minimax, + snapshot: self.minimaxSnapshot(sessionUsed: 55, weeklyUsed: 55)) + + #expect(notifier.quotaWarningPosts.map(\.provider) == [.minimax, .minimax]) + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session, .weekly]) + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + + @Test + func `antigravity quota warnings use named session and weekly durations`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-antigravity") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 40, weeklyUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 60, weeklyUsed: 60)) + + #expect(notifier.quotaWarningPosts.map(\.provider) == [.antigravity, .antigravity]) + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session, .weekly]) + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + + @Test + func `antigravity legacy quota warnings do not infer weekly from family slots`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-antigravity-legacy") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 40, claudeUsed: 40)) + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 60, claudeUsed: 60)) + + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session]) + } + + @Test + func `antigravity quota warning mode change resets warning baseline`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-antigravity-mode") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityQuotaSummarySnapshot(sessionUsed: 20, weeklyUsed: 20)) + store.handleQuotaWarningTransitions( + provider: .antigravity, + snapshot: self.antigravityLegacySnapshot(geminiUsed: 80, claudeUsed: 40)) + + #expect(notifier.quotaWarningPosts.isEmpty) + let key = UsageStore.QuotaWarningStateKey(provider: .antigravity, window: .session) + #expect(store.quotaWarningState[key]?.lastRemaining == 20) + #expect(store.quotaWarningState[key]?.source == .antigravityLegacy) + } + + @Test + func `disabling quota warning window clears fired state`() { + let settings = self + .makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-disabled-clears-state") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + settings.setQuotaWarningWindowEnabled(.session, enabled: false) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(store.quotaWarningState[UsageStore.QuotaWarningStateKey(provider: .codex, window: .session)] == nil) + } + + private func minimaxSnapshot(sessionUsed: Double, weeklyUsed: Double) -> UsageSnapshot { + let now = Date() + return MiniMaxUsageSnapshot( + planName: "Plus", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: [ + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "5 hours", + timeRange: "15:00-20:00(UTC+8)", + usage: Int(sessionUsed), + limit: 100, + percent: sessionUsed, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Resets in 1 hour"), + MiniMaxServiceUsage( + serviceType: "text-generation", + windowType: "Weekly", + timeRange: "06/01 00:00 - 06/08 00:00(UTC+8)", + usage: Int(weeklyUsed), + limit: 100, + percent: weeklyUsed, + resetsAt: now.addingTimeInterval(6 * 24 * 3600), + resetDescription: "Resets in 6 days"), + ]).toUsageSnapshot() + } + + private func antigravityQuotaSummarySnapshot(sessionUsed: Double, weeklyUsed: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + window: RateWindow( + usedPercent: sessionUsed, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow( + usedPercent: weeklyUsed, + windowMinutes: 7 * 24 * 60, + resetsAt: nil, + resetDescription: nil)), + ], + updatedAt: Date()) + } + + private func antigravityLegacySnapshot(geminiUsed: Double, claudeUsed: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: geminiUsed, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: RateWindow( + usedPercent: claudeUsed, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date()) + } } diff --git a/Tests/CodexBarTests/UsageStoreTimeoutTests.swift b/Tests/CodexBarTests/UsageStoreTimeoutTests.swift new file mode 100644 index 0000000000..0b3a8e60de --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreTimeoutTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import CodexBar + +struct UsageStoreTimeoutTests { + private final class ProbeGate: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var released = false + + func wait() async { + await withCheckedContinuation { continuation in + let shouldResume = self.lock.withLock { + guard !self.released else { return true } + self.continuation = continuation + return false + } + if shouldResume { + continuation.resume() + } + } + } + + func release() { + let continuation = self.lock.withLock { + self.released = true + let continuation = self.continuation + self.continuation = nil + return continuation + } + continuation?.resume() + } + + var isReleased: Bool { + self.lock.withLock { self.released } + } + } + + @Test + func `timeout does not wait for a cancellation ignoring probe`() async { + let gate = ProbeGate() + defer { gate.release() } + + let result = await UsageStore.runWithTimeout(seconds: 0.03) { + await gate.wait() + return "late result" + } + + #expect(result == "Probe timed out after 0s") + #expect(!gate.isReleased) + } + + @Test + func `completed probe wins timeout race`() async { + let result = await UsageStore.runWithTimeout(seconds: 10) { + "probe result" + } + + #expect(result == "probe result") + } +} diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift new file mode 100644 index 0000000000..53170ec374 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -0,0 +1,198 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct UsageStoreWidgetSnapshotTests { + @Test + func `widget snapshot includes antigravity grouped usage rows`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-grouped" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "antigravity-grouped-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) + #expect(entry.usageRows?.map(\.id) == ["primary", "secondary"]) + #expect(entry.usageRows?.map(\.title) == ["Gemini Models", "Claude and GPT"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [90, 80]) + } + + @Test + func `widget snapshot includes antigravity quota summary rows`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-quota-summary" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 27, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "antigravity-quota-summary-gemini-5h", + title: "Gemini Models Five Hour Limit", + window: RateWindow(usedPercent: 9, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-gemini-weekly", + title: "Gemini Models Weekly Limit", + window: RateWindow(usedPercent: 18, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-5h", + title: "Claude and GPT models Five Hour Limit", + window: RateWindow(usedPercent: 27, windowMinutes: 300, resetsAt: nil, resetDescription: nil)), + NamedRateWindow( + id: "antigravity-quota-summary-3p-weekly", + title: "Claude and GPT models Weekly Limit", + window: RateWindow(usedPercent: 36, windowMinutes: 10080, resetsAt: nil, resetDescription: nil)), + ], + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "antigravity-quota-summary-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) + #expect(entry.usageRows?.map(\.title) == [ + "Gemini Models Five Hour Limit", + "Gemini Models Weekly Limit", + "Claude and GPT models Five Hour Limit", + "Claude and GPT models Weekly Limit", + ]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [91, 82, 73, 64]) + } + + @Test + func `widget snapshot labels antigravity compact fallback with model name`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-compact-fallback" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = try AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Experimental Model", + modelId: "MODEL_PLACEHOLDER_NEW", + remainingFraction: 0.36, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + .toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "antigravity-compact-fallback-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) + #expect(entry.primary == nil) + #expect(entry.usageRows?.map(\.id) == ["antigravity-compact-fallback-MODEL_PLACEHOLDER_NEW"]) + #expect(entry.usageRows?.map(\.title) == ["Experimental Model"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [36]) + } + + @Test + func `widget snapshot excludes mimo balance from quota rows`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-mimo-balance" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = MiMoUsageSnapshot( + balance: 25.51, + currency: "USD", + updatedAt: Date()) + .toUsageSnapshot() + store._setSnapshotForTesting(snapshot, provider: .mimo) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "mimo-balance-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .mimo }) + #expect(entry.primary == nil) + #expect(entry.secondary == nil) + #expect(entry.usageRows?.isEmpty == true) + } +} diff --git a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift new file mode 100644 index 0000000000..b6545f0743 --- /dev/null +++ b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +struct UserFacingLocalizationCoverageTests { + @Test + func `selected user-facing UI surfaces avoid raw English literals`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let forbiddenMarkersByFile: [String: [String]] = [ + "Sources/CodexBar/CostHistoryChartMenuView.swift": [ + ".value(\"Day\"", + ".value(\"Cost\"", + ".value(\"Cap start\"", + ".value(\"Cap end\"", + ], + "Sources/CodexBar/CreditsHistoryChartMenuView.swift": [ + ".value(\"Day\"", + ".value(\"Credits used\"", + ".value(\"Cap start\"", + ".value(\"Cap end\"", + "Text(\"Total (30d):", + "\\(total) credits", + "\\(used) credits", + ], + "Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift": [ + ".value(\"Series\"", + ".value(\"Capacity Start\"", + ".value(\"Capacity End\"", + ".value(\"Utilization Start\"", + ".value(\"Utilization End\"", + ], + "Sources/CodexBar/PreferencesCodexAccountsSection.swift": [ + "?? \"No system account\"", + "return \"Adding Account…\"", + "return \"Add Account\"", + "return \"Re-authenticating…\"", + "return \"Re-auth\"", + "ProviderSettingsSection(title: \"Accounts\")", + "Text(\"Active\")", + "Text(\"Choose which Codex account CodexBar should follow.\")", + "Text(\"Account\")", + "Text(\"No Codex accounts detected yet.\")", + "Text(\"System\")", + "Text(\"The default Codex account on this Mac.\")", + "Text(\"(System)\")", + "Button(\"Remove\")", + ], + "Sources/CodexBar/PreferencesProviderDetailView.swift": [ + ".help(\"Refresh\")", + "accessibilityLabel: \"Usage used\"", + ], + "Sources/CodexBar/PreferencesProviderErrorView.swift": [ + ".help(\"Copy error\")", + ], + "Sources/CodexBar/PreferencesProviderSettingsRows.swift": [ + "Text(self.title)", + "Text(self.toggle.title)", + "Text(self.toggle.subtitle)", + "Button(action.title)", + "Text(self.picker.title)", + "Text(option.title)", + "Text(trimmedTitle)", + "Text(trimmedSubtitle)", + "Text(self.descriptor.title)", + "Text(self.descriptor.subtitle)", + "Text(\"No token accounts yet.\")", + "Button(\"Remove\")", + "TextField(\"Label\"", + "Button(\"Add\")", + "TextField(\"Org ID (optional)\"", + ".help(\"Optional organization ID for accounts linked to multiple Anthropic organizations.\")", + "Button(\"Open token file\")", + "Button(\"Reload\")", + "Text(\"No organizations loaded. Click Refresh after setting your API key.\")", + "Button(\"Refresh organizations\")", + ], + "Sources/CodexBar/PreferencesProviderSidebarView.swift": [ + ".help(\"Drag to reorder\")", + "\"Disabled —", + ".accessibilityLabel(\"Reorder\")", + ], + "Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift": [ + "Text(\"Subscription Utilization\")", + ], + "Sources/CodexBar/StatusItemController+CostMenuCard.swift": [ + "static let costMenuTitle", + ], + "Sources/CodexBar/UsageBreakdownChartMenuView.swift": [ + ".value(\"Day\"", + ".value(\"Credits used\"", + ".value(\"Service\"", + ".value(\"Cap start\"", + ".value(\"Cap end\"", + ], + ] + + var violations: [String] = [] + for (relativePath, markers) in forbiddenMarkersByFile.sorted(by: { $0.key < $1.key }) { + let source = try String(contentsOf: root.appendingPathComponent(relativePath), encoding: .utf8) + for marker in markers where source.contains(marker) { + violations.append("\(relativePath): \(marker)") + } + } + + #expect( + violations.isEmpty, + "Raw user-facing localization markers remain:\n\(violations.joined(separator: "\n"))") + } +} diff --git a/Tests/CodexBarTests/VeniceSettingsReaderTests.swift b/Tests/CodexBarTests/VeniceSettingsReaderTests.swift new file mode 100644 index 0000000000..bc44a0645b --- /dev/null +++ b/Tests/CodexBarTests/VeniceSettingsReaderTests.swift @@ -0,0 +1,73 @@ +import CodexBarCore +import Testing + +struct VeniceSettingsReaderTests { + @Test + func `reads VENICE_API_KEY`() { + let env = ["VENICE_API_KEY": "ven-abc123"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-abc123") + } + + @Test + func `falls back to VENICE_KEY`() { + let env = ["VENICE_KEY": "ven-fallback"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-fallback") + } + + @Test + func `VENICE_API_KEY takes priority over VENICE_KEY`() { + let env = ["VENICE_API_KEY": "ven-primary", "VENICE_KEY": "ven-secondary"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-primary") + } + + @Test + func `trims whitespace`() { + let env = ["VENICE_API_KEY": " ven-trimmed "] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-trimmed") + } + + @Test + func `strips double quotes`() { + let env = ["VENICE_API_KEY": "\"ven-quoted\""] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-quoted") + } + + @Test + func `strips single quotes`() { + let env = ["VENICE_KEY": "'ven-single'"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-single") + } + + @Test + func `returns nil when no key present`() { + #expect(VeniceSettingsReader.apiKey(environment: [:]) == nil) + } + + @Test + func `returns nil for empty key`() { + let env = ["VENICE_API_KEY": ""] + #expect(VeniceSettingsReader.apiKey(environment: env) == nil) + } + + @Test + func `returns nil for whitespace-only key`() { + let env = ["VENICE_API_KEY": " "] + #expect(VeniceSettingsReader.apiKey(environment: env) == nil) + } +} + +struct VeniceProviderTokenResolverTests { + @Test + func `resolves from environment`() { + let env = ["VENICE_API_KEY": "ven-resolve-test"] + let resolution = ProviderTokenResolver.veniceResolution(environment: env) + #expect(resolution?.token == "ven-resolve-test") + #expect(resolution?.source == .environment) + } + + @Test + func `returns nil when key absent`() { + let resolution = ProviderTokenResolver.veniceResolution(environment: [:]) + #expect(resolution == nil) + } +} diff --git a/Tests/CodexBarTests/VeniceUsageFetcherTests.swift b/Tests/CodexBarTests/VeniceUsageFetcherTests.swift new file mode 100644 index 0000000000..fc226997ca --- /dev/null +++ b/Tests/CodexBarTests/VeniceUsageFetcherTests.swift @@ -0,0 +1,297 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct VeniceUsageFetcherTests { + @Test + func `parses DIEM balance response`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 90.50, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.canConsume == true) + #expect(snapshot.consumptionCurrency == "DIEM") + #expect(snapshot.diemBalance == 90.50) + #expect(snapshot.usdBalance == nil) + #expect(snapshot.diemEpochAllocation == 100.0) + } + + @Test + func `parses USD balance response`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": null, + "usd": 25.75 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.canConsume == true) + #expect(snapshot.consumptionCurrency == "USD") + #expect(snapshot.diemBalance == nil) + #expect(snapshot.usdBalance == 25.75) + #expect(snapshot.diemEpochAllocation == nil) + } + + @Test + func `parses string-encoded balances and allocation`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": "90.50", + "usd": "25.75" + }, + "diemEpochAllocation": "100.0" + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.diemBalance == 90.50) + #expect(snapshot.usdBalance == 25.75) + #expect(snapshot.diemEpochAllocation == 100.0) + } + + @Test + func `parses both DIEM and USD present`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "BUNDLED_CREDITS", + "balances": { + "diem": 50.0, + "usd": 10.0 + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.diemBalance == 50.0) + #expect(snapshot.usdBalance == 10.0) + } + + @Test + func `uses DIEM allocation progress for bundled credits currency`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "BUNDLED_CREDITS", + "balances": { + "diem": 50.0, + "usd": 10.0 + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("DIEM 50.00 / 100.00") == true) + #expect(usage.primary?.usedPercent == 50.0) + } + + @Test + func `uses USD display when consumptionCurrency is USD and both balances exist`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": 50.0, + "usd": 12.34 + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "$12.34 USD remaining") + #expect(usage.primary?.usedPercent == 0) + } + + @Test + func `handles canConsume=false`() throws { + let json = """ + { + "canConsume": false, + "consumptionCurrency": "USD", + "balances": { + "diem": null, + "usd": 100.0 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "Balance unavailable for API calls") + } + + @Test + func `displays DIEM with epoch allocation`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 75.0, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("DIEM 75.00 / 100.00") == true) + #expect(usage.primary?.usedPercent == 25.0) + } + + @Test + func `displays DIEM without allocation`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 50.0, + "usd": null + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("DIEM 50.00 remaining") == true) + #expect(usage.primary?.usedPercent == 0) + } + + @Test + func `displays USD balance`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": null, + "usd": 15.50 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("$15.50") == true) + #expect(usage.primary?.usedPercent == 0) + } + + @Test + func `handles zero balances`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": 0.0, + "usd": 0.0 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "No Venice API balance available") + #expect(usage.primary?.usedPercent == 100) + } + + @Test + func `handles null balances with canConsume=true`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": null, + "balances": { + "diem": null, + "usd": null + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "No Venice API balance available") + #expect(usage.primary?.usedPercent == 100) + } + + @Test + func `identity uses venice provider ID`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 90.0, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.providerID == .venice) + #expect(usage.identity?.accountEmail == nil) + #expect(usage.identity?.accountOrganization == nil) + } + + @Test + func `throws on malformed JSON`() { + let json = "[{ \"canConsume\": true }]" + #expect { + _ = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case VeniceUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `throws on invalid JSON`() { + let json = "{ invalid json }" + #expect { + _ = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case VeniceUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `clamps used percent to 0-100 range`() throws { + // Negative used percent should be clamped to 0 + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 150.0, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + } +} diff --git a/Tests/CodexBarTests/VertexAIOAuthCredentialsTests.swift b/Tests/CodexBarTests/VertexAIOAuthCredentialsTests.swift new file mode 100644 index 0000000000..9527fdb3c4 --- /dev/null +++ b/Tests/CodexBarTests/VertexAIOAuthCredentialsTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct VertexAIOAuthCredentialsTests { + @Test + func `service account credentials from GOOGLE_APPLICATION_CREDENTIALS use gcloud token`() async throws { + let fileURL = try Self.writeServiceAccountCredentials() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + let env = ["GOOGLE_APPLICATION_CREDENTIALS": fileURL.path] + + #expect(VertexAIOAuthCredentialsStore.hasCredentials(environment: env)) + + let override: @Sendable ([String: String]) async throws -> String = { environment in + #expect(environment["GOOGLE_APPLICATION_CREDENTIALS"] == fileURL.path) + return "ya29.service-account\n" + } + let credentials = try await VertexAIOAuthCredentialsStore.$gcloudAccessTokenOverrideForTesting.withValue( + override) + { + try await VertexAIOAuthCredentialsStore.loadForFetch(environment: env) + } + + #expect(credentials.accessToken == "ya29.service-account") + #expect(credentials.projectId == "service-project") + #expect(credentials.email == "codexbar@test.iam.gserviceaccount.com") + #expect(!credentials.needsRefresh) + } + + @Test + func `user ADC credentials still parse from CLOUDSDK_CONFIG`() throws { + let configDir = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-vertex-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: configDir) } + + let credentialsURL = configDir.appendingPathComponent("application_default_credentials.json") + let credentialsJSON = """ + { + "client_id": "client-id", + "client_secret": "client-secret", + "refresh_token": "refresh-token" + } + """ + try credentialsJSON.write(to: credentialsURL, atomically: true, encoding: .utf8) + + let configurationsDir = configDir + .appendingPathComponent("configurations", isDirectory: true) + try FileManager.default.createDirectory(at: configurationsDir, withIntermediateDirectories: true) + try "project = configured-project\n".write( + to: configurationsDir.appendingPathComponent("config_default"), + atomically: true, + encoding: .utf8) + + let env = ["CLOUDSDK_CONFIG": configDir.path] + let credentials = try VertexAIOAuthCredentialsStore.load(environment: env) + + #expect(credentials.refreshToken == "refresh-token") + #expect(credentials.projectId == "configured-project") + } + + private static func writeServiceAccountCredentials() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-vertex-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("service-account.json") + let json = """ + { + "type": "service_account", + "project_id": "service-project", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----\\n", + "client_email": "codexbar@test.iam.gserviceaccount.com", + "client_id": "1234567890", + "token_uri": "https://oauth2.googleapis.com/token" + } + """ + try json.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } +} diff --git a/Tests/CodexBarTests/WarpUsageFetcherTests.swift b/Tests/CodexBarTests/WarpUsageFetcherTests.swift index 9fb74999c3..7d9bf1fc5d 100644 --- a/Tests/CodexBarTests/WarpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/WarpUsageFetcherTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct WarpUsageFetcherTests { @Test - func parsesSnapshotAndAggregatesBonusCredits() throws { + func `parses snapshot and aggregates bonus credits`() throws { let json = """ { "data": { @@ -66,7 +65,7 @@ struct WarpUsageFetcherTests { } @Test - func graphQLErrorsThrowAPIError() { + func `graph QL errors throw API error`() { let json = """ { "errors": [ @@ -84,7 +83,7 @@ struct WarpUsageFetcherTests { } @Test - func nullUnlimitedAndStringNumericsParseSafely() throws { + func `null unlimited and string numerics parse safely`() throws { let json = """ { "data": { @@ -112,7 +111,7 @@ struct WarpUsageFetcherTests { } @Test - func unexpectedTypenameReturnsParseError() { + func `unexpected typename returns parse error`() { let json = """ { "data": { @@ -132,7 +131,7 @@ struct WarpUsageFetcherTests { } @Test - func missingRequestLimitInfoReturnsParseError() { + func `missing request limit info returns parse error`() { let json = """ { "data": { @@ -153,7 +152,7 @@ struct WarpUsageFetcherTests { } @Test - func invalidRootReturnsParseError() { + func `invalid root returns parse error`() { let json = """ [{ "data": {} }] """ @@ -167,7 +166,7 @@ struct WarpUsageFetcherTests { } @Test - func toUsageSnapshotOmitsSecondaryWhenNoBonusCredits() { + func `to usage snapshot omits secondary when no bonus credits`() { let source = WarpUsageSnapshot( requestLimit: 100, requestsUsed: 10, @@ -184,7 +183,7 @@ struct WarpUsageFetcherTests { } @Test - func toUsageSnapshotKeepsBonusWindowWhenBonusExists() throws { + func `to usage snapshot keeps bonus window when bonus exists`() throws { let source = WarpUsageSnapshot( requestLimit: 100, requestsUsed: 10, @@ -202,7 +201,7 @@ struct WarpUsageFetcherTests { } @Test - func toUsageSnapshotUnlimitedPrimaryDoesNotShowResetDate() throws { + func `to usage snapshot unlimited primary does not show reset date`() throws { let source = WarpUsageSnapshot( requestLimit: 0, requestsUsed: 0, @@ -221,7 +220,7 @@ struct WarpUsageFetcherTests { } @Test - func apiErrorSummaryIncludesPlainTextBodies() { + func `api error summary includes plain text bodies`() { // Regression: Warp edge returns 429 with a non-JSON body ("Rate exceeded.") when User-Agent is missing/wrong. let summary = WarpUsageFetcher._apiErrorSummaryForTesting( statusCode: 429, diff --git a/Tests/CodexBarTests/WebKitTeardownTests.swift b/Tests/CodexBarTests/WebKitTeardownTests.swift index f824620bc0..b3d4dac15d 100644 --- a/Tests/CodexBarTests/WebKitTeardownTests.swift +++ b/Tests/CodexBarTests/WebKitTeardownTests.swift @@ -3,13 +3,12 @@ import AppKit import Testing @testable import CodexBarCore -@Suite @MainActor struct WebKitTeardownTests { final class Owner {} @Test - func scheduleCleanupRegistersOwner() { + func `schedule cleanup registers owner`() { let owner = Owner() WebKitTeardown.resetForTesting() WebKitTeardown.scheduleCleanup(owner: owner, window: nil, webView: nil) diff --git a/Tests/CodexBarTests/WidgetSnapshotTests.swift b/Tests/CodexBarTests/WidgetSnapshotTests.swift index 6be63f89e5..d431e7eb6b 100644 --- a/Tests/CodexBarTests/WidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/WidgetSnapshotTests.swift @@ -2,23 +2,29 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct WidgetSnapshotTests { @Test - func widgetSnapshotRoundTrip() throws { + func `widget snapshot round trip`() throws { let entry = WidgetSnapshot.ProviderEntry( provider: .codex, updatedAt: Date(), primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot(id: "session", title: "Session", percentLeft: 90), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "weekly", title: "Weekly", percentLeft: 80), + ], creditsRemaining: 123.4, codeReviewRemainingPercent: 80, tokenUsage: WidgetSnapshot.TokenUsageSummary( sessionCostUSD: 12.3, sessionTokens: 1200, last30DaysCostUSD: 456.7, - last30DaysTokens: 9800), + last30DaysTokens: 9800, + currencyCode: "eur", + sessionLabel: "Latest billing day", + last30DaysLabel: "This month"), dailyUsage: [ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-20", totalTokens: 1200, costUSD: 12.3), ]) @@ -39,11 +45,15 @@ struct WidgetSnapshotTests { #expect(decoded.entries.count == 1) #expect(decoded.entries.first?.provider == .codex) #expect(decoded.entries.first?.tokenUsage?.sessionTokens == 1200) + #expect(decoded.entries.first?.tokenUsage?.currencyCode == "EUR") + #expect(decoded.entries.first?.tokenUsage?.sessionLabel == "Latest billing day") + #expect(decoded.entries.first?.tokenUsage?.last30DaysLabel == "This month") + #expect(decoded.entries.first?.usageRows?.map(\.id) == ["session", "weekly"]) #expect(decoded.enabledProviders == [.codex, .claude]) } @Test - func widgetSnapshotRoundTripPreservesKiloProvider() throws { + func `widget snapshot round trip preserves kilo provider`() throws { let entry = WidgetSnapshot.ProviderEntry( provider: .kilo, updatedAt: Date(), @@ -80,7 +90,7 @@ struct WidgetSnapshotTests { } @Test - func widgetSnapshotRoundTripPreservesKiloZeroTotalEdgeState() throws { + func `widget snapshot round trip preserves kilo zero total edge state`() throws { let now = Date() let kiloSnapshot = KiloUsageSnapshot( creditsUsed: 0, @@ -121,4 +131,76 @@ struct WidgetSnapshotTests { #expect(decoded.entries.first?.primary?.resetDescription == "0/0 credits") #expect(decoded.enabledProviders == [.kilo]) } + + @Test + func `widget snapshot decodes legacy payload without usage rows`() throws { + let json = """ + { + "entries": [ + { + "provider": "codex", + "updatedAt": "2026-04-04T06:30:00Z", + "primary": null, + "secondary": { + "usedPercent": 25, + "windowMinutes": 10080, + "resetsAt": null, + "resetDescription": null + }, + "tertiary": null, + "creditsRemaining": null, + "codeReviewRemainingPercent": null, + "tokenUsage": null, + "dailyUsage": [] + } + ], + "enabledProviders": ["codex"], + "generatedAt": "2026-04-04T06:30:00Z" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(WidgetSnapshot.self, from: Data(json.utf8)) + + #expect(decoded.entries.count == 1) + #expect(decoded.entries.first?.usageRows == nil) + #expect(decoded.entries.first?.secondary?.usedPercent == 25) + } + + @Test + func `widget snapshot decodes legacy token usage as usd`() throws { + let json = """ + { + "entries": [ + { + "provider": "codex", + "updatedAt": "2026-04-04T06:30:00Z", + "primary": null, + "secondary": null, + "tertiary": null, + "creditsRemaining": null, + "codeReviewRemainingPercent": null, + "tokenUsage": { + "sessionCostUSD": 1.25, + "sessionTokens": 1200, + "last30DaysCostUSD": 9.50, + "last30DaysTokens": 4200 + }, + "dailyUsage": [] + } + ], + "generatedAt": "2026-04-04T06:30:00Z" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(WidgetSnapshot.self, from: Data(json.utf8)) + + #expect(decoded.entries.first?.tokenUsage?.currencyCode == "USD") + #expect(decoded.entries.first?.tokenUsage?.sessionLabel == "Today") + #expect(decoded.entries.first?.tokenUsage?.last30DaysLabel == "30d") + #expect(decoded.enabledProviders == [.codex]) + } } diff --git a/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift b/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift new file mode 100644 index 0000000000..3579ba4fd9 --- /dev/null +++ b/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift @@ -0,0 +1,169 @@ +import Foundation +import SweetCookieKit +import Testing +@testable import CodexBarCore + +struct WindsurfDevinSessionImporterTests { + @Test + func `defaults to Chrome before fallback Chromium browsers`() { + #expect(WindsurfDevinSessionImporter.defaultPreferredBrowsers == [.chrome]) + #expect(!WindsurfDevinSessionImporter.fallbackBrowsers.contains(.chrome)) + #expect(WindsurfDevinSessionImporter.fallbackBrowsersExcluding([.chrome, .edge]).first == .chromeBeta) + #expect(!WindsurfDevinSessionImporter.fallbackBrowsersExcluding([.chrome, .edge]).contains(.edge)) + } + + @Test + func `reads Devin app storage before legacy Windsurf origin`() { + #expect(WindsurfDevinSessionImporter.localStorageOrigins.map(\.absoluteString) == [ + "https://app.devin.ai", + "https://windsurf.com", + ]) + } + + @Test + func `decodes quoted local storage strings`() { + #expect(WindsurfDevinSessionImporter + .decodedStorageValue(#""devin-session-token$abc""#) == "devin-session-token$abc") + #expect(WindsurfDevinSessionImporter.decodedStorageValue("auth1_xyz") == "auth1_xyz") + } + + @Test + func `builds session only when all local storage keys exist`() { + let storage = [ + "devin_session_token": "devin-session-token$abc", + "devin_auth1_token": "auth1_xyz", + "devin_account_id": "account-123", + "devin_primary_org_id": "org-456", + ] + + let session = WindsurfDevinSessionImporter.session(from: storage, sourceLabel: "Chrome Default") + + #expect(session?.session.sessionToken == "devin-session-token$abc") + #expect(session?.session.auth1Token == "auth1_xyz") + #expect(session?.session.accountID == "account-123") + #expect(session?.session.primaryOrgID == "org-456") + #expect(session?.sourceLabel == "Chrome Default") + } + + @Test + func `keeps partial app origin separate from complete legacy origin`() throws { + let appOrigin = try #require(URL(string: "https://app.devin.ai")) + let legacyOrigin = try #require(URL(string: "https://windsurf.com")) + + let snapshots = WindsurfDevinSessionImporter.localStorageSnapshots(from: [ + ( + origin: appOrigin, + entries: [ + Self.entry(origin: appOrigin, key: "devin_session_token", value: "app-session"), + Self.entry(origin: appOrigin, key: "devin_auth1_token", value: "app-auth1"), + ]), + ( + origin: legacyOrigin, + entries: [ + Self.entry(origin: legacyOrigin, key: "devin_session_token", value: "legacy-session"), + Self.entry(origin: legacyOrigin, key: "devin_auth1_token", value: "legacy-auth1"), + Self.entry(origin: legacyOrigin, key: "devin_account_id", value: "legacy-account"), + Self.entry(origin: legacyOrigin, key: "devin_primary_org_id", value: "legacy-org"), + ]), + ]) + + #expect(snapshots == [ + WindsurfDevinSessionImporter.LocalStorageSnapshot( + storage: [ + "devin_session_token": "legacy-session", + "devin_auth1_token": "legacy-auth1", + "devin_account_id": "legacy-account", + "devin_primary_org_id": "legacy-org", + ], + sourceSuffix: "windsurf.com"), + ]) + } + + @Test + func `keeps text entry fallback after structured origin snapshots`() throws { + let appOrigin = try #require(URL(string: "https://app.devin.ai")) + + let snapshots = WindsurfDevinSessionImporter.localStorageSnapshots( + from: [ + ( + origin: appOrigin, + entries: [ + Self.entry(origin: appOrigin, key: "devin_session_token", value: "stale-app-session"), + Self.entry(origin: appOrigin, key: "devin_auth1_token", value: "stale-app-auth1"), + Self.entry(origin: appOrigin, key: "devin_account_id", value: "stale-app-account"), + Self.entry(origin: appOrigin, key: "devin_primary_org_id", value: "stale-app-org"), + ]), + ], + textEntries: [ + Self.textEntry(key: "devin_session_token", value: "legacy-text-session"), + Self.textEntry(key: "devin_auth1_token", value: "legacy-text-auth1"), + Self.textEntry(key: "devin_account_id", value: "legacy-text-account"), + Self.textEntry(key: "devin_primary_org_id", value: "legacy-text-org"), + ]) + + #expect(snapshots == [ + WindsurfDevinSessionImporter.LocalStorageSnapshot( + storage: [ + "devin_session_token": "stale-app-session", + "devin_auth1_token": "stale-app-auth1", + "devin_account_id": "stale-app-account", + "devin_primary_org_id": "stale-app-org", + ], + sourceSuffix: "app.devin.ai"), + WindsurfDevinSessionImporter.LocalStorageSnapshot( + storage: [ + "devin_session_token": "legacy-text-session", + "devin_auth1_token": "legacy-text-auth1", + "devin_account_id": "legacy-text-account", + "devin_primary_org_id": "legacy-text-org", + ], + sourceSuffix: nil), + ]) + } + + @Test + func `deduplicates repeated session tokens while preserving first source`() { + let sessions = [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "devin-session-token$abc", + auth1Token: "auth1_xyz", + accountID: "account-123", + primaryOrgID: "org-456"), + sourceLabel: "Chrome Default"), + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "devin-session-token$abc", + auth1Token: "auth1_other", + accountID: "account-999", + primaryOrgID: "org-999"), + sourceLabel: "Chrome Profile 1"), + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "devin-session-token$def", + auth1Token: "auth1_def", + accountID: "account-456", + primaryOrgID: "org-789"), + sourceLabel: "Chrome Profile 2"), + ] + + let deduplicated = WindsurfDevinSessionImporter.deduplicateSessions(sessions) + + #expect(deduplicated.count == 2) + #expect(deduplicated[0].sourceLabel == "Chrome Default") + #expect(deduplicated[0].session.sessionToken == "devin-session-token$abc") + #expect(deduplicated[1].session.sessionToken == "devin-session-token$def") + } + + private static func entry(origin: URL, key: String, value: String) -> ChromiumLocalStorageEntry { + ChromiumLocalStorageEntry( + origin: origin.absoluteString, + key: key, + value: value, + rawValueLength: value.utf8.count) + } + + private static func textEntry(key: String, value: String) -> ChromiumLevelDBTextEntry { + ChromiumLevelDBTextEntry(key: key, value: value) + } +} diff --git a/Tests/CodexBarTests/WindsurfProviderTests.swift b/Tests/CodexBarTests/WindsurfProviderTests.swift new file mode 100644 index 0000000000..c0b9dd54c5 --- /dev/null +++ b/Tests/CodexBarTests/WindsurfProviderTests.swift @@ -0,0 +1,55 @@ +import Testing +@testable import CodexBarCore + +struct WindsurfProviderTests { + private func makeContext( + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `local probe is unavailable in explicit web mode`() async { + let strategy = WindsurfLocalFetchStrategy() + + #expect(await strategy.isAvailable(self.makeContext(sourceMode: .web)) == false) + #expect(await strategy.isAvailable(self.makeContext(sourceMode: .auto))) + #expect(await strategy.isAvailable(self.makeContext(sourceMode: .cli))) + } + + @Test + func `web mode with cookies off does not fall back to local probe`() async { + let settings = ProviderSettingsSnapshot.make( + windsurf: .init( + usageDataSource: .web, + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext(sourceMode: .web, settings: settings) + + let outcome = await WindsurfProviderDescriptor.descriptor.fetchPlan.fetchOutcome( + context: context, + provider: .windsurf) + + guard case let .failure(error) = outcome.result else { + Issue.record("Expected web-only Windsurf fetch to fail when cookies are off") + return + } + + #expect(error is ProviderFetchError) + #expect(outcome.attempts.map(\.strategyID) == ["windsurf.web", "windsurf.local"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false]) + } +} diff --git a/Tests/CodexBarTests/WindsurfStatusProbeTests.swift b/Tests/CodexBarTests/WindsurfStatusProbeTests.swift new file mode 100644 index 0000000000..42524a9e7c --- /dev/null +++ b/Tests/CodexBarTests/WindsurfStatusProbeTests.swift @@ -0,0 +1,330 @@ +import CodexBarCore +import Foundation +import SQLite3 +import Testing + +struct WindsurfStatusProbeTests { + // MARK: - Helper + + private static func decode(_ json: String) throws -> WindsurfCachedPlanInfo { + try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: Data(json.utf8)) + } + + // MARK: - JSON Decoding + + @Test + func `decodes full plan info`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "startTimestamp": 1771610750000, + "endTimestamp": 1774029950000, + "usage": { + "messages": 50000, + "usedMessages": 35650, + "remainingMessages": 14350, + "flowActions": 150000, + "usedFlowActions": 0, + "remainingFlowActions": 150000 + }, + "quotaUsage": { + "dailyRemainingPercent": 9, + "weeklyRemainingPercent": 54, + "dailyResetAtUnix": 1774080000, + "weeklyResetAtUnix": 1774166400 + } + } + """) + + #expect(info.planName == "Pro") + #expect(info.startTimestamp == 1_771_610_750_000) + #expect(info.endTimestamp == 1_774_029_950_000) + #expect(info.usage?.messages == 50000) + #expect(info.usage?.usedMessages == 35650) + #expect(info.usage?.remainingMessages == 14350) + #expect(info.usage?.flowActions == 150_000) + #expect(info.usage?.usedFlowActions == 0) + #expect(info.usage?.remainingFlowActions == 150_000) + #expect(info.quotaUsage?.dailyRemainingPercent == 9) + #expect(info.quotaUsage?.weeklyRemainingPercent == 54) + #expect(info.quotaUsage?.dailyResetAtUnix == 1_774_080_000) + #expect(info.quotaUsage?.weeklyResetAtUnix == 1_774_166_400) + } + + @Test + func `decodes minimal plan info`() throws { + let info = try Self.decode(""" + {"planName": "Free"} + """) + + #expect(info.planName == "Free") + #expect(info.usage == nil) + #expect(info.quotaUsage == nil) + #expect(info.endTimestamp == nil) + } + + @Test + func `decodes empty object`() throws { + let info = try Self.decode("{}") + + #expect(info.planName == nil) + #expect(info.usage == nil) + #expect(info.quotaUsage == nil) + } + + // MARK: - toUsageSnapshot Conversion + + @Test + func `converts full plan to usage snapshot`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "startTimestamp": 1771610750000, + "endTimestamp": 1774029950000, + "usage": { + "messages": 50000, "usedMessages": 35650, "remainingMessages": 14350, + "flowActions": 150000, "usedFlowActions": 0, "remainingFlowActions": 150000 + }, + "quotaUsage": { + "dailyRemainingPercent": 9, "weeklyRemainingPercent": 54, + "dailyResetAtUnix": 1774080000, "weeklyResetAtUnix": 1774166400 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + // Primary = daily: usedPercent = 100 - 9 = 91 + #expect(snapshot.primary?.usedPercent == 91) + #expect(snapshot.primary?.resetsAt != nil) + + // Secondary = weekly: usedPercent = 100 - 54 = 46 + #expect(snapshot.secondary?.usedPercent == 46) + #expect(snapshot.secondary?.resetsAt != nil) + + // Identity + #expect(snapshot.identity?.providerID == .windsurf) + #expect(snapshot.identity?.loginMethod == "Pro") + #expect(snapshot.identity?.accountOrganization != nil) + } + + @Test + func `converts minimal plan to usage snapshot`() throws { + let info = try Self.decode(""" + {"planName": "Free"} + """) + + let snapshot = info.toUsageSnapshot() + + // Without quotaUsage, primary and secondary should be nil + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.identity?.loginMethod == "Free") + #expect(snapshot.identity?.accountOrganization == nil) + } + + @Test + func `converts usage counts when quota usage is absent`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "usage": { + "messages": 50000, + "usedMessages": 1200, + "remainingMessages": 48800, + "flowActions": 150000, + "usedFlowActions": 0, + "remainingFlowActions": 150000, + "flexCredits": 123700, + "usedFlexCredits": 0, + "remainingFlexCredits": 123700 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 2.4) + #expect(snapshot.primary?.resetDescription == "1200 / 50000 messages") + #expect(snapshot.secondary?.usedPercent == 0) + #expect(snapshot.secondary?.resetDescription == "0 / 150000 flow actions") + } + + @Test + func `usage counts infer used amount from remaining`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "usage": { + "messages": 100, + "remainingMessages": 25 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 75) + #expect(snapshot.primary?.resetDescription == "75 / 100 messages") + #expect(snapshot.secondary == nil) + } + + @Test + func `daily at zero remaining shows 100 percent used`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": {"dailyRemainingPercent": 0, "weeklyRemainingPercent": 100} + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 100) + #expect(snapshot.secondary?.usedPercent == 0) + } + + @Test + func `weekly at full remaining shows 0 percent used`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": {"dailyRemainingPercent": 100, "weeklyRemainingPercent": 100} + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.secondary?.usedPercent == 0) + } + + @Test + func `reset dates are correctly converted from unix timestamps`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": { + "dailyRemainingPercent": 50, "weeklyRemainingPercent": 50, + "dailyResetAtUnix": 1774080000, "weeklyResetAtUnix": 1774166400 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.resetsAt == Date(timeIntervalSince1970: 1_774_080_000)) + #expect(snapshot.secondary?.resetsAt == Date(timeIntervalSince1970: 1_774_166_400)) + } + + @Test + func `end timestamp converts to expiry description`() throws { + let futureMs = Int64(Date().addingTimeInterval(86400 * 30).timeIntervalSince1970 * 1000) + let info = try Self.decode(""" + {"planName": "Pro", "endTimestamp": \(futureMs)} + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.identity?.accountOrganization?.hasPrefix("Expires ") == true) + } + + // MARK: - Probe Database Decoding + + @Test + func `probe decodes UTF-8 JSON blob`() throws { + let dbURL = try Self.makeTemporaryDatabase( + jsonData: Data(#"{"planName":"UTF-8 Pro"}"#.utf8)) + defer { try? FileManager.default.removeItem(at: dbURL.deletingLastPathComponent()) } + + let info = try WindsurfStatusProbe(dbPath: dbURL.path).fetch() + + #expect(info.planName == "UTF-8 Pro") + } + + @Test + func `probe decodes UTF-16LE JSON blob`() throws { + let jsonData = try #require(#"{"planName":"UTF-16 Pro"}"#.data(using: .utf16LittleEndian)) + let dbURL = try Self.makeTemporaryDatabase(jsonData: jsonData) + defer { try? FileManager.default.removeItem(at: dbURL.deletingLastPathComponent()) } + + let info = try WindsurfStatusProbe(dbPath: dbURL.path).fetch() + + #expect(info.planName == "UTF-16 Pro") + } + + // MARK: - Probe Error Cases + + @Test + func `probe throws dbNotFound for missing file`() { + let probe = WindsurfStatusProbe(dbPath: "/nonexistent/path/state.vscdb") + + #expect(throws: WindsurfStatusProbeError.self) { + _ = try probe.fetch() + } + } + + private static func makeTemporaryDatabase(jsonData: Data) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("windsurf-status-probe-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let dbURL = directory.appendingPathComponent("state.vscdb") + + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { + throw TestSQLiteError.openFailed(String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_close(db) } + + try self.execute( + """ + CREATE TABLE ItemTable( + key TEXT PRIMARY KEY, + value BLOB + ); + """, + db: db) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO ItemTable(key, value) VALUES('windsurf.settings.cachedPlanInfo', ?);", + -1, + &stmt, + nil) == SQLITE_OK + else { + throw TestSQLiteError.prepareFailed(String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + let bindResult = jsonData.withUnsafeBytes { buffer in + sqlite3_bind_blob(stmt, 1, buffer.baseAddress, Int32(jsonData.count), transient) + } + guard bindResult == SQLITE_OK else { + throw TestSQLiteError.bindFailed(String(cString: sqlite3_errmsg(db))) + } + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw TestSQLiteError.stepFailed(String(cString: sqlite3_errmsg(db))) + } + + return dbURL + } + + private static func execute(_ sql: String, db: OpaquePointer?) throws { + var errorMessage: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &errorMessage) == SQLITE_OK else { + defer { sqlite3_free(errorMessage) } + let message = errorMessage.map { String(cString: $0) } ?? "unknown error" + throw TestSQLiteError.execFailed(message) + } + } + + private enum TestSQLiteError: Error { + case openFailed(String) + case execFailed(String) + case prepareFailed(String) + case bindFailed(String) + case stepFailed(String) + } +} diff --git a/Tests/CodexBarTests/WindsurfWebFetcherTests.swift b/Tests/CodexBarTests/WindsurfWebFetcherTests.swift new file mode 100644 index 0000000000..2ce34f8c22 --- /dev/null +++ b/Tests/CodexBarTests/WindsurfWebFetcherTests.swift @@ -0,0 +1,565 @@ +import Foundation +import SweetCookieKit +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct WindsurfWebFetcherTests { + @Test + func `missing session guidance names current and legacy origins`() { + let message = WindsurfWebFetcherError.noSessionData.errorDescription + + #expect(message?.contains("app.devin.ai") == true) + #expect(message?.contains("windsurf.com") == true) + } + + private struct ResponseFixture { + let planName: String + let dailyRemaining: Int + let weeklyRemaining: Int + let planEndUnix: Int64 + let dailyResetUnix: Int64 + let weeklyResetUnix: Int64 + } + + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [WindsurfWebFetcherStubURLProtocol.self] + return URLSession(configuration: config) + } + + @Test + func `manual devin session sends protobuf request and auth headers`() async throws { + defer { + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(url.host == "windsurf.com") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/proto") + #expect(request.value(forHTTPHeaderField: "Connect-Protocol-Version") == "1") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://windsurf.com") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://windsurf.com/profile") + #expect(request.value(forHTTPHeaderField: "x-auth-token") == "devin-session-token$abc") + #expect(request.value(forHTTPHeaderField: "x-devin-session-token") == "devin-session-token$abc") + #expect(request.value(forHTTPHeaderField: "x-devin-auth1-token") == "auth1_xyz") + #expect(request.value(forHTTPHeaderField: "x-devin-account-id") == "account-123") + #expect(request.value(forHTTPHeaderField: "x-devin-primary-org-id") == "org-456") + + let body = try WindsurfPlanStatusProtoCodec.decodeRequest(Self.requestBodyData(from: request)) + #expect(body.authToken == "devin-session-token$abc") + #expect(body.includeTopUpStatus == true) + + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Pro", + dailyRemaining: 68, + weeklyRemaining: 84, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + let manualSession = """ + { + "devin_session_token": "devin-session-token$abc", + "devin_auth1_token": "auth1_xyz", + "devin_account_id": "account-123", + "devin_primary_org_id": "org-456" + } + """ + + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .manual, + manualSessionInput: manualSession, + timeout: 2, + session: self.makeSession()) + + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 1) + #expect(snapshot.identity?.providerID == .windsurf) + #expect(snapshot.identity?.loginMethod == "Pro") + #expect(snapshot.primary?.usedPercent == 32) + #expect(snapshot.secondary?.usedPercent == 16) + } + + @Test + func `auto session import retries next profile after auth failure`() async throws { + defer { + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "stale-token", + auth1Token: "stale-auth1", + accountID: "stale-account", + primaryOrgID: "stale-org"), + sourceLabel: "Chrome Default"), + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "fresh-token", + auth1Token: "fresh-auth1", + accountID: "fresh-account", + primaryOrgID: "fresh-org"), + sourceLabel: "Chrome Profile 1"), + ] + } + + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + let token = request.value(forHTTPHeaderField: "x-devin-session-token") + + if token == "stale-token" { + return Self.makeResponse( + url: url, + body: Data("unauthorized".utf8), + contentType: "text/plain", + statusCode: 401) + } + + #expect(token == "fresh-token") + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Teams", + dailyRemaining: 75, + weeklyRemaining: 90, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .auto, + timeout: 2, + session: self.makeSession()) + + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 2) + #expect(snapshot.identity?.loginMethod == "Teams") + #expect(snapshot.primary?.usedPercent == 25) + #expect(snapshot.secondary?.usedPercent == 10) + } + + @Test + func `auto session import tries fallback browsers after preferred sessions fail`() async throws { + defer { + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "stale-chrome-token", + auth1Token: "stale-auth1", + accountID: "stale-account", + primaryOrgID: "stale-org"), + sourceLabel: "Chrome Default"), + ] + } + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "fresh-edge-token", + auth1Token: "fresh-auth1", + accountID: "fresh-account", + primaryOrgID: "fresh-org"), + sourceLabel: "Microsoft Edge Default"), + ] + } + + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + let token = request.value(forHTTPHeaderField: "x-devin-session-token") + + if token == "stale-chrome-token" { + return Self.makeResponse( + url: url, + body: Data("unauthorized".utf8), + contentType: "text/plain", + statusCode: 401) + } + + #expect(token == "fresh-edge-token") + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Teams", + dailyRemaining: 64, + weeklyRemaining: 80, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .auto, + timeout: 2, + session: self.makeSession()) + + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 2) + #expect(snapshot.identity?.loginMethod == "Teams") + #expect(snapshot.primary?.usedPercent == 36) + #expect(snapshot.secondary?.usedPercent == 20) + } + + @Test + func `auto import uses complete legacy origin when app origin is partial`() async throws { + defer { + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + let appOrigin = try #require(URL(string: "https://app.devin.ai")) + let legacyOrigin = try #require(URL(string: "https://windsurf.com")) + let snapshots = WindsurfDevinSessionImporter.localStorageSnapshots(from: [ + ( + origin: appOrigin, + entries: [ + Self.localStorageEntry(origin: appOrigin, key: "devin_session_token", value: "app-session"), + Self.localStorageEntry(origin: appOrigin, key: "devin_auth1_token", value: "app-auth1"), + ]), + ( + origin: legacyOrigin, + entries: [ + Self.localStorageEntry(origin: legacyOrigin, key: "devin_session_token", value: "legacy-session"), + Self.localStorageEntry(origin: legacyOrigin, key: "devin_auth1_token", value: "legacy-auth1"), + Self.localStorageEntry(origin: legacyOrigin, key: "devin_account_id", value: "legacy-account"), + Self.localStorageEntry(origin: legacyOrigin, key: "devin_primary_org_id", value: "legacy-org"), + ]), + ]) + + let sessionInfos = snapshots.compactMap { snapshot in + WindsurfDevinSessionImporter.session( + from: snapshot.storage, + sourceLabel: "Chrome Default (\(snapshot.sourceSuffix ?? "unknown"))") + } + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = { _, _ in sessionInfos } + + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(request.value(forHTTPHeaderField: "x-devin-session-token") == "legacy-session") + #expect(request.value(forHTTPHeaderField: "x-devin-auth1-token") == "legacy-auth1") + #expect(request.value(forHTTPHeaderField: "x-devin-account-id") == "legacy-account") + #expect(request.value(forHTTPHeaderField: "x-devin-primary-org-id") == "legacy-org") + + let body = try WindsurfPlanStatusProtoCodec.decodeRequest(Self.requestBodyData(from: request)) + #expect(body.authToken == "legacy-session") + + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Pro", + dailyRemaining: 70, + weeklyRemaining: 85, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .auto, + timeout: 2, + session: self.makeSession()) + + #expect(snapshots.count == 1) + #expect(sessionInfos.map(\.sourceLabel) == ["Chrome Default (windsurf.com)"]) + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 1) + #expect(snapshot.identity?.loginMethod == "Pro") + #expect(snapshot.primary?.usedPercent == 30) + #expect(snapshot.secondary?.usedPercent == 15) + } + + @Test + func `manual mode with empty session does not fall back to imported session`() async { + defer { + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "auto-token", + auth1Token: "auto-auth1", + accountID: "auto-account", + primaryOrgID: "auto-org"), + sourceLabel: "Chrome Default"), + ] + } + WindsurfWebFetcherStubURLProtocol.requests = [] + + await #expect { + _ = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .manual, + manualSessionInput: " \n", + timeout: 2, + session: self.makeSession()) + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message == "empty input" + } + #expect(WindsurfWebFetcherStubURLProtocol.requests.isEmpty) + } + + @Test + func `manual key value session input is accepted`() throws { + let parsed = try WindsurfWebFetcher.parseManualSessionInput( + """ + devin_session_token=devin-session-token$abc + devin_auth1_token=auth1_xyz + devin_account_id=account-123 + devin_primary_org_id=org-456 + """) + + #expect(parsed.sessionToken == "devin-session-token$abc") + #expect(parsed.auth1Token == "auth1_xyz") + #expect(parsed.accountID == "account-123") + #expect(parsed.primaryOrgID == "org-456") + } + + @Test + func `manual JSON camelCase aliases are accepted`() throws { + let parsed = try WindsurfWebFetcher.parseManualSessionInput( + """ + { + "devinSessionToken": "devin-session-token$abc", + "devinAuth1Token": "auth1_xyz", + "devinAccountId": "account-123", + "devinPrimaryOrgId": "org-456" + } + """) + + #expect(parsed.sessionToken == "devin-session-token$abc") + #expect(parsed.auth1Token == "auth1_xyz") + #expect(parsed.accountID == "account-123") + #expect(parsed.primaryOrgID == "org-456") + } + + @Test + func `manual session input rejects empty string`() { + #expect { + try WindsurfWebFetcher.parseManualSessionInput(" \n") + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message == "empty input" + } + } + + @Test + func `manual session input rejects invalid text`() { + #expect { + try WindsurfWebFetcher.parseManualSessionInput("not a valid session bundle") + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message.contains("expected JSON") + } + } + + @Test + func `manual session input rejects missing required fields`() { + #expect { + try WindsurfWebFetcher.parseManualSessionInput( + """ + { + "devin_session_token": "devin-session-token$abc", + "devin_auth1_token": "auth1_xyz", + "devin_account_id": "account-123" + } + """) + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message.contains("expected JSON") + } + } + + private static func makeResponse( + url: URL, + body: Data, + contentType: String, + statusCode: Int) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (response, body) + } + + private static func requestBodyData(from request: URLRequest) -> Data { + if let data = request.httpBody { + return data + } + + guard let stream = request.httpBodyStream else { + return Data() + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let count = stream.read(buffer, maxLength: bufferSize) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + + return data + } + + private static func makePlanStatusResponse(_ fixture: ResponseFixture) -> Data { + let planInfo = self.message([ + self.stringField(2, fixture.planName), + ]) + + let planStatus = self.message([ + self.messageField(1, planInfo), + self.messageField(3, self.timestamp(seconds: fixture.planEndUnix)), + self.varintField(14, UInt64(fixture.dailyRemaining)), + self.varintField(15, UInt64(fixture.weeklyRemaining)), + self.varintField(17, UInt64(fixture.dailyResetUnix)), + self.varintField(18, UInt64(fixture.weeklyResetUnix)), + ]) + + return self.message([ + self.messageField(1, planStatus), + ]) + } + + private static func timestamp(seconds: Int64) -> Data { + self.message([ + self.varintField(1, UInt64(seconds)), + ]) + } + + private static func message(_ fields: [Data]) -> Data { + fields.reduce(into: Data()) { partialResult, field in + partialResult.append(field) + } + } + + private static func stringField(_ number: Int, _ value: String) -> Data { + self.lengthDelimitedField(number, Data(value.utf8)) + } + + private static func messageField(_ number: Int, _ value: Data) -> Data { + self.lengthDelimitedField(number, value) + } + + private static func lengthDelimitedField(_ number: Int, _ value: Data) -> Data { + var data = Data() + data.append(self.fieldKey(number, wireType: 2)) + data.append(self.varint(UInt64(value.count))) + data.append(value) + return data + } + + private static func varintField(_ number: Int, _ value: UInt64) -> Data { + var data = Data() + data.append(self.fieldKey(number, wireType: 0)) + data.append(self.varint(value)) + return data + } + + private static func fieldKey(_ number: Int, wireType: UInt64) -> Data { + self.varint(UInt64((number << 3) | Int(wireType))) + } + + private static func varint(_ value: UInt64) -> Data { + var remaining = value + var data = Data() + while remaining >= 0x80 { + data.append(UInt8((remaining & 0x7F) | 0x80)) + remaining >>= 7 + } + data.append(UInt8(remaining)) + return data + } + + private static func localStorageEntry(origin: URL, key: String, value: String) -> ChromiumLocalStorageEntry { + ChromiumLocalStorageEntry( + origin: origin.absoluteString, + key: key, + value: value, + rawValueLength: value.utf8.count) + } +} + +final class WindsurfWebFetcherStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var requests: [URLRequest] = [] + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with _: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/ZaiAvailabilityTests.swift b/Tests/CodexBarTests/ZaiAvailabilityTests.swift index 6ce2295892..4083171211 100644 --- a/Tests/CodexBarTests/ZaiAvailabilityTests.swift +++ b/Tests/CodexBarTests/ZaiAvailabilityTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct ZaiAvailabilityTests { @Test - func enablesZaiWhenTokenExistsInStore() throws { + func `enables zai when token exists in store`() throws { let suite = "ZaiAvailabilityTests-token" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -31,7 +30,7 @@ struct ZaiAvailabilityTests { } @Test - func enablesZaiWhenTokenExistsInTokenAccounts() throws { + func `enables zai when token exists in token accounts`() throws { let suite = "ZaiAvailabilityTests-token-accounts" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/ZaiMenuCardTests.swift b/Tests/CodexBarTests/ZaiMenuCardTests.swift new file mode 100644 index 0000000000..4433fdf9f5 --- /dev/null +++ b/Tests/CodexBarTests/ZaiMenuCardTests.swift @@ -0,0 +1,70 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ZaiMenuCardTests { + @Test + func `zai metrics titles are Tokens MCP and 5-hour when session token limit present`() throws { + let now = Date() + let zai = ZaiUsageSnapshot( + tokenLimit: ZaiLimitEntry( + type: .tokensLimit, + unit: .weeks, + number: 1, + usage: nil, + currentValue: nil, + remaining: nil, + percentage: 9, + usageDetails: [], + nextResetTime: nil), + sessionTokenLimit: ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: 1000, + currentValue: 750, + remaining: 250, + percentage: 25, + usageDetails: [], + nextResetTime: nil), + timeLimit: ZaiLimitEntry( + type: .timeLimit, + unit: .minutes, + number: 1, + usage: 100, + currentValue: 50, + remaining: 50, + percentage: 50, + usageDetails: [], + nextResetTime: nil), + planName: "pro", + updatedAt: now) + let snapshot = zai.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.zai]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .zai, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Tokens", "MCP", "5-hour"]) + let tertiary = try #require(model.metrics.first(where: { $0.title == "5-hour" })) + #expect(tertiary.detailText == "750 / 1K (250 remaining)") + } +} diff --git a/Tests/CodexBarTests/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift index c4184396ac..e541c9f888 100644 --- a/Tests/CodexBarTests/ZaiProviderTests.swift +++ b/Tests/CodexBarTests/ZaiProviderTests.swift @@ -2,38 +2,36 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct ZaiSettingsReaderTests { @Test - func apiTokenReadsFromEnvironment() { + func `api token reads from environment`() { let token = ZaiSettingsReader.apiToken(environment: ["Z_AI_API_KEY": "abc123"]) #expect(token == "abc123") } @Test - func apiTokenStripsQuotes() { + func `api token strips quotes`() { let token = ZaiSettingsReader.apiToken(environment: ["Z_AI_API_KEY": "\"token-xyz\""]) #expect(token == "token-xyz") } @Test - func apiHostReadsFromEnvironment() { + func `api host reads from environment`() { let host = ZaiSettingsReader.apiHost(environment: [ZaiSettingsReader.apiHostKey: " open.bigmodel.cn "]) #expect(host == "open.bigmodel.cn") } @Test - func quotaURLInfersScheme() { + func `quota URL infers scheme`() { let url = ZaiSettingsReader .quotaURL(environment: [ZaiSettingsReader.quotaURLKey: "open.bigmodel.cn/api/coding"]) #expect(url?.absoluteString == "https://open.bigmodel.cn/api/coding") } } -@Suite struct ZaiUsageSnapshotTests { @Test - func mapsUsageSnapshotWindows() { + func `maps usage snapshot windows`() { let reset = Date(timeIntervalSince1970: 123) let tokenLimit = ZaiLimitEntry( type: .tokensLimit, @@ -69,11 +67,13 @@ struct ZaiUsageSnapshotTests { #expect(usage.primary?.resetDescription == "5 hours window") #expect(usage.secondary?.usedPercent == 20) #expect(usage.secondary?.resetDescription == "30 days window") + #expect(usage.tertiary == nil) #expect(usage.zaiUsage?.tokenLimit?.usage == 100) + #expect(usage.zaiUsage?.sessionTokenLimit == nil) } @Test - func mapsUsageSnapshotWindowsWithMissingFields() { + func `maps usage snapshot windows with missing fields`() { let reset = Date(timeIntervalSince1970: 123) let tokenLimit = ZaiLimitEntry( type: .tokensLimit, @@ -101,7 +101,7 @@ struct ZaiUsageSnapshotTests { } @Test - func mapsUsageSnapshotWindowsWithMissingRemainingUsesCurrentValue() { + func `maps usage snapshot windows with missing remaining uses current value`() { let reset = Date(timeIntervalSince1970: 123) let tokenLimit = ZaiLimitEntry( type: .tokensLimit, @@ -125,7 +125,7 @@ struct ZaiUsageSnapshotTests { } @Test - func mapsUsageSnapshotWindowsWithMissingCurrentValueUsesRemaining() { + func `maps usage snapshot windows with missing current value uses remaining`() { let reset = Date(timeIntervalSince1970: 123) let tokenLimit = ZaiLimitEntry( type: .tokensLimit, @@ -149,7 +149,7 @@ struct ZaiUsageSnapshotTests { } @Test - func mapsUsageSnapshotWindowsWithMissingRemainingAndCurrentValueFallsBackToPercentage() { + func `maps usage snapshot windows with missing remaining and current value falls back to percentage`() { let reset = Date(timeIntervalSince1970: 123) let tokenLimit = ZaiLimitEntry( type: .tokensLimit, @@ -173,10 +173,9 @@ struct ZaiUsageSnapshotTests { } } -@Suite struct ZaiUsageParsingTests { @Test - func emptyBodyReturnsParseFailed() { + func `empty body returns parse failed`() { #expect { _ = try ZaiUsageFetcher.parseUsageSnapshot(from: Data()) } throws: { error in @@ -186,7 +185,7 @@ struct ZaiUsageParsingTests { } @Test - func parsesUsageResponse() throws { + func `parses usage response`() throws { let json = """ { "code": 200, @@ -228,10 +227,53 @@ struct ZaiUsageParsingTests { #expect(snapshot.tokenLimit?.usage == 40_000_000) #expect(snapshot.timeLimit?.usageDetails.first?.modelCode == "search-prime") #expect(snapshot.tokenLimit?.percentage == 34.0) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.resetDescription == "Monthly") } @Test - func missingDataReturnsApiError() { + func `zai mcp time limit displays monthly instead of one minute window`() throws { + let json = """ + { + "code": 200, + "msg": "Operation successful", + "data": { + "limits": [ + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 100, + "currentValue": 50, + "remaining": 50, + "percentage": 50, + "usageDetails": [] + }, + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 34, + "nextResetTime": 1768507567547 + } + ] + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.timeLimit?.windowDescription == "1 minute") + #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.resetDescription == "Monthly") + } + + @Test + func `missing data returns api error`() { let json = """ { "code": 1001, "msg": "Authorization Token Missing", "success": false } """ @@ -245,7 +287,7 @@ struct ZaiUsageParsingTests { } @Test - func successWithoutDataReturnsParseFailed() { + func `success without data returns parse failed`() { let json = """ { "code": 200, "msg": "Operation successful", "success": true } """ @@ -259,7 +301,7 @@ struct ZaiUsageParsingTests { } @Test - func successWithoutLimitsParsesEmptyUsage() throws { + func `success without limits parses empty usage`() throws { let json = """ { "code": 200, @@ -277,7 +319,7 @@ struct ZaiUsageParsingTests { } @Test - func parsesNewSchemaWithMissingTokenLimitFields() throws { + func `parses new schema with missing token limit fields`() throws { let json = """ { "code": 200, @@ -323,31 +365,285 @@ struct ZaiUsageParsingTests { } } -@Suite +struct ZaiHourlyUsageTests { + @Test + func `model usage parser decodes hourly model payload`() throws { + let json = """ + { + "code": 200, + "msg": "success", + "success": true, + "data": { + "x_time": ["2026-05-14 08:00", "2026-05-14 09:00"], + "modelDataList": [ + { "modelName": "glm-4.6", "tokensUsage": [100, null] }, + { "modelName": "glm-4.5", "tokensUsage": [50, 25] } + ] + } + } + """ + + let usage = try ZaiUsageFetcher.parseModelUsage(from: Data(json.utf8)) + + #expect(usage.xTime == ["2026-05-14 08:00", "2026-05-14 09:00"]) + #expect(usage.modelNames == ["glm-4.6", "glm-4.5"]) + #expect(usage.modelDataList[0].tokensUsage == [100, nil]) + #expect(usage.modelDataList[1].tokensUsage == [50, 25]) + } + + @Test + func `today hourly bars filter earlier days and skip empty hours`() { + let reference = Self.localDate(year: 2026, month: 5, day: 14, hour: 12) + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: reference) ?? reference + let modelData = ZaiModelUsageData( + xTime: [ + Self.hourString(yesterday), + "2026-05-14 08:00", + "2026-05-14 09:00", + ], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.6", tokensUsage: [999, 100, 0]), + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [0, 50, nil]), + ]) + + let bars = ZaiHourlyBars.from(modelData: modelData, range: .today(referenceDate: reference), now: reference) + + #expect(bars.map(\.label) == ["08"]) + #expect(bars.first?.totalTokens == 150) + #expect(bars.first?.segments.count == 2) + } + + @Test + func `last 24 hour bars filter data outside trailing window`() { + let reference = Self.localDate(year: 2026, month: 5, day: 14, hour: 12) + let old = Calendar.current.date(byAdding: .hour, value: -25, to: reference) ?? reference + let inWindow = Calendar.current.date(byAdding: .hour, value: -23, to: reference) ?? reference + let modelData = ZaiModelUsageData( + xTime: [ + Self.hourString(old), + Self.hourString(inWindow), + Self.hourString(reference), + ], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.6", tokensUsage: [10, 20, 30]), + ]) + + let bars = ZaiHourlyBars.from(modelData: modelData, range: .last24h, now: reference) + + #expect(bars.map(\.label) == [Self.hourLabel(inWindow), Self.hourLabel(reference)]) + #expect(bars.map(\.totalTokens) == [20, 30]) + } + + private static func localDate(year: Int, month: Int, day: Int, hour: Int) -> Date { + Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: hour)) ?? Date() + } + + private static func hourString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: date) + } + + private static func hourLabel(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: date) + } +} + +struct ZaiThreeLimitTests { + @Test + func `parses three limit entries into session weekly and mcp slots`() throws { + let json = """ + { + "code": 200, + "msg": "操作成功", + "data": { + "limits": [ + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 25, + "nextResetTime": 1775020168897 + }, + { + "type": "TOKENS_LIMIT", + "unit": 6, + "number": 1, + "percentage": 9, + "nextResetTime": 1775588029998 + }, + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 1000, + "currentValue": 224, + "remaining": 776, + "percentage": 22, + "nextResetTime": 1777575229998, + "usageDetails": [ + { "modelCode": "search-prime", "usage": 210 }, + { "modelCode": "web-reader", "usage": 14 } + ] + } + ], + "level": "pro" + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + // Weekly token limit (unit:6=weeks, longer window) → tokenLimit (primary) + #expect(snapshot.tokenLimit?.unit == .weeks) + #expect(snapshot.tokenLimit?.number == 1) + #expect(snapshot.tokenLimit?.percentage == 9.0) + #expect(snapshot.tokenLimit?.windowMinutes == 10080) + + // 5-hour token limit (unit:3=hours, number:5 → 300 min) → sessionTokenLimit (tertiary) + #expect(snapshot.sessionTokenLimit?.unit == .hours) + #expect(snapshot.sessionTokenLimit?.number == 5) + #expect(snapshot.sessionTokenLimit?.percentage == 25.0) + #expect(snapshot.sessionTokenLimit?.windowMinutes == 300) + + // MCP time limit → timeLimit (secondary) + #expect(snapshot.timeLimit?.usage == 1000) + #expect(snapshot.timeLimit?.usageDetails.first?.modelCode == "search-prime") + + // UsageSnapshot slot mapping + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 9.0) + #expect(usage.primary?.windowMinutes == 10080) + #expect(usage.secondary != nil) // MCP + #expect(usage.tertiary?.usedPercent == 25.0) + #expect(usage.tertiary?.windowMinutes == 300) + } + + @Test + func `unit 6 maps to weeks with correct window minutes`() { + let entry = ZaiLimitEntry( + type: .tokensLimit, + unit: .weeks, + number: 1, + usage: nil, + currentValue: nil, + remaining: nil, + percentage: 9, + usageDetails: [], + nextResetTime: nil) + #expect(entry.windowMinutes == 10080) + #expect(entry.windowDescription == "1 week") + #expect(entry.windowLabel == "1 week window") + } + + @Test + func `two limit entries remain backward compatible`() throws { + let json = """ + { + "code": 200, + "msg": "Operation successful", + "data": { + "limits": [ + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 100, + "currentValue": 50, + "remaining": 50, + "percentage": 50, + "usageDetails": [] + }, + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 34, + "nextResetTime": 1768507567547 + } + ] + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + #expect(snapshot.tokenLimit != nil) + #expect(snapshot.sessionTokenLimit == nil) + #expect(snapshot.timeLimit != nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary != nil) + #expect(usage.secondary != nil) + #expect(usage.tertiary == nil) + } +} + struct ZaiAPIRegionTests { @Test - func defaultsToGlobalEndpoint() { + func `dashboard URLs follow selected region`() { + #expect( + ZaiAPIRegion.global.dashboardURL.absoluteString == + "https://z.ai/manage-apikey/coding-plan/personal/my-plan") + #expect( + ZaiAPIRegion.bigmodelCN.dashboardURL.absoluteString == + "https://bigmodel.cn/coding-plan/personal/usage") + #expect( + ZaiProviderDescriptor.descriptor.metadata.dashboardURL == + ZaiAPIRegion.global.dashboardURL.absoluteString) + } + + @Test + func `defaults to global endpoint`() { let url = ZaiUsageFetcher.resolveQuotaURL(region: .global, environment: [:]) #expect(url.absoluteString == "https://api.z.ai/api/monitor/usage/quota/limit") } @Test - func usesBigModelRegionWhenSelected() { + func `uses big model region when selected`() { let url = ZaiUsageFetcher.resolveQuotaURL(region: .bigmodelCN, environment: [:]) #expect(url.absoluteString == "https://open.bigmodel.cn/api/monitor/usage/quota/limit") } @Test - func quotaUrlEnvironmentOverrideWins() { + func `quota url environment override wins`() { let env = [ZaiSettingsReader.quotaURLKey: "https://open.bigmodel.cn/api/coding/paas/v4"] let url = ZaiUsageFetcher.resolveQuotaURL(region: .global, environment: env) #expect(url.absoluteString == "https://open.bigmodel.cn/api/coding/paas/v4") } @Test - func apiHostEnvironmentAppendsQuotaPath() { + func `api host environment appends quota path`() { let env = [ZaiSettingsReader.apiHostKey: "open.bigmodel.cn"] let url = ZaiUsageFetcher.resolveQuotaURL(region: .global, environment: env) #expect(url.absoluteString == "https://open.bigmodel.cn/api/monitor/usage/quota/limit") } + + @Test + func `dashboard follows known endpoint overrides`() { + let china = ZaiUsageFetcher.resolveDashboardURL( + region: .global, + environment: [ZaiSettingsReader.apiHostKey: "open.bigmodel.cn"]) + #expect(china == ZaiAPIRegion.bigmodelCN.dashboardURL) + + let global = ZaiUsageFetcher.resolveDashboardURL( + region: .bigmodelCN, + environment: [ZaiSettingsReader.apiHostKey: "api.z.ai"]) + #expect(global == ZaiAPIRegion.global.dashboardURL) + } + + @Test + func `dashboard keeps selected region for custom endpoint override`() { + let dashboard = ZaiUsageFetcher.resolveDashboardURL( + region: .bigmodelCN, + environment: [ZaiSettingsReader.apiHostKey: "zai.internal.example"]) + + #expect(dashboard == ZaiAPIRegion.bigmodelCN.dashboardURL) + } } diff --git a/Tests/CodexBarTests/ZedStatusProbeTests.swift b/Tests/CodexBarTests/ZedStatusProbeTests.swift new file mode 100644 index 0000000000..148faef701 --- /dev/null +++ b/Tests/CodexBarTests/ZedStatusProbeTests.swift @@ -0,0 +1,314 @@ +import CodexBarCore +import Foundation +import Testing + +struct ZedStatusProbeTests { + private struct StubCredentialsReader: ZedCredentialsReading { + let credentials: ZedCredentials? + + func loadCredentials(serviceURL _: String) throws -> ZedCredentials? { + self.credentials + } + } + + private static let subscriptionPeriod = """ + "subscription_period": { + "started_at": "2026-05-13T00:00:00.000Z", + "ended_at": "2026-06-13T00:00:00.000Z" + } + """ + + private static func fixture(plan: String, used: Int, limit: String, overdue: Bool = false) -> Data { + Data( + """ + { + "user": { + "id": 4242, + "github_login": "octocat", + "name": "The Octocat" + }, + "feature_flags": [], + "plan": { + "plan_v3": "\(plan)", + \(self.subscriptionPeriod), + "usage": { + "edit_predictions": { + "used": \(used), + "limit": \(limit) + } + }, + "has_overdue_invoices": \(overdue) + } + } + """.utf8) + } + + private static func httpResponse(data: Data, statusCode: Int) -> (Data, URLResponse) { + let response = HTTPURLResponse( + url: URL(string: "https://cloud.zed.dev/client/users/me")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return (data, response) + } + + @Test + func `decodes free plan with limited edit predictions`() throws { + let response = try ZedStatusProbe.parseResponse(Self.fixture(plan: "zed_free", used: 12, limit: "50")) + #expect(response.plan.planV3 == "zed_free") + #expect(response.plan.usage.editPredictions.used == 12) + #expect(response.plan.usage.editPredictions.limit == .limited(50)) + #expect(response.user.githubLogin == "octocat") + } + + @Test + func `decodes pro plan with unlimited edit predictions`() throws { + let response = try ZedStatusProbe.parseResponse(Self.fixture(plan: "zed_pro", used: 0, limit: "\"unlimited\"")) + #expect(response.plan.planV3 == "zed_pro") + #expect(response.plan.usage.editPredictions.limit == .unlimited) + } + + @Test + func `decodes pro trial student and business plans`() throws { + let trial = try ZedStatusProbe.parseResponse(Self.fixture( + plan: "zed_pro_trial", + used: 3, + limit: "\"unlimited\"")) + let student = try ZedStatusProbe.parseResponse(Self.fixture(plan: "zed_student", used: 1, limit: "25")) + let business = try ZedStatusProbe.parseResponse(Self.fixture( + plan: "zed_business", + used: 0, + limit: "\"unlimited\"")) + + #expect(trial.plan.planV3 == "zed_pro_trial") + #expect(student.plan.planV3 == "zed_student") + #expect(business.plan.planV3 == "zed_business") + } + + @Test + func `maps free plan to usage snapshot`() throws { + let response = try ZedStatusProbe.parseResponse(Self.fixture(plan: "zed_free", used: 10, limit: "20")) + let snapshot = ZedUsageSnapshot(response: response).toUsageSnapshot() + + #expect(snapshot.identity?.loginMethod == "Zed Free") + #expect(snapshot.identity?.accountEmail == "octocat") + #expect(snapshot.primary?.resetDescription == "10 / 20 predictions") + #expect(snapshot.primary?.usedPercent == 50) + #expect(snapshot.secondary?.resetsAt != nil) + #expect(snapshot.extraRateWindows == nil) + } + + @Test + func `maps pro plan with unlimited edit predictions`() throws { + let response = try ZedStatusProbe.parseResponse(Self.fixture(plan: "zed_pro", used: 0, limit: "\"unlimited\"")) + let snapshot = ZedUsageSnapshot(response: response).toUsageSnapshot() + + #expect(snapshot.identity?.loginMethod == "Zed Pro") + #expect(snapshot.primary?.resetDescription == "Unlimited") + #expect(snapshot.extraRateWindows == nil) + } + + @Test + func `maps overdue invoices warning window`() throws { + let response = try ZedStatusProbe.parseResponse( + Self.fixture(plan: "zed_pro", used: 0, limit: "\"unlimited\"", overdue: true)) + let snapshot = ZedUsageSnapshot(response: response).toUsageSnapshot() + + #expect(snapshot.extraRateWindows?.contains(where: { $0.id == "zed.overdue-invoices" }) == true) + } + + @Test + func `reads credentials url from settings`() { + let settings = ZedClientSettings( + credentialsURL: "https://preview.zed.dev", + serverURL: "https://zed.dev") + #expect(settings.keychainServiceURL == "https://preview.zed.dev") + + let fallback = ZedClientSettings(credentialsURL: nil, serverURL: "https://custom.zed.dev") + #expect(fallback.keychainServiceURL == "https://custom.zed.dev") + + let defaultSettings = ZedClientSettings(credentialsURL: nil, serverURL: nil) + #expect(defaultSettings.keychainServiceURL == ZedStatusProbe.defaultKeychainServiceURL) + } + + @Test + func `uses documented zed settings path`() { + let expected = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/zed/settings.json") + #expect(ZedStatusProbe.defaultSettingsURL == expected) + } + + @Test + func `loads client settings from json`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBar-ZedSettings-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let settingsURL = directory.appendingPathComponent("settings.json") + try Data( + """ + { + "credentials_url": "zed-preview-key", + "server_url": "https://staging.zed.dev" + } + """.utf8) + .write(to: settingsURL) + + let settings = try #require(ZedClientSettings.load(from: settingsURL)) + #expect(settings.credentialsURL == "zed-preview-key") + #expect(settings.serverURL == "https://staging.zed.dev") + } + + @Test + func `maps server url independently from keychain identifier`() { + let production = ZedClientSettings(credentialsURL: "zed-preview-key", serverURL: "https://zed.dev") + let staging = ZedClientSettings(credentialsURL: nil, serverURL: "https://staging.zed.dev") + let localhost = ZedClientSettings(credentialsURL: nil, serverURL: "http://localhost:3000") + let custom = ZedClientSettings(credentialsURL: nil, serverURL: "https://zed.example.com") + let untrustedOverride = ZedClientSettings( + credentialsURL: "https://zed.dev", + serverURL: "https://zed.example.com") + let invalid = ZedClientSettings(credentialsURL: nil, serverURL: "file:///tmp/zed") + + #expect(production.keychainServiceURL == "zed-preview-key") + #expect(production.cloudAPIURL?.absoluteString == "https://cloud.zed.dev/client/users/me") + #expect(staging.cloudAPIURL?.absoluteString == "https://cloud.zed.dev/client/users/me") + #expect(localhost.cloudAPIURL == nil) + #expect(custom.cloudAPIURL?.absoluteString == "https://zed.example.com/client/users/me") + #expect(untrustedOverride.cloudAPIURL == nil) + #expect(invalid.cloudAPIURL == nil) + } + + @Test + func `display plan names normalize zed enums`() { + #expect(ZedUsageSnapshot.displayPlanName("zed_pro") == "Zed Pro") + #expect(ZedUsageSnapshot.displayPlanName("zed_pro_trial") == "Zed Pro Trial") + #expect(ZedUsageSnapshot.displayPlanName("zed_student") == "Zed Student") + } + + @Test + func `fetch uses authorization header from keychain credentials`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://cloud.zed.dev/client/users/me") + #expect(request.value(forHTTPHeaderField: "Authorization") == "4242 test-token") + return Self.httpResponse( + data: Self.fixture(plan: "zed_pro", used: 0, limit: "\"unlimited\""), + statusCode: 200) + } + + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "4242", accessToken: "test-token")), + transport: transport, + settingsLoader: { ZedClientSettings(credentialsURL: nil, serverURL: nil) }) + + let snapshot = try await probe.fetch() + #expect(snapshot.response.plan.planV3 == "zed_pro") + } + + @Test + func `fetch sends credentials only to configured server`() async throws { + let transport = ProviderHTTPTransportStub { request in + #expect(request.url?.absoluteString == "https://zed.example.com/client/users/me") + #expect(request.value(forHTTPHeaderField: "Authorization") == "4242 custom-token") + return Self.httpResponse( + data: Self.fixture(plan: "zed_pro", used: 0, limit: "\"unlimited\""), + statusCode: 200) + } + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "4242", accessToken: "custom-token")), + transport: transport, + settingsLoader: { + ZedClientSettings( + credentialsURL: "https://zed.example.com", + serverURL: "https://zed.example.com") + }) + + _ = try await probe.fetch() + } + + @Test + func `fetch rejects invalid server before reading credentials`() async { + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "4242", accessToken: "must-not-send")), + transport: ProviderHTTPTransportStub { _ in + Issue.record("Should not send credentials to an invalid server URL") + return Self.httpResponse(data: Data(), statusCode: 500) + }, + settingsLoader: { + ZedClientSettings(credentialsURL: "custom-keychain-id", serverURL: "file:///tmp/zed") + }) + + await #expect(throws: ZedStatusProbeError.invalidServerURL("file:///tmp/zed")) { + _ = try await probe.fetch() + } + } + + @Test + func `fetch rejects cross-origin credential override`() async { + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "4242", accessToken: "must-not-send")), + transport: ProviderHTTPTransportStub { _ in + Issue.record("Should not send credentials to an untrusted custom server") + return Self.httpResponse(data: Data(), statusCode: 500) + }, + settingsLoader: { + ZedClientSettings( + credentialsURL: "https://zed.dev", + serverURL: "https://attacker.example.com") + }) + + await #expect(throws: ZedStatusProbeError.untrustedServerConfiguration) { + _ = try await probe.fetch() + } + } + + @Test + func `fetch surfaces not signed in when keychain is empty`() async { + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader(credentials: nil), + transport: ProviderHTTPTransportStub { _ in + Issue.record("Should not call cloud API without credentials") + return Self.httpResponse(data: Data(), statusCode: 500) + }, + settingsLoader: { nil }) + + await #expect(throws: ZedStatusProbeError.notSignedIn) { + _ = try await probe.fetch() + } + } + + @Test + func `fetch surfaces unauthorized responses`() async { + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "1", accessToken: "bad")), + transport: ProviderHTTPTransportStub { _ in + Self.httpResponse(data: Data("{}".utf8), statusCode: 401) + }, + settingsLoader: { nil }) + + await #expect(throws: ZedStatusProbeError.unauthorized) { + _ = try await probe.fetch() + } + } + + @Test + func `fetch preserves transport cancellation`() async { + let probe = ZedStatusProbe( + credentialsReader: StubCredentialsReader( + credentials: ZedCredentials(userID: "1", accessToken: "cancelled")), + transport: ProviderHTTPTransportStub { _ in + throw URLError(.cancelled) + }, + settingsLoader: { nil }) + + await #expect(throws: CancellationError.self) { + _ = try await probe.fetch() + } + } +} diff --git a/TestsLinux/AntigravityCLIStrategyLinuxTests.swift b/TestsLinux/AntigravityCLIStrategyLinuxTests.swift new file mode 100644 index 0000000000..fcc8195e52 --- /dev/null +++ b/TestsLinux/AntigravityCLIStrategyLinuxTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(Linux) +struct AntigravityCLIStrategyLinuxTests { + @Test + func `cli local strategy is available with HTTP fallback`() async throws { + let binaryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-antigravity-\(UUID().uuidString)") + try Data("#!/bin/sh\n".utf8).write(to: binaryURL) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: binaryURL.path) + defer { try? FileManager.default.removeItem(at: binaryURL) } + + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .cli, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: ["ANTIGRAVITY_CLI_PATH": binaryURL.path], + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + let isAvailable = await AntigravityCLIHTTPSFetchStrategy().isAvailable(context) + + #expect(isAvailable) + } + + @Test + func `cli local endpoints include Linux HTTP fallback`() { + #expect( + AntigravityStatusProbe.cliEndpoints(ports: [55624]) == [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 55624, + csrfToken: "", + source: .cliHTTPS), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 55624, + csrfToken: "", + source: .cliHTTPS), + ]) + } + + @Test + func `language server endpoints include Linux HTTP fallback`() { + #expect( + AntigravityStatusProbe.connectionCandidates( + listeningPorts: [64440], + languageServerCSRFToken: "language-token", + extensionServerPort: nil, + extensionServerCSRFToken: nil) == [ + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "https", + port: 64440, + csrfToken: "language-token", + source: .languageServer), + AntigravityStatusProbe.AntigravityConnectionEndpoint( + scheme: "http", + port: 64440, + csrfToken: "language-token", + source: .languageServer), + ]) + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } +} +#endif diff --git a/TestsLinux/AntigravityProcessLauncherLinuxTests.swift b/TestsLinux/AntigravityProcessLauncherLinuxTests.swift new file mode 100644 index 0000000000..49c0e3378d --- /dev/null +++ b/TestsLinux/AntigravityProcessLauncherLinuxTests.swift @@ -0,0 +1,64 @@ +#if canImport(Glibc) || canImport(Musl) +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif +import Testing +@testable import CodexBarCore + +struct AntigravityProcessLauncherLinuxTests { + @Test + func `pty launcher uses home and closes unrelated descriptors`() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("antigravity-spawn-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + let inheritedSourceFD = open("/dev/null", O_RDONLY) + guard inheritedSourceFD >= 0 else { + Issue.record("Failed to open descriptor fixture") + return + } + defer { close(inheritedSourceFD) } + let inheritedFD = fcntl(inheritedSourceFD, F_DUPFD, 200) + guard inheritedFD >= 200 else { + Issue.record("Failed to duplicate descriptor fixture") + return + } + defer { close(inheritedFD) } + + let outputURL = tempDirectory.appendingPathComponent("result.txt") + let scriptURL = tempDirectory.appendingPathComponent("probe.sh") + let script = """ + #!/bin/sh + pwd > \(outputURL.path) + if [ -e /proc/self/fd/\(inheritedFD) ]; then + echo inherited >> \(outputURL.path) + else + echo closed >> \(outputURL.path) + fi + """ + // Direct writes close the executable before spawn; atomic replacement can race with exec on overlay + // filesystems. + try Data(script.utf8).write(to: scriptURL) + #expect(chmod(scriptURL.path, 0o700) == 0) + + let handle = try AntigravityPTYProcessLauncher().launch(binary: scriptURL.path) + defer { + handle.killRoot() + handle.terminateTree(signal: SIGKILL, knownDescendants: []) + handle.closePTY() + } + + for _ in 0..<200 where !FileManager.default.fileExists(atPath: outputURL.path) { + Thread.sleep(forTimeInterval: 0.01) + } + let lines = try String(contentsOf: outputURL, encoding: .utf8) + .split(separator: "\n") + .map(String.init) + #expect(lines == [NSHomeDirectory(), "closed"]) + } +} +#endif diff --git a/TestsLinux/AzureEndpointOverrideSecurityTests.swift b/TestsLinux/AzureEndpointOverrideSecurityTests.swift new file mode 100644 index 0000000000..4ba9684257 --- /dev/null +++ b/TestsLinux/AzureEndpointOverrideSecurityTests.swift @@ -0,0 +1,71 @@ +import CodexBarCore +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing + +@Suite +struct AzureEndpointOverrideSecurityTests { + @Test + func azureOpenAIEndpointOverrideMustBeHTTPSOrBareHost() throws { + let httpsURL = try #require(AzureOpenAISettingsReader.endpointURL(from: "https://proxy.example.com/base")) + #expect(httpsURL.absoluteString == "https://proxy.example.com/base") + + let bareURL = try #require(AzureOpenAISettingsReader.endpointURL(from: "resource.openai.azure.com")) + #expect(bareURL.absoluteString == "https://resource.openai.azure.com") + + let hostPortURL = try #require(AzureOpenAISettingsReader.endpointURL(from: "localhost:8443/openai")) + #expect(hostPortURL.absoluteString == "https://localhost:8443/openai") + + let trimmedURL = try #require(AzureOpenAISettingsReader.endpointURL(from: " https://trimmed.example.com/base ")) + #expect(trimmedURL.absoluteString == "https://trimmed.example.com/base") + + #expect(AzureOpenAISettingsReader.endpointURL(from: "http://attacker.test") == nil) + #expect(AzureOpenAISettingsReader.endpointURL(from: "https://user:pass@proxy.example.com") == nil) + #expect(AzureOpenAISettingsReader.endpointURL(from: "https://proxy.example.com%2f.attacker.test") == nil) + + #expect(throws: AzureOpenAISettingsError.invalidEndpointOverride( + AzureOpenAISettingsReader.endpointEnvironmentKey)) + { + try AzureOpenAISettingsReader.validateEndpointOverrides(environment: [ + AzureOpenAISettingsReader.endpointEnvironmentKey: "http://attacker.test", + ]) + } + } + + @Test + func azureOpenAIHTTPOverrideIsRejectedBeforeAPIKeyRequest() async throws { + let endpoint = try #require(URL(string: "http://127.0.0.1:31337")) + let transport = CapturingTransport { request in + Issue.record("Azure OpenAI should reject insecure endpoint overrides before sending api-key headers") + #expect(request.value(forHTTPHeaderField: "api-key") == nil) + throw CapturingTransportError.unexpectedRequest + } + + do { + _ = try await AzureOpenAIUsageFetcher.fetchUsage( + apiKey: "AZURE_CANARY_KEY", + endpoint: endpoint, + deploymentName: "canary-deployment", + transport: transport, + updatedAt: Date(timeIntervalSince1970: 1_800_000_000)) + Issue.record("Expected AzureOpenAIUsageError.invalidEndpointOverride") + } catch { + #expect(error as? AzureOpenAIUsageError == .invalidEndpointOverride( + AzureOpenAISettingsReader.endpointEnvironmentKey)) + } + } +} + +private enum CapturingTransportError: Error { + case unexpectedRequest +} + +private struct CapturingTransport: ProviderHTTPTransport { + let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await self.handler(request) + } +} diff --git a/TestsLinux/CodexBarLoggingPerformanceTests.swift b/TestsLinux/CodexBarLoggingPerformanceTests.swift new file mode 100644 index 0000000000..1b2e202b9e --- /dev/null +++ b/TestsLinux/CodexBarLoggingPerformanceTests.swift @@ -0,0 +1,82 @@ +import Logging +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CodexBarLoggingPerformanceTests { + @Test + func `filtered log messages are not evaluated`() { + let probe = LogEvaluationProbe() + let logger = CodexBarLogger(minimumLevel: .info) { _, message, _ in + probe.loggedMessages.append(message) + } + + logger.debug(probe.expensiveMessage()) + + #expect(probe.evaluations == 0) + #expect(probe.loggedMessages.isEmpty) + + logger.info(probe.expensiveMessage()) + + #expect(probe.evaluations == 1) + #expect(probe.loggedMessages == ["evaluated"]) + } + + @Test + func `disabled file logging does not format metadata`() { + let sink = FileLogSink() + var handler = FileLogHandler(label: "test", sink: sink) + let probe = LogEvaluationProbe() + handler[metadataKey: "expensive"] = .stringConvertible(ExpensiveMetadataValue { + probe.evaluations += 1 + }) + + handler.log(event: LogEvent( + level: .info, + message: "hello", + metadata: nil, + source: "test", + file: #filePath, + function: #function, + line: #line)) + + #expect(probe.evaluations == 0) + } + + @Test + func `redactor leaves ordinary log lines unchanged`() { + let line = "CodexBar starting version=1.2.3 build=456" + + #expect(LogRedactor.redact(line) == line) + } + + @Test + func `redactor still redacts sensitive log lines`() { + let line = "Authorization: Bearer secret-token\nContact: user@example.com" + let redacted = LogRedactor.redact(line) + + #expect(redacted.contains("secret-token") == false) + #expect(redacted.contains("user@example.com") == false) + #expect(redacted.contains("Authorization: ")) + #expect(redacted.contains("")) + } +} + +private final class LogEvaluationProbe: @unchecked Sendable { + var evaluations = 0 + var loggedMessages: [String] = [] + + func expensiveMessage() -> String { + self.evaluations += 1 + return "evaluated" + } +} + +private struct ExpensiveMetadataValue: CustomStringConvertible, Sendable { + let onRender: @Sendable () -> Void + + var description: String { + self.onRender() + return "rendered" + } +} diff --git a/TestsLinux/CodexOAuthCredentialsStoreLinuxTests.swift b/TestsLinux/CodexOAuthCredentialsStoreLinuxTests.swift new file mode 100644 index 0000000000..01853c49f1 --- /dev/null +++ b/TestsLinux/CodexOAuthCredentialsStoreLinuxTests.swift @@ -0,0 +1,31 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct CodexOAuthCredentialsStoreLinuxTests { + @Test + func saveKeepsAuthJSONPrivate() throws { + #if os(macOS) || os(Linux) + let codexHome = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-oauth-permissions-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: codexHome) } + + let credentials = CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: "id-token", + accountId: "account-123", + lastRefresh: Date()) + + try CodexOAuthCredentialsStore.save(credentials, env: ["CODEX_HOME": codexHome.path]) + + let authURL = codexHome.appendingPathComponent("auth.json") + let attributes = try FileManager.default.attributesOfItem(atPath: authURL.path) + let permissions = try #require(attributes[.posixPermissions] as? NSNumber) + #expect(permissions.intValue & 0o777 == 0o600) + #else + #expect(Bool(true)) + #endif + } +} diff --git a/TestsLinux/CostUsageScanExecutorLinuxTests.swift b/TestsLinux/CostUsageScanExecutorLinuxTests.swift new file mode 100644 index 0000000000..e286e535c1 --- /dev/null +++ b/TestsLinux/CostUsageScanExecutorLinuxTests.swift @@ -0,0 +1,29 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +struct CostUsageScanExecutorLinuxTests { + @Test + func returnsWorkValue() async throws { + let value = try await CostUsageScanExecutor.run { _ in 42 } + #expect(value == 42) + } + + @Test + func cancelledTaskThrowsCancellationError() async { + let task = Task { + try await CostUsageScanExecutor.run { checkCancellation in + while true { + try checkCancellation() + Thread.sleep(forTimeInterval: 0.005) + } + } + } + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } +} diff --git a/TestsLinux/OpenAIDashboardParserLinuxTests.swift b/TestsLinux/OpenAIDashboardParserLinuxTests.swift new file mode 100644 index 0000000000..06f7417bde --- /dev/null +++ b/TestsLinux/OpenAIDashboardParserLinuxTests.swift @@ -0,0 +1,60 @@ +import CodexBarCore +import Foundation +import Testing + +/// Cross-platform tests for the OpenAI dashboard text parser. +/// +/// The dashboard renders reset countdowns like "Resets Wednesday at 3pm". The parser +/// converts a textual weekday into the next occurrence of that weekday so it can be +/// formatted into a concrete `Date`. These tests pin down full-weekday-name coverage +/// because the underlying regex previously missed "Wednesday" and "Saturday" (their +/// abbreviations were not long enough to combine with the optional "day" suffix). +@Suite +struct OpenAIDashboardParserLinuxTests { + private static func fixedNow() -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + // 2026-05-21 is a Thursday in the Gregorian calendar; using a fixed anchor keeps + // the test deterministic regardless of when it executes. + return calendar.date(from: DateComponents(year: 2026, month: 5, day: 21))! + } + + private static func body(forWeekday weekday: String) -> String { + """ + 5h limit + 50% remaining + Resets \(weekday) + """ + } + + @Test + func parsesResetLineForEveryFullWeekdayName() { + let now = Self.fixedNow() + for weekday in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] { + let result = OpenAIDashboardParser.parseRateLimits( + bodyText: Self.body(forWeekday: weekday), + now: now) + #expect( + result.primary?.resetsAt != nil, + "Expected a parsed resetsAt for weekday \(weekday)") + } + } + + @Test + func parsesResetLineForLowercaseWednesday() { + let now = Self.fixedNow() + let result = OpenAIDashboardParser.parseRateLimits( + bodyText: Self.body(forWeekday: "wednesday"), + now: now) + #expect(result.primary?.resetsAt != nil) + } + + @Test + func parsesResetLineForLowercaseSaturday() { + let now = Self.fixedNow() + let result = OpenAIDashboardParser.parseRateLimits( + bodyText: Self.body(forWeekday: "saturday"), + now: now) + #expect(result.primary?.resetsAt != nil) + } +} diff --git a/TestsLinux/OpenCodeGoLinuxTests.swift b/TestsLinux/OpenCodeGoLinuxTests.swift new file mode 100644 index 0000000000..dd85a8d50f --- /dev/null +++ b/TestsLinux/OpenCodeGoLinuxTests.swift @@ -0,0 +1,74 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBarCLI + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +#if canImport(SQLite3) || canImport(CSQLite3) +@Suite +struct OpenCodeGoLinuxTests { + @Test + func autoSourceDoesNotRequireWebSupport() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .opencodego)) + #expect(CodexBarCLI.sourceModeRequiresWebSupport(.web, provider: .opencodego)) + } + + @Test + func commandCodeManualCookieDoesNotRequireMacOSWebSupport() { + let settings = ProviderSettingsSnapshot.make( + commandcode: .init(cookieSource: .manual, manualCookieHeader: "session=manual")) + + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .commandcode, + settings: settings)) + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .commandcode, + settings: settings)) + } + + @Test + func localReaderLoadsOpenCodeDatabase() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodeGoLinuxTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + + let databaseURL = root.appendingPathComponent("opencode.db") + let authURL = root.appendingPathComponent("auth.json") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let createdMs = Int64((now.timeIntervalSince1970 - 60) * 1000) + try Self.createDatabase(at: databaseURL, createdMs: createdMs) + + let snapshot = try OpenCodeGoLocalUsageReader(authURL: authURL, databaseURL: databaseURL).fetch(now: now) + + #expect(snapshot.rollingUsagePercent == 50) + #expect(snapshot.weeklyUsagePercent == 20) + #expect(snapshot.monthlyUsagePercent == 10) + } + + private static func createDatabase(at url: URL, createdMs: Int64) throws { + var database: OpaquePointer? + guard sqlite3_open(url.path, &database) == SQLITE_OK else { + sqlite3_close(database) + throw OpenCodeGoLocalUsageError.sqliteFailed("open failed") + } + defer { sqlite3_close(database) } + + let data = "{\"time\":{\"created\":\(createdMs)},\"cost\":6,\"providerID\":\"opencode-go\",\"role\":\"assistant\"}" + let sql = """ + CREATE TABLE message (id TEXT PRIMARY KEY, time_created INTEGER NOT NULL, data TEXT NOT NULL); + INSERT INTO message (id, time_created, data) VALUES ('message-1', \(createdMs), '\(data)'); + """ + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw OpenCodeGoLocalUsageError.sqliteFailed("fixture creation failed") + } + } +} +#endif diff --git a/TestsLinux/PlatformGatingTests.swift b/TestsLinux/PlatformGatingTests.swift index 61bb5bcf25..82171c6176 100644 --- a/TestsLinux/PlatformGatingTests.swift +++ b/TestsLinux/PlatformGatingTests.swift @@ -1,8 +1,14 @@ import CodexBarCore import Testing +@testable import CodexBarCLI @Suite struct PlatformGatingTests { + @Test + func ampAutoSource_doesNotRequireWebSupport() { + #expect(!CodexBarCLI.sourceModeRequiresWebSupport(.auto, provider: .amp)) + } + @Test func claudeWebFetcher_isNotSupportedOnLinux() async { #if os(Linux) diff --git a/TestsLinux/ProviderEndpointOverrideSecurityLinuxTests.swift b/TestsLinux/ProviderEndpointOverrideSecurityLinuxTests.swift new file mode 100644 index 0000000000..713637f11b --- /dev/null +++ b/TestsLinux/ProviderEndpointOverrideSecurityLinuxTests.swift @@ -0,0 +1,137 @@ +@testable import CodexBarCore +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing + +@Suite +struct ProviderEndpointOverrideSecurityLinuxTests { + @Test + func mimoInvalidEndpointOverrideDoesNotFallbackToLocalCache() { + #expect(MiMoWebFetchStrategy.shouldFallbackToLocal( + error: MiMoSettingsError.invalidEndpointOverride(MiMoSettingsReader.apiURLKey)) == false) + #expect(MiMoWebFetchStrategy.shouldFallbackToLocal(error: MiMoSettingsError.missingCookie()) == true) + #expect(MiMoWebFetchStrategy.shouldFallbackToLocal(error: MiMoSettingsError.invalidCookie) == true) + } + + @Test + func deepgramRejectsInsecureOverrideBeforeSendingToken() async { + let transport = FailingTransport() + do { + _ = try await DeepgramUsageFetcher.fetchUsage( + apiKey: "dg-test-token", + environment: [DeepgramUsageFetcher.apiURLKey: "http://attacker.test/v1"], + transport: transport) + Issue.record("Expected DeepgramUsageError.invalidEndpointOverride") + } catch DeepgramUsageError.invalidEndpointOverride(DeepgramUsageFetcher.apiURLKey) { + // Expected. + } catch { + Issue.record("Expected DeepgramUsageError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func zaiRejectsInsecureQuotaOverrideBeforeSendingToken() async { + do { + _ = try await ZaiUsageFetcher.fetchUsage( + apiKey: "zai-test-token", + environment: [ZaiSettingsReader.quotaURLKey: "http://attacker.test/quota"]) + Issue.record("Expected ZaiSettingsError.invalidEndpointOverride") + } catch ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.quotaURLKey) { + // Expected. + } catch { + Issue.record("Expected ZaiSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func zaiRejectsInsecureAPIHostOverrideWhenQuotaURLIsAbsent() { + #expect(throws: ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.apiHostKey)) { + try ZaiSettingsReader.validateEndpointOverrides( + environment: [ZaiSettingsReader.apiHostKey: "http://attacker.test"]) + } + } + + @Test + func zaiQuotaResolutionIgnoresInvalidLowerPriorityAPIHost() throws { + let environment = [ + ZaiSettingsReader.quotaURLKey: "https://zai-proxy.test/quota", + ZaiSettingsReader.apiHostKey: "http://attacker.test", + ] + + try ZaiSettingsReader.validateQuotaEndpointOverride(environment: environment) + #expect(ZaiUsageFetcher.resolveQuotaURL(region: .global, environment: environment).absoluteString == + "https://zai-proxy.test/quota") + } + + @Test + func zaiCombinedFetchRejectsInvalidAPIHostBeforeQuotaRequest() async { + let environment = [ + ZaiSettingsReader.quotaURLKey: "https://127.0.0.1:31337/quota", + ZaiSettingsReader.apiHostKey: "http://127.0.0.1:31337", + ] + + await #expect(throws: ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.apiHostKey)) { + try await ZaiUsageFetcher.fetchUsageWithModelUsage( + apiKey: "ZAI_CANARY_KEY", + environment: environment) + } + } + + @Test + func zaiModelUsageRejectsInsecureAPIHostOverride() async { + do { + _ = try await ZaiUsageFetcher.fetchModelUsage( + apiKey: "zai-test-token", + environment: [ZaiSettingsReader.apiHostKey: "http://attacker.test"]) + Issue.record("Expected ZaiSettingsError.invalidEndpointOverride") + } catch ZaiSettingsError.invalidEndpointOverride(ZaiSettingsReader.apiHostKey) { + // Expected. + } catch { + Issue.record("Expected ZaiSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func mimoRejectsInsecureOverrideBeforeSendingCookie() async { + let transport = FailingTransport() + do { + _ = try await MiMoUsageFetcher.fetchUsage( + cookieHeader: "api-platform_serviceToken=session-token; userId=user-1", + environment: [MiMoSettingsReader.apiURLKey: "http://attacker.test/api/v1"], + session: transport) + Issue.record("Expected MiMoSettingsError.invalidEndpointOverride") + } catch MiMoSettingsError.invalidEndpointOverride(MiMoSettingsReader.apiURLKey) { + // Expected. + } catch { + Issue.record("Expected MiMoSettingsError.invalidEndpointOverride, got \(error)") + } + } + + @Test + func affectedProviderOverridesAcceptHTTPSAndBareHosts() throws { + try DeepgramUsageFetcher.validateEndpointOverrides(environment: [ + DeepgramUsageFetcher.apiURLKey: "deepgram-proxy.test/v1", + ]) + try ZaiSettingsReader + .validateEndpointOverrides(environment: [ZaiSettingsReader.quotaURLKey: "https://zai-proxy.test/quota"]) + try ZaiSettingsReader.validateEndpointOverrides(environment: [ZaiSettingsReader.apiHostKey: "localhost:9443"]) + try MiMoSettingsReader + .validateEndpointOverrides(environment: [MiMoSettingsReader.apiURLKey: "mimo-proxy.test/api/v1"]) + + #expect(ZaiSettingsReader.quotaURL(environment: [ZaiSettingsReader.quotaURLKey: "zai-proxy.test/quota"])? + .absoluteString == "https://zai-proxy.test/quota") + #expect(MiMoSettingsReader.apiURL(environment: [MiMoSettingsReader.apiURLKey: "mimo-proxy.test/api/v1"]) + .absoluteString == "https://mimo-proxy.test/api/v1") + } +} + +private struct FailingTransport: ProviderHTTPTransport { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + Issue + .record( + "Endpoint override validation should fail before any request is sent to \(request.url?.absoluteString ?? "")") + throw URLError(.badURL) + } +} diff --git a/TestsLinux/SettingsReaderQuoteUnwrapTrapTests.swift b/TestsLinux/SettingsReaderQuoteUnwrapTrapTests.swift new file mode 100644 index 0000000000..91c0e1e7a9 --- /dev/null +++ b/TestsLinux/SettingsReaderQuoteUnwrapTrapTests.swift @@ -0,0 +1,75 @@ +import CodexBarCore +import Foundation +import Testing + +/// Regression tests for a copy-pasted quote-unwrap helper that traps on length-1 input. +/// +/// 32 provider settings readers (plus `CodexBarConfig` and `CLIConfigCommand`) share a +/// `cleaned(_:)` helper of the form: +/// +/// if (value.hasPrefix("\"") && value.hasSuffix("\"")) || +/// (value.hasPrefix("'") && value.hasSuffix("'")) +/// { +/// value.removeFirst() +/// value.removeLast() +/// } +/// +/// For a value of length 1 (the single character `"` or `'`), both `hasPrefix` and +/// `hasSuffix` return true, `removeFirst()` empties the string, and `removeLast()` then +/// traps with "Can't remove last element from empty collection." This is reachable from +/// a misconfigured env var (e.g. `ALIBABA_TOKEN_PLAN_COOKIE='"'`) and from quoted JSON +/// values in `~/.codexbar/config.json`, both of which are user-controllable. +/// +/// These tests exercise two representative public readers — Alibaba Token Plan (the +/// newest addition in #1098) and the Ollama API key reader (added in #1087) — by +/// passing the trap-inducing single-quote inputs and asserting the readers return nil +/// instead of crashing. The patch swaps `removeFirst()/removeLast()` for +/// `String(value.dropFirst().dropLast())`, which is empty-safe. +@Suite +struct SettingsReaderQuoteUnwrapTrapTests { + @Test + func alibabaTokenPlanCookieHeader_returnsNilForLoneDoubleQuoteValue() { + let env = [AlibabaTokenPlanSettingsReader.cookieHeaderKey: "\""] + #expect(AlibabaTokenPlanSettingsReader.cookieHeader(environment: env) == nil) + } + + @Test + func alibabaTokenPlanCookieHeader_returnsNilForLoneApostropheValue() { + let env = [AlibabaTokenPlanSettingsReader.cookieHeaderKey: "'"] + #expect(AlibabaTokenPlanSettingsReader.cookieHeader(environment: env) == nil) + } + + @Test + func alibabaTokenPlanCookieHeader_unwrapsProperlyDoubleQuotedValue() { + let env = [AlibabaTokenPlanSettingsReader.cookieHeaderKey: "\"abc=def\""] + #expect(AlibabaTokenPlanSettingsReader.cookieHeader(environment: env) == "abc=def") + } + + @Test + func alibabaTokenPlanCookieHeader_unwrapsProperlySingleQuotedValue() { + let env = [AlibabaTokenPlanSettingsReader.cookieHeaderKey: "'abc=def'"] + #expect(AlibabaTokenPlanSettingsReader.cookieHeader(environment: env) == "abc=def") + } + + @Test + func ollamaAPIKey_returnsNilForLoneDoubleQuoteValue() { + for key in OllamaAPISettingsReader.apiKeyEnvironmentKeys { + let env = [key: "\""] + #expect(OllamaAPISettingsReader.apiKey(environment: env) == nil) + } + } + + @Test + func ollamaAPIKey_returnsNilForLoneApostropheValue() { + for key in OllamaAPISettingsReader.apiKeyEnvironmentKeys { + let env = [key: "'"] + #expect(OllamaAPISettingsReader.apiKey(environment: env) == nil) + } + } + + @Test + func ollamaAPIKey_unwrapsProperlyQuotedValue() { + let env = [OllamaAPISettingsReader.apiKeyEnvironmentKeys[0]: "\"sk-token\""] + #expect(OllamaAPISettingsReader.apiKey(environment: env) == "sk-token") + } +} diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000000..ec6c5ec89c --- /dev/null +++ b/VISION.md @@ -0,0 +1,20 @@ +# Vision + +CodexBar is the menu bar control surface for AI provider limits, credits, spend, status, and reset windows. It should keep adding useful provider coverage while preserving fast refreshes, privacy-first local data handling, and shared provider-driven UI instead of one-off surfaces. + +## Merge by Default + +- Performance improvements, unless they add too much complexity. +- Bug fixes with clear cause and bounded risk. +- New model/provider support that follows existing descriptor, strategy, settings, and test patterns. +- Small UI or UX tweaks. +- Documentation fixes. + +## Needs Sign-Off + +- New features. +- Package, dependency, or toolchain changes. +- Broad refactors or architecture changes. +- Changes that add meaningful maintenance complexity. +- Behavior changes that affect provider auth, data storage, releases, or user privacy. +- Provider additions that need new host APIs, bespoke UI, broad filesystem access, or unclear auth/privacy behavior. diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..f6e6a2fc27 --- /dev/null +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj @@ -0,0 +1,364 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */; }; + 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */; }; + 6F12082A467310EEDD1F3439 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E430B27E4F28973A5E77EA3F /* WidgetKit.framework */; }; + 7A3A654DC5A5B85C9C41EA02 /* CodexBarCore in Frameworks */ = {isa = PBXBuildFile; productRef = 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */; }; + 7F0E34471853E41206F690FB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F15F392AF9D502F0503F8F /* SwiftUI.framework */; }; + 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */; }; + B2C3D4E5F6071829A3B4C5D6 /* BurnDownWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F6071829A3B4C5D7 /* BurnDownWidgetViews.swift */; }; + C3D4E5F607182930B4C5D6E7 /* CombinedBurnDownWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D4E5F607182930B4C5D6E8 /* CombinedBurnDownWidgetViews.swift */; }; + D4E5F60718293041C5D6E7F8 /* BurnDownWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F60718293041C5D6E7F9 /* BurnDownWidgetProvider.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 02F15F392AF9D502F0503F8F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetBundle.swift; sourceTree = ""; }; + 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetProvider.swift; sourceTree = ""; }; + 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetViews.swift; sourceTree = ""; }; + B2C3D4E5F6071829A3B4C5D7 /* BurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetViews.swift; sourceTree = ""; }; + C3D4E5F607182930B4C5D6E8 /* CombinedBurnDownWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombinedBurnDownWidgetViews.swift; sourceTree = ""; }; + D4E5F60718293041C5D6E7F9 /* BurnDownWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurnDownWidgetProvider.swift; sourceTree = ""; }; + E430B27E4F28973A5E77EA3F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodexBarWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + EFBE36CB6481E7133E2A5CF3 /* CodexBar */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CodexBar; path = ..; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + F5F0F061CD72D02EB841D35C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7A3A654DC5A5B85C9C41EA02 /* CodexBarCore in Frameworks */, + 7F0E34471853E41206F690FB /* SwiftUI.framework in Frameworks */, + 6F12082A467310EEDD1F3439 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4FAD1E2FCD6C4AC65D308ABC /* Packages */ = { + isa = PBXGroup; + children = ( + EFBE36CB6481E7133E2A5CF3 /* CodexBar */, + ); + name = Packages; + sourceTree = ""; + }; + 74E0E4CB8C1E1700BE59E54D = { + isa = PBXGroup; + children = ( + B37422CB8DFAAFC8B3B8C1B6 /* CodexBarWidget */, + 4FAD1E2FCD6C4AC65D308ABC /* Packages */, + CEE79B6AB070A55FA0FB7E12 /* Frameworks */, + B7E03090CEF29F6B74205FAE /* Products */, + ); + sourceTree = ""; + }; + B37422CB8DFAAFC8B3B8C1B6 /* CodexBarWidget */ = { + isa = PBXGroup; + children = ( + 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */, + 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */, + 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */, + B2C3D4E5F6071829A3B4C5D7 /* BurnDownWidgetViews.swift */, + C3D4E5F607182930B4C5D6E8 /* CombinedBurnDownWidgetViews.swift */, + D4E5F60718293041C5D6E7F9 /* BurnDownWidgetProvider.swift */, + ); + name = CodexBarWidget; + path = ../Sources/CodexBarWidget; + sourceTree = ""; + }; + B7E03090CEF29F6B74205FAE /* Products */ = { + isa = PBXGroup; + children = ( + E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + CEE79B6AB070A55FA0FB7E12 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 02F15F392AF9D502F0503F8F /* SwiftUI.framework */, + E430B27E4F28973A5E77EA3F /* WidgetKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E9AFBF11687E131ED1AD113A /* CodexBarWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0BDFA2A2ECD62489A63741CF /* Build configuration list for PBXNativeTarget "CodexBarWidgetExtension" */; + buildPhases = ( + 7FB8FE18C057D477EA90DD38 /* Sources */, + F5F0F061CD72D02EB841D35C /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CodexBarWidgetExtension; + packageProductDependencies = ( + 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */, + ); + productName = CodexBarWidgetExtension; + productReference = E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 31A080B4D7A0849832821889 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + }; + }; + buildConfigurationList = A17A8A4315AD7DBD4D417FCA /* Build configuration list for PBXProject "CodexBarWidgetExtension" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 74E0E4CB8C1E1700BE59E54D; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + B0BE8F0E2917CB6B2EDF1826 /* XCLocalSwiftPackageReference ".." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = B7E03090CEF29F6B74205FAE /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E9AFBF11687E131ED1AD113A /* CodexBarWidgetExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 7FB8FE18C057D477EA90DD38 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */, + 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */, + 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */, + B2C3D4E5F6071829A3B4C5D6 /* BurnDownWidgetViews.swift in Sources */, + C3D4E5F607182930B4C5D6E7 /* CombinedBurnDownWidgetViews.swift in Sources */, + D4E5F60718293041C5D6E7F8 /* BurnDownWidgetProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 4A864C1BFFF710E1A519CCF5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 76EC15BB9FE2307D815E380E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 7B3A3941F0FAA0B4ADAB8760 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = NO; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CodexBarTeamID = "$(CODEXBAR_TEAM_ID)"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(CODEXBAR_WIDGET_BUNDLE_ID)"; + PRODUCT_NAME = CodexBarWidget; + SDKROOT = macosx; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 88E6F58603FDA13ED00BF91D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = NO; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CodexBarTeamID = "$(CODEXBAR_TEAM_ID)"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(CODEXBAR_WIDGET_BUNDLE_ID)"; + PRODUCT_NAME = CodexBarWidget; + SDKROOT = macosx; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0BDFA2A2ECD62489A63741CF /* Build configuration list for PBXNativeTarget "CodexBarWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7B3A3941F0FAA0B4ADAB8760 /* Debug */, + 88E6F58603FDA13ED00BF91D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + A17A8A4315AD7DBD4D417FCA /* Build configuration list for PBXProject "CodexBarWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4A864C1BFFF710E1A519CCF5 /* Debug */, + 76EC15BB9FE2307D815E380E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + B0BE8F0E2917CB6B2EDF1826 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CodexBarCore; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 31A080B4D7A0849832821889 /* Project object */; +} diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/WidgetExtension/Info.plist b/WidgetExtension/Info.plist new file mode 100644 index 0000000000..bf7636ef98 --- /dev/null +++ b/WidgetExtension/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + CodexBar + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + CodexBarWidget + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CodexBarTeamID + $(CODEXBAR_TEAM_ID) + LSMinimumSystemVersion + 14.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/WidgetExtension/project.yml b/WidgetExtension/project.yml new file mode 100644 index 0000000000..269c830c37 --- /dev/null +++ b/WidgetExtension/project.yml @@ -0,0 +1,42 @@ +name: CodexBarWidgetExtension +options: + deploymentTarget: + macOS: "14.0" +packages: + CodexBar: + path: .. +targets: + CodexBarWidgetExtension: + type: app-extension + platform: macOS + deploymentTarget: "14.0" + sources: + - path: ../Sources/CodexBarWidget + dependencies: + - package: CodexBar + product: CodexBarCore + - sdk: SwiftUI.framework + - sdk: WidgetKit.framework + info: + path: Info.plist + properties: + CFBundleDisplayName: CodexBar + CFBundleName: CodexBarWidget + CFBundlePackageType: XPC! + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + CodexBarTeamID: "$(CODEXBAR_TEAM_ID)" + LSMinimumSystemVersion: "14.0" + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + settings: + base: + APPLICATION_EXTENSION_API_ONLY: true + CODE_SIGNING_ALLOWED: false + ENABLE_DEBUG_DYLIB: false + ENABLE_APP_SANDBOX: true + INFOPLIST_KEY_CodexBarTeamID: "$(CODEXBAR_TEAM_ID)" + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks" + PRODUCT_BUNDLE_IDENTIFIER: "$(CODEXBAR_WIDGET_BUNDLE_ID)" + PRODUCT_NAME: CodexBarWidget + SWIFT_VERSION: "6.0" diff --git a/appcast.xml b/appcast.xml index c7bc8f030a..d086d9d5ed 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,234 +2,138 @@ CodexBar - - 0.18.0-beta.3 - Fri, 13 Feb 2026 18:57:54 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 51 - 0.18.0-beta.3 - 14.0 - CodexBar 0.18.0-beta.3 -

Highlights

-
    -
  • Claude OAuth/keychain flows were reworked across a series of follow-up PRs to reduce prompt storms, stabilize background behavior, surface a setting to control prompt policy and make failure modes deterministic (#245, #305, #308, #309, #364). Thanks @manikv12!
  • -
  • Claude: harden Claude Code PTY capture for /usage and /status (prompt automation, safer command palette confirmation, partial UTF-8 handling, and parsing guards against status-bar context meters) (#320).
  • -
  • New provider: Warp (credits + add-on credits) (#352). Thanks @Kathie-yu!
  • -
  • Provider correctness fixes landed for Cursor plan parsing and MiniMax region routing (#240, #234, #344). Thanks @robinebers and @theglove44!
  • -
  • Menu bar animation behavior was hardened in merged mode and fallback mode (#283, #291). Thanks @vignesh07 and @Ilakiancs!
  • -
  • CI/tooling reliability improved via pinned lint tools, deterministic macOS test execution, and PTY timing test stabilization plus Node 24-ready GitHub Actions upgrades (#292, #312, #290).
  • -
-

Claude OAuth & Keychain

-
    -
  • Claude OAuth creds are cached in CodexBar Keychain to reduce repeated prompts.
  • -
  • Prompts can still appear when Claude OAuth credentials are expired, invalid, or missing and re-auth is required.
  • -
  • In Auto mode, background refresh keeps prompts suppressed; interactive prompts are limited to user actions (menu open or manual refresh).
  • -
  • OAuth-only mode remains strict (no silent Web/CLI fallback); Auto mode may do one delegated CLI refresh + one OAuth retry before falling back.
  • -
  • Preferences now expose a Claude Keychain prompt policy (Never / Only on user action / Always allow prompts) under Providers → Claude; if global Keychain access is disabled in Advanced, this control remains visible but inactive.
  • -
-

Provider & Usage Fixes

-
    -
  • Warp: add Warp provider support (credits + add-on credits), configurable via Settings or WARP_API_KEY/WARP_TOKEN (#352). Thanks @Kathie-yu!
  • -
  • Cursor: compute usage against plan.limit rather than breakdown.total to avoid incorrect limit interpretation (#240). Thanks @robinebers!
  • -
  • MiniMax: correct API region URL selection to route requests to the expected regional endpoint (#234). Thanks @theglove44!
  • -
  • MiniMax: always show the API region picker and retry the China endpoint when the global host rejects the token to avoid upgrade regressions for users without a persisted region (#344). Thanks @apoorvdarshan!
  • -
  • Claude: add Opus 4.6 pricing so token cost scanning tracks USD consumed correctly (#348). Thanks @arandaschimpf!
  • -
  • z.ai: handle quota responses with missing token-limit fields, avoid incorrect used-percent calculations, and harden empty-response behavior with safer logging (#346). Thanks @MohamedMohana and @halilertekin!
  • -
  • z.ai: fix provider visibility in the menu when enabled with token-account credentials (availability now considers the effective fetch environment).
  • -
  • Amp: detect login redirects during usage fetch and fail fast when the session is invalid (#339). Thanks @JosephDoUrden!
  • -
  • Resource loading: fix app bundle lookup path to avoid "could not load resource bundle" startup failures (#223). Thanks @validatedev!
  • -
  • OpenAI Web dashboard: keep WebView instances cached for reuse to reduce repeated network fetch overhead; tests were updated to avoid network-dependent flakes (#284). Thanks @vignesh07!
  • -
  • Token-account precedence: selected token account env injection now correctly overrides provider config apiKey values in app and CLI environments. Thanks @arvindcr4!
  • -
  • Claude: make Claude CLI probing more resilient by scoping auto-input to the active subcommand and trimming to the latest Usage panel before parsing to avoid false matches from earlier screen fragments (#320).
  • -
-

Menu Bar & UI Behavior

-
    -
  • Prevent fallback-provider loading animation loops (battery/CPU drain when no providers are enabled) (#283). Thanks @vignesh07!
  • -
  • Prevent status overlay rendering for disabled providers while in merged mode (#291). Thanks @Ilakiancs!
  • -
-

CI, Tooling & Test Stability

-
    -
  • Pin SwiftFormat/SwiftLint versions and harden lint installer behavior (version drift + temp-file leak fixes) (#292).
  • -
  • Use more deterministic macOS CI test settings (including non-parallel paths where needed) and align runner/toolchain behavior for stability (#292).
  • -
  • Stabilize PTY command timing tests to reduce CI flakiness (#312).
  • -
  • Upgrade actions/checkout to v6 and actions/github-script to v8 for Node 24 compatibility in upstream-monitor.yml (#290). Thanks @salmanmkc!
  • -
  • Tests: add TaskLocal-based keychain/cache overrides so keychain gating and KeychainCacheStore test stores do not leak across concurrent test execution (#320).
  • -
-

Docs & Maintenance

-
    -
  • Update docs for Claude data fetch behavior and keychain troubleshooting notes.
  • -
  • Update MIT license year.
  • -
-

View full changelog

-]]>
- -
- - 0.18.0-beta.2 - Wed, 21 Jan 2026 08:42:37 +0000 + + 0.37.2 + Mon, 22 Jun 2026 10:42:03 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 50 - 0.18.0-beta.2 + 92 + 0.37.2 14.0 - CodexBar 0.18.0-beta.2 -

Highlights

-
    -
  • OpenAI web dashboard refresh cadence now follows 5× the base refresh interval.
  • -
  • OpenAI web dashboard WebView is torn down after each scrape to reduce idle CPU.
  • -
  • Codex settings now include a toggle to disable OpenAI web extras.
  • -
-

Providers

+ CodexBar 0.37.2 +

Added

    -
  • Providers: add Dia browser support across cookie import and profile detection (#209). Thanks @validatedev!
  • -
  • Codex: include archived session logs in local token cost scanning and dedupe by session id.
  • -
  • Claude: harden CLI /usage parsing and avoid ANTHROPIC_* env interference during probes.
  • +
  • Diagnostics: write redacted provider reports to a file with platform and app-version context. Thanks @Yuxin-Qiao!
  • +
  • CLI server: report the startup build version from /health so clients can detect stale helper processes after updates. Thanks @enieuwy!
-

Menu & Menu Bar

+

Fixed

    -
  • Menu: opening OpenAI web submenus triggers a refresh when the data is stale.
  • -
  • Menu: fix usage line labels to honor “Show usage as used”.
  • -
  • Debug: add a toggle to keep Codex/Claude CLI sessions alive between probes.
  • -
  • Debug: add a button to reset CLI probe sessions.
  • -
  • App icon: use the classic icon on macOS 15 and earlier while keeping Liquid Glass for macOS 26+ (#178). Thanks @zerone0x!
  • +
  • Claude: pause background CLI usage probes briefly after rate limiting while keeping manual refresh available. Thanks @kiranmagic7!
  • +
  • Codex OAuth: publish refreshed auth.json credentials with private file permissions already applied. Thanks @Hinotoi-agent!
  • +
  • Provider endpoints: reject unsafe Deepgram, z.ai, and Xiaomi MiMo overrides before attaching credentials. Thanks @Hinotoi-agent!
  • +
  • Azure OpenAI: reject unsafe endpoint overrides before attaching API keys while keeping invalid configurations visible with an actionable error. Thanks @Hinotoi-agent!

View full changelog

]]>
- +
- - 0.18.0-beta.1 - Sun, 18 Jan 2026 23:09:38 +0000 + + 0.37.1 + Mon, 22 Jun 2026 00:16:24 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 49 - 0.18.0-beta.1 + 91 + 0.37.1 14.0 - CodexBar 0.18.0-beta.1 -

Highlights

+ CodexBar 0.37.1 +

Fixed

    -
  • New providers: OpenCode (web usage), Vertex AI, Kiro, Kimi, Kimi K2, Augment, Amp, Synthetic.
  • -
  • Provider source controls: usage source pickers for Codex/Claude, manual cookie headers, cookie caching with source/timestamp.
  • -
  • Menu bar upgrades: display mode picker (percent/pace/both), auto-select near limit, absolute reset times, pace summary line.
  • -
  • CLI/config revamp: config-backed provider settings, JSON-only errors, config validate/dump.
  • -
-

Providers

-
    -
  • OpenCode: add web usage provider with workspace override + Chrome-first cookie import (#188). Thanks @anthnykr!
  • -
  • OpenCode: refresh provider logo (#190). Thanks @anthnykr!
  • -
  • Vertex AI: add provider with quota-based usage from gcloud ADC. Thanks @bahag-chaurasiak!
  • -
  • Vertex AI: token costs are shown via the Claude provider (same local logs).
  • -
  • Vertex AI: harden quota usage parsing for edge-case responses.
  • -
  • Kiro: add CLI-based usage provider via kiro-cli. Thanks @neror!
  • -
  • Kiro: clean up provider wiring and show plan name in the menu.
  • -
  • Kiro: harden CLI idle handling to avoid partial usage snapshots (#145). Thanks @chadneal!
  • -
  • Kimi: add usage provider with cookie-based API token stored in Keychain (#146). Thanks @rehanchrl!
  • -
  • Kimi K2: add API-key usage provider for credit totals (#147). Thanks @0-CYBERDYNE-SYSTEMS-0!
  • -
  • Augment: add provider with browser-cookie usage tracking.
  • -
  • Augment: prefer Auggie CLI usage with web fallback, plus session refresh + recovery tools (#142). Thanks @bcharleson!
  • -
  • Amp: add provider with Amp Free usage tracking (#167). Thanks @duailibe!
  • -
  • Synthetic: add API-key usage provider with quota snapshots (#171). Thanks @monotykamary!
  • -
  • JetBrains AI: include IDEs missing quota files, expand custom paths, and add Android Studio base paths (#194). Thanks @steipete!
  • -
  • Cursor: support legacy request-based plans and show individual on-demand usage (#125) — thanks @vltansky
  • -
  • Cursor: avoid Intel crash when opening login and harden WebKit teardown. Thanks @meghanto!
  • -
  • Cursor: load stored session cookies before reads to make relaunches deterministic.
  • -
  • z.ai: add BigModel CN region option for API endpoint selection (#140). Thanks @nailuoGG!
  • -
  • MiniMax: add China mainland region option + host overrides (#143). Thanks @nailuoGG!
  • -
  • MiniMax: support API token or cookie auth; API token takes precedence and hides cookie UI (#149). Thanks @aonsyed!
  • -
  • Gemini: prefer loadCodeAssist project IDs for quota fetches (#172). Thanks @lolwierd!
  • -
  • Gemini: honor loadCodeAssist project IDs for quota + support Nix CLI layout (#184). Thanks @HaukeSchnau!
  • -
  • Claude: fix OAuth “Extra usage” spend/limit units when the API returns minor currency units (#97).
  • -
  • Claude: rescale extra usage costs when plan hints are missing and prefer web plan hints for extras (#181). Thanks @jorda0mega!
  • -
  • Usage formatting: fix currency parsing/formatting on non-US locales (e.g., pt-BR). Thanks @mneves75!
  • -
-

Provider Sources & Security

-
    -
  • Providers: cache browser cookies in Keychain (per provider) and show cached source/time in settings.
  • -
  • Codex/Claude/Cursor/Factory/MiniMax: cookie sources now include Manual (paste a Cookie header) in addition to Automatic.
  • -
  • Codex/Claude/Cursor/Factory/MiniMax: skip cookie imports from browsers without usable cookie stores (profile/cookie DB) to avoid unnecessary Keychain prompts.
  • -
  • Providers: suppress repeated Chromium Keychain prompts after access denied and honor disabled Keychain access.
  • -
-

Preferences & Settings

-
    -
  • Preferences: swap provider refresh button and enable toggle order.
  • -
  • Preferences: animate settings width and widen Providers on selection.
  • -
  • Preferences: shrink default settings size and reduce overall height.
  • -
  • Preferences: move “Hide personal information” to Advanced.
  • -
  • Providers: shorten fetch subtitle to relative time only.
  • -
  • Preferences: soften provider sidebar background and stabilize drag reordering.
  • -
  • Preferences: restrict provider drag handle to handle-only.
  • -
  • Preferences: move provider refresh timing to a dedicated second line.
  • -
  • Preferences: tighten provider usage metrics spacing.
  • -
  • Preferences: show refresh timing inline in provider detail subtitle.
  • -
  • Preferences: move “Access OpenAI via web” into Providers → Codex.
  • -
  • Preferences: add usage source pickers for Codex + Claude with auto fallback.
  • -
  • Preferences: add cookie source pickers with contextual helper text for the selected mode.
  • -
  • Preferences: move “Disable Keychain access” to Advanced and require manual cookies when enabled.
  • -
  • Preferences: add per-provider menu bar metric picker (#185) — thanks @HaukeSchnau
  • -
  • Preferences: tighten provider rows (inline pickers, compact layout, inline refresh + auto-source status).
  • -
  • Preferences: remove the “experimental” label from Antigravity.
  • -
-

Menu & Menu Bar

-
    -
  • Menu: add a toggle to show reset times as absolute clock values (instead of countdowns).
  • -
  • Menu: show an “Open Terminal” action when Claude OAuth fails.
  • -
  • Menu: add “Hide personal information” toggle and redact emails in menu UI (#137). Thanks @t3dotgg!
  • -
  • Menu: keep a pace summary line alongside the visual marker (#155). Thanks @antons!
  • -
  • Menu: reduce provider-switch flicker and avoid redundant menu card sizing for faster opens (#132). Thanks @ibehnam!
  • -
  • Menu: keep background refresh on open without forcing token usage (#158). Thanks @weequan93!
  • -
  • Menu: Cursor switcher shows On-Demand remaining when Plan is exhausted in show-remaining mode (#193). Thanks @vltansky!
  • -
  • Menu: avoid single-letter wraps in provider switcher titles.
  • -
  • Menu: widen provider switcher buttons to avoid clipped titles.
  • -
  • Menu bar: rebuild provider status items on reorder so icons update correctly.
  • -
  • Menu bar: optional auto-select provider closest to its rate limit and keep switcher progress visible (#159). Thanks @phillco!
  • -
  • Menu bar: add display mode picker for percent/pace/both in the menu bar icon (#169). Thanks @PhilETaylor!
  • -
  • Menu bar: fix combined loading indicator flicker during loading animation (incl. debug replay).
  • -
  • Menu bar: prevent blink updates from clobbering the loading animation.
  • -
-

CLI & Config

-
    -
  • CLI: respect the reset time display setting.
  • -
  • CLI: add pink accents, usage bars, and weekly pace lines to text output.
  • -
  • CLI: add config-backed provider settings, --json-only, and --source api for key-based providers.
  • -
  • CLI: add config validate/config dump commands and per-provider JSON error payloads.
  • -
  • CLI/App: move provider secrets + ordering to ~/.codexbar/config.json (no Keychain persistence).
  • -
  • Providers: resolve API tokens from config/env only (no Keychain fallback).
  • -
-

Dev & Tests

-
    -
  • Dev: move Chromium profile discovery into SweetCookieKit (adds Helium net.imput.helium). Thanks @hhushhas!
  • -
  • Dev: bump SweetCookieKit to 0.2.0.
  • -
  • Dev: migrate stored Keychain items to reduce rebuild prompts.
  • -
  • Dev: move path debug snapshot off the main thread and debounce refreshes to avoid startup hitches (#131). Thanks @ibehnam!
  • -
  • Tests: expand Kiro CLI coverage.
  • -
  • Tests: stabilize Claude PTY integration cleanup and reset CLI sessions after probes.
  • -
  • Tests: kill leaked codex app-server after tests.
  • -
  • Tests: add regression coverage for merged loading icon layout stability.
  • -
  • Tests: cover config validation and JSON-only CLI errors.
  • -
  • Build: stabilize Swift test runtime.
  • +
  • MiniMax: recover detailed token-plan windows from the remains API when the coding-plan page only exposes coarse usage. Thanks @Yuxin-Qiao!
  • +
  • Cost history: remove the redundant tooltip from submenu-backed Cost rows. Thanks @Zihao-Qi!
  • +
  • Menu refresh: keep the menu open and show in-place progress when Refresh is clicked. Thanks @elijahfriedman!
  • +
  • Menu: align provider usage-card spacing with the Overview layout. Thanks @Zihao-Qi!
  • +
  • Memory pressure: avoid actor-isolation crashes when system callbacks arrive on a utility queue. Thanks @Zihao-Qi!
  • +
  • Menu: remove extra separators and spacing around Storage, Cost, and Subscription Utilization rows. Thanks @elijahfriedman!
  • +
  • Antigravity: show limits as unavailable when OAuth identifies the account but quota endpoints deny access. Thanks @Yuxin-Qiao!

View full changelog

]]>
- +
- 0.17.0 - Wed, 31 Dec 2025 23:12:24 +0100 + 0.37.0 + Sat, 20 Jun 2026 03:07:41 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 48 - 0.17.0 + 90 + 0.37.0 14.0 - CodexBar 0.17.0 -
    -
  • New providers: MiniMax.
  • -
  • Keychain: show a preflight explanation before macOS prompts for OAuth tokens or cookie decryption.
  • -
  • Providers: defer z.ai + Copilot Keychain reads until the user interacts with the token field.
  • -
  • Menu bar: avoid status item menu reattachment and layout flips during refresh to reduce icon flicker.
  • -
  • Dev: align SweetCookieKit local-storage tests with Swift Testing.
  • -
  • Charts: align hover selection bands with visible bars in credits + usage breakdown history.
  • -
  • About: fix website link in the About panel. Thanks @felipeorlando!
  • + CodexBar 0.37.0 +

    Added

    +
      +
    • Widgets: add single-window and combined burn-down charts for Codex and Claude session/weekly limits. Thanks @jamesjlopez!
    • +
    • AWS Bedrock: show optional rolling 14-day Claude token and request totals from CloudWatch. Thanks @zyaiire!
    • +
    • Codex: optionally show both session-window and weekly percentages in the compact menu bar label. Thanks @thepraggyverse!
    • +
    • Cursor: show personal on-demand spend alongside the shared team pool. Thanks @yashiels!
    • +
    • Documentation: link the community KDE Plasma panel integration. Thanks @tylxr59!
    • +
    • Codex: expose explicitly configured profile homes as switchable accounts without copying their credentials. Thanks @kiranmagic7!
    • +
    • Codex: show available manual rate-limit reset credits and their next expiry for signed-in OAuth accounts. Thanks @rogdex24!
    • +
    • Mistral: add Vibe monthly-plan usage and menu bar metric selection. Thanks @lfmundim!
    • +
    • Storage: show a compact segmented provider breakdown with an expandable Other group. Thanks @elijahfriedman!
    • +
    • Settings: add an optional enabled-first alphabetical sort for the Providers sidebar without changing custom order. Thanks @elijahfriedman!
    • +
    • Linux CLI: publish static musl release tarballs for x86_64 and aarch64. Thanks @Yuxin-Qiao!
    • +
    • Documentation: add safe troubleshooting for browser Keychain prompts that persist after uninstall. Thanks @Yuxin-Qiao!
    • +
    • Diagnostics: report provider-neutral usage confidence and mark fully decoded Codex OAuth windows exact. Thanks @Yuxin-Qiao!
    • +
    • Codex agents: add a read-only codexbar skill for bounded, redacted provider usage JSON. Thanks @coygeek!
    • +
    • Display: add a Hide critters option for plain menu bar quota capsules. Thanks @elijahfriedman!
    • +
    +

    Changed

    +
      +
    • Packaging: strip local symbols from release executables to reduce the installed app and download size. Thanks @jieshu666!
    • +
    • Logging: skip message, metadata, and redaction work for filtered or disabled log destinations. Thanks @ProspectOre!
    • +
    • Cost history: cache date parsers per thread to reduce repeated report-decoding overhead. Thanks @ProspectOre!
    • +
    • Linux CLI: accept an opt-in static SQLite library directory for musl builds. Thanks @Yuxin-Qiao!
    • +
    • Linux CLI: add musl source compatibility for static Linux SDK builds. Thanks @Yuxin-Qiao!
    • +
    • Cost history: resize the chart details to the hovered day's model breakdown instead of reserving the tallest day. Thanks @elijahfriedman!
    • +
    • Antigravity: use current backend quota labels in menus and widgets while preferring a usable quota lane over an exhausted one. Thanks @Yuxin-Qiao!
    • +
    • Pi: cache session filename and timestamp parsers to reduce cost-history refresh overhead. Thanks @ProspectOre!
    • +
    • Menu bar: reuse the icon-observation signature during provider refreshes instead of computing it twice. Thanks @abe238!
    • +
    • LiteLLM: show personal and team spend amounts directly on budget rows while suppressing duplicate budget sections. Thanks @hololee!
    • +
    +

    Fixed

    +
      +
    • Menu: align cost and utilization rows with provider content and use native bottom action items. Thanks @elijahfriedman!
    • +
    • Charts: keep hover selection on bar widths, preserve single-day details, and remove redundant cost-menu detail lines. Thanks @elijahfriedman!
    • +
    • Cost history: keep chart date labels aligned with their bars and visible without clipping. Thanks @elijahfriedman!
    • +
    • Claude settings: dim and disable Avoid Keychain prompts while global Keychain access is disabled. Thanks @Zihao-Qi!
    • +
    • Linux CLI: read OpenCode Go local SQLite usage in automatic mode and allow Command Code billing with a configured manual cookie.
    • +
    • MiniMax diagnostics: include safe per-service usage and boosted quota limits for mismatch reports. Thanks @sagelga!
    • +
    • Xiaomi MiMo: retry another imported browser session when a stale session redirects API requests to login. Thanks @Yuxin-Qiao!
    • +
    • MiniMax: retry the China API region when the global token endpoint reports a structured invalid-key response.
    • +
    • Menu refresh: scope manual refreshes to the visible provider, keep Command-R consistent with mouse refresh, and avoid animated refresh-row compositing. Thanks @jangisaac-dev!
    • +
    • Localization: improve Catalan app and website translations. Thanks @pmontp19!
    • +
    • Claude web: persist renewed session cookies after successful usage requests so imported sessions stay current. Thanks @ProspectOre!
    • +
    • Kiro: keep parsed usage available when the optional account probe times out or fails. Thanks @Yuxin-Qiao!
    • +
    • Cursor: ignore an exhausted Auto or API subquota only when another independent quota remains usable, while preserving the overall cap. Thanks @Yuxin-Qiao!
    • +
    • Memory: release idle OpenAI WebViews under system pressure without blocking the main thread. Thanks @ProspectOre!
    • +
    • Memory: trim rebuildable menu and OpenAI debug caches under system pressure. Thanks @ProspectOre!
    • +
    • Provider plans: keep Claude and Kiro plan matching on one rendered line to avoid bogus labels from adjacent usage hints. Thanks @elijahfriedman!
    • +
    • Antigravity: use current Gemini 5-hour and weekly quota-summary lanes for the compact menu bar icon and merged highest-usage selection. Thanks @Zihao-Qi!
    • +
    • Usage bars: render values rounded to 0% or 100% as fully empty or full. Thanks @Zihao-Qi!
    • +
    • Codex web: keep cookie-import deadlines responsive when browser cookie work blocks the shared worker pool.
    • +
    • z.ai: open the usage dashboard for the configured global or China API region. Thanks @renbaoshuo!
    • +
    • Usage dashboards: tint inline history bars with each provider's branding color. Thanks @elijahfriedman!
    • +
    • Command Code: avoid repeated depleted notifications when subscription lookup intermittently fails. Thanks @LPFchan!
    • +
    • Codex pace: extrapolate historically exhausted weeks for run-out forecasts and avoid contradictory reset headlines. Thanks @Yuxin-Qiao!
    • +
    • Localization: correct the German in-progress refresh label. Thanks @ChrisLauinger77!
    • +
    • Localization: correct misleading literal German UI translations. Thanks @madebyjulz!
    • +
    • Install docs: describe the official Homebrew cask as universal on Intel and Apple silicon. Thanks @ChrisGVE!
    • +
    • Settings: switch tabs immediately before animated window resizing and reduce Providers sidebar work. Thanks @elijahfriedman!
    • +
    • Windsurf: import complete Devin sessions from the current app origin before legacy browser storage. Thanks @kiranmagic7!
    • +
    • Antigravity: humanize raw model identifiers while preserving server-provided quota labels. Thanks @bcharleson!
    • +
    • Menu bar: show provider status markers only for the provider rendered in each icon. Thanks @Zihao-Qi!
    • +
    • Codex CLI: make automatic usage reads prefer OAuth and CLI sources instead of blocking on the optional web dashboard.
    • +
    • Codex web: apply --web-timeout to the full cookie import, account verification, retry, and dashboard fetch path.
    • +
    • OpenCode Go: allow configured manual cookies in the Linux CLI while keeping browser-cookie import gated to macOS. Thanks @Yuxin-Qiao!
    • +
    • Provider probes: cap captured subprocess output at 1 MiB per stream without dropping valid text at a truncated UTF-8 boundary. Thanks @ProspectOre!
    • +
    • Provider switcher: keep Codex quota rows visible when switching away and back during a manual refresh, including menus with usage-history sections. Thanks @Yuxin-Qiao!
    • +
    • Bedrock: ignore invalid billing dates when selecting the latest usage values. Thanks @ProspectOre!
    • +
    • Usage history: let opted-in providers persist weekly utilization and keep saved charts visible. Thanks @kiranmagic7!
    • +
    • Localization: improve Japanese terminology consistency and localize next-day reset times across all 21 app languages. Thanks @tukuyomil032!
    • +
    • Menu bar: keep visible quota values stable while a manual refresh is in flight without rewinding background-refresh countdowns. Thanks @Zihao-Qi!
    • +
    • Menu bar: stop informational usage-card rows from highlighting like clickable actions. Thanks @elijahfriedman!
    • +
    • Localization: validate placeholder integrity across every app language and repair malformed Vietnamese interpolation tokens. Thanks @Yuxin-Qiao!

    View full changelog

    ]]>
    - + 0.14.0 @@ -272,4 +176,4 @@ - + \ No newline at end of file diff --git a/bin/install-codexbar-cli.sh b/bin/install-codexbar-cli.sh index 50fb953377..742a974e2f 100755 --- a/bin/install-codexbar-cli.sh +++ b/bin/install-codexbar-cli.sh @@ -10,23 +10,20 @@ if [[ ! -x "$HELPER" ]]; then exit 1 fi -install_script=$(mktemp) -cat > "$install_script" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -HELPER="__HELPER__" -TARGETS=("/usr/local/bin/codexbar" "/opt/homebrew/bin/codexbar") - -for t in "${TARGETS[@]}"; do - mkdir -p "$(dirname "$t")" - ln -sf "$HELPER" "$t" - echo "Linked $t -> $HELPER" -done -EOF - -perl -pi -e "s#__HELPER__#$HELPER#g" "$install_script" +osascript - "$HELPER" <<'APPLESCRIPT' +on run argv + set helperPath to item 1 of argv + set installCommand to "set -euo pipefail" & linefeed & ¬ + "HELPER=" & quoted form of helperPath & linefeed & ¬ + "TARGETS=(\"/usr/local/bin/codexbar\" \"/opt/homebrew/bin/codexbar\")" & linefeed & ¬ + "for t in \"${TARGETS[@]}\"; do" & linefeed & ¬ + " mkdir -p \"$(dirname \"$t\")\"" & linefeed & ¬ + " ln -sf \"$HELPER\" \"$t\"" & linefeed & ¬ + " echo \"Linked $t -> $HELPER\"" & linefeed & ¬ + "done" -osascript -e "do shell script \"bash '$install_script'\" with administrator privileges" -rm -f "$install_script" + do shell script "bash -c " & quoted form of installCommand with administrator privileges +end run +APPLESCRIPT echo "CodexBar CLI installed. Try: codexbar usage" diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 072cd9e7f6..b8f2d80cb1 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -13,9 +13,12 @@ read_when: ### Building and Running ```bash -# Full build, test, package, and launch (recommended) +# Full build, package, and launch (recommended) ./Scripts/compile_and_run.sh +# Also run the sharded test suite before packaging/relaunching +./Scripts/compile_and_run.sh --test + # Just build and package (no tests) ./Scripts/package_app.sh @@ -26,7 +29,7 @@ read_when: ### Development Workflow 1. **Make code changes** in `Sources/CodexBar/` -2. **Run** `./Scripts/compile_and_run.sh` to rebuild and launch +2. **Run** `./Scripts/compile_and_run.sh --test` to test, rebuild, and launch 3. **Check logs** in Console.app (filter by "codexbar") 4. **Optional file log**: enable Debug → Logging → "Enable file logging" to write `~/Library/Logs/CodexBar/CodexBar.log` (verbosity defaults to "Verbose") @@ -37,7 +40,9 @@ read_when: You'll see **one keychain prompt per stored credential** on the first launch. This is a **one-time migration** that converts existing keychain items to use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. ### Subsequent Rebuilds -**Zero prompts!** The migration flag is stored in UserDefaults, so future rebuilds won't prompt. +The migration flag is stored in UserDefaults, so migrated CodexBar-owned items should not prompt again. Ad-hoc +signing can still prompt for other keychain surfaces; use `./Scripts/compile_and_run.sh --clear-adhoc-keychain` +when you intentionally want to reset ad-hoc keychain state. ### Why This Happens - Ad-hoc signed development builds change code signature on every rebuild @@ -50,28 +55,20 @@ You'll see **one keychain prompt per stored credential** on the first launch. Th defaults delete com.steipete.codexbar KeychainMigrationV1Completed ``` -## Auto-Refresh for Augment Cookies +## Augment Cookie Refresh ### How It Works -CodexBar automatically refreshes Augment cookies from your browser: - -1. **Automatic Import**: On every usage refresh, CodexBar imports fresh cookies from your browser -2. **Browser Priority**: Chrome → Arc → Safari → Firefox → Brave (configurable) -3. **Session Detection**: Looks for Auth0/NextAuth session cookies -4. **Fallback**: If import fails, uses last known good cookies from keychain +CodexBar checks Augment through the provider fetch pipeline. Auto mode tries the Augment CLI first, then the +browser-cookie web path. The web path reuses cached cookies when possible and imports from supported browsers when +the cache is missing or rejected. ### Refresh Frequency - Default: Every 5 minutes (configurable in Preferences → General) -- Minimum: 30 seconds -- Cookie import happens automatically on each refresh +- Minimum: 1 minute +- Cookie import happens automatically when cached cookies need refresh ### Supported Browsers -- Chrome -- Arc -- Safari -- Firefox -- Brave -- Edge +- Safari, Chrome variants, Edge variants, Brave, Arc variants, Dia, and Firefox. ### Manual Cookie Override If automatic import fails: @@ -102,24 +99,23 @@ CodexBar/ ## Common Tasks ### Add a New Provider -1. Create `Sources/CodexBar/Providers/YourProvider/` -2. Implement `ProviderImplementation` protocol -3. Add to `ProviderRegistry.swift` -4. Add icon to `Resources/ProviderIcon-yourprovider.svg` +1. Add a `UsageProvider` case in `Sources/CodexBarCore/Providers/Providers.swift` +2. Add core descriptor/fetcher wiring under `Sources/CodexBarCore/Providers/YourProvider/` +3. Add app-side implementation under `Sources/CodexBar/Providers/YourProvider/` +4. Register the descriptor in `ProviderDescriptorRegistry` +5. Register the implementation in `ProviderImplementationRegistry` +6. Add icon assets such as `Resources/ProviderIcon-yourprovider.svg` ### Debug Cookie Issues -```bash -# Enable verbose logging -export CODEXBAR_LOG_LEVEL=debug -./Scripts/compile_and_run.sh - -# Check logs in Console.app -# Filter: subsystem:com.steipete.codexbar category:augment-cookie -``` +1. Enable Debug → Logging → "Enable file logging" or raise verbosity in the app settings. +2. Reproduce with `./Scripts/compile_and_run.sh`. +3. Check logs in Console.app: + - Filter: `subsystem:com.steipete.codexbar category:augment` + - Importer messages include the `[augment-cookie]` prefix ### Run Tests Only ```bash -swift test +make test ``` ### Format Code @@ -133,13 +129,13 @@ swiftlint --strict ### Local Development Build ```bash ./Scripts/package_app.sh -# Creates: CodexBar.app (ad-hoc signed) +# Creates: CodexBar.app (Developer ID by default; set CODEXBAR_SIGNING=adhoc for ad-hoc signing) ``` ### Release Build (Notarized) ```bash ./Scripts/sign-and-notarize.sh -# Creates: CodexBar-arm64.zip (notarized for distribution) +# Creates: CodexBar-.zip and CodexBar-.dSYM.zip ``` See `docs/RELEASING.md` for full release process. @@ -162,15 +158,31 @@ defaults read com.steipete.codexbar KeychainMigrationV1Completed # Should output: 1 # Check migration logs -log show --predicate 'category == "KeychainMigration"' --last 5m +log show --predicate 'category == "keychain-migration"' --last 5m ``` ### Cookies Not Refreshing -1. Check browser is supported (Chrome, Arc, Safari, Firefox, Brave) +1. Check the browser is supported by the Augment provider metadata 2. Verify you're logged into Augment in that browser 3. Check Preferences → Providers → Augment → Cookie source is "Automatic" 4. Enable debug logging and check Console.app +### Main-Thread Hangs + +Debug builds start the hang watchdog automatically. To diagnose a release build, +enable it explicitly and restart CodexBar: + +```bash +defaults write com.steipete.codexbar debugMainThreadHangWatchdog -bool true +``` + +Hangs are written to the app log. Hangs over two seconds also request a process +sample under `~/Library/Logs/CodexBar/`. Disable the release opt-in with: + +```bash +defaults delete com.steipete.codexbar debugMainThreadHangWatchdog +``` + ## Architecture Notes ### Menu Bar App Pattern @@ -181,12 +193,13 @@ log show --predicate 'category == "KeychainMigration"' --last 5m ### Cookie Management - Automatic browser import via SweetCookieKit -- Keychain storage for persistence +- Keychain cache for some imported browser cookies and OAuth/device-flow credentials +- `~/.codexbar/config.json` for provider settings, manual cookies, and stored API keys - Manual override for debugging -- Auto-refresh on every usage poll +- Browser-cookie import when cached sessions need refresh ### Usage Polling - Background timer (configurable frequency) - Parallel provider fetches -- Exponential backoff on errors -- Widget snapshot for iOS widget +- First failure can be suppressed when prior data exists +- WidgetKit snapshot for macOS widgets diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index a32098e4ea..ba7b47bc07 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -15,6 +15,9 @@ When developing CodexBar, you may see frequent keychain permission prompts like: > **CodexBar wants to access key "Claude Code-credentials" in your keychain.** This happens because each rebuild creates a new code signature, and macOS treats it as a "different" app. +That can affect both CodexBar-owned entries (`com.steipete.CodexBar`, `com.steipete.codexbar.cache`) and +third-party items such as `Claude Code-credentials`, so an ad-hoc-signed rebuild can keep re-triggering +password/keychain approval dialogs even after you previously chose **Always Allow**. ### Quick Fix (Temporary) @@ -96,11 +99,23 @@ The build script creates `CodexBar.app` in the project root. Old numbered builds This script: 1. Kills existing CodexBar instances 2. Runs `swift build` (release mode) -3. Runs `swift test` (all tests) +3. Runs the sharded full test suite when `--test` is passed 4. Packages the app with `./Scripts/package_app.sh` 5. Launches `CodexBar.app` 6. Verifies it stays running +Launching an unbundled `CodexBar` executable, including SwiftPM builds using `.build` or a custom scratch path, disables +Keychain access for that process to avoid repeated password prompts. Use the packaged `CodexBar.app` when local +validation needs browser cookies or stored credentials; packaged app bundles keep their normal Keychain behavior +regardless of signing mode. + +When the script falls back to ad-hoc signing, it preserves CodexBar-owned keychain state by default. +That means you may still see keychain prompts for existing CodexBar cache entries, but allowing those prompts keeps the +cached browser/OAuth state available across normal rebuilds. +If you want a clean reset of CodexBar-owned keychain state for an ad-hoc build, run +`./Scripts/compile_and_run.sh --clear-adhoc-keychain` before relaunching. +Third-party keychain items still need stable signing if you want macOS to remember **Always Allow** across rebuilds. + ### Quick Build (No Tests) ```bash @@ -111,7 +126,7 @@ swift build -c release ### Run Tests Only ```bash -swift test +make test ``` ### Debug Build diff --git a/docs/FORK_QUICK_START.md b/docs/FORK_QUICK_START.md index 3f60c9f87d..9168d37536 100644 --- a/docs/FORK_QUICK_START.md +++ b/docs/FORK_QUICK_START.md @@ -41,7 +41,7 @@ read_when: swift build # Run tests -swift test +make test # Format code swiftformat Sources Tests @@ -57,11 +57,9 @@ cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexba ### Release ```bash -# Sign and notarize (keep in foreground!) -./Scripts/sign-and-notarize.sh - -# Create appcast -./Scripts/make_appcast.sh +# Edit .mac-release.env first: MAC_RELEASE_REPO, feed URL, download URL, +# bundle id, and Sparkle public/signing key must point at your fork. +./Scripts/release.sh # See full release process cat docs/RELEASING.md @@ -130,13 +128,13 @@ git push origin feature/my-feature ### Testing Changes ```bash # Run all tests -swift test +make test # Run specific test swift test --filter AugmentTests # Build and test together -./Scripts/compile_and_run.sh +./Scripts/compile_and_run.sh --test ``` ### Updating Documentation diff --git a/docs/FORK_ROADMAP.md b/docs/FORK_ROADMAP.md index 0c73db9762..bf52353de0 100644 --- a/docs/FORK_ROADMAP.md +++ b/docs/FORK_ROADMAP.md @@ -118,7 +118,7 @@ This document outlines the development roadmap for the CodexBar fork maintained **Files to Create:** - `Scripts/sync_upstream.sh` -- `docs/UPSTREAM_SYNC.md` +- `docs/UPSTREAM_STRATEGY.md` - `.github/workflows/upstream-sync-check.yml` --- @@ -228,5 +228,5 @@ This document outlines the development roadmap for the CodexBar fork maintained - [Augment Provider](augment.md) - Augment-specific documentation - [Development Guide](DEVELOPMENT.md) - Build and test instructions - [Provider Authoring](provider.md) - How to create new providers -- [Upstream Sync](UPSTREAM_SYNC.md) - Syncing with original repository (TBD) +- [Upstream Strategy](UPSTREAM_STRATEGY.md) - Syncing with original repository - [Quotio Analysis](QUOTIO_ANALYSIS.md) - Feature comparison (TBD) diff --git a/docs/FORK_SETUP.md b/docs/FORK_SETUP.md index d2b732a2ee..a40b64daa0 100644 --- a/docs/FORK_SETUP.md +++ b/docs/FORK_SETUP.md @@ -255,7 +255,7 @@ git cherry-pick git diff upstream/main # 4. Test -swift test +make test # 5. Push to your fork git push origin upstream-pr/fix-cursor-bonus diff --git a/docs/ISSUE_LABELING.md b/docs/ISSUE_LABELING.md new file mode 100644 index 0000000000..8e0ba964a6 --- /dev/null +++ b/docs/ISSUE_LABELING.md @@ -0,0 +1,199 @@ +--- +summary: "Issue labeling policy for triage, prioritization, and backlog hygiene." +read_when: + - Triageing GitHub issues + - Adding or updating issue labels + - Organizing the backlog +--- + +# Issue labeling guide + +This repo uses labels to make the issue tracker easier to scan by: + +- **type** — what kind of issue is this? +- **priority** — how urgent is it? +- **area** — what subsystem is affected? +- **provider** — which provider/service is involved? +- **workflow state** — what kind of follow-up is needed? + +The goal is not to perfectly label everything. The goal is to make open issues easy to sort into: + +- what is broken now, +- what needs maintainer attention, +- what is accepted backlog, +- and what belongs to a specific provider or subsystem. + +## Labeling rules + +For most open issues, aim to apply: + +- **1 type label** +- **1 priority label** +- **1 workflow label** +- **1 area label** +- **0–1 provider labels** + +That means most issues should end up with **3–5 labels max**. + +## Type labels + +Use the existing GitHub-style labels: + +- `bug` — broken behavior, crash, mismatch, false negative, bad parsing, auth failure +- `enhancement` — feature request, UX improvement, support for a new workflow +- `documentation` — docs, onboarding, missing setup guidance +- `question` — only for issues that are primarily asking for clarification or support + +Avoid using `question` as a generic fallback when the issue is actually a bug or feature request. + +## Priority labels + +- `priority:high` — crashes, install failures, auth/account breakage, provider unusable, severe resource issues +- `priority:medium` — real issue or good feature request, but not urgent +- `priority:low` — minor polish, optional UX improvements, long-tail backlog + +## Workflow labels + +- `needs-triage` — new issue that has not been categorized yet +- `needs-repro` — needs logs, screenshots, exact steps, or a current repro +- `needs-design` — valid request, but needs a product/UX decision before implementation +- `blocked-upstream` — likely caused or limited by upstream provider behavior +- `accepted` — intentionally kept open as part of the backlog/roadmap + +## Area labels + +- `area:auth-keychain` — keychain prompts, login state, token refresh, account switching +- `area:install-distribution` — Homebrew, packaging, launch/install failures, binary detection +- `area:usage-accuracy` — usage %, reset windows, plan parsing, cost/token math +- `area:performance` — CPU, battery, memory, background sessions/process churn +- `area:ui-ux` — menu bar behavior, settings, copy, visual layout, interaction polish +- `area:widget` — widget registration, app groups, widget gallery visibility +- `area:docs-onboarding` — setup docs, onboarding docs, missing instructions +- `area:notifications` — threshold alerts, prompt waiting, quota notifications +- `area:export-integration` — Prometheus, HTTP server mode, external integrations +- `area:accounts` — multiple accounts, account discovery, account switching UX + +## Provider labels + +Only apply one when a provider is clearly the main subject: + +- `provider:claude` +- `provider:codex` +- `provider:cursor` +- `provider:copilot` +- `provider:gemini` +- `provider:alibaba` +- `provider:factory` +- `provider:antigravity` +- `provider:opencode` +- `provider:zai` +- `provider:openrouter` + +Not every issue needs a provider label. + +## Close-time labels + +These are mostly useful when resolving issues, not as backlog-organizing labels: + +- `duplicate` +- `invalid` +- `wontfix` +- `stale` + +## Recommended minimum viable label set + +If starting from a sparse tracker, add these first: + +### Priority +- `priority:high` +- `priority:medium` +- `priority:low` + +### Workflow +- `needs-triage` +- `needs-repro` +- `needs-design` +- `accepted` + +### Area +- `area:auth-keychain` +- `area:install-distribution` +- `area:usage-accuracy` +- `area:performance` +- `area:ui-ux` +- `area:widget` +- `area:docs-onboarding` + +### Provider +- `provider:claude` +- `provider:codex` +- `provider:cursor` +- `provider:copilot` + +This smaller set already gives most of the value. + +## Examples + +### Example 1 — severe Claude keychain issue +Issue: repeated Claude keychain prompts, user can’t keep the app running normally. + +Suggested labels: +- `bug` +- `priority:high` +- `area:auth-keychain` +- `provider:claude` + +### Example 2 — roadmap feature +Issue: multiple account support. + +Suggested labels: +- `enhancement` +- `priority:high` +- `area:accounts` +- `needs-design` + +### Example 3 — needs better repro +Issue: generic usage mismatch with unclear screenshots and no exact values. + +Suggested labels: +- `bug` +- `priority:medium` +- `area:usage-accuracy` +- `needs-repro` + +### Example 4 — accepted backlog UI request +Issue: show burn rate / pacing indicators. + +Suggested labels: +- `enhancement` +- `priority:medium` +- `area:usage-accuracy` +- `accepted` + +## Suggested rollout + +1. **Create the new labels** +2. **Backfill the top-priority open issues first** + - all `priority:high` bugs + - major roadmap items + - maintainer-triage issues +3. **Apply labels to new issues at intake** +4. **Backfill older backlog issues gradually** + +## Practical guidance + +- Prefer **fewer, clearer labels** over many vague labels. +- Do not label everything `question`. +- Do not use both `needs-repro` and `accepted` on the same issue unless there is a strong reason. +- If an issue is provider-specific, add the provider label early. +- If an issue is obviously real and intended to stay open, add `accepted` so it doesn’t look abandoned. + +## Current workflow-specific labels + +These already exist and should stay scoped to their current purpose: + +- `upstream-sync` +- `needs-review` +- `changes requested` + +They should not replace the general issue triage labels above. diff --git a/docs/KEYCHAIN_FIX.md b/docs/KEYCHAIN_FIX.md index 61deda6bcf..3c96f05efc 100644 --- a/docs/KEYCHAIN_FIX.md +++ b/docs/KEYCHAIN_FIX.md @@ -48,7 +48,7 @@ Load order for credentials: 5. Claude CLI keychain service: `Claude Code-credentials` (promptable fallback). Prompt mitigation: -- Non-interactive keychain probes use `KeychainNoUIQuery` (`LAContext.interactionNotAllowed` + `kSecUseAuthenticationUIFail`). +- Non-interactive keychain probes use `KeychainNoUIQuery` with `LAContext.interactionNotAllowed`. - Pre-alert is shown only when preflight suggests interaction may be required. - Denials are cooled down in the background via `claudeOAuthKeychainDeniedUntil` (`ClaudeOAuthKeychainAccessGate`). User actions (menu open / manual refresh) clear this cooldown. @@ -80,6 +80,9 @@ This is OS/keychain ACL behavior, not a `ThisDeviceOnly` migration issue. - Browser-imported Claude session cookies are cached in keychain service `com.steipete.codexbar.cache`. - Account key is `cookie.claude`. - Cache writes use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. +- Users can clear browser-cookie cache entries from **Preferences → Debug → Caches** or with + `codexbar cache clear --cookies`. `--provider ` scopes cookie clearing to one provider and includes scoped + Codex managed-account cookie keys. ## What still uses `ThisDeviceOnly` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0fdf6585f7..e4b08bd581 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -21,14 +21,15 @@ SwiftPM-only; package/sign/notarize manually (no Xcode project). Sparkle feed is - Sparkle key probe runs up front; appcast entry + signature verified automatically after generation. - Release notes are extracted directly from the current changelog section and passed to the GitHub release (no manual notes flag needed). - Sparkle appcast notes are generated as HTML from the same changelog section and embedded into the appcast entry. -- Requires tools/env on PATH: `swiftformat`, `swiftlint`, `swift`, `sign_update`, `generate_appcast`, `gh`, `python3`, `zip`, `curl`, plus `APP_STORE_CONNECT_*` and `SPARKLE_PRIVATE_KEY_FILE`. +- Requires tools/env on PATH: `swiftformat`, `swiftlint`, `swift`, `sign_update`, `generate_keys`, `generate_appcast`, `gh`, `python3`, `zip`, `curl`, plus `APP_STORE_CONNECT_*`. `SPARKLE_PRIVATE_KEY_FILE` is only needed when overriding the default Keychain Sparkle key. ## Prereqs - Xcode 26+ installed at `/Applications/Xcode.app` (for ictool/iconutil and SDKs). - Developer ID Application cert installed: `Developer ID Application: Peter Steinberger (Y5PE65HELJ)`. - ASC API creds in env: `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`. -- Sparkle keys: public key already in Info.plist; private key path set via `SPARKLE_PRIVATE_KEY_FILE` when generating appcast. +- Sparkle keys: public key expectation is in `.mac-release.env`; CodexBar still uses the older shared AGCY key, so the manifest includes the local Dropbox fallback path. `SPARKLE_PRIVATE_KEY_FILE` overrides it. - Ensure shell has release env vars loaded (usually `source ~/.profile`) before running `Scripts/release.sh`. +- Shared release helper: `Scripts/mac-release` resolves `MAC_RELEASE_TOOL`, sibling `../agent-scripts`, or `~/Projects/agent-scripts`. ## Icon (glass .icon → .icns) ``` @@ -45,7 +46,7 @@ What it does: - Packages `CodexBar.app` with Info.plist and Icon.icns - Embeds Sparkle.framework, Updater, Autoupdate, XPCs - Codesigns **everything** with runtime + timestamp (deep) and adds rpath -- Zips to `CodexBar-.zip` +- Zips to `CodexBar-macos-universal-.zip` - Submits to notarytool, waits, staples, validates Gotchas fixed: @@ -55,44 +56,41 @@ Gotchas fixed: - Manual sanity check before uploading: `find CodexBar.app -name '._*'` should return nothing; then `spctl --assess --type execute --verbose CodexBar.app` and `codesign --verify --deep --strict --verbose CodexBar.app` should both pass on the packaged bundle. ## Appcast (Sparkle) -After notarization: +After notarization, or let `Scripts/release.sh` do this: ``` -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-priv.key \ -./Scripts/make_appcast.sh CodexBar-0.1.0.zip \ +./Scripts/make_appcast.sh CodexBar-macos-universal-0.1.0.zip \ https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml -Generates HTML release notes from `CHANGELOG.md` (via `Scripts/changelog-to-html.sh`) and embeds them into the appcast entry. ``` +Generates HTML release notes from `CHANGELOG.md` (via `Scripts/changelog-to-html.sh`) and embeds them into the appcast entry. Uploads not handled automatically—commit/publish appcast + zip to the feed location (GitHub Releases/raw URL). ## Tag & release ``` -git tag v -./Scripts/make_appcast.sh ... -# upload zip + appcast to Releases -# then create GitHub release (gh release create v ...) +./Scripts/release.sh ``` ## Homebrew (Cask) CodexBar ships a Homebrew **Cask** in `../homebrew-tap`. When installed via Homebrew, CodexBar disables Sparkle and the app must be updated via `brew`. -After publishing the GitHub release, update the tap cask + Linux CLI formula (see `docs/releasing-homebrew.md`). +After publishing the GitHub release, `.github/workflows/release-cli.yml` builds the macOS, glibc Linux, and static musl Linux CLI tarballs for arm64 and x86_64, uploads them plus checksums, then dispatches the Homebrew tap update for both the CLI formula and app cask. Homebrew continues to use the glibc Linux assets. If the final dispatch is rate-limited, the tarballs and app zip may still be present; rerun or manually update the tap formula/cask from the published assets. ## Checklist (quick) - [ ] Read both this file and `~/Projects/agent-scripts/docs/RELEASING-MAC.md`; resolve any conflicts toward CodexBar’s specifics. - [ ] Update versions (scripts/Info.plist, CHANGELOG, About text) — changelog top section must be finalized; release script pulls notes from it automatically. -- [ ] `swiftformat`, `swiftlint`, `swift test` (zero warnings/errors) +- [ ] `swiftformat`, `swiftlint`, `make test` (zero warnings/errors) - [ ] `./Scripts/build_icon.sh` if icon changed - [ ] `./Scripts/sign-and-notarize.sh` -- [ ] Generate Sparkle appcast with private key - - Sparkle ed25519 private key path: `/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/sparkle-private-key-KEEP-SECURE.txt` (primary) and `/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle-VibeTunnel/sparkle-private-key-KEEP-SECURE.txt` (older backup) +- [ ] Generate Sparkle appcast via `Scripts/release.sh` or `Scripts/make_appcast.sh`; use `SPARKLE_PRIVATE_KEY_FILE` only if overriding Keychain signing. - Upload the dSYM archive alongside the app zip on the GitHub release; the release script now automates this and will fail if it’s missing. - - After publishing the release, run `Scripts/check-release-assets.sh ` to confirm both the app zip and dSYM zip are present on GitHub. - - Generate the appcast + HTML release notes: `./Scripts/make_appcast.sh CodexBar-.zip https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml` + - After publishing the release and the Release CLI workflow finishes, run `Scripts/check-release-assets.sh ` to confirm the app zip, dSYM zip, CLI tarballs, and CLI checksums are present on GitHub. + - Generate the appcast + HTML release notes: `./Scripts/make_appcast.sh CodexBar-macos-universal-.zip https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml` - Beta channel: prefix the command with `SPARKLE_CHANNEL=beta` to tag the entry. - - Verify the enclosure signature + size: `SPARKLE_PRIVATE_KEY_FILE=... ./Scripts/verify_appcast.sh ` + - Verify the enclosure signature + size: `./Scripts/verify_appcast.sh ` - [ ] Upload zip + appcast to feed; publish tag + GitHub release so Sparkle URL is live (avoid 404) -- [ ] Homebrew tap: update `../homebrew-tap/Casks/codexbar.rb` (url + sha256) and `../homebrew-tap/Formula/codexbar.rb` (Linux CLI tarball urls + sha256), then verify: +- [ ] Homebrew tap: wait for the Release CLI workflow to update `../homebrew-tap/Casks/codexbar.rb` (app zip url + sha256) and `../homebrew-tap/Formula/codexbar.rb` (CLI tarball urls + sha256), then verify: + - `gh run watch --exit-status` + - `Scripts/check-release-assets.sh v` - `brew uninstall --cask codexbar || true` - `brew untap steipete/tap || true; brew tap steipete/tap` - `brew install --cask steipete/tap/codexbar && open -a CodexBar` @@ -100,7 +98,7 @@ After publishing the GitHub release, update the tap cask + Linux CLI formula (se - [ ] Changelog sanity: single top-level title, no duplicate version sections, versions strictly descending with no repeats - [ ] Release pages: title format `CodexBar `, notes as Markdown list (no stray blank lines) - [ ] Changelog/release notes are user-facing: avoid internal-only bullets (build numbers, script bumps) and keep entries concise -- [ ] Download uploaded `CodexBar-.zip`, unzip via `ditto`, run, and verify signature (`spctl -a -t exec -vv CodexBar.app` + `stapler validate`) +- [ ] Download uploaded `CodexBar-macos-universal-.zip`, unzip via `ditto`, run, and verify signature (`spctl -a -t exec -vv CodexBar.app` + `stapler validate`) - [ ] Confirm `appcast.xml` points to the new zip/version and renders the HTML release notes (not escaped tags) - [ ] Verify on GitHub Releases: assets present (zip, appcast), release notes match changelog, version/tag correct - [ ] Open the appcast URL in browser to confirm the new entry is visible and enclosure URL is reachable diff --git a/docs/abacus.md b/docs/abacus.md new file mode 100644 index 0000000000..c4f9893aec --- /dev/null +++ b/docs/abacus.md @@ -0,0 +1,67 @@ +--- +summary: "Abacus AI provider: browser cookie auth for ChatLLM/RouteLLM compute credit tracking." +read_when: + - Adding or modifying the Abacus AI provider + - Debugging Abacus cookie imports or API responses + - Adjusting Abacus usage display or credit formatting +--- + +# Abacus AI Provider + +The Abacus AI provider tracks ChatLLM/RouteLLM compute credit usage via browser cookie authentication. + +## Features + +- **Monthly credit gauge**: Shows credits used vs. plan total with pace tick indicator. +- **Reserve/deficit estimate**: Projected credit usage through the billing cycle. +- **Reset timing**: Displays the next billing date from the Abacus billing API. +- **Subscription tiers**: Detects Basic and Pro plans. +- **Cookie auth**: Automatic browser cookie import (Safari, Chrome, Firefox) or manual cookie header. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Abacus AI** +3. Log in to [apps.abacus.ai](https://apps.abacus.ai) in your browser +4. Cookie import happens automatically on the next refresh + +### Manual cookie mode + +1. In **Settings → Providers → Abacus AI**, set Cookie source to **Manual** +2. Open your browser DevTools on `apps.abacus.ai`, copy the `Cookie:` header from any API request +3. Paste the header into the cookie field in CodexBar + +## How it works + +Two API endpoints are fetched concurrently using browser session cookies: + +- `GET https://apps.abacus.ai/api/_getOrganizationComputePoints` — returns `totalComputePoints` and `computePointsLeft` (values are in credit units, no conversion needed). +- `POST https://apps.abacus.ai/api/_getBillingInfo` — returns `nextBillingDate` (ISO 8601) and `currentTier` (plan name). + +Cookie domains: `abacus.ai`, `apps.abacus.ai`. Session cookies are validated before use (anonymous/marketing-only cookie sets are skipped). Valid cookies are cached in Keychain and reused until the session expires. + +The billing cycle window is set to 30 days for pace calculation. + +## CLI + +```bash +codexbar usage --provider abacusai --verbose +``` + +## Troubleshooting + +### "No Abacus AI session found" + +Log in to [apps.abacus.ai](https://apps.abacus.ai) in a supported browser (Safari, Chrome, Firefox), then refresh CodexBar. + +### "Abacus AI session expired" + +Re-login to Abacus AI. The cached cookie will be cleared automatically and a fresh one imported on the next refresh. + +### "Unauthorized" + +Your session cookies may be invalid. Log out and back in to Abacus AI, or paste a fresh `Cookie:` header in manual mode. + +### Credits show 0 + +Verify that your Abacus AI account has an active subscription with compute credits allocated. diff --git a/docs/alibaba-coding-plan.md b/docs/alibaba-coding-plan.md new file mode 100644 index 0000000000..834e62e385 --- /dev/null +++ b/docs/alibaba-coding-plan.md @@ -0,0 +1,75 @@ +--- +summary: "Alibaba Coding Plan provider data sources: browser-session baseline, secondary API mode, and honest quota fallback behavior." +read_when: + - Debugging Alibaba Coding Plan API key handling or quota parsing + - Updating Alibaba Coding Plan endpoints or region behavior + - Adjusting Alibaba Coding Plan provider UI/menu behavior +--- + +# Alibaba Coding Plan provider + +Alibaba Coding Plan supports both browser-session and API-key paths, but the supported baseline is browser-session fetching from the Model Studio/Bailian console. API mode remains secondary and may still be limited by account/region behavior. + +## Cookie sources (web mode) +1) Automatic browser import (Model Studio/Bailian cookies). +2) Manual cookie header from Settings. +3) Environment variable `ALIBABA_CODING_PLAN_COOKIE`. + +When the RPC endpoint returns `ConsoleNeedLogin`, CodexBar treats that as a console-session requirement. In API mode it is surfaced as an explicit API-path limitation; in `auto` mode fallback remains observable through the fetch-attempt chain. + +## Token sources (fallback order) +1) Config token (`~/.codexbar/config.json` -> `providers[].apiKey` for provider `alibaba`). +2) Environment variables, checked in order: + - `ALIBABA_CODING_PLAN_API_KEY` + - `ALIBABA_QWEN_API_KEY` + - `DASHSCOPE_API_KEY` + +## Region + endpoint behavior +- International host: `https://modelstudio.console.alibabacloud.com` +- China mainland host: `https://bailian.console.aliyun.com` +- Quota request path: + - `POST /data/api.json?action=zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2&product=broadscope-bailian&api=queryCodingPlanInstanceInfoV2` +- Region is selected in Preferences -> Providers -> Alibaba Coding Plan -> Gateway region. +- Auto fallback behavior: + - If International fails with credential/host-style API errors, CodexBar retries China mainland once. + +### CN API-key limitation (known) +- In some China mainland accounts/environments, the current Alibaba `/data/api.json` coding-plan endpoint can still return console-login-required responses (`ConsoleNeedLogin`) even when an API key is configured. +- In that case, API-key mode may not be functionally available for that account/endpoint, and web session mode is required. +- CodexBar now surfaces this as an API error in API mode (instead of a cookie-login-required message) so the limitation is explicit. + +## Overrides +- Override host base: `ALIBABA_CODING_PLAN_HOST` + - Example: `ALIBABA_CODING_PLAN_HOST=modelstudio.console.alibabacloud.com` +- Override full quota URL: `ALIBABA_CODING_PLAN_QUOTA_URL` + - Example: `ALIBABA_CODING_PLAN_QUOTA_URL=https://modelstudio.console.alibabacloud.com/data/api.json?action=...` +- Security policy: endpoint overrides are only accepted when they use `https://`, omit userinfo, and do not contain encoded host delimiters. Custom HTTPS proxy/test domains continue to work for compatibility, but `http://` endpoints are rejected so cookies and API credentials are not sent in cleartext. +- Strict provider-host mode: set `ALIBABA_CODING_PLAN_REQUIRE_PROVIDER_ENDPOINT_OVERRIDES=true` to additionally reject custom proxy/test domains and only accept the known Alibaba Coding Plan console and RPC hosts. + +## Request headers +- `Authorization: Bearer ` +- `x-api-key: ` +- `X-DashScope-API-Key: ` +- `Content-Type: application/json` +- `Accept: application/json` + +## Parsing + mapping +- Plan name (best effort): + - `codingPlanInstanceInfos[].planName` / `instanceName` / `packageName` +- Quota windows (from `codingPlanQuotaInfo`): + - `per5HourUsedQuota` + `per5HourTotalQuota` + `per5HourQuotaNextRefreshTime` -> primary (5-hour) + - `perWeekUsedQuota` + `perWeekTotalQuota` + `perWeekQuotaNextRefreshTime` -> secondary (weekly) + - `perBillMonthUsedQuota` + `perBillMonthTotalQuota` + `perBillMonthQuotaNextRefreshTime` -> tertiary (monthly) +- Each window maps to `usedPercent = used / total * 100` (bounded to valid range). +- If the payload proves the plan is active but does not expose defensible quota counters, CodexBar preserves the visible plan state without manufacturing a normal quantitative quota window. +- If neither real counters nor a defensible active-plan fallback signal exist, parsing fails explicitly instead of degrading to fake `0%` usage. + +## Dashboard links +- International console: `https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan` +- China mainland console: `https://bailian.console.aliyun.com/cn-beijing/?tab=model#/efm/coding_plan` + +## Key files +- `Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift` +- `Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift` +- `Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageSnapshot.swift` +- `Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift` diff --git a/docs/alibaba-token-plan.md b/docs/alibaba-token-plan.md new file mode 100644 index 0000000000..1d8884605e --- /dev/null +++ b/docs/alibaba-token-plan.md @@ -0,0 +1,61 @@ +--- +summary: "Alibaba Token Plan provider notes: Bailian cookie auth, subscription summary endpoint, and setup." +read_when: + - Adding or modifying the Alibaba Token Plan provider + - Debugging Alibaba Token Plan cookie import or subscription summary fetching + - Explaining Alibaba Token Plan setup and limitations to users +--- + +# Alibaba Token Plan Provider + +The Alibaba Token Plan provider tracks Bailian token-plan credits from the Alibaba Cloud console. + +## Features + +- **Token-plan usage display**: Shows used, total, and remaining token-plan credits when Bailian returns quota totals. +- **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. +- **Expiry awareness**: Shows the nearest token-plan expiration date as the reset time when the subscription summary includes it. + +## Setup + +1. Open **Settings -> Providers** +2. Enable **Alibaba Token Plan** +3. Leave **Cookie source** on **Auto** (recommended) + +### Manual cookie import (optional) + +1. Open `https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan` +2. Copy a `Cookie:` header from your browser's Network tab +3. Paste it into **Alibaba Token Plan -> Cookie source -> Manual** + +## How it works + +- Fetches `POST https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3&_tag=` +- Sends form-encoded fields for `product=BssOpenAPI-V3`, `action=GetSubscriptionSummary`, `region=cn-beijing`, and `params={"ProductCode":"sfm_tokenplanteams_dp_cn"}` +- Uses Alibaba/Bailian login cookies, with `sec_token` added when it can be resolved from the dashboard page +- Parses `TotalValue`, `TotalSurplusValue`, `TotalCount`, and `NearestExpireDate` from the subscription summary response +- Supports `ALIBABA_TOKEN_PLAN_HOST` and `ALIBABA_TOKEN_PLAN_QUOTA_URL` for testing endpoint overrides + +## Limitations + +- Alibaba Token Plan currently supports the Bailian web-cookie path only +- API-key auth, token cost summaries, and automatic status polling are not supported +- The default endpoint is the China mainland Bailian token-plan subscription summary + +## Troubleshooting + +### "No Alibaba Token Plan session cookies found in browsers" + +Log in at `https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan` in Chrome, then refresh CodexBar. + +### "Alibaba Token Plan cookie header is invalid" + +The pasted header is empty or not a valid Cookie header. Re-copy the request from the Token Plan page after logging in again. + +### "Alibaba Token Plan login required" + +Your Bailian session is stale. Sign out and back in on the Bailian console, then refresh CodexBar. + +### Empty subscription summary + +If Bailian returns `TotalCount: 0`, CodexBar keeps the provider visible but does not show a quota window because the account has no active token-plan subscription summary to graph. diff --git a/docs/amp.md b/docs/amp.md index 9856abf1d7..dacdf04c69 100644 --- a/docs/amp.md +++ b/docs/amp.md @@ -1,5 +1,5 @@ --- -summary: "Amp provider notes: settings scrape, cookie auth, and free-tier usage." +summary: "Amp provider notes: CLI usage, web fallback, cookie auth, and credits." read_when: - Adding or modifying the Amp provider - Debugging Amp cookie import or settings parsing @@ -8,19 +8,28 @@ read_when: # Amp Provider -The Amp provider tracks your Amp Free usage by scraping the Amp settings page with browser cookies. +The Amp provider tracks Amp Free usage plus individual and workspace credits. It prefers the local Amp CLI, then an Amp +access token, and finally browser cookies. ## Features - **Amp Free meter**: Shows how much daily free usage remains. - **Time-to-full reset**: “Resets in …” indicates when free usage replenishes to full. -- **Browser cookie auth**: No API keys needed. +- **Individual credits**: Shows the remaining paid credit balance when Amp reports one. +- **Workspace credits**: Shows each workspace's remaining paid credit balance separately. +- **CLI-first fetch**: Uses `amp usage` when the Amp CLI is installed and signed in. +- **Access token support**: Uses `AMP_API_KEY` or the access token saved in CodexBar settings. +- **Browser cookie fallback**: Reads the legacy settings-page payload when the CLI and access token are unavailable. ## Setup 1. Open **Settings → Providers** 2. Enable **Amp** -3. Leave **Cookie source** on **Auto** (recommended) +3. Install and sign in to the Amp CLI, add an Amp access token, or leave **Cookie source** on **Auto** for web fallback + +### Access token (optional) + +Create an access token in Amp settings, then paste it into **Amp → Access token** or set `AMP_API_KEY`. ### Manual cookie import (optional) @@ -30,10 +39,16 @@ The Amp provider tracks your Amp Free usage by scraping the Amp settings page wi ## How it works -- Fetches `https://ampcode.com/settings` -- Parses the embedded `freeTierUsage` payload +- Runs `amp usage` first in automatic mode +- Calls `POST https://ampcode.com/api/internal?userDisplayBalanceInfo` with an Amp access token +- Falls back to the settings page with browser cookies +- Parses the same usage display format returned to the CLI - Computes time-to-full from the hourly replenishment rate +### “Amp access token is invalid or expired” + +Create a new access token in Amp settings, update `AMP_API_KEY` or CodexBar settings, then refresh. + ## Troubleshooting ### “No Amp session cookie found” diff --git a/docs/antigravity.md b/docs/antigravity.md index ab99af30f3..827cb10ed4 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -1,43 +1,168 @@ --- -summary: "Antigravity provider notes: local LSP probing, port discovery, quota parsing, and UI mapping." +summary: "Antigravity provider notes: OAuth usage, multi-account switching, local LSP probing, and quota parsing." read_when: - Adding or modifying the Antigravity provider - Debugging Antigravity port detection or quota parsing - Adjusting Antigravity menu labels or model mapping + - Working with Antigravity OAuth or account switching --- # Antigravity provider -Antigravity is a local-only provider. We talk directly to the Antigravity language server running on the same machine. +Antigravity supports four usage data sources: + +1. The Antigravity 2.0 app's local `language_server` (preferred when the app is open). +2. The `agy` CLI's embedded HTTPS localhost server (preferred over the IDE because it exposes richer quota data). +3. The Antigravity IDE extension `language_server` (used after `agy` CLI because current IDE local payloads only expose session/model quota data). +4. Google OAuth-backed remote usage (explicit OAuth mode, and the account-scoped fallback used for multi-account switching). The OAuth path can store multiple Google accounts through the shared token-account switcher. + +The local and CLI paths both prefer Antigravity's internal `RetrieveUserQuotaSummary` quota payload and may fall back to +`GetUserStatus`, then `GetCommandModelConfigs`; CodexBar never scrapes the desktop UI or the `agy` TUI. + +As of Antigravity 2.x, the Antigravity app and `agy` CLI payloads can be richer than Google OAuth and IDE payloads. +`RetrieveUserQuotaSummary` exposes the same two groups shown by Antigravity's Model Quota UI: + +- `Gemini Models`: weekly limit and five-hour limit. +- `Claude and GPT models`: weekly limit and five-hour limit. + +Older local payloads may only include raw Claude, GPT-OSS, Gemini tiers, account plan, and session reset timestamps. +Current Antigravity IDE local endpoints return `GetUserStatus`, `GetAvailableModels`, and `GetCascadeModelConfigData` +with five-hour/session reset data, but not the app/CLI `RetrieveUserQuotaSummary` weekly/session grouping. OAuth +payloads can be less complete and may only prove model availability. Treat `auto` as the authoritative user-facing mode: +it accepts the first account-matching source in Antigravity app -> `agy` CLI -> Antigravity IDE order, and adds OAuth +when CodexBar has a selected/injected Google account or an existing shared credentials file. An all-100% +`fetchAvailableModels` payload is only accepted after `retrieveUserQuota` echoes bucket fractions; this can be an +availability-style fallback rather than the full Antigravity quota summary. +When OAuth identifies the account but quota endpoints deny access, CodexBar shows `Limits not available` instead of an +empty quota card. + +## OAuth account switching + +- Login still uses Antigravity's Google OAuth client, discovered from `Antigravity.app` or overridden with `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET`. +- A successful login writes the latest shared credentials to `~/.codexbar/antigravity/oauth_creds.json` and upserts a token-account entry for the Google account. +- Each token-account entry stores serialized `AntigravityOAuthCredentials` and is injected into remote fetches through `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON`. +- When a token account is selected, the OAuth fetcher uses that account before falling back to the shared credentials file. + In `auto` mode the ambient Antigravity app, `agy` CLI, and IDE probes still run first, but a snapshot whose account + does not match the selected account is rejected so the pipeline falls through to the account-scoped OAuth fetch (see + `AntigravitySelectedAccountGuard`). If no account is selected/injected, `auto` includes OAuth only when the legacy + shared credentials file already exists. Explicit `cli`/`oauth` source modes stay authoritative and are not re-checked. +- Removing the last saved token account that matches `~/.codexbar/antigravity/oauth_creds.json` deletes that shared file, + so a removed CodexBar account does not silently continue refreshing through the legacy shared cache. +- The menu action is labeled `Add Account...`; switching between saved accounts scopes Google OAuth fetches. + +## Remote OAuth data sources + +- `POST https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` +- `POST https://cloudcode-pa.googleapis.com/v1internal:onboardUser` +- `POST https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` +- `POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota` +- `POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary` (available, but current observed OAuth + responses are model-bucket shaped rather than Antigravity 2.0's two quota groups) ## Data sources + fallback order -1) **Process detection** +### 1) Antigravity app local probe + +When the Antigravity 2.0 app is running: + +1. **Process detection** - Command: `ps -ax -o pid=,command=`. - - Match process name: `language_server_macos` plus Antigravity markers: - - `--app_data_dir antigravity` OR path contains `/antigravity/`. + - The app local strategy scopes detection to the **Antigravity app** language server only + (`AntigravityStatusProbe(processScope: .appOnly)`). It deliberately does **not** + attach to an IDE or `agy` CLI process: a lower-information IDE payload should not mask + `agy`'s richer quota summary, and a stale or still-initializing `agy` can accept the + connection before it is ready. `agy` is owned exclusively by the CLI HTTPS source below, + which waits for real API readiness. The probe still classifies all kinds + (`processInfo(scope: .ideAndCLI)` is used by `isRunning()` for status reporting): + - the **Antigravity app** language server: process names such as `language_server`, `language_server_macos`, + `language_server_macos_arm`, or `language-server` plus + Antigravity markers (`--app_data_dir antigravity`, an Antigravity app bundle path, + or a path containing `/antigravity/`); or + - the **IDE** language server: the Antigravity IDE extension language server, usually under + `Antigravity IDE.app/.../extensions/antigravity/bin/` with `--app_data_dir antigravity-ide`; or + - the **CLI**: an `antigravity-cli` / `antigravity_cli` path segment, or the + `agy` binary (path-anchored so unrelated arguments/binaries do not match). + - CodexBar collects all valid local app language-server candidates and probes each reachable one. If multiple + app processes are open, it prefers the richer quota-summary snapshot over the legacy `GetUserStatus` + two-pool fallback. - Extract CLI flags: - - `--csrf_token ` (required). - - `--extension_server_port ` (HTTP fallback). + - `--csrf_token `. Requirement depends on the match kind: + - **App/IDE** matches still require it - a tokenless desktop language-server match is + skipped so a later valid server can be found, otherwise `missingCSRFToken` + is reported (unchanged behavior). + - **CLI** matches accept an empty token, because the CLI's language server + exposes no `--csrf_token` flag and requires none. + - `--extension_server_port ` (HTTP fallback; app/IDE only). + - `--extension_server_csrf_token ` (preferred HTTP fallback token when present). -2) **Port discovery** - - Command: `lsof -nP -iTCP -sTCP:LISTEN -p `. +2. **Port discovery** + - Command: `lsof -nP -iTCP -sTCP:LISTEN -a -p `. - All listening ports are probed. -3) **Connect port probe (HTTPS)** +3. **Connect port probe (HTTPS)** - `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetUnleashData` - Headers: - `X-Codeium-Csrf-Token: ` - `Connect-Protocol-Version: 1` - First 200 OK response selects the connect port. -4) **Quota fetch** +4. **Quota fetch** - Primary: + - `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary` + - Fallback 1: - `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetUserStatus` - - Fallback: + - Fallback 2: - `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs` - If HTTPS fails, retry over HTTP on `extension_server_port`. +### 2) `agy` CLI HTTPS source + +When source mode is `auto` or `cli` and the desktop local probe fails, CodexBar resolves `agy` via: + +- `ANTIGRAVITY_CLI_PATH` +- `PATH` / login-shell path lookup +- Well-known paths: + - `~/.local/bin/agy` + - `/opt/homebrew/bin/agy` + - `/usr/local/bin/agy` + +CodexBar launches `agy` in a PTY because the CLI exposes its quota server only while the interactive process is alive. +The implementation still does **not** scrape terminal output; it only keeps the process alive, drains discarded PTY +rendering, discovers listening ports with `lsof`, and probes the local HTTPS server: + +- First: `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary` +- Fallback 1: `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetUserStatus` +- Fallback 2: `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetCommandModelConfigs` + +The fallback can return quota without the account email or plan fields from `GetUserStatus`. + +Differences from the desktop local probe: + +- The CLI HTTPS endpoint does **not** require `X-Codeium-Csrf-Token`. +- Readiness is endpoint-based: CodexBar retries until one of the quota endpoints parses, because fresh `agy` + processes can bind a port before the quota service is initialized. +- App runtime uses a bounded warm session: `agy` is kept alive briefly after a refresh, then stopped on idle. CLI runtime + tears it down immediately after the one-shot fetch. +- Repeated endpoint failures force a relaunch instead of reusing a wedged process forever. +- CodexBar records the launched pid + executable identity and conservatively reaps only its own matching stale `agy` + process on the next launch. It never blind-kills a user-launched `agy`. + +### 3) Antigravity IDE local probe + +When the Antigravity 2.0 app and `agy` CLI are unavailable, CodexBar probes Antigravity IDE language servers with +`AntigravityStatusProbe(processScope: .ideOnly)`. Current observed IDE payloads return model-level/session quota data +through `GetUserStatus`, `GetAvailableModels`, and `GetCascadeModelConfigData`; `RetrieveUserQuotaSummary` returns 404 +from the IDE local server. This means the IDE fallback can show session bars, but should not be expected to provide the +weekly limit shown by Antigravity 2.0. + +### 4) OAuth remote fallback + +When source mode is `auto`, OAuth is used after app, `agy` CLI, and IDE paths fail if CodexBar has a selected/injected +Google account or an existing shared credentials file. The app, `agy` CLI, and IDE probes still run first, but in +`auto` mode their snapshots are accepted only when the reported account matches the selected account; otherwise the +pipeline falls through to this account-scoped OAuth fetch. When source mode is `oauth`, only OAuth is used and the +shared OAuth file can still be used as a fallback credential source. + ## Request body (summary) - Minimal metadata payload: - `ideName: antigravity` @@ -46,14 +171,27 @@ Antigravity is a local-only provider. We talk directly to the Antigravity langua - `ideVersion: unknown` ## Parsing and model mapping -- Source fields: +- Preferred source fields: + - `response.groups[].displayName` + - `response.groups[].buckets[].bucketId` + - `response.groups[].buckets[].displayName` + - `response.groups[].buckets[].remaining.remainingFraction` + - `response.groups[].buckets[].description` +- Legacy source fields: - `userStatus.cascadeModelConfigData.clientModelConfigs[].quotaInfo.remainingFraction` - `userStatus.cascadeModelConfigData.clientModelConfigs[].quotaInfo.resetTime` -- Mapping priority: - 1) Claude (label contains `claude` but not `thinking`) - 2) Gemini Pro Low (label contains `pro` + `low`) - 3) Gemini Flash (label contains `gemini` + `flash`) - 4) Fallback: lowest remaining percent +- Preferred quota summary UI: + - Render `Gemini Session`, `Gemini Weekly`, `Claude + GPT Session`, and `Claude + GPT Weekly` as named windows. + - Keep Antigravity's bucket description as reset prose; infer `windowMinutes` from the bucket ID/display name. + - Use the most constrained known bucket as the compact/menu-bar metric. +- Legacy user-facing quota groups: + - `Gemini` groups Gemini Pro and Gemini Flash text models. + - `Claude + GPT` groups Claude text models and GPT/GPT-OSS text models. +- Representative selection: + - Hidden model rows such as Lite, autocomplete, and image variants do not drive summary bars. + - For each group, CodexBar uses the lowest remaining known quota row and preserves that row's reset metadata. + - Rows with reset metadata but no remaining fraction stay visible as unavailable reset context only when their group + has no known usage row. - `resetTime` parsing: - ISO-8601 preferred; numeric epoch seconds as fallback. - Identity: @@ -62,14 +200,22 @@ Antigravity is a local-only provider. We talk directly to the Antigravity langua ## UI mapping - Provider metadata: - Display: `Antigravity` - - Labels: `Claude` (primary), `Gemini Pro` (secondary), `Gemini Flash` (tertiary) + - Labels: `Gemini` (primary), `Claude + GPT` (secondary) - Status badge: Google Workspace incidents for the Gemini product. +- Antigravity exposes many model rows, but current local payloads show them collapsing into two real usage pools: + Gemini and Claude/GPT. Detailed usage should not list every raw Gemini tier unless a future source exposes a genuinely + distinct unknown or consumed quota window. +- Some Antigravity local/CLI model config entries include reset metadata but omit `remainingFraction`. Those windows stay + in `extraRateWindows` for reset context and are marked with `usageKnown: false`; clients should not render their + `usedPercent` as a real exhausted quota. ## Constraints - Internal protocol; fields may change. -- Requires `lsof` for port detection. -- Local HTTPS uses a self-signed cert; the probe allows insecure TLS. +- Requires `lsof` for local/CLI port detection. +- Local HTTPS uses a self-signed cert; the probe allows insecure TLS only for loopback hosts. ## Key files +- `Sources/CodexBarCore/Providers/Antigravity/AntigravityCLISession.swift` +- `Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift` - `Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift` - `Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift` diff --git a/docs/architecture.md b/docs/architecture.md index 0162b3e395..f3dc337bde 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,8 +12,6 @@ read_when: - `Sources/CodexBar`: state + UI (UsageStore, SettingsStore, StatusItemController, menus, icon rendering). - `Sources/CodexBarWidget`: WidgetKit extension wired to the shared snapshot. - `Sources/CodexBarCLI`: bundled CLI for `codexbar` usage/status output. -- `Sources/CodexBarMacros`: SwiftSyntax macros for provider registration. -- `Sources/CodexBarMacroSupport`: shared macro support used by app/core/CLI targets. - `Sources/CodexBarClaudeWatchdog`: helper process for stable Claude CLI PTY sessions. - `Sources/CodexBarClaudeWebProbe`: CLI helper to diagnose Claude web fetches. diff --git a/docs/augment.md b/docs/augment.md index b7f5fcdc94..0790ebdd6e 100644 --- a/docs/augment.md +++ b/docs/augment.md @@ -79,9 +79,9 @@ When the CLI is unavailable or not authenticated, CodexBar falls back to browser The provider includes an automatic session keepalive system: -- **Check Interval**: Every 5 minutes +- **Check Interval**: Every 1 minute - **Refresh Buffer**: Refreshes 5 minutes before cookie expiration -- **Rate Limiting**: Minimum 2 minutes between refresh attempts +- **Rate Limiting**: Minimum 1 minute between refresh attempts - **Session Cookies**: Refreshed every 30 minutes (no expiration date) This ensures your session stays active without manual intervention. @@ -170,7 +170,7 @@ This prevents cookies from other subdomains being sent to the API. ### Session Refresh Mechanism -1. Keepalive checks cookie expiration every 5 minutes +1. Keepalive checks cookie expiration every 1 minute 2. If expiration is within 5 minutes, triggers refresh 3. Pings `/api/auth/session` to trigger cookie update 4. Waits 1 second for browser to update cookies diff --git a/docs/bedrock.md b/docs/bedrock.md new file mode 100644 index 0000000000..aed954d40d --- /dev/null +++ b/docs/bedrock.md @@ -0,0 +1,121 @@ +--- +summary: "AWS Bedrock provider: Cost Explorer spend, CloudWatch Claude activity, credentials, and budgets." +read_when: + - Setting up AWS Bedrock usage tracking + - Debugging Bedrock Cost Explorer or CloudWatch fetches + - Updating Bedrock credentials, region, budget, or activity handling +--- + +# AWS Bedrock provider + +CodexBar reads AWS Cost Explorer for Bedrock spend and can compare the current month against an optional budget. When +permitted, it also reads CloudWatch for rolling 14-day Claude token and request totals in the configured region. + +## Authentication + +CodexBar supports two authentication modes, selected in Preferences → Providers → AWS Bedrock → Authentication. + +### Access keys (default) + +Provide static AWS credentials through Settings or the environment inherited by CodexBar/the CLI: + +```bash +export AWS_ACCESS_KEY_ID="..." +export AWS_SECRET_ACCESS_KEY="..." +export AWS_REGION="us-east-1" +``` + +Optional: + +```bash +export AWS_SESSION_TOKEN="..." +export CODEXBAR_BEDROCK_BUDGET="250" +``` + +### AWS profile + +Resolve credentials from a named profile in `~/.aws/config` / `~/.aws/credentials` instead of pasting keys. Set the +profile name in Settings (or via `AWS_PROFILE`). CodexBar shells out to the AWS CLI +(`aws configure export-credentials --profile `), so this works with **SSO**, **assume-role**, +`credential_process`, and MFA-cached profiles — not just static credentials. + +Requirements: + +- AWS CLI v2 on your `PATH` (CodexBar also checks `/opt/homebrew/bin/aws`, `/usr/local/bin/aws`, and `~/.local/bin/aws`). + Override the location with `AWS_CLI_PATH` if it lives elsewhere. +- For SSO profiles, an active session (`aws sso login --profile `). Credentials are resolved fresh on each + refresh; the AWS CLI caches the SSO token, so this does not re-prompt unless the session has expired. + +The profile's region is read automatically (`aws configure get region`); leave the Region field blank to use it, or set +`AWS_REGION` / the Region field to override. + +Relevant environment variables: + +```bash +export CODEXBAR_BEDROCK_AUTH_MODE="profile" # set automatically by Settings; "keys" or "profile" +export AWS_PROFILE="work" +export AWS_CLI_PATH="/opt/homebrew/bin/aws" # optional override +``` + +The AWS identity (from either mode) must have permission to call Cost Explorer APIs, including `ce:GetCostAndUsage`. +Grant `cloudwatch:GetMetricData` to add the optional Claude activity totals. Without that permission, cost and budget +tracking continue unchanged. + +## Data source + +- Service: AWS Cost Explorer. +- Region: `AWS_REGION` or `AWS_DEFAULT_REGION`, defaulting to `us-east-1`. +- Usage: current-month Bedrock spend and historical daily cost buckets. +- Claude activity: rolling 14-day input tokens, output tokens, and requests from the configured region's `AWS/Bedrock` + CloudWatch metrics. Other model families are excluded. +- Budget: `CODEXBAR_BEDROCK_BUDGET`, when set to a positive dollar amount. +- Test override: `CODEXBAR_BEDROCK_API_URL` replaces the Cost Explorer endpoint; use HTTPS or loopback HTTP. +- Test override: `CODEXBAR_BEDROCK_CLOUDWATCH_API_URL` replaces the CloudWatch endpoint; use HTTPS or loopback HTTP. + +## Display + +- Shows month-to-date Bedrock spend. +- Shows budget progress when a budget is configured. +- Shows rolling 14-day Claude tokens and requests when CloudWatch access is available. +- Reuses the shared inline dashboard for daily cost history when enough buckets are available. + +## CLI + +```bash +codexbar --provider bedrock --source api +codexbar --provider bedrock --format json --pretty +``` + +## Troubleshooting + +### "No AWS Bedrock cost data available" + +- Confirm the credentials are visible to CodexBar. +- Confirm the AWS account has Cost Explorer enabled. +- Confirm the IAM principal can call `ce:GetCostAndUsage`. +- To include Claude token/request totals, confirm the principal can call `cloudwatch:GetMetricData` in the configured + region. +- If using temporary credentials, include `AWS_SESSION_TOKEN`. +- In profile mode, confirm the AWS CLI is installed (or set `AWS_CLI_PATH`) and that the profile name is correct. + +### "AWS profile session expired" + +The profile's SSO/temporary session has expired. Run `aws sso login --profile ` (or refresh the underlying +credentials) and retry. + +### "AWS CLI not found" + +Profile mode requires AWS CLI v2. Install it (e.g. `brew install awscli`) or point CodexBar at the binary with +`AWS_CLI_PATH`. + +### Wrong region + +Set `AWS_REGION` or `AWS_DEFAULT_REGION`. Bedrock usage is regional, but Cost Explorer itself is account-level; CodexBar still needs a signing region for the request. + +## Key files + +- `Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockProfileCredentialProvider.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift` diff --git a/docs/chutes.md b/docs/chutes.md new file mode 100644 index 0000000000..162b611078 --- /dev/null +++ b/docs/chutes.md @@ -0,0 +1,52 @@ +--- +summary: "Chutes provider: API key setup, subscription usage, and quota windows." +read_when: + - Configuring Chutes usage + - Debugging Chutes subscription or quota requests +--- + +# Chutes Provider + +CodexBar reads subscription and quota usage from Chutes' management API with a manually configured API key. + +## Authentication + +Create a Chutes API key using the [official authentication guide](https://chutes.ai/docs/getting-started/authentication), then add it in CodexBar Settings → Providers → Chutes. + +You can also set the environment variable: + +```bash +export CHUTES_API_KEY="cpk_..." +``` + +Or configure it through the CLI: + +```bash +printf '%s' "$CHUTES_API_KEY" | codexbar config set-api-key --provider chutes --stdin +``` + +## Data Source + +CodexBar requests: + +- `GET https://api.chutes.ai/users/me/subscription_usage` +- `GET https://api.chutes.ai/users/me/quotas` when subscription data does not contain every usage window +- `GET https://api.chutes.ai/users/me/quota_usage/{chute_id}` for quota details when available + +All requests use `Authorization: Bearer cpk_...`. Subscription usage is required; quota-detail requests are best-effort. + +## Display + +The provider prefers the rolling four-hour window as the primary meter and monthly subscription usage as the secondary meter. Accounts without a subscription can still show available pay-as-you-go quota data. + +## CLI Usage + +```bash +codexbar --provider chutes +``` + +## Troubleshooting + +- Confirm the key can read `https://api.chutes.ai/users/me/subscription_usage`. +- A `401` or `403` means Chutes rejected the key. +- `CHUTES_API_URL` can override the management API base URL, but CodexBar accepts HTTPS endpoints only. diff --git a/docs/claude-comparison-since-0.18.0beta2.md b/docs/claude-comparison-since-0.18.0beta2.md index b2797a7fa0..dece49f234 100644 --- a/docs/claude-comparison-since-0.18.0beta2.md +++ b/docs/claude-comparison-since-0.18.0beta2.md @@ -1,3 +1,11 @@ +--- +summary: "Claude fetch behavior comparison covering OAuth, web, CLI, and Keychain prompt changes." +read_when: + - Reviewing Claude fetch regressions since 0.18.0 beta 2 + - Changing Claude OAuth, web, CLI, or Keychain prompt behavior + - Comparing old and current Claude credential flows +--- + # Claude Fetch Comparison (`7b79b2d` vs `HEAD`) This document compares Claude data fetching behavior between: @@ -14,7 +22,7 @@ Focus areas: ## High-level Changes - OAuth moved from "load token and fail if expired" to "auto-refresh when expired". -- Claude keychain reads now use stricter non-interactive probes (`kSecUseAuthenticationUIFail`) before any promptable path. +- Claude keychain reads now use stricter non-interactive probes (`LAContext.interactionNotAllowed`) before any promptable path. - New Claude keychain prompt cooldown gate: 6-hour suppression after denial. - New OAuth refresh failure gate: - `invalid_grant` => terminal block until auth fingerprint changes. diff --git a/docs/claude.md b/docs/claude.md index 22737efd92..7aa34287db 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -9,18 +9,43 @@ read_when: # Claude provider -Claude supports three usage data paths plus local cost usage. Source selection is automatic unless debug override is set. +Claude supports three usage data paths plus local cost usage. The main provider pipeline uses runtime-specific +automatic selection, but the codebase still has multiple active Claude `.auto` decision sites while the refactor is +pending. For the exact current-state parity contract, see +[docs/refactor/claude-current-baseline.md](refactor/claude-current-baseline.md). + +When an Anthropic Admin API key is configured, Claude can also show organization-level spend/messages/tokens in the +same inline dashboard pattern used by the OpenAI API provider. ## Data sources + selection order ### Default selection (debug menu disabled) -1) OAuth API (if Claude CLI credentials include `user:profile` scope). -2) CLI PTY (`claude`), if OAuth is unavailable or fails. -3) Web API (browser cookies, `sessionKey`), if OAuth + CLI are unavailable or fail. +- If an Admin API key is configured, the Admin API strategy is used for Claude API spend/usage. +- App runtime main pipeline: OAuth API → CLI PTY → Web API. +- CLI runtime main pipeline: Web API → CLI PTY. +- Explicit picker modes (OAuth/Web/CLI) bypass automatic fallback. +- A lower-level direct Claude fetcher still contains a separate `.auto` order. That inconsistency is tracked in + [docs/refactor/claude-current-baseline.md](refactor/claude-current-baseline.md). Usage source picker: - Preferences → Providers → Claude → Usage source (Auto/OAuth/Web/CLI). +Admin API key setup: +- Preferences → Providers → Claude → Admin API key, stored in `~/.codexbar/config.json`. +- CLI/env: `printf '%s' "$ANTHROPIC_ADMIN_KEY" | codexbar config set-api-key --provider claude --stdin`. +- Token accounts can also hold `sk-ant-admin...` keys; they route to the Admin API instead of cookie/OAuth usage. +- Environment fallback: `ANTHROPIC_ADMIN_KEY`. + +## Admin API +- Key prefix: `sk-ant-admin...`. +- Endpoints: + - `/v1/organizations/cost_report` + - `/v1/organizations/usage_report/messages` +- Output: + - Today/7d/30d spend and message/token summaries. + - Inline 30-day dashboard chart when daily buckets are present. + - Identity login method: `Admin API`. + ## Keychain prompt policy (Claude OAuth) - Preferences → Providers → Claude → Keychain prompt policy. - Options: @@ -37,8 +62,9 @@ Usage source picker: ## OAuth API (preferred) - Credentials: - - Keychain service: `Claude Code-credentials` (primary on macOS). + - CodexBar OAuth cache when available. - File fallback: `~/.claude/.credentials.json`. + - Claude CLI Keychain bootstrap/repair fallback: `Claude Code-credentials`. - Requires `user:profile` scope (CLI tokens with only `user:inference` cannot call usage). - Endpoint: - `GET https://api.anthropic.com/api/oauth/usage` @@ -47,18 +73,24 @@ Usage source picker: - `anthropic-beta: oauth-2025-04-20` - Mapping: - `five_hour` → session window. - - `seven_day` → weekly window. + - `seven_day` → weekly window; also becomes the primary fallback when `five_hour` is absent or has no utilization. - `seven_day_sonnet` / `seven_day_opus` → model-specific weekly window. + - `seven_day_routines` / `seven_day_cowork` → Daily Routines extra window. + - Claude Design/Omelette keys are ignored because Claude Design shares the main Claude usage limit. - `extra_usage` → Extra usage cost (monthly spend/limit). -- Plan inference: `rate_limit_tier` from credentials maps to Max/Pro/Team/Enterprise. +- Successful OAuth login enables Claude and selects OAuth as the usage source. +- Plan inference: `subscriptionType` is preferred when present; `rate_limit_tier` falls back to + Max/Pro/Team/Enterprise. ## Web API (cookies) - Preferences → Providers → Claude → Cookie source (Automatic or Manual). - Manual mode accepts a `Cookie:` header from a claude.ai request. - Multi-account manual tokens: add entries to `~/.codexbar/config.json` (`tokenAccounts`) and set Claude cookies to Manual. The menu can show all accounts stacked or a switcher bar (Preferences → Advanced → Display). -- Claude token accounts accept either `sessionKey` cookies or OAuth access tokens (`sk-ant-oat...`). OAuth tokens use - the Anthropic OAuth usage endpoint; to force cookie mode, paste `sessionKey=` or a full `Cookie:` header. +- Claude token accounts accept either `sessionKey` cookies or OAuth access tokens (`sk-ant-oat...`). OAuth-token + accounts route to the OAuth path and disable cookie mode; session-key or cookie-header accounts stay in manual + cookie mode. The exact edge-routing rules are documented in + [docs/refactor/claude-current-baseline.md](refactor/claude-current-baseline.md). - Cookie source order: 1) Safari: `~/Library/Cookies/Cookies.binarycookies` 2) Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies` @@ -75,12 +107,17 @@ Usage source picker: - `GET https://claude.ai/api/account` → email + plan hints. - Outputs: - Session + weekly + model-specific percent used. + - Daily Routines extra window when returned by the usage API. - Extra usage spend/limit (if enabled). - Account email + inferred plan. ## CLI PTY (fallback) - Runs `claude` in a PTY session (`ClaudeCLISession`). - Default behavior: exit after each probe; Debug → "Keep CLI sessions alive" keeps it running between probes. +- Probe working directory: `~/Library/Application Support/CodexBar/ClaudeProbe` with local Claude settings that disable + deep-link URL handler registration during headless probes. +- After transient probes exit, CodexBar removes Claude Code `.jsonl` session artifacts for that dedicated + `ClaudeProbe` project directory so background `/usage` polling does not clutter the user's Claude project history. - Command flow: 1) Start CLI with `--allowed-tools ""` (no tools). 2) Auto-respond to first-run prompts (trust files, workspace, telemetry). @@ -94,17 +131,23 @@ Usage source picker: ## Cost usage (local log scan) - Source roots: - - `$CLAUDE_CONFIG_DIR` (comma-separated), each root uses `/projects`. - - Fallback roots: - - `~/.config/claude/projects` - - `~/.claude/projects` -- Files: `**/*.jsonl` under the project roots. + - Native Claude logs: + - `$CLAUDE_CONFIG_DIR` (comma-separated), each root uses `/projects`. + - Fallback roots: + - `~/.config/claude/projects` + - `~/.claude/projects` + - Supported pi sessions: + - `~/.pi/agent/sessions/**/*.jsonl` +- Files: `**/*.jsonl` under the native project roots plus supported pi session files. - Parsing: - - Lines with `type: "assistant"` and `message.usage`. + - Native Claude logs parse lines with `type: "assistant"` and `message.usage`. - Uses per-model token counts (input, cache read/create, output). - Deduplicates streaming chunks by `message.id + requestId` (usage is cumulative per chunk). + - pi sessions attribute `anthropic` assistant usage to Claude and bucket it by assistant-turn timestamp, so a single pi + session can contribute to multiple models/days. - Cache: - - `~/Library/Caches/CodexBar/cost-usage/claude-v1.json` + - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/claude-v2.json` + - pi session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v1.json` ## Key files - OAuth: `Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/*` @@ -112,4 +155,6 @@ Usage source picker: - CLI PTY: `Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift`, `Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift` - Cost usage: `Sources/CodexBarCore/CostUsageFetcher.swift`, + `Sources/CodexBarCore/PiSessionCostScanner.swift`, + `Sources/CodexBarCore/PiSessionCostCache.swift`, `Sources/CodexBarCore/Vendored/CostUsage/*` diff --git a/docs/cli-configuration.md b/docs/cli-configuration.md new file mode 100644 index 0000000000..a13ab6883a --- /dev/null +++ b/docs/cli-configuration.md @@ -0,0 +1,103 @@ +--- +summary: "CodexBar CLI configuration commands for provider toggles, API keys, and isolated config files." +read_when: + - Using codexbar config from scripts or CI + - Enabling or disabling providers without opening Settings + - Storing provider API keys from the command line +--- + +# CLI configuration + +`codexbar config` edits the same resolved config file used by the app's Settings → Providers pane. +New installs use `~/.config/codexbar/config.json`; absolute `XDG_CONFIG_HOME` paths and `CODEXBAR_CONFIG` are +supported, and existing `~/.codexbar/config.json` installs keep using the legacy file when no XDG config exists. +The CLI writes the file with `0600` permissions. + +## Providers + +List persistent provider toggles: + +```bash +codexbar config providers +codexbar config providers --json --pretty +``` + +Enable or disable a provider: + +```bash +codexbar config enable --provider grok +codexbar config disable --provider cursor +``` + +These are persistent app/CLI settings. They are different from `codexbar usage --provider grok`, which is a one-shot +command override and does not edit config. + +If every provider is disabled, `codexbar usage` with no `--provider` prints no text output, and +`codexbar usage --json` prints `[]`. Passing `--provider ` still fetches that provider for the one command. + +## API keys + +API keys are stored under the provider entry in config: + +```bash +printf '%s' "$ELEVENLABS_API_KEY" | codexbar config set-api-key --provider elevenlabs --stdin +``` + +`set-api-key` enables the provider by default. Add `--no-enable` when you only want to save the key: + +```bash +printf '%s' "$OPENROUTER_API_KEY" | codexbar config set-api-key --provider openrouter --stdin --no-enable +``` + +Useful examples: + +```bash +printf '%s' "$OPENAI_ADMIN_KEY" | codexbar config set-api-key --provider openai --stdin +printf '%s' "$ANTHROPIC_ADMIN_KEY" | codexbar config set-api-key --provider claude --stdin +printf '%s' "$DEEPGRAM_API_KEY" | codexbar config set-api-key --provider deepgram --stdin +printf '%s' "$GROQ_API_KEY" | codexbar config set-api-key --provider groq --stdin +printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmproxy --stdin +printf '%s' "$Z_AI_API_KEY" | codexbar config set-api-key --provider zai --stdin +``` + +Only providers that consume config-backed API keys accept this command. Admin API providers may require a key with +organization/usage permissions, not a normal inference key. Browser/OAuth providers such as Grok use their own provider +sessions instead of an xAI API key for CodexBar's billing view, so enable them with +`codexbar config enable --provider grok`. + +LLM Proxy also needs a base URL. Use `LLM_PROXY_BASE_URL` for CLI runs, or add `"enterpriseHost"` to the provider entry +in the CodexBar config file. + +## Isolated config files + +For tests, demos, and CI, point CodexBar at a temporary config file: + +```bash +export CODEXBAR_CONFIG=/tmp/codexbar-config.json +codexbar config enable --provider grok +codexbar config providers --json --pretty +``` + +The override applies to both reads and writes for the current process environment. + +## Cost history window + +The app setting controls the menu's local cost-history window. For one-off CLI reports, pass `--days`: + +```bash +codexbar cost --provider codex --days 90 +codexbar cost --provider claude --days 180 --format json --pretty +``` + +The accepted range is 1...365 days. + +## Validation + +After hand-editing config: + +```bash +codexbar config validate +codexbar config dump --pretty +``` + +`dump` prints normalized config, including providers omitted from a hand-written file. diff --git a/docs/cli.md b/docs/cli.md index a13c8f830c..7c4c5e9e0f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -8,21 +8,24 @@ read_when: # CodexBar CLI -A lightweight Commander-based CLI that mirrors the menubar app’s data paths (Codex web/RPC → PTY fallback; Claude web by default with CLI fallback and OAuth debug). +A lightweight Commander-based CLI that mirrors the menu bar app’s provider fetchers and config file. Use it when you need usage numbers in scripts, CI, or dashboards without UI. ## Install - In the app: **Preferences → Advanced → Install CLI**. This symlinks `CodexBarCLI` to `/usr/local/bin/codexbar` and `/opt/homebrew/bin/codexbar`. -- From the repo: `./bin/install-codexbar-cli.sh` (same symlink targets). +- From the repo, after installing `CodexBar.app` in `/Applications`: `./bin/install-codexbar-cli.sh` (same symlink targets). - Manual: `ln -sf "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI" /usr/local/bin/codexbar`. -### Linux install -- Homebrew (Linuxbrew, Linux only): `brew install steipete/tap/codexbar`. -- Download `CodexBarCLI-v-linux-.tar.gz` from GitHub Releases (x86_64 + aarch64). -- Extract; run `./codexbar` (symlink) or `./CodexBarCLI`. +### Release tarball install (macOS/Linux) +- Homebrew formula (Linux today): `brew install steipete/tap/codexbar`. +- Download release tarballs from GitHub Releases: + - macOS: `CodexBarCLI-v-macos-arm64.tar.gz`, `CodexBarCLI-v-macos-x86_64.tar.gz` + - Linux (glibc): `CodexBarCLI-v-linux-aarch64.tar.gz`, `CodexBarCLI-v-linux-x86_64.tar.gz` + - Linux (static musl): `CodexBarCLI-v-linux-musl-aarch64.tar.gz`, `CodexBarCLI-v-linux-musl-x86_64.tar.gz` +- Extract and run `./codexbar` (symlink) or `./CodexBarCLI`. ``` -tar -xzf CodexBarCLI-v0.17.0-linux-x86_64.tar.gz +tar -xzf CodexBarCLI-v0.17.0-macos-x86_64.tar.gz ./codexbar --version ./codexbar usage --format json --pretty ``` @@ -33,45 +36,66 @@ tar -xzf CodexBarCLI-v0.17.0-linux-x86_64.tar.gz - Dependencies: Swift 6.2+, Commander package (`https://github.com/steipete/Commander`). ## Configuration -CodexBar reads `~/.codexbar/config.json` for provider settings, secrets, and ordering. +CodexBar reads the resolved config file for provider settings, secrets, and ordering. New installs use +`~/.config/codexbar/config.json`; absolute `XDG_CONFIG_HOME` paths and `CODEXBAR_CONFIG` are supported, and existing +`~/.codexbar/config.json` installs keep using the legacy file when no XDG config exists. See `docs/configuration.md` for the schema. ## Command - `codexbar` defaults to the `usage` command. - `--format text|json` (default: text). -- `codexbar cost` prints local token cost usage (Claude + Codex) without web/CLI access. +- `codexbar cost` prints local token cost usage for Claude + Codex without web/CLI access. - `--format text|json` (default: text). - `--refresh` ignores cached scans. +- `codexbar serve` starts a foreground localhost-only HTTP server for usage and cost JSON. + - `--port ` defaults to `8080`. + - `--refresh-interval ` defaults to `60` and controls the in-memory response cache TTL. + - `--request-timeout ` defaults to `30` and bounds each request before returning `504 Gateway Timeout`; use `0` to keep waiting indefinitely. + - Provider config is reloaded for each usage/cost request; cache entries are keyed by the loaded config so provider toggles and source changes do not require restarting `serve`. + - Transient refresh failures fall back to the last good response for up to ten refresh intervals (minimum five minutes) so polling clients do not flicker between data and errors; disabled when `--refresh-interval 0`. + - v1 binds to `127.0.0.1` only and rejects non-loopback `Host` headers. It does not expose remote bind, auth, CORS, TLS, or daemon mode. + - Endpoints: `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`. + - `GET /health` returns `{"status":"ok"}` plus a `version` field with the running build (e.g. `"0.37.2"`) when resolvable; clients can compare it against `codexbar --version` to detect a `serve` process still running an older binary after an update. + - Codex usage responses include every visible Codex account, matching the menu bar switcher. +- `codexbar cache clear` clears local CodexBar caches. + - `--cookies` removes cached browser-cookie headers from the CodexBar Keychain cache. + - `--cookies --provider ` removes browser-cookie cache entries for that provider, including managed Codex account scopes. + - `--cost` removes local cost-usage scan caches. + - `--all` clears both cookies and cost caches. `--provider` is cookie-only and cannot be combined with `--cost` or `--all`. - `--provider ` (default: enabled providers in config; falls back to defaults when missing). - Provider IDs live in the config file (see `docs/configuration.md`). - - `--account
-
- CodexBar menu bar popover showing session and weekly usage. -
Menu card shows session + weekly usage, resets, and credits when available.
-
+

Free & open source · macOS 14+ · Universal via GitHub Releases and Homebrew

-
-
-

Install & updates

-

- Download from GitHub Releases or install via Homebrew. Sparkle updates are enabled on GitHub builds only. -

-
-
-

Providers

-

- Codex · Claude Code · Cursor · Gemini · Antigravity · Droid · Copilot · z.ai · Kilo
- Open to new providers — see the provider guide. -

-
-
-

Privacy

-

- Reuses existing browser cookies (Safari/Chrome/Firefox) for dashboard access — no passwords stored. When cookies - are missing, CodexBar falls back to local CLI output. Audit notes in - issue #12. -

-
+
+
+

53 providers, one menu bar

+

Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.

+
+
-
-
-

Usage tracking

-

- Session + weekly meters with reset countdowns, plus optional credits and code review limits. -

-
-
-

Status + widgets

-

- Provider status polling with incident badges, plus a WidgetKit snapshot that mirrors the menu card. -

-
-
-

CLI + automation

-

- Bundled codexbar CLI for scripts and CI, with Linux CLI builds on GitHub Releases. -

-
+
+
+ CodexBar menu bar popover showing provider tiles with usage bars, reset countdowns, and credit details. +
+
+
+

Resets you can plan around

+

Per-provider session, weekly, and monthly windows with countdowns to the next reset — no more guessing whether to start a long task.

+
+
+

Credits, spend, and cost scans

+

Credit balances and monthly spend where the provider exposes them, plus configurable local cost scans for Codex and Claude.

+
+
+

Status & incidents

+

Provider status polling surfaces incident badges in the menu and an indicator overlay on the bar icon.

+
+
+

CLI & widgets

+

WidgetKit widgets for supported providers and a bundled codexbar CLI for scripts and CI — macOS and Linux builds on every release.

+
+
-
-
-

macOS permissions

-

- Full Disk Access only for Safari cookies, Keychain access for tokens, and Files & Folders prompts when - provider CLIs access project directories. -

-
+
+
+

Install & updates

+

+ Universal app via GitHub Releases with Sparkle auto-updates, or via + brew install --cask steipete/tap/codexbar (updates via brew upgrade). +

+
+
+

CLI

+

+ A bundled codexbar binary plus standalone tarballs for macOS and Linux on each release. On Linux: + brew install steipete/tap/codexbar. +

+
+
+

Privacy

+

+ Reuses existing provider sessions — OAuth, device flow, API keys, browser cookies, local files — so no passwords are stored. + Audit notes in issue #12. +

+
+
+

macOS permissions

+

+ Full Disk Access for Safari cookies, Keychain access for cookie decryption and OAuth flows, and Files & Folders prompts + when provider helpers read project directories. +

+
+ - + - - + diff --git a/docs/keychain-prompts.md b/docs/keychain-prompts.md new file mode 100644 index 0000000000..ae9ee040f3 --- /dev/null +++ b/docs/keychain-prompts.md @@ -0,0 +1,87 @@ +--- +summary: "Safe troubleshooting for macOS Keychain and browser Safe Storage prompts." +read_when: + - Investigating Chrome Safe Storage or browser Safe Storage prompts + - Explaining prompts that appear after uninstalling CodexBar + - Collecting safe support details without exposing secrets +--- + +# Keychain prompts + +CodexBar can trigger macOS Keychain prompts when an enabled provider imports browser cookies, reads a provider-owned +OAuth item, or uses a CodexBar-owned cache entry. Chromium browser cookie import commonly asks for the browser's +Safe Storage item, such as "Chrome Safe Storage", "Brave Safe Storage", or "Microsoft Edge Safe Storage". + +CodexBar does not need your browser password. macOS owns the prompt, and the prompt should identify the app or binary +that is requesting access. For support reports, include that requesting app/path when possible and do not paste +passwords, cookie headers, OAuth tokens, API keys, or Keychain item values. + +## If the prompt appears after uninstalling CodexBar + +Deleting `CodexBar.app` prevents a new process from launching from that bundle, but it does not terminate a process +that is already running from it. That process can continue to request Keychain access until it quits. If macOS still +shows a prompt such as "CodexBar wants to use your confidential information stored in 'Chrome Safe Storage'", the +usual causes are: + +- A CodexBar process or bundled helper is still running. +- CodexBar is still enabled in Login Items and relaunched from an existing install. +- Another copy of `CodexBar.app` exists elsewhere on the machine. +- The uninstall path did not remove the same copy that launched the process. Finder, Homebrew cask, Sparkle updates, + and manually copied apps can leave different install paths in play. +- The prompt is naming the requesting binary, not proving that the copy you deleted is the one still running. + +Safe checks: + +```bash +pgrep -fl 'CodexBar|CodexBarCLI' +ls -ld /Applications/CodexBar.app +brew info --cask codexbar +mdfind 'kMDItemCFBundleIdentifier == "com.steipete.codexbar"' +``` + +Also check: + +- **Activity Monitor**: search for `CodexBar` and `CodexBarCLI`. +- **System Settings -> General -> Login Items**: remove CodexBar if it remains listed. +- **Keychain prompt screenshot**: capture the full prompt, especially any requesting app/path details. Redact user + names or unrelated window contents if needed, but do not include secrets. + +If you find a still-running process, quit CodexBar from the menu if possible, or quit it from Activity Monitor. If you +find another installed copy, confirm whether that copy is the one macOS names in the prompt before changing anything +else. + +## Stop CodexBar from using Keychain + +If CodexBar is still installed and you want it to stop all Keychain access: + +1. Open **CodexBar -> Settings -> Advanced**. +2. In **Keychain access**, enable **Disable Keychain access**. +3. Relaunch CodexBar. + +This disables Keychain reads and writes from CodexBar. Browser-cookie-based providers will be skipped because +CodexBar can no longer decrypt browser cookies. Manual cookie headers, API keys, and CLI/OAuth flows that do not rely +on Keychain can still work where the provider supports them. + +## Browser Safe Storage prompts + +For normal browser-cookie import prompts, either allow CodexBar in the Keychain item's Access Control list or disable +Keychain access: + +1. Open **Keychain Access.app**. +2. Select the `login` keychain. +3. Search for the item named in the prompt, for example `Chrome Safe Storage`. +4. Open the item, choose **Access Control**, and add `CodexBar.app` under "Always allow access by these applications". +5. Relaunch CodexBar. + +Avoid "Allow all applications" unless you intentionally want every app to access that item. Do not paste or share the +item's secret value when asking for help. + +## What to include in a support issue + +- CodexBar version and install source: GitHub release, Homebrew cask, Sparkle update, or another source. +- macOS version. +- The uninstall method if this happened after uninstalling. +- Whether Activity Monitor or `pgrep` still shows CodexBar. +- Whether System Settings -> General -> Login Items still lists CodexBar. +- Whether `/Applications/CodexBar.app`, Homebrew cask metadata, or Spotlight finds another copy. +- A screenshot of the Keychain prompt showing the requested item and requesting app/path, with secrets redacted. diff --git a/docs/kilo.md b/docs/kilo.md index b8a6fa2b0c..6425bedecd 100644 --- a/docs/kilo.md +++ b/docs/kilo.md @@ -35,3 +35,20 @@ Kilo supports API and CLI-backed auth. Source mode can be `auto`, `api`, or `cli - Missing API token: set `KILO_API_KEY` or provider `apiKey`. - Missing CLI session file: run `kilo login` to create `~/.local/share/kilo/auth.json`. - Unauthorized API token (401/403): refresh `KILO_API_KEY` or rerun `kilo login`. + +## Organizations + +CodexBar can show usage for any Kilo organization the API key belongs to. + +- Open Preferences → Providers → Kilo, set the API key, then click **Refresh + organizations**. +- Toggle the organizations you want to display alongside Personal. Personal is + always shown. +- When at least one organization is enabled, the menu renders one Kilo card per + enabled scope. +- The CodexBar fetcher sends the standard `X-KILOCODE-ORGANIZATIONID` header on + every usage call to scope the response to that organization. +- CLI source mode (`auth.json`): the header is applied to CLI-resolved tokens + as well. If a CLI token isn't authorized for the chosen organization, that + card surfaces an unauthorized error while Personal and other enabled scopes + continue to render normally. diff --git a/docs/kimi-k2.md b/docs/kimi-k2.md index a40aa35669..a94beb777e 100644 --- a/docs/kimi-k2.md +++ b/docs/kimi-k2.md @@ -8,13 +8,18 @@ read_when: # Kimi K2 provider -Kimi K2 is API-only. Usage is reported by the credit counter behind `GET https://kimi-k2.ai/api/user/credits`, -so CodexBar only needs a valid API key to pull your remaining balance and usage. +> This is a legacy, unofficial provider for the `kimi-k2.ai` credit endpoint. +> For the official Kimi API account and billing surface, use the Moonshot / Kimi +> API provider instead. + +Kimi K2 is API-only. Usage is reported by the credit counter behind +`GET https://kimi-k2.ai/api/user/credits`, so CodexBar only needs a valid API +key for that legacy endpoint to pull your remaining balance and usage. ## Data sources + fallback order 1) **API key** stored in `~/.codexbar/config.json` or supplied via `KIMI_K2_API_KEY` / `KIMI_API_KEY` / `KIMI_KEY`. - CodexBar stores the key in config after you paste it in Preferences → Providers → Kimi K2. + CodexBar stores the key in config after you paste it in Preferences → Providers → Kimi K2 (unofficial). 2) **Credit endpoint** - `GET https://kimi-k2.ai/api/user/credits` - Request headers: `Authorization: Bearer `, `Accept: application/json` diff --git a/docs/kimi.md b/docs/kimi.md index 4a3650e4c3..612949c5d2 100644 --- a/docs/kimi.md +++ b/docs/kimi.md @@ -14,14 +14,31 @@ Tracks usage for [Kimi For Coding](https://www.kimi.com/code) in CodexBar. - Displays weekly request quota (from membership tier) - Shows current 5-hour rate limit usage -- Automatic and manual authentication methods +- API-key, automatic cookie, and manual cookie authentication methods - Automatic refresh countdown ## Setup -Choose one of two authentication methods: +Choose one of three authentication methods: -### Method 1: Automatic Browser Import (Recommended) +### Method 1: Kimi Code API Key (Recommended) + +Create an API key in the [Kimi Code Console](https://www.kimi.com/code/console), then save it in CodexBar: + +```bash +codexbar config set-api-key --provider kimi --api-key "kimi-api-key-here" +``` + +Or provide it through the environment: + +```bash +export KIMI_CODE_API_KEY="kimi-code-api-key-here" +``` + +CodexBar calls `GET https://api.kimi.com/coding/v1/usages` with the API key. Set +`KIMI_CODE_BASE_URL` only when testing a compatible HTTPS proxy or alternate host. + +### Method 2: Automatic Browser Import **No setup needed!** If you're already logged in to Kimi in Arc, Chrome, Safari, Edge, Brave, or Chromium: @@ -32,7 +49,7 @@ Choose one of two authentication methods: **Note**: Requires Full Disk Access to read browser cookies (System Settings → Privacy & Security → Full Disk Access → CodexBar). -### Method 2: Manual Token Entry +### Method 3: Manual Token Entry For advanced users or when automatic import fails: @@ -44,7 +61,7 @@ For advanced users or when automatic import fails: 6. Copy the `kimi-auth` cookie value (JWT token) 7. Paste it into the "Auth Token" field in CodexBar -### Method 3: Environment Variable +### Cookie Environment Variable Alternatively, set the `KIMI_AUTH_TOKEN` environment variable: @@ -56,14 +73,44 @@ export KIMI_AUTH_TOKEN="jwt-token-here" When multiple sources are available, CodexBar uses this order: -1. Manual token (from Settings UI) -2. Environment variable (`KIMI_AUTH_TOKEN`) -3. Browser cookies (Arc → Chrome → Safari → Edge → Brave → Chromium) +1. API key (`providers[].apiKey` or `KIMI_CODE_API_KEY`) in Auto mode +2. Manual cookie/token (from Settings UI) when web fallback is used +3. Cookie environment variable (`KIMI_AUTH_TOKEN`) +4. Browser cookies (Arc → Chrome → Safari → Edge → Brave → Chromium) **Note**: Browser cookie import requires Full Disk Access permission. ## API Details +### Kimi Code API key + +**Endpoint**: `GET https://api.kimi.com/coding/v1/usages` + +**Authentication**: Bearer token (from `providers[].apiKey` or `KIMI_CODE_API_KEY`) + +**Response**: +```json +{ + "usage": { + "limit": "2048", + "used": "214", + "remaining": "1834", + "resetTime": "2026-01-09T15:23:13.716839300Z" + }, + "limits": [{ + "window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"}, + "detail": { + "limit": "200", + "used": "139", + "remaining": "61", + "resetTime": "2026-01-06T13:33:02.717479433Z" + } + }] +} +``` + +### Kimi web cookie fallback + **Endpoint**: `POST https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages` **Authentication**: Bearer token (from `kimi-auth` cookie) diff --git a/docs/litellm.md b/docs/litellm.md new file mode 100644 index 0000000000..edd5953511 --- /dev/null +++ b/docs/litellm.md @@ -0,0 +1,54 @@ +--- +summary: "LiteLLM provider setup and usage data shape." +read_when: + - Configuring LiteLLM usage tracking + - Troubleshooting LiteLLM API-key usage in CodexBar +--- + +# LiteLLM + +LiteLLM uses a virtual key plus the proxy base URL. The key reads its own identity and budget data through LiteLLM's +authenticated information endpoints. + +Configure it in Settings -> Providers -> LiteLLM, or in `~/.codexbar/config.json`: + +```json +{ + "id": "litellm", + "enabled": true, + "apiKey": "", + "enterpriseHost": "https://litellm.example.com" +} +``` + +Equivalent environment variables: + +```bash +export LITELLM_API_KEY=sk-... +export LITELLM_BASE_URL=https://litellm.example.com +``` + +`LITELLM_BASE_URL` may include `/v1`; CodexBar strips that suffix before calling LiteLLM management endpoints. + +## Data Source + +The provider calls: + +1. `GET /key/info` to discover the authenticated key's `user_id` and `team_id`. +2. `GET /user/info?user_id=` to read personal spend, budget, and teams. +3. For team-only keys without a `user_id`, `GET /team/info?team_id=` to read team spend and budget. + +All requests use `Authorization: Bearer `. CodexBar does not request or store a LiteLLM master key. + +For user-bound keys, personal usage is shown as the primary window. If the key has a team, its exact matching team +budget is shown as the secondary window and becomes the automatic menu bar metric because that budget is enforced for +the key. Team-only keys show that team budget as their sole usage window. Spend remains visible as an API-spend row +when LiteLLM does not configure a budget. + +The virtual key must be allowed to read its own `/key/info` data and the corresponding user or team information +endpoint. CodexBar validates returned user and team IDs against `/key/info` before displaying usage. + +## Security + +Treat LiteLLM keys as secrets. CodexBar stores configured keys only in provider config or token-account storage and +sends them only to the configured LiteLLM base URL. diff --git a/docs/llm-proxy.md b/docs/llm-proxy.md new file mode 100644 index 0000000000..49ce39eaf7 --- /dev/null +++ b/docs/llm-proxy.md @@ -0,0 +1,41 @@ +--- +summary: "LLM Proxy provider setup and quota-stats usage source." +read_when: + - Configuring LLM Proxy usage tracking + - Debugging aggregate proxy quota or provider breakdown display +--- + +# LLM Proxy + +CodexBar reads aggregate usage from an LLM-API-Key-Proxy compatible `/v1/quota-stats` endpoint. + +## Setup + +Store the API key: + +```bash +printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmproxy --stdin +``` + +Set the base URL with `LLM_PROXY_BASE_URL`, or add `enterpriseHost` to the provider config: + +```json +{ + "id": "llmproxy", + "enabled": true, + "apiKey": "", + "enterpriseHost": "https://proxy.example.com" +} +``` + +The base URL may point at either the service root or `/v1`; CodexBar normalizes both to `/v1/quota-stats`. + +## Menu display + +- Primary: lowest remaining quota group, rendered as percent used. +- Secondary: total requests. +- Tertiary: total tokens. +- Extra rows: top provider summaries by request count. +- Cost: approximate spend when the proxy reports `approx_cost`. + +`quota_groups` may be either an array or a keyed object; CodexBar accepts both shapes. diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 0000000000..84fade7223 --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,12 @@ +# CodexBar + +A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 53 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. + +Canonical documentation: +- CodexBar — every AI coding limit in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 53 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. + +Source: https://github.com/steipete/CodexBar + +Guidance for agents: +- Prefer the canonical documentation URLs above over README excerpts or package metadata. +- Fetch only the pages needed for the current task; this is an index, not a full-site corpus. diff --git a/docs/logos/abacus-ai-dark.svg b/docs/logos/abacus-ai-dark.svg new file mode 100644 index 0000000000..468bb3dfe4 --- /dev/null +++ b/docs/logos/abacus-ai-dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/docs/logos/abacus-ai.png b/docs/logos/abacus-ai.png new file mode 100644 index 0000000000..46572923b9 Binary files /dev/null and b/docs/logos/abacus-ai.png differ diff --git a/docs/logos/alibaba.svg b/docs/logos/alibaba.svg new file mode 100644 index 0000000000..bb458d7ed0 --- /dev/null +++ b/docs/logos/alibaba.svg @@ -0,0 +1 @@ +Alibaba \ No newline at end of file diff --git a/docs/logos/amp.svg b/docs/logos/amp.svg new file mode 100644 index 0000000000..f8f9d31c0c --- /dev/null +++ b/docs/logos/amp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/logos/antigravity.svg b/docs/logos/antigravity.svg new file mode 100644 index 0000000000..3ed10ab651 --- /dev/null +++ b/docs/logos/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/docs/logos/augment.svg b/docs/logos/augment.svg new file mode 100644 index 0000000000..5e81dd27bd --- /dev/null +++ b/docs/logos/augment.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/docs/logos/chutes.svg b/docs/logos/chutes.svg new file mode 100644 index 0000000000..ff752b4554 --- /dev/null +++ b/docs/logos/chutes.svg @@ -0,0 +1,7 @@ + + Chutes + + + + + diff --git a/docs/logos/claude.svg b/docs/logos/claude.svg new file mode 100644 index 0000000000..62dc0db12d --- /dev/null +++ b/docs/logos/claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/docs/logos/codebuff-dark.svg b/docs/logos/codebuff-dark.svg new file mode 100644 index 0000000000..6d5f9e4550 --- /dev/null +++ b/docs/logos/codebuff-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/logos/codebuff.png b/docs/logos/codebuff.png new file mode 100644 index 0000000000..47b4b7beea Binary files /dev/null and b/docs/logos/codebuff.png differ diff --git a/docs/logos/codebuff.svg b/docs/logos/codebuff.svg new file mode 100644 index 0000000000..5791738f47 --- /dev/null +++ b/docs/logos/codebuff.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/logos/codex-dark.svg b/docs/logos/codex-dark.svg new file mode 100644 index 0000000000..0d789fb754 --- /dev/null +++ b/docs/logos/codex-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/logos/codex.svg b/docs/logos/codex.svg new file mode 100644 index 0000000000..c77ccfdd90 --- /dev/null +++ b/docs/logos/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/docs/logos/copilot.svg b/docs/logos/copilot.svg new file mode 100644 index 0000000000..dd11956e04 --- /dev/null +++ b/docs/logos/copilot.svg @@ -0,0 +1 @@ +GithubCopilot \ No newline at end of file diff --git a/docs/logos/cursor.svg b/docs/logos/cursor.svg new file mode 100644 index 0000000000..e4135e124b --- /dev/null +++ b/docs/logos/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/docs/logos/deepseek.svg b/docs/logos/deepseek.svg new file mode 100644 index 0000000000..185c108b45 --- /dev/null +++ b/docs/logos/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/docs/logos/devin.svg b/docs/logos/devin.svg new file mode 100644 index 0000000000..e2b1cd5f67 --- /dev/null +++ b/docs/logos/devin.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/logos/droid-dark.svg b/docs/logos/droid-dark.svg new file mode 100644 index 0000000000..f7807d8abe --- /dev/null +++ b/docs/logos/droid-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/logos/droid.svg b/docs/logos/droid.svg new file mode 100644 index 0000000000..38aad643a7 --- /dev/null +++ b/docs/logos/droid.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/docs/logos/elevenlabs.svg b/docs/logos/elevenlabs.svg new file mode 100644 index 0000000000..ce9145e074 --- /dev/null +++ b/docs/logos/elevenlabs.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/logos/gemini.svg b/docs/logos/gemini.svg new file mode 100644 index 0000000000..62681dfa7c --- /dev/null +++ b/docs/logos/gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/docs/logos/jetbrains-ai.svg b/docs/logos/jetbrains-ai.svg new file mode 100644 index 0000000000..8734b4987e --- /dev/null +++ b/docs/logos/jetbrains-ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/logos/kilo.svg b/docs/logos/kilo.svg new file mode 100644 index 0000000000..74c381b1d8 --- /dev/null +++ b/docs/logos/kilo.svg @@ -0,0 +1 @@ +Kilo Code \ No newline at end of file diff --git a/docs/logos/kimi-k2.svg b/docs/logos/kimi-k2.svg new file mode 100644 index 0000000000..45b894ba54 --- /dev/null +++ b/docs/logos/kimi-k2.svg @@ -0,0 +1 @@ +Kimi diff --git a/docs/logos/kimi.svg b/docs/logos/kimi.svg new file mode 100644 index 0000000000..45b894ba54 --- /dev/null +++ b/docs/logos/kimi.svg @@ -0,0 +1 @@ +Kimi diff --git a/docs/logos/kiro-dark.svg b/docs/logos/kiro-dark.svg new file mode 100644 index 0000000000..6dfa798ded --- /dev/null +++ b/docs/logos/kiro-dark.svg @@ -0,0 +1,19 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + diff --git a/docs/logos/kiro.svg b/docs/logos/kiro.svg new file mode 100644 index 0000000000..5fe3cf6847 --- /dev/null +++ b/docs/logos/kiro.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/logos/litellm.svg b/docs/logos/litellm.svg new file mode 100644 index 0000000000..3a6c20b880 --- /dev/null +++ b/docs/logos/litellm.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/docs/logos/minimax.svg b/docs/logos/minimax.svg new file mode 100644 index 0000000000..b1b0a6a2df --- /dev/null +++ b/docs/logos/minimax.svg @@ -0,0 +1 @@ +MiniMax \ No newline at end of file diff --git a/docs/logos/mistral.svg b/docs/logos/mistral.svg new file mode 100644 index 0000000000..00ee1b9e51 --- /dev/null +++ b/docs/logos/mistral.svg @@ -0,0 +1 @@ +Mistral AI \ No newline at end of file diff --git a/docs/logos/ollama.svg b/docs/logos/ollama.svg new file mode 100644 index 0000000000..432f73e738 --- /dev/null +++ b/docs/logos/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/docs/logos/opencode-dark.svg b/docs/logos/opencode-dark.svg new file mode 100644 index 0000000000..ff40ad7928 --- /dev/null +++ b/docs/logos/opencode-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/logos/opencode.svg b/docs/logos/opencode.svg new file mode 100644 index 0000000000..e215d428c7 --- /dev/null +++ b/docs/logos/opencode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/logos/openrouter.svg b/docs/logos/openrouter.svg new file mode 100644 index 0000000000..c3ce7aab31 --- /dev/null +++ b/docs/logos/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/docs/logos/perplexity.svg b/docs/logos/perplexity.svg new file mode 100644 index 0000000000..42968a82ec --- /dev/null +++ b/docs/logos/perplexity.svg @@ -0,0 +1 @@ +Perplexity \ No newline at end of file diff --git a/docs/logos/poe.svg b/docs/logos/poe.svg new file mode 100644 index 0000000000..757be15421 --- /dev/null +++ b/docs/logos/poe.svg @@ -0,0 +1 @@ +Poe diff --git a/docs/logos/synthetic-dark.svg b/docs/logos/synthetic-dark.svg new file mode 100644 index 0000000000..b50f4da2cc --- /dev/null +++ b/docs/logos/synthetic-dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs/logos/synthetic.svg b/docs/logos/synthetic.svg new file mode 100644 index 0000000000..87dfcb7383 --- /dev/null +++ b/docs/logos/synthetic.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/logos/t3chat.svg b/docs/logos/t3chat.svg new file mode 100644 index 0000000000..68a174a697 --- /dev/null +++ b/docs/logos/t3chat.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/logos/vertex-ai.svg b/docs/logos/vertex-ai.svg new file mode 100644 index 0000000000..1a6a483ab5 --- /dev/null +++ b/docs/logos/vertex-ai.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/logos/warp.svg b/docs/logos/warp.svg new file mode 100644 index 0000000000..8b12c83411 --- /dev/null +++ b/docs/logos/warp.svg @@ -0,0 +1 @@ +Warp \ No newline at end of file diff --git a/docs/logos/zai-dark.svg b/docs/logos/zai-dark.svg new file mode 100644 index 0000000000..9026552086 --- /dev/null +++ b/docs/logos/zai-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/logos/zai.svg b/docs/logos/zai.svg new file mode 100644 index 0000000000..4f511bd72f --- /dev/null +++ b/docs/logos/zai.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/logos/zed.svg b/docs/logos/zed.svg new file mode 100644 index 0000000000..fb03bda231 --- /dev/null +++ b/docs/logos/zed.svg @@ -0,0 +1,4 @@ + + Zed + + diff --git a/docs/manus.md b/docs/manus.md new file mode 100644 index 0000000000..7ee9198463 --- /dev/null +++ b/docs/manus.md @@ -0,0 +1,73 @@ +--- +summary: "Manus provider: browser session_id cookie auth for credit balance, monthly credits, and daily refresh tracking." +read_when: + - Adding or modifying the Manus provider + - Debugging Manus cookie imports or API responses + - Adjusting Manus usage display or credit formatting +--- + +# Manus Provider + +The Manus provider tracks credit usage on [manus.im](https://manus.im) via browser `session_id` cookie authentication. + +## Features + +- **Monthly credit gauge**: Shows Pro monthly credits used vs. plan total (`proMonthlyCredits` − `periodicCredits`). +- **Daily refresh gauge**: Shows daily refresh credits used vs. max refresh allotment, with reset timing. +- **Balance display**: Total credits available, shown in the menu identity line. +- **Cookie auth**: Automatic browser cookie import (Safari, Chrome, Firefox) or manual cookie header. +- **Env var support**: `MANUS_SESSION_TOKEN` (raw token) or `MANUS_COOKIE` (full cookie header) for CLI/headless usage. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Manus** +3. Log in to [manus.im](https://manus.im) in your browser +4. Cookie import happens automatically on the next refresh + +### Manual cookie mode + +1. In **Settings → Providers → Manus**, set Cookie source to **Manual** +2. Open your browser DevTools on `manus.im`, copy the `Cookie:` header from any API request (must contain `session_id=...`) +3. Paste the header into the cookie field in CodexBar + +### Environment variables (CLI / headless) + +- `MANUS_SESSION_TOKEN`: the raw `session_id` value. +- `MANUS_COOKIE`: a full cookie header; the provider extracts `session_id` from it. + +Either works; raw-token form is preferred when only one value is needed. + +## How it works + +A single API endpoint is fetched with a bearer token derived from the `session_id` cookie value: + +- `POST https://api.manus.im/user.v1.UserService/GetAvailableCredits` — returns credit fields including `totalCredits`, `freeCredits`, `periodicCredits`, `proMonthlyCredits`, `refreshCredits`, `maxRefreshCredits`, `nextRefreshTime`, and `refreshInterval`. + +Cookie domain: `manus.im`. Valid `session_id` cookies are cached in Keychain and reused until the session expires. + +The response parser tolerates both a direct object and common envelope shapes (`data` / `result` / `response` / `availableCredits`). Payloads missing all expected credit fields are rejected as a parse error rather than surfacing a misleading zero-credit snapshot. + +## Token accounts + +Manus supports multiple accounts via the standard token-account mechanism. Add entries to `~/.codexbar/config.json` (`tokenAccounts`) with the full `Cookie:` header (containing `session_id=...`), then switch between accounts from the menu. + +## CLI + +```bash +codexbar usage --provider manus --verbose +``` + +## Troubleshooting + +### "No Manus session token provided" + +Log in to [manus.im](https://manus.im) in a supported browser (Safari, Chrome, Firefox), then refresh CodexBar. Alternatively, set `MANUS_SESSION_TOKEN` or `MANUS_COOKIE`, or paste a cookie header in manual mode. + +### "Invalid Manus session token" + +Your session has expired or been revoked. Log out and back in to Manus, or paste a fresh `Cookie:` header in manual mode. + +### "Response missing expected credits fields" + +The API returned a 200 response that doesn't look like a credits payload (often an error object). Re-login to Manus; if it persists, the upstream response schema may have changed. diff --git a/docs/mimo.md b/docs/mimo.md new file mode 100644 index 0000000000..ff8a4f95c9 --- /dev/null +++ b/docs/mimo.md @@ -0,0 +1,99 @@ +--- +summary: "Xiaomi MiMo provider notes: cookie auth, balance endpoint, and setup." +read_when: + - Adding or modifying the Xiaomi MiMo provider + - Debugging MiMo cookie import or balance fetching + - Explaining MiMo setup and limitations to users +--- + +# Xiaomi MiMo Provider + +The Xiaomi MiMo provider tracks your current balance from the Xiaomi MiMo console. + +## Features + +- **Balance display**: Shows total balance plus paid and granted components when MiMo returns them. +- **Token plan usage**: Shows current token-plan credits while retaining balance as a second metric. +- **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. +- **Near-real-time updates**: Balance usually reflects within a few minutes. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Xiaomi MiMo** +3. Leave **Cookie source** on **Auto** (recommended) + +CodexBar imports cookies from these browsers in order: **Safari**, **Chrome** / **Chrome Beta** / **Chrome Canary**, **Firefox**, and **Microsoft Edge**. Switch to **Manual** and paste a `Cookie:` header if your active MiMo session lives in Arc, Brave, or another browser profile CodexBar does not auto-detect. + +Safari cookie import may require granting CodexBar Full Disk Access in **System Settings → Privacy & Security**. + +### Manual cookie import (optional) + +1. Open `https://platform.xiaomimimo.com/#/console/balance` +2. Copy a `Cookie:` header from your browser’s Network tab +3. Paste it into **Xiaomi MiMo → Cookie source → Manual** + +## How it works + +- Fetches balance and token-plan detail/usage endpoints under `https://platform.xiaomimimo.com/api/v1` +- Requires the `api-platform_serviceToken` and `userId` cookies +- Accepts optional MiMo cookies like `api-platform_ph` and `api-platform_slh` when present +- Supports `MIMO_API_URL` to override the base API URL for testing. Override values must be explicit HTTPS URLs or + bare hosts/paths that CodexBar normalizes to HTTPS. Explicit `http://` values fail closed before MiMo cookies are + attached to a request, and invalid endpoint overrides do not fall back to local MiMo usage accounting. + +## Limitations + +- Token cost, status polling, and debug log output are not supported yet +- Widgets do not support Xiaomi MiMo yet +- Auto import covers Safari, Chrome variants, Firefox, and Edge only; other browsers use **Manual** mode + +## Troubleshooting + +### “No Xiaomi MiMo browser session found” + +Log in at `https://platform.xiaomimimo.com/#/console/balance` in Safari, Chrome, Firefox, or Edge, then refresh CodexBar. If your session lives in another browser, switch the MiMo provider to **Cookie source → Manual** and paste the `Cookie:` header instead. + +### “Xiaomi MiMo requires the api-platform_serviceToken and userId cookies” + +The pasted header or imported browser session is missing required cookies. Re-copy the request from the balance page after logging in again. + +### “Xiaomi MiMo browser session expired” + +Your MiMo login is stale. Sign out and back in on the MiMo site, then refresh CodexBar. + +## Local fallback (opt-in) + +When the platform.xiaomimimo.com cookie path is unavailable — Chrome session cookies expire on Chrome relaunch, Chrome Safe Storage keychain access blocked, no SSO login from this machine, etc. — and you drive MiMo inference through a local wrapper such as `cc-mimo` (Claude Code CLI with `ANTHROPIC_BASE_URL=https://token-plan-sgp.xiaomimimo.com/anthropic`), CodexBar can surface **local token accounting** from that wrapper’s session jsonl as graceful degradation — the MiMo card shows lifetime/weekly token sums instead of `login required`. + +This fallback is **implicit opt-in**: it only activates when `~/.codexbar/mimo-local-usage.json` exists. Users who do not run a local wrapper see no change. + +### Setup (optional) + +1. Drop `Scripts/mimo-usage.py` (shipped with this repo) into your `PATH`: + + ```bash + ln -sf "$(pwd)/Scripts/mimo-usage.py" ~/.local/bin/mimo-usage + chmod +x ~/.local/bin/mimo-usage + ``` + +2. Run `mimo-usage --update` once to populate `~/.codexbar/mimo-local-usage.json`. The tracker scans `~/.claude-envs/mimo/.claude/projects/**/*.jsonl` (default path for a `cc-mimo`-style wrapper) and aggregates input, output, cache-read, and cache-creation tokens per time window (today / this week / all time). + +3. Trigger updates either on each wrapper invocation (recommended — call `mimo-usage --update` post-exec from your MiMo CLI launcher) or via a `launchd` / `cron` job every 5 minutes. + +4. CodexBar picks up the file on its next refresh. The MiMo card displays `Xiaomi MiMo (local)` with a `Local · · · · ` summary and the cache's actual update time. Local activity is not rendered as a quota percentage. The `Balance updates / Daily billing finalizes` footer is suppressed for `local` source since neither applies. + +### Wrapper integration example + +```bash +"$CLAUDE_CLI" "$@" +_exit=$? +mimo-usage --update 2>/dev/null || true +exit $_exit +``` + +### Limitations + +- **Local accounting only** — this is not real platform quota. The Xiaomi platform may rate-limit your account before your local counter reflects it. +- Override the session root with `MIMO_CLAUDE_HOME` and the cache path with `MIMO_LOCAL_USAGE_PATH` when a wrapper uses non-default locations. +- Cache schema (`~/.codexbar/mimo-local-usage.json`) is internal; do not rely on the JSON shape for external tooling. diff --git a/docs/minimax.md b/docs/minimax.md index 6b7b7d647a..82ae1d1108 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -1,5 +1,5 @@ --- -summary: "MiniMax provider data sources: API token or browser cookies + coding plan remains API." +summary: "MiniMax provider data sources: Coding Plan tokens, browser cookies, and web-session parsing." read_when: - Debugging MiniMax usage parsing - Updating MiniMax cookie handling or coding plan scraping @@ -8,53 +8,39 @@ read_when: # MiniMax provider -MiniMax supports API tokens or web sessions. Usage is fetched from the Coding Plan remains API using -either a Bearer API token or a session cookie header. +MiniMax supports Coding Plan API tokens or web sessions. Web-session mode uses MiniMax browser/session state and +falls back across the provider's supported web requests when needed. -## Data sources + fallback order +## Data sources -1) **API token** (preferred) - - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`) or `MINIMAX_API_KEY`. - - When present, MiniMax uses the API token and ignores cookies entirely. +1) **Coding Plan API token** + - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`), `MINIMAX_CODING_API_KEY`, + or `MINIMAX_API_KEY`. + - When both environment variables are present, `MINIMAX_CODING_API_KEY` wins so a standard `sk-api-*` key does + not mask a coding-plan `sk-cp-*` key. + - Auto mode can fall back to the web/cookie path when API-token credentials are rejected or the global endpoint + returns 404. -2) **Cached cookie header** (automatic, only when no API token) - - Keychain cache: `com.steipete.codexbar.cache` (account `cookie.minimax`). +2) **Cached/imported browser session** (automatic web path) + - Uses CodexBar's standard cookie cache and browser import flow. 3) **Browser cookie import** (automatic) - - Cookie order from provider metadata (default: Safari → Chrome → Firefox). - - Merges Chromium profile cookies across the primary + Network stores before attempting a request. - - Tries each browser source until the Coding Plan API accepts the cookies. - - Domain filters: `platform.minimax.io`, `minimax.io`. - -4) **Browser local storage access token** (Chromium-based) - - Reads `access_token` (and related tokens) from Chromium local storage (LevelDB) to authorize the remains API. - - If decoding fails, falls back to a text-entry scan for `minimax.io` keys/values and filters for MiniMax JWT claims. - - Used automatically; no UI field. - - Also extracts `GroupId` when present (appends query param). - -5) **Manual session cookie header** (optional override) + - Uses provider metadata for browser order and MiniMax domain filters. + - Chromium browser storage can supplement imported cookies with access-token context when available. + +4) **Manual session cookie header** (optional web-path override) - Stored in `~/.codexbar/config.json` via Preferences → Providers → MiniMax (Cookie source → Manual). - Accepts a raw `Cookie:` header or a full "Copy as cURL" string. - - When a cURL string is pasted, MiniMax extracts the cookie header plus `Authorization: Bearer …` and - `GroupId=…` for the remains API. - - CLI/runtime env: `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`. - -## Endpoints -- API token endpoint: `https://api.minimax.io/v1/coding_plan/remains` - - Requires `Authorization: Bearer `. -- Global host (cookies): `https://platform.minimax.io` -- China mainland host: `https://platform.minimaxi.com` -- `GET {host}/user-center/payment/coding-plan` - - HTML parse for "Available usage" and plan name. -- `GET {host}/v1/api/openplatform/coding_plan/remains` - - Fallback when HTML parsing fails. - - Sent with a `Referer` to the Coding Plan page. - - Adds `Authorization: Bearer ` when available. - - Adds `GroupId` query param when known. + - Low-level no-settings runtime can read `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`. + +## Requests +- Web sessions use the global host or China mainland host. - Region picker in Providers settings toggles the host; environment overrides: - `MINIMAX_HOST=platform.minimaxi.com` - `MINIMAX_CODING_PLAN_URL=...` (full URL override) - `MINIMAX_REMAINS_URL=...` (full URL override) +- Security policy: endpoint overrides are only accepted when they use `https://`, omit userinfo, and do not contain encoded host delimiters. Custom HTTPS proxy/test domains continue to work for compatibility, but `http://` endpoints are rejected so cookies and authorization headers are not sent in cleartext. +- Strict provider-host mode: set `MINIMAX_REQUIRE_PROVIDER_ENDPOINT_OVERRIDES=true` to additionally reject custom proxy/test domains and only accept MiniMax-owned hosts under `minimax.io` or `minimaxi.com`. ## Cookie capture (optional override) - Open the Coding Plan page and DevTools → Network. @@ -62,25 +48,48 @@ either a Bearer API token or a session cookie header. - Copy the `Cookie` request header (or use “Copy as cURL” and paste the whole line). - Paste into Preferences → Providers → MiniMax only if automatic import fails. -## Notes -- Cookies alone often return status 1004 (“cookie is missing, log in again”); the remains API expects a Bearer token. -- MiniMax stores `access_token` in Chromium local storage (LevelDB). Some entries serialize the storage key without a scheme - (ex: `minimax.io`), so origin matching must account for host-only keys. -- Raw JWT scan fallback remains as a safety net if Chromium key formats change. -- If local storage keys don’t decode (some Chrome builds), the MiniMax-specific text scan avoids a full raw-byte scan. - -## Cookie file paths -- Safari: `~/Library/Cookies/Cookies.binarycookies` -- Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies` -- Firefox: `~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite` - ## Snapshot mapping -- Primary: percent used from `model_remains` (used/total) or HTML "Available usage". -- Window: derived from `start_time`/`end_time` or HTML duration text. -- Reset: derived from `remains_time` (fallback to `end_time`) or HTML "Resets in …". -- Plan/tier: best-effort from response fields or HTML title. +- Primary usage, reset timing, and plan/tier are derived from Coding Plan response fields or page text. +- Web-session billing history, when available, is mapped into the shared inline usage dashboard: + - 30-day token trend. + - Top model and top method breakdowns. + - Summary rows for recent billing-history totals. + +If the billing-history endpoint is unavailable but normal Coding Plan quota data is present, CodexBar still shows the +quota card and omits the chart instead of treating the whole provider as failed. ## Key files - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift` - `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift` + +## CLI diagnose command + +The generic `diagnose` command performs a real provider diagnostic invocation and emits a safe, redacted JSON export +for issue reporting and verification. MiniMax adds a provider-specific `details` block with safe usage metadata. + +### Usage +``` +codexbar diagnose --provider minimax --format json --pretty +``` + +### Output +- Structural diagnostic JSON with provider, source/source mode, auth summary, usage summary, fetch attempts, and error categories. +- Per-service quota percentages, used values, limits, remaining values, reset metadata, and unlimited state. These are + the same non-secret values shown in the menu and help diagnose boosted quota denominators. +- All sensitive fields (API tokens, cookies, emails, auth headers) are redacted via `LogRedactor`. +- Errors are mapped to safe categories (`network`, `auth`, `api`, `parse`) with user-friendly descriptions. +- No raw API responses, raw error messages, tokens, cookies, emails, account IDs, org IDs, or billing history. + +### What is excluded from output +- Raw API tokens (`sk-cp-*`, `sk-api-*`) and authorization headers +- Cookie header values +- Email addresses +- Account IDs, org IDs +- Raw error messages (replaced with safe category-based descriptions) +- Raw HTTP responses or request bodies +- Billing history details + +### Exit codes +- `0`: Diagnostic completed successfully (even if provider auth is not configured) +- `1`: Unknown error or invalid arguments diff --git a/docs/model-pricing.md b/docs/model-pricing.md new file mode 100644 index 0000000000..1b4b2f01a2 --- /dev/null +++ b/docs/model-pricing.md @@ -0,0 +1,42 @@ +--- +summary: "models.dev pricing metadata pipeline, cache, lookup rules, and token-cost units." +read_when: + - Updating models.dev pricing metadata support + - Debugging model-pricing cache refresh or lookup behavior + - Routing provider cost calculations through shared pricing metadata +--- + +# Model pricing metadata + +CodexBar has an additive models.dev pricing pipeline for future cost lookup work. Existing hardcoded pricing remains unchanged for now. + +## Source and cache + +- Source API: `https://models.dev/api.json` +- No API key is required. +- Local cache: `~/Library/Caches/CodexBar/model-pricing/models-dev-v1.json` +- TTL: 24 hours + +The pipeline lets future scanner code read the last valid cache synchronously with `ModelsDevPricingPipeline.lookup` and refresh stale metadata separately with `ModelsDevPricingPipeline.refreshIfNeeded`. If a refresh fails, the last valid cache remains usable. + +## Lookup rules + +Pricing is scoped by provider id and model id. This prevents two providers with the same model id or display name from sharing pricing accidentally. + +Planned local source mapping: + +- Codex/OpenAI logs: models.dev provider id `openai` +- Claude logs: models.dev provider id `anthropic` +- Vertex AI Claude logs: models.dev provider id `google-vertex-anthropic` + +The first integration PR only adds the parser, client, cache, provider-scoped lookup, and tests. It does not route live cost calculations through models.dev yet. + +## Units + +models.dev publishes costs as USD per 1M tokens. CodexBar converts those to USD per token in the metadata layer: + +```text +perToken = modelsDevCost / 1_000_000 +``` + +When models.dev includes `cost.context_over_200k`, CodexBar parses those values as the above-200k-token pricing lane and converts them with the same per-1M-token rule. diff --git a/docs/moonshot.md b/docs/moonshot.md new file mode 100644 index 0000000000..b57ce8a933 --- /dev/null +++ b/docs/moonshot.md @@ -0,0 +1,50 @@ +--- +summary: "Moonshot / Kimi API provider data sources: API key + balance endpoint." +read_when: + - Adding or tweaking Moonshot balance parsing + - Updating Moonshot / Kimi API key handling + - Documenting Moonshot / Kimi API provider behavior +--- + +# Moonshot / Kimi API provider + +Moonshot / Kimi API is API-only. Balance is reported by `GET /v1/users/me/balance`, +so CodexBar only needs a valid API key to show the current account balance. + +## Rationale + +Kimi API docs use the Moonshot API surface for current Kimi models: examples read +`MOONSHOT_API_KEY` and call `https://api.moonshot.ai/v1`, including the Kimi K2.6 +quickstart. This provider is therefore named after the account and billing surface, +not a specific Kimi model version. + +The existing `Kimi K2` provider remains separate because it targets the legacy +`kimi-k2.ai` credit endpoint. Migrating or deprecating that provider should be a +separate cleanup so existing user settings are not silently repointed. + +## Data sources + +1. **API key** stored in `~/.codexbar/config.json` or supplied via `MOONSHOT_API_KEY` / `MOONSHOT_KEY`. + CodexBar stores the key in config after you paste it in Settings → Providers → Moonshot / Kimi API. +2. **Region** + - International: `https://api.moonshot.ai/v1/users/me/balance` + - China mainland: `https://api.moonshot.cn/v1/users/me/balance` + - Configure with Settings → Providers → Moonshot → API region or `MOONSHOT_REGION`. +3. **Balance endpoint** + - Request headers: `Authorization: Bearer `, `Accept: application/json` + - Response contains `available_balance`, `voucher_balance`, and `cash_balance`. + +## Usage details + +- The menu card shows the available balance. +- If `cash_balance` is negative, the card also surfaces the deficit. +- There is no session or weekly window — Moonshot / Kimi API does not expose per-window quota via API. +- Settings config takes precedence over environment variables when both are present. + +## Key files + +- `Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift` (descriptor + fetch strategy) +- `Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift` (HTTP client + JSON parser) +- `Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift` (env var resolution) +- `Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift` (settings field + activation logic) +- `Sources/CodexBar/Providers/Moonshot/MoonshotSettingsStore.swift` (SettingsStore extension) diff --git a/docs/ollama.md b/docs/ollama.md index 5be1127468..aec630650b 100644 --- a/docs/ollama.md +++ b/docs/ollama.md @@ -1,5 +1,5 @@ --- -summary: "Ollama provider notes: settings scrape, cookie auth, and Cloud Usage parsing." +summary: "Ollama provider notes: API key auth, settings scrape, cookie auth, and Cloud Usage parsing." read_when: - Adding or modifying the Ollama provider - Debugging Ollama cookie import or settings parsing @@ -8,20 +8,24 @@ read_when: # Ollama Provider -The Ollama provider scrapes the **Plan & Billing** page to extract Cloud Usage limits for session and weekly windows. +The Ollama provider can verify Ollama Cloud API-key access and scrape the **Plan & Billing** page to extract Cloud +Usage limits for session and weekly windows. ## Features - **Plan badge**: Reads the plan tier (Free/Pro/Max) from the Cloud Usage header. - **Session + weekly usage**: Parses the percent-used values shown in the usage bars. - **Reset timestamps**: Uses the `data-time` attribute on the “Resets in …” elements. -- **Browser cookie auth**: No API keys required. +- **API key auth**: Verifies direct `https://ollama.com/api` access with `OLLAMA_API_KEY` or a configured key. +- **Browser cookie auth**: Required for Cloud Usage quota windows because Ollama does not expose those limits through + the documented API. ## Setup 1. Open **Settings → Providers**. 2. Enable **Ollama**. -3. Leave **Cookie source** on **Auto** (recommended, imports Chrome cookies by default). +3. For API-key mode, paste an API key from `https://ollama.com/settings/keys` or set `OLLAMA_API_KEY`. +4. For quota bars, leave **Cookie source** on **Auto** (recommended, imports Chrome cookies by default). ### Manual cookie import (optional) @@ -31,7 +35,8 @@ The Ollama provider scrapes the **Plan & Billing** page to extract Cloud Usage l ## How it works -- Fetches `https://ollama.com/settings` using browser cookies. +- API-key mode fetches `https://ollama.com/api/tags` with `Authorization: Bearer ` to verify Cloud API access. +- Cookie mode fetches `https://ollama.com/settings` using browser cookies. - Parses: - Plan badge under **Cloud Usage**. - **Session usage** and **Weekly usage** percentages. diff --git a/docs/openai.md b/docs/openai.md new file mode 100644 index 0000000000..6db22965db --- /dev/null +++ b/docs/openai.md @@ -0,0 +1,61 @@ +--- +summary: "OpenAI API provider: Admin API key usage/cost graphs and legacy balance fallback." +read_when: + - Updating OpenAI API Platform usage or cost display + - Debugging OPENAI_ADMIN_KEY or OPENAI_API_KEY behavior +--- + +# OpenAI API provider + +CodexBar's OpenAI API provider targets the API Platform organization dashboard, not ChatGPT/Codex subscription limits. + +## Data sources + +1. Preferred: `OPENAI_ADMIN_KEY` or configured key with Admin API access. + - `GET https://api.openai.com/v1/organization/costs` + - `GET https://api.openai.com/v1/organization/usage/completions` + - Daily buckets use `bucket_width=1d`, costs are grouped by `line_item`, and completion usage is grouped by `model`. + - Optional project scoping comes from `OPENAI_PROJECT_ID` or `providers[].workspaceID` for `openai`. + Project-scoped requests add `project_ids=` to both Admin API endpoints. +2. Fallback: legacy `GET https://api.openai.com/v1/dashboard/billing/credit_grants` for normal API keys that cannot access organization usage. + +## Setup + +Store a key in the shared app/CLI config: + +```bash +printf '%s' "$OPENAI_ADMIN_KEY" | codexbar config set-api-key --provider openai --stdin +``` + +Settings → Providers → OpenAI writes the same `~/.codexbar/config.json` field. `OPENAI_ADMIN_KEY` is preferred over +`OPENAI_API_KEY` because it unlocks organization costs and usage; a normal API key only supports the legacy balance +fallback. + +To scope Admin API usage to a project, set the OpenAI Project ID field in Settings or add `workspaceID` to the `openai` +provider config: + +```json +{ + "id": "openai", + "apiKey": "", + "workspaceID": "proj_..." +} +``` + +Project scoping is tied to the configured Admin API key. Selected OpenAI token accounts intentionally scrub +`OPENAI_PROJECT_ID`/`workspaceID` so one account cannot inherit another account's project filter. Project-scoped Admin +API failures do not fall back to the legacy billing endpoint, because that endpoint is not project-filtered. + +## Menu display + +- Admin API data renders inline Today/7d/configured-window KPIs plus a compact spend chart. +- The inline usage card opens a hosted chart submenu with daily spend, token, and request trends plus selected-day detail. +- Top model and top spend labels come from the configured completion/cost buckets when the Admin API returns them. +- Legacy balance data keeps the older available/used credit summary and does not show organization graphs. +- Project-scoped Admin API data labels the account as `Admin API: ` and the organization line as + `Project: `. + +## Notes + +- Costs are the source of truth for financial totals. Token usage and cost buckets can differ slightly from dashboard billing reconciliation. +- Admin API keys are organization-scoped and cannot be used for normal model inference. diff --git a/docs/opencode.md b/docs/opencode.md index f4927c5f1a..a40f3cfe3f 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -1,5 +1,5 @@ --- -summary: "OpenCode provider notes: browser cookie import, _server endpoints, and usage parsing." +summary: "OpenCode provider notes: browser cookies, local SQLite usage, and parsing." read_when: - Adding or modifying the OpenCode provider - Debugging OpenCode usage parsing or cookie import @@ -9,6 +9,7 @@ read_when: ## Data sources - Browser cookies from `opencode.ai`. +- OpenCode Go local history from `~/.local/share/opencode/opencode.db` on macOS and Linux. - `POST https://opencode.ai/_server` with server function IDs: - `workspaces` (`def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f`) - `subscription.get` (`7abeebee372f304e050aaaf92be863f4a86490e382f8c79db68fd94040d691b4`) @@ -26,3 +27,4 @@ read_when: - Workspace override accepts a raw `wrk_…` ID or a full `https://opencode.ai/workspace/...` URL. - Cached cookies: Keychain cache `com.steipete.codexbar.cache` (account `cookie.opencode`, source + timestamp). Browser import only runs when the cached cookie fails. +- OpenCode Go auto mode tries web usage first, then derives quota windows from local `opencode-go` assistant costs. diff --git a/docs/openrouter.md b/docs/openrouter.md index a0d7985e3a..38a023c18e 100644 --- a/docs/openrouter.md +++ b/docs/openrouter.md @@ -1,3 +1,11 @@ +--- +summary: "OpenRouter provider: API key credits, rate limits, and daily/weekly/monthly spend." +read_when: + - Debugging OpenRouter API key usage or spend parsing + - Updating OpenRouter credits or key-limit display + - Explaining OpenRouter setup and environment variables +--- + # OpenRouter Provider [OpenRouter](https://openrouter.ai) is a unified API that provides access to multiple AI models from different providers (OpenAI, Anthropic, Google, Meta, and more) through a single endpoint. @@ -18,19 +26,27 @@ export OPENROUTER_API_KEY="sk-or-v1-..." You can also configure the API key in CodexBar Settings → Providers → OpenRouter. +### CLI config + +```bash +printf '%s' "$OPENROUTER_API_KEY" | codexbar config set-api-key --provider openrouter --stdin +``` + ## Data Source The OpenRouter provider fetches usage data from two API endpoints: 1. **Credits API** (`/api/v1/credits`): Returns total credits purchased and total usage. The balance is calculated as `total_credits - total_usage`. -2. **Key API** (`/api/v1/key`): Returns rate limit information for your API key. +2. **Key API** (`/api/v1/key`): Returns rate limit information plus current daily, weekly, and monthly spend for your API key. ## Display The OpenRouter menu card shows: -- **Primary meter**: Credit usage percentage (how much of your purchased credits have been used) +- **Primary meter**: API key limit usage when the key has a configured limit +- **Spend notes**: Daily, weekly, and monthly API key spend when OpenRouter returns those fields +- **Spend chart**: Day/week/month spend can reuse the shared inline dashboard when enough history is available - **Balance**: Displayed in the identity section as "Balance: $X.XX" ## CLI Usage diff --git a/docs/packaging.md b/docs/packaging.md index ec058fcda5..16e998152b 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -11,11 +11,11 @@ read_when: - `Scripts/package_app.sh`: builds host arch by default; set `ARCHES="arm64 x86_64"` for universal. Verifies slices. - `Scripts/compile_and_run.sh`: uses host arch; pass `--release-universal` or `--release-arches="arm64 x86_64"` for release packaging. - `Scripts/sign-and-notarize.sh`: signs, notarizes, staples, zips (accepts `ARCHES` for universal). -- `Scripts/make_appcast.sh`: generates Sparkle appcast and embeds HTML release notes. +- `Scripts/make_appcast.sh`: wrapper around the shared `mac-release make-appcast` helper; app metadata comes from `.mac-release.env`. - `Scripts/changelog-to-html.sh`: converts the per-version changelog section to HTML for Sparkle. ## Bundle contents -- `CodexBarWidget.appex` bundled with app-group entitlements. +- `CodexBarWidget.appex` is built by `WidgetExtension/CodexBarWidgetExtension.xcodeproj` as a real macOS app extension, then bundled with app-group entitlements. - `CodexBarCLI` copied to `CodexBar.app/Contents/Helpers/` for symlinking. - SwiftPM resource bundles (e.g. `KeyboardShortcuts_KeyboardShortcuts.bundle`) copied into `Contents/Resources` (required for `KeyboardShortcuts.Recorder`). diff --git a/docs/perf-energy-issue-139-main-fix-validation-2026-02-19.md b/docs/perf-energy-issue-139-main-fix-validation-2026-02-19.md index 682e3fb7a9..1be0844d9c 100644 --- a/docs/perf-energy-issue-139-main-fix-validation-2026-02-19.md +++ b/docs/perf-energy-issue-139-main-fix-validation-2026-02-19.md @@ -1,3 +1,11 @@ +--- +summary: "Post-fix validation for issue #139 Codex CLI timeout and energy improvements." +read_when: + - Validating Codex CLI timeout or retry changes + - Reviewing post-fix evidence for issue 139 + - Investigating CodexBar CPU exposure under failed CLI probes +--- + # CodexBar Issue #139 Main Fix Validation (Post-Fix vs Pre-Fix) Date: 2026-02-19 diff --git a/docs/perf-energy-issue-139-simulation-report-2026-02-19.md b/docs/perf-energy-issue-139-simulation-report-2026-02-19.md index 3ad413bf1f..0b04a57d88 100644 --- a/docs/perf-energy-issue-139-simulation-report-2026-02-19.md +++ b/docs/perf-energy-issue-139-simulation-report-2026-02-19.md @@ -1,3 +1,11 @@ +--- +summary: "Issue #139 performance and energy simulation report for Codex CLI subprocess failure paths." +read_when: + - Investigating CodexBar CPU or energy regressions + - Changing Codex CLI PTY probe timing or retry behavior + - Reviewing issue 139 performance evidence +--- + # CodexBar Issue #139 Performance/Energy Simulation Report Date: 2026-02-19 diff --git a/docs/poe.md b/docs/poe.md new file mode 100644 index 0000000000..097cda9fbe --- /dev/null +++ b/docs/poe.md @@ -0,0 +1,45 @@ +--- +summary: "Poe provider: API key setup, point balance, and recent usage history." +read_when: + - Configuring Poe usage + - Debugging Poe balance or history requests +--- + +# Poe Provider + +CodexBar reads Poe's official usage API with a manually configured API key. It does not perform OAuth login or import browser cookies. + +## Authentication + +Create or copy an API key from [Poe API Keys](https://poe.com/api/keys), then add it in CodexBar Settings → Providers → Poe. + +You can also set the environment variable: + +```bash +export POE_API_KEY="..." +``` + +Or configure it through the CLI: + +```bash +printf '%s' "$POE_API_KEY" | codexbar config set-api-key --provider poe --stdin +``` + +## Data Source + +CodexBar requests: + +- `GET https://api.poe.com/usage/current_balance` +- `GET https://api.poe.com/usage/points_history` + +The current balance request is required. Recent points history is best-effort, so a history error does not hide a valid balance. + +## Display + +The provider shows the current point balance in the menu and menu bar. When available, recent history is grouped by day and shown in the usage detail. + +## CLI Usage + +```bash +codexbar --provider poe +``` diff --git a/docs/provider.md b/docs/provider.md index d259274100..9b191e353a 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -14,7 +14,7 @@ Goal: adding a provider should feel like: - add one implementation (UI hooks only) - done (tests + docs) -This doc describes the **current provider architecture** (post-macro registry) and the exact steps to add a new provider. +This doc describes the **current provider architecture** and the exact steps to add a new provider. ## Terms - **Provider**: a source of usage/quota/status data (Codex, Claude, Gemini, Antigravity, Cursor, …). @@ -39,7 +39,8 @@ Common building blocks already exist: - OpenAI dashboard web scrape: `OpenAIDashboardFetcher` (WKWebView + JS) - cost usage: local log scanner (Codex + Claude) -The old “switch provider” wiring is gone. Everything should be driven by the descriptor and its strategies. +Provider behavior is descriptor-driven. Two explicit, exhaustive registries form the bootstrap boundary: +`ProviderDescriptorRegistry` owns core descriptors and `ProviderImplementationRegistry` owns app implementations. ## Provider descriptor (source of truth) @@ -93,12 +94,11 @@ Rule: providers do not talk to `FileManager`, `Security`, or “browser internal ## Minimal provider example (copy-paste) ```swift -import CodexBarMacroSupport import Foundation -@ProviderDescriptorRegistration -@ProviderDescriptorDefinition public enum ExampleProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { ProviderDescriptor( id: .example, @@ -169,12 +169,11 @@ Checklist: - `Probe.swift` / `Fetcher.swift`: concrete fetcher logic. - `Models.swift`: snapshot structs. - `Parser.swift` (if needed). -- Attach `@ProviderDescriptorRegistration` + `@ProviderDescriptorDefinition` to the descriptor type. - Implement `static func makeDescriptor() -> ProviderDescriptor`. -- Attach `@ProviderImplementationRegistration` to the implementation type (macros auto-register). - - No manual list edits. +- Define a cached `public static let descriptor` and `static func makeDescriptor() -> ProviderDescriptor`. +- Add the descriptor to `ProviderDescriptorRegistry.descriptorsByID`. - Add `Sources/CodexBar/Providers//ProviderImplementation.swift`: - `ProviderImplementation` only for settings/login UI hooks. +- Add an exhaustive case to `ProviderImplementationRegistry.makeImplementation(for:)`. - Add icons + color in descriptor: - `iconName` must match `ProviderIcon-` asset. - Color used in menu cards + switcher. diff --git a/docs/providers.md b/docs/providers.md index 99da2542de..5e0c85b773 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)." +summary: "Provider data sources and parsing overview for every registered CodexBar provider." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -8,68 +8,135 @@ read_when: # Providers +CodexBar currently registers 53 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +OpenCode vs OpenCode Go, because the auth source and quota shape differ. + ## Fetch strategies (current) -Legend: web (browser cookies/WebView), cli (RPC/PTy), oauth (API), api token, local probe, web dashboard. -Source labels (CLI/header): `openai-web`, `web`, `oauth`, `api`, `local`, plus provider-specific CLI labels (e.g. `codex-cli`, `claude`). +Legend: web (browser cookies/WebView), cli (RPC/PTy or provider CLI), oauth (provider OAuth), api token, local probe, web dashboard. +Source labels (CLI/header): `openai-web`, `web`, `oauth`, `api`, `local`, `cli`, plus provider-specific CLI labels (e.g. `codex-cli`, `claude`). Cookie-based providers expose a Cookie source picker (Automatic or Manual) in Settings → Providers. -Browser cookie imports are cached in Keychain (`com.steipete.codexbar.cache`, account `cookie.`) and reused -until the session is invalid, to avoid repeated Keychain prompts. +Some browser cookie imports are cached in Keychain and reused until the session is invalid. API keys, manual cookie +headers, source selection, provider ordering, and token accounts are stored in `~/.codexbar/config.json`. | Provider | Strategies (ordered for auto) | | --- | --- | -| Codex | Web dashboard (`openai-web`) → CLI RPC/PTy (`codex-cli`); app uses CLI usage + optional dashboard scrape. | -| Claude | App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). CLI Auto: Web API (`web`) → CLI PTY (`claude`). | -| Gemini | OAuth API via Gemini CLI credentials (`api`). | +| Codex | App Auto: OAuth API (`oauth`) → CLI RPC/PTy (`codex-cli`). CLI Auto: Web dashboard (`openai-web`) → CLI RPC/PTy (`codex-cli`). | +| OpenAI | Admin API key (`api`) for organization spend/usage; legacy API-key balance fallback. | +| Azure OpenAI | API key + endpoint + deployment probe (`api`) for deployment status validation. | +| Claude | Admin API key (`api`) when configured; otherwise App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). CLI Auto: Web API (`web`) → CLI PTY (`claude`). | +| Gemini | OAuth-backed API via Gemini CLI credentials (`api`). | | Antigravity | Local LSP/HTTP probe (`local`). | | Cursor | Web API via cookies → stored WebKit session (`web`). | | OpenCode | Web dashboard via cookies (`web`). | +| OpenCode Go | Web dashboard via cookies (`web`) -> local SQLite usage (`local`) in auto mode; optional workspace ID. | +| Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). | +| Alibaba Token Plan | Bailian subscription summary API via browser or manual cookies (`web`). | | Droid/Factory | Web cookies → stored tokens → local storage → WorkOS cookies (`web`). | -| z.ai | API token (Keychain/env) → quota API (`api`). | -| MiniMax | Manual cookie header (Keychain/env) → browser cookies (+ local storage access token) → coding plan page (HTML) with remains API fallback (`web`). | -| Kimi | API token (JWT from `kimi-auth` cookie) → usage API (`api`). | -| Kilo | API token (`KILO_API_KEY`) → usage API (`api`); auto falls back to CLI session auth (`cli`). | -| Copilot | API token (device flow/env) → copilot_internal API (`api`). | -| Kimi K2 | API key (Keychain/env) → credit endpoint (`api`). | +| Devin | Chrome localStorage session or manual Bearer token → daily and weekly quota API (`web`). | +| z.ai | API token from config/env → quota API (`api`). | +| Manus | Browser `session_id` cookie (auto/manual/env) → credits API (`web`). | +| MiniMax | Manual/browser session via Coding Plan web path (`web`), or Coding Plan API token (`api`). | +| Kimi | Kimi Code API key (`api`), then `kimi-auth` cookie/manual token/env fallback (`web`). | +| Kilo | API token from config/env → usage API (`api`); auto falls back to CLI session auth (`cli`). | +| Copilot | Device-flow/env/config token → `copilot_internal` API (`api`). | +| Kimi K2 (unofficial) | API key from config/env → legacy credit endpoint (`api`). | | Kiro | CLI command via `kiro-cli chat --no-interactive "/usage"` (`cli`). | | Vertex AI | Google ADC OAuth (gcloud) → Cloud Monitoring quota usage (`oauth`). | +| Augment | `auggie` CLI first, then browser-cookie web fallback (`cli`, `web`). | | JetBrains AI | Local XML quota file (`local`). | -| Amp | Web settings page via browser cookies (`web`). | +| Amp | Local `amp usage` CLI, access-token API, then browser-cookie legacy fallback (`cli`, `api`, `web`). | +| T3 Chat | Web tRPC customer-data endpoint via browser cookies (`web`). | | Warp | API token (config/env) → GraphQL request limits (`api`). | -| Ollama | Web settings page via browser cookies (`web`). | +| ElevenLabs | API key from config/env → subscription usage API (`api`). | +| Windsurf | Web session bundle from browser localStorage (`web`) → local SQLite cache (`local`). | +| Ollama | API key verifies Cloud API access (`api`); browser cookies expose Cloud quota windows (`web`). | +| Synthetic | API key from config/env → quota API (`api`). | | OpenRouter | API token (config, overrides env) → credits API (`api`). | +| Perplexity | Browser cookies/manual cookie/env session token → credits API (`web`). | +| Xiaomi MiMo | Browser cookies → balance/token plan endpoints (`web`). | +| Doubao | API key from config/env → Volcengine Ark chat-completions probe (`api`). | +| Abacus AI | Browser cookies → compute points + billing API (`web`). | +| Mistral | Console billing and Vibe subscription usage via browser cookies (`web`). | +| DeepSeek | API key from env or token accounts → balance endpoint (`api`). | +| Moonshot | API key from config/env → balance endpoint (`api`). | +| Codebuff | API token from config/env or `codebuff login` credentials → usage API (`api`). | +| Crof | API key from config/env → credit balance + requests quota API (`api`). | +| Venice | API key from config/env → DIEM/USD balance API (`api`). | +| Command Code | Web billing API via Command Code session cookies (`web`). | +| StepFun | Username/password login or manual Oasis token (`web`). | +| AWS Bedrock | AWS credentials → Cost Explorer spend/budgets and optional CloudWatch Claude activity (`api`). | +| Grok | `grok agent stdio` JSON-RPC `x.ai/billing` (`cli`) → grok.com billing gRPC-web via Chrome session cookies (`web`); local `~/.grok/sessions` signals as fallback. | +| GroqCloud | API key → Prometheus metrics API for request/token/cache-hit rates (`api`). | +| LLM Proxy | API key + base URL → `/v1/quota-stats` aggregate proxy usage (`api`). | +| LiteLLM | API key + base URL → `/key/info`, then `/user/info` or `/team/info` budget usage (`api`). | +| Deepgram | API key → project discovery and usage breakdown API (`api`). | +| Chutes | API key from config/env → subscription usage and quota API (`api`). | +| Zed | Zed editor Keychain session → `cloud.zed.dev/client/users/me` for plan and quota data (`local`). | ## Codex -- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. +- App Auto: OAuth API first; falls back to CLI only when OAuth credentials are missing or auth/refresh is invalid. +- Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. +- Battery saver toggle (currently off by default): reduces routine OpenAI web refreshes but still allows explicit manual refreshes. - CLI RPC default: `codex ... app-server` JSON-RPC (`account/read`, `account/rateLimits/read`). -- CLI PTY fallback: `/status` scrape. -- Local cost usage: scans `~/.codex/sessions/**/*.jsonl` (last 30 days). +- CLI PTY: manual diagnostics/parser coverage only; automatic refresh does not launch bare Codex TUI. +- Local cost usage: scans `CODEX_HOME` (or `~/.codex`) `sessions` and sibling `archived_sessions` JSONL files for the configured history window. - Status: Statuspage.io (OpenAI). - Details: `docs/codex.md`. +## OpenAI +- API key from `~/.codexbar/config.json`, `OPENAI_ADMIN_KEY`, or `OPENAI_API_KEY`. +- Admin API keys are preferred and fetch organization costs plus completion usage for inline Today/7d/configured-window dashboards. +- Normal API keys fall back to the legacy credit-grants balance endpoint when organization usage is unavailable. +- Details: `docs/openai.md`. + +## Azure OpenAI +- API key, endpoint, and deployment from `~/.codexbar/config.json` or `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, and `AZURE_OPENAI_DEPLOYMENT_NAME`. +- `AZURE_OPENAI_ENDPOINT` and configured endpoint overrides must be HTTPS URLs or bare hosts normalized to HTTPS; explicit `http://` URLs, user info, and encoded host-delimiter tricks fail closed before `api-key` headers are attached. +- Validates the configured deployment with a minimal chat-completions request; it does not expose Azure spend or quota history. +- Use `AZURE_OPENAI_API_VERSION` to override the API version. Set it to `v1` for Azure's OpenAI-compatible v1 API path. +- Status: Azure status page link. + ## Claude +- Admin API: `sk-ant-admin...` key in Settings/config, token accounts, or `ANTHROPIC_ADMIN_KEY`. +- Admin API shows organization spend/messages summaries with the same inline dashboard pattern as OpenAI API. - App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). - CLI Auto: Web API (`web`) → CLI PTY (`claude`). -- Local cost usage: scans `~/.config/claude/projects/**/*.jsonl` (last 30 days). +- Local cost usage: scans `CLAUDE_CONFIG_DIR` when set, otherwise `~/.config/claude/projects` and `~/.claude/projects` JSONL files for the configured history window. - Status: Statuspage.io (Anthropic). - Details: `docs/claude.md`. ## z.ai -- API token from Keychain or `Z_AI_API_KEY` env var. -- Quota endpoint: `https://api.z.ai/api/monitor/usage/quota/limit` (global) or `https://open.bigmodel.cn/api/monitor/usage/quota/limit` (BigModel CN); override with `Z_AI_API_HOST` or `Z_AI_QUOTA_URL`. +- API token from `~/.codexbar/config.json` (`providers[].apiKey`) or `Z_AI_API_KEY` env var. +- Supports global and BigModel CN quota hosts; override with `Z_AI_API_HOST` or `Z_AI_QUOTA_URL`. +- z.ai endpoint overrides must be HTTPS or bare hosts normalized to HTTPS. `Z_AI_QUOTA_URL` takes precedence for + quota resolution; combined usage validates both configured endpoints before sending bearer auth. - Status: none yet. - Details: `docs/zai.md`. +## Devin +- Automatic auth reads the current `auth1_session` token and organization metadata from Chrome localStorage. +- Manual auth accepts the `Authorization: Bearer ...` value from an app.devin.ai request. +- Usage endpoint: `GET /api//billing/quota/usage`. +- Shows daily and weekly quota percentages with their reset timestamps. +- Details: `docs/devin.md`. + +## Manus +- Session token via browser `session_id` cookie, manual Settings entry, `MANUS_SESSION_TOKEN`, or `MANUS_COOKIE`. +- Credits endpoint: `POST https://api.manus.im/user.v1.UserService/GetAvailableCredits`. +- Auto mode prefers cached/browser cookies before env fallback; manual mode accepts either a bare `session_id` value or a full Cookie header. +- Status: none yet. + ## MiniMax -- Session cookie header from Keychain or `MINIMAX_COOKIE`/`MINIMAX_COOKIE_HEADER` env var. -- Hosts: `platform.minimax.io` (global) or `platform.minimaxi.com` (China mainland) via region picker or `MINIMAX_HOST`; full overrides via `MINIMAX_CODING_PLAN_URL` / `MINIMAX_REMAINS_URL`. -- `GET {host}/v1/api/openplatform/coding_plan/remains`. +- Coding Plan API token or web session from configured/manual/browser sources. +- Supports global and China mainland hosts via provider region settings and environment overrides. +- Web-session billing history can render 30-day token charts plus top model/method breakdowns when MiniMax exposes it. - Status: none yet. - Details: `docs/minimax.md`. ## Kimi -- Auth token (JWT from `kimi-auth` cookie) via manual entry or `KIMI_AUTH_TOKEN` env var. -- `POST https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages`. +- Kimi Code API key via `~/.codexbar/config.json` or `KIMI_CODE_API_KEY`. +- Web fallback uses the JWT from `kimi-auth` cookie via manual entry or `KIMI_AUTH_TOKEN` env var. - Shows weekly quota and 5-hour rate limit (300 minutes). - Status: none yet. - Details: `docs/kimi.md`. @@ -78,14 +145,13 @@ until the session is invalid, to avoid repeated Keychain prompts. - API token from `~/.codexbar/config.json` (`providers[].apiKey`) or `KILO_API_KEY`. - Auto mode tries API first and falls back to CLI auth when API credentials are missing or unauthorized. - CLI auth source: `~/.local/share/kilo/auth.json` (`kilo.access`), typically created by `kilo login`. -- Usage endpoint: `https://app.kilo.ai/api/trpc`. - Status: none yet. - Details: `docs/kilo.md`. -## Kimi K2 -- API key via Settings (Keychain) or `KIMI_K2_API_KEY`/`KIMI_API_KEY` env var. -- `GET https://kimi-k2.ai/api/user/credits`. -- Shows credit usage based on consumed/remaining totals. +## Kimi K2 (unofficial) +- API key via `~/.codexbar/config.json` or `KIMI_K2_API_KEY`/`KIMI_API_KEY` env var. +- Shows credit usage from the legacy `kimi-k2.ai` consumed/remaining totals. +- Use Moonshot / Kimi API for the official Kimi API account and billing surface. - Status: none yet. - Details: `docs/kimi-k2.md`. @@ -110,10 +176,34 @@ until the session is invalid, to avoid repeated Keychain prompts. ## OpenCode - Web dashboard via browser cookies (`opencode.ai`). -- `POST https://opencode.ai/_server` (workspaces + subscription usage). - Status: none yet. - Details: `docs/opencode.md`. +## OpenCode Go +- Web dashboard via browser or manual cookies (`opencode.ai`). +- Auto mode falls back to local usage from `~/.local/share/opencode/opencode.db` on macOS and Linux. +- Uses the workspace Go page/server data for rolling 5-hour, weekly, and optional monthly usage windows. +- Optional workspace ID comes from `~/.codexbar/config.json` (`providers[].workspaceID`) or `CODEXBAR_OPENCODEGO_WORKSPACE_ID`. +- Status: none yet. +- Details: `docs/opencode.md`. + +## Alibaba Coding Plan +- Web mode uses Alibaba console RPC with form payload + `sec_token`. +- Cookie sources: browser import (`auto`) or manual header (`cookieSource: manual`). +- API key fallback from Settings (`providers[].apiKey`) or `ALIBABA_CODING_PLAN_API_KEY` env var. +- Region hosts: international (`ap-southeast-1`) and China mainland (`cn-beijing`). +- Host overrides: `ALIBABA_CODING_PLAN_HOST` or `ALIBABA_CODING_PLAN_QUOTA_URL`. +- Status: `https://status.aliyun.com` (link only, no auto-polling). +- Details: `docs/alibaba-coding-plan.md`. + +## Alibaba Token Plan +- Web mode posts to the Bailian `GetSubscriptionSummary` endpoint with form-encoded params and optional `sec_token`. +- Cookie sources: browser import (`auto`), manual Cookie header, or `ALIBABA_TOKEN_PLAN_COOKIE`. +- Default quota URL: `https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3`. +- Host overrides: `ALIBABA_TOKEN_PLAN_HOST` or `ALIBABA_TOKEN_PLAN_QUOTA_URL`. +- Status: `https://status.aliyun.com` (link only, no auto-polling). +- Details: `docs/alibaba-token-plan.md`. + ## Droid (Factory) - Web API via Factory cookies, bearer tokens, and WorkOS refresh tokens. - Multiple fallback strategies (cookies → stored tokens → local storage → WorkOS cookies). @@ -122,6 +212,7 @@ until the session is invalid, to avoid repeated Keychain prompts. ## Copilot - GitHub device flow OAuth token + `api.github.com/copilot_internal/user`. +- Supports multiple token accounts and account switching from provider settings/menu surfaces. - Status: Statuspage.io (GitHub). - Details: `docs/copilot.md`. @@ -134,17 +225,25 @@ until the session is invalid, to avoid repeated Keychain prompts. ## Warp - API token from Settings or `WARP_API_KEY` / `WARP_TOKEN` env var. -- GraphQL credit limits: `https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo`. - Shows monthly credits usage and next refresh time. - Status: none yet. - Details: `docs/warp.md`. +## ElevenLabs +- API key from Settings, token accounts, `ELEVENLABS_API_KEY`, or `XI_API_KEY`. +- Reads `GET /v1/user/subscription` from `api.elevenlabs.io`. +- Shows character credit usage, reset timing, and voice slot usage when available. +- Override the API base URL with `ELEVENLABS_API_URL`. +- Status: `https://status.elevenlabs.io` (link only, no auto-polling). +- Details: `docs/elevenlabs.md`. + ## Vertex AI - OAuth credentials from `gcloud auth application-default login` (ADC). - Quota usage via Cloud Monitoring `consumer_quota` metrics for `aiplatform.googleapis.com`. -- Token cost: scans `~/.claude/projects/` logs filtered to Vertex AI-tagged entries. +- Token cost: uses the Claude local-log scanner filtered to Vertex AI-tagged entries. - Requires Cloud Monitoring API access in the current project. - Details: `docs/vertexai.md`. + ## JetBrains AI - Local XML quota file from IDE configuration directory. - Auto-detects installed JetBrains IDEs; uses most recently used. @@ -152,24 +251,188 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: none (no status page). - Details: `docs/jetbrains.md`. +## Zed +- Reads the signed-in Zed editor session from the macOS Keychain (`credentials_url` / `https://zed.dev`). +- Calls `GET https://cloud.zed.dev/client/users/me` for plan, billing cycle, Edit Predictions quota, and overdue invoice flag. +- Sign in to the Zed editor first. +- Details: `docs/zed.md`. + +## Augment +- Auto mode tries the `auggie` CLI first. +- Web fallback uses browser cookies, with manual cookie header support. +- Tracks credit usage and account/subscription data where available. +- Status: none yet. +- Details: `docs/augment.md`. + ## Amp -- Web settings page (`https://ampcode.com/settings`) via browser cookies. -- Parses Amp Free usage from the settings HTML. +- Auto mode tries the local `amp usage` command first. +- API mode calls Amp's balance endpoint with an access token. +- Web fallback reads the legacy settings page with browser cookies. +- Tracks Amp Free usage, account identity, and individual and workspace credit balances. - Status: none yet. - Details: `docs/amp.md`. +## T3 Chat +- Web tRPC endpoint (`https://t3.chat/api/trpc/getCustomerData`) via browser cookies. +- Parses JSONL response lines and extracts customer data from the embedded tRPC payload. +- Shows the 4-hour Base bucket and monthly Overage bucket documented in the T3 Chat FAQ. +- Status: none yet. + ## Ollama - Web settings page (`https://ollama.com/settings`) via browser cookies. - Parses Cloud Usage plan badge, session/weekly usage, and reset timestamps. - Status: none yet. - Details: `docs/ollama.md`. +## Synthetic +- API key from `~/.codexbar/config.json` (`providers[].apiKey`) or `SYNTHETIC_API_KEY`. +- Shows rolling five-hour, weekly token, search-hourly, and cost/credit quota lanes when present. +- Status: none yet. + ## OpenRouter -- API token from `~/.codexbar/config.json` (`providerConfig.openrouter.apiKey`) or `OPENROUTER_API_KEY` env var. -- Credits endpoint: `https://openrouter.ai/api/v1/credits` (returns total credits purchased and usage). -- Key info endpoint: `https://openrouter.ai/api/v1/key` (returns rate limit info). +- API token from `~/.codexbar/config.json` (`providers[].apiKey`) or `OPENROUTER_API_KEY` env var. +- Reads credits and key rate-limit info from OpenRouter APIs. +- Shows daily, weekly, and monthly API-key spend when `/api/v1/key` returns those fields. - Override base URL with `OPENROUTER_API_URL` env var. - Status: `https://status.openrouter.ai` (link only, no auto-polling yet). - Details: `docs/openrouter.md`. +## Perplexity +- Browser session cookie from automatic import, manual header/token, or `PERPLEXITY_SESSION_TOKEN` / `PERPLEXITY_COOKIE`. +- Tracks recurring credits, bonus/promotional credits, purchased credits, and renewal date when present. +- Status: `https://status.perplexity.com/` (link only, no auto-polling). + +## Xiaomi MiMo +- Browser cookies from automatic import or manual `Cookie:` header. +- Reads balance and token-plan usage from `platform.xiaomimimo.com`. +- Status: none yet. +- Details: `docs/mimo.md`. + +## Doubao +- API key via `ARK_API_KEY`, `VOLCENGINE_API_KEY`, `DOUBAO_API_KEY`, or provider config. +- Probes Volcengine Ark chat completions and reads request rate-limit headers when present. +- Status: none yet. +- Details: `docs/doubao.md`. + +## Abacus AI +- Browser cookies (`abacus.ai`, `apps.abacus.ai`) via automatic import or manual header. +- Reads organization compute points and billing data. +- Shows monthly credit gauge with pace tick and reserve/deficit estimate. +- Status: none yet. +- Details: `docs/abacus.md`. + +## Mistral +- Session cookie (`ory_session_*`) from browser auto-import or manual `Cookie:` header. +- CSRF token (`csrftoken` cookie) sent as `X-CSRFTOKEN` for billing and Vibe usage requests. +- Domains: `admin.mistral.ai` for API billing and `console.mistral.ai` for optional Vibe subscription usage. Admin Ory cookies are never forwarded to the console origin. +- Reads monthly usage and pricing from the Mistral billing API. +- Cost is computed client-side from token counts and response pricing. +- Reads Vibe monthly-plan usage percentage and reset time when the console endpoint is available. +- The menu bar metric can show either pay-as-you-go API spend or monthly-plan usage. +- Resets at end of calendar month. +- Status: `https://status.mistral.ai` (link only, no auto-polling). + +## DeepSeek +- API key via `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY` env var or DeepSeek token accounts. +- Shows total balance with paid vs. granted breakdown; USD preferred when multiple currencies present. +- Status: `https://status.deepseek.com` (link only, no auto-polling). +- Details: `docs/deepseek.md`. + +## Moonshot / Kimi API +- API key via `MOONSHOT_API_KEY` / `MOONSHOT_KEY` env var or provider config. +- Reads `GET /v1/users/me/balance` from the selected Moonshot region. +- Region: international (`api.moonshot.ai`) or China mainland (`api.moonshot.cn`), configurable in Settings or `MOONSHOT_REGION`. +- Shows available balance; negative cash balance is surfaced as a deficit. +- Status: none yet. +- Details: `docs/moonshot.md`. + +## Venice +- API key via `VENICE_API_KEY` / `VENICE_KEY` env var or Venice token accounts. +- Shows current DIEM or USD balance; DIEM epoch allocation progress when available. +- Status: none yet. +- Details: `docs/venice.md`. + +## Codebuff +- API token from `~/.codexbar/config.json`, `CODEBUFF_API_KEY`, or `~/.config/manicode/credentials.json` created by `codebuff login`. +- Reads usage and subscription data from Codebuff APIs. +- Shows credit balance, weekly rate limit, reset timing, subscription status, and auto-top-up flag when present. +- Override base URL with `CODEBUFF_API_URL`. +- Status: none yet. +- Details: `docs/codebuff.md`. + +## Crof +- API key from `~/.codexbar/config.json`, `CROF_API_KEY`, or `CROFAI_API_KEY`. +- Reads `credits`, `requests_plan`, and `usable_requests` from `GET https://crof.ai/usage_api/`. +- Shows request quota as the primary usage window and dollar credits as the secondary row. +- Infers the daily request reset from midnight America/Chicago until the usage API exposes reset metadata. +- Status: none yet. +- Details: `docs/crof.md`. + +## Command Code +- Browser session cookies from automatic import or manual `Cookie:` header. +- Linux CLI supports configured manual cookies; automatic browser import remains macOS-only. +- Reads monthly USD credits and billing-cycle usage from `api.commandcode.ai`. +- Automatic import looks for better-auth session cookies from `commandcode.ai` / `www.commandcode.ai`. +- Status: none yet. +- Details: `docs/command-code.md`. + +## Grok +- `grok agent stdio` (ACP) JSON-RPC `x.ai/billing` method; requires `grok login` (SuperGrok OAuth/OIDC). +- Reads cached credentials from `~/.grok/auth.json` for identity (email, team). +- Falls back to grok.com's billing gRPC-web endpoint via Chrome session cookies when the CLI does not expose billing. +- CLI/test runs do not import browser cookies unless `CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT=1` is set. +- Local fallback aggregates `~/.grok/sessions/**/signals.json` token counts when the RPC is unavailable. +- Status: link only to `https://status.x.ai` (no auto-polling yet). +- Details: `docs/grok.md`. + +## AWS Bedrock +- AWS credentials from `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optional `AWS_SESSION_TOKEN`. +- Region from `AWS_REGION` / `AWS_DEFAULT_REGION`, defaulting to `us-east-1`. +- Reads AWS Cost Explorer for Bedrock spend and can compare usage against `CODEXBAR_BEDROCK_BUDGET`. +- Optionally reads rolling 14-day Claude token and request totals from CloudWatch with `cloudwatch:GetMetricData`. +- Override Cost Explorer base URL with `CODEXBAR_BEDROCK_API_URL` for tests. +- Details: `docs/bedrock.md`. + +## Deepgram +- API key from config or `DEEPGRAM_API_KEY`. +- Optional project ID from provider settings or `DEEPGRAM_PROJECT_ID`; otherwise aggregates all visible projects. +- Optional API base URL override via `DEEPGRAM_API_URL`; overrides must be HTTPS or bare hosts normalized to HTTPS. +- Reads Deepgram usage breakdowns for audio hours, agent hours, token totals, TTS characters, and requests. +- Details: `docs/deepgram.md`. + +## Xiaomi MiMo +- Browser cookies or manual Cookie header for `platform.xiaomimimo.com` balance and token-plan endpoints. +- Optional testing override via `MIMO_API_URL`; overrides must be HTTPS or bare hosts normalized to HTTPS, and invalid + overrides fail closed instead of falling back to local MiMo usage accounting. +- Local MiMo token accounting is available only when the opt-in cache file exists. +- Details: `docs/mimo.md`. + +## LiteLLM +- API key from config or `LITELLM_API_KEY`; base URL from config `enterpriseHost` or `LITELLM_BASE_URL`. +- Reads `/key/info` first, then `/user/info?user_id=...` for user-bound keys or `/team/info?team_id=...` for team-only keys. +- User-bound keys show personal budget usage as the primary window and the key's exact matching team as the secondary window. +- Team-only keys show the team budget as their sole usage window. Automatic menu-bar selection prefers the enforced team budget. +- Spend remains visible in the API-spend row when LiteLLM has no budget limit configured. +- Accepts base URLs with or without a `/v1` suffix; management requests are sent to the proxy root. +- Details: `docs/litellm.md`. + +## Poe +- API key from config or `POE_API_KEY`. +- Reads the current point balance and recent points history from Poe's official usage API. +- History failures are non-fatal; the current balance remains available. +- Details: `docs/poe.md`. + +## Chutes +- API key from config or `CHUTES_API_KEY`. +- Reads subscription usage first, then fills missing rolling, monthly, or pay-as-you-go quota data from the quota APIs. +- Uses Chutes' management API at `https://api.chutes.ai`; `CHUTES_API_URL` can override it with an HTTPS endpoint. +- Details: `docs/chutes.md`. + +## StepFun +- Username/password login or manual Oasis-Token. +- Reads Step Plan 5-hour and weekly rate-limit windows from `platform.stepfun.com`. +- Shows subscription plan name when the Step Plan status API returns one. +- Status: none yet. +- Details: `docs/stepfun.md`. + See also: `docs/provider.md` for architecture notes. diff --git a/docs/quotio-comparison.md b/docs/quotio-comparison.md index ea96ab3fce..78fee8d278 100644 --- a/docs/quotio-comparison.md +++ b/docs/quotio-comparison.md @@ -1,3 +1,11 @@ +--- +summary: "Quotio comparison notes and possible CodexBar enhancement ideas." +read_when: + - Evaluating Quotio-inspired UX or architecture ideas + - Planning provider quota-tracking roadmap items + - Comparing CodexBar scope against proxy-based quota tools +--- + # Quotio Comparison & Enhancement Opportunities **Date:** 2026-01-04 diff --git a/docs/refactor/claude-current-baseline.md b/docs/refactor/claude-current-baseline.md new file mode 100644 index 0000000000..bedeca3860 --- /dev/null +++ b/docs/refactor/claude-current-baseline.md @@ -0,0 +1,155 @@ +--- +summary: "Current Claude behavior baseline before vNext refactor work." +read_when: + - Planning Claude refactor tickets + - Changing Claude runtime/source selection + - Changing Claude OAuth prompt or cooldown behavior + - Changing Claude token-account routing +--- + +# Claude current baseline + +This document is the current-state parity reference for Claude behavior in CodexBar. + +Use it when later tickets need to preserve or intentionally change Claude behavior. When the refactor plan, +summary docs, and running code disagree, treat current code plus characterization coverage as authoritative, and use +this document as the human-readable summary of that current state. + +## Scope of this baseline + +This baseline captures the current behavior surface that later refactor work must preserve unless a future ticket +changes it intentionally: + +- runtime/source-mode selection, +- prompt and cooldown behavior that affects Claude OAuth repair flows, +- token-account routing at the app and CLI edges, +- provider siloing and web-enrichment rules, +- the current relationship between the public Claude doc and the vNext refactor plan. + +## Active behavior owners + +Current Claude behavior is defined by several active owners, not one central planner: + +- `Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift` + owns the main provider-pipeline strategy order and fallback rules. +- `Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift` + still owns a separate direct `.auto` path, delegated refresh, prompt/cooldown handling, and web-extra enrichment. +- `Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift` + owns app-side token-account routing into cookie or OAuth behavior. +- `Sources/CodexBarCLI/TokenAccountCLI.swift` + owns CLI-side token-account routing and effective source-mode overrides. +- `Sources/CodexBarCore/TokenAccountSupport.swift` + owns the current string heuristics that distinguish Claude OAuth access tokens from cookie/session-key inputs. + +## Current runtime and source-mode behavior + +### Main provider pipeline + +The generic provider pipeline currently resolves Claude strategies in this order: + +| Runtime | Selected mode | Ordered strategies | Fallback behavior | +| --- | --- | --- | --- | +| app | auto | `oauth -> cli -> web` | OAuth can fall through to CLI/Web. CLI can fall through to Web only when Web is available. Web is terminal. | +| app | oauth | `oauth` | No fallback. | +| app | cli | `cli` | No fallback. | +| app | web | `web` | No fallback. | +| cli | auto | `web -> cli` | Web can fall through to CLI. CLI is terminal. | +| cli | oauth | `oauth` | No fallback. | +| cli | cli | `cli` | No fallback. | +| cli | web | `web` | No fallback. | + +This behavior is owned by `Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift` +through `ProviderFetchPlan` and `ProviderFetchPipeline`. + +### Other active `.auto` decision sites + +The codebase still contains multiple active `.auto` decision sites: + +| Owner | Current behavior | +| --- | --- | +| `ClaudeProviderDescriptor.resolveUsageStrategy(...)` | Chooses `oauth`, then `cli`, then `web`, with final `cli` fallback when none are available. | +| `ClaudeUsageFetcher.loadLatestUsage(.auto)` | Chooses `oauth`, then `web`, then `cli`, with final `oauth` fallback. | + +This inconsistency is intentional to record here. RAT-107 directly characterizes the active direct-fetcher branches it +can reach cleanly in tests and records the remaining current-state behavior without reconciling it. + +## Prompt and cooldown baseline + +Current behavior that later refactor work must preserve: + +- The default Claude keychain prompt mode is `onlyOnUserAction`. +- Prompt policy is only applicable when the Claude OAuth read strategy is `securityFramework`. +- User-initiated interaction clears a prior Claude keychain cooldown denial before retrying availability or repair. +- Startup bootstrap prompting is allowed only when all of these are true: + - runtime is app, + - interaction is background, + - refresh phase is startup, + - prompt mode is `onlyOnUserAction`, + - no cached Claude credentials exist. +- Background delegated refresh is blocked when prompt policy is `onlyOnUserAction` and the caller did not explicitly + allow background delegated refresh. +- Prompt mode `never` blocks delegated refresh attempts. +- Expired credential owner behavior remains owner-specific: + - `.claudeCLI`: delegated refresh path, + - `.codexbar`: direct refresh path, + - `.environment`: no auto-refresh. + +## Token-account routing baseline + +Accepted Claude token-account input shapes today: + +- raw OAuth access token with `sk-ant-oat...` prefix, +- `Bearer sk-ant-oat...` input, +- raw session key, +- full cookie header. + +Current routing rules: + +- OAuth-token-shaped inputs are not treated as cookies. +- Cookie/header-shaped inputs are any value that already contains `Cookie:` or `=`. +- App-side Claude snapshot behavior: + - OAuth token account keeps the usage source setting as-is, disables cookie mode (`.off`), clears the manual cookie + header, and relies on environment-token injection. + - Session-key or cookie-header account keeps the usage source setting as-is, forces manual cookie mode, and + normalizes raw session keys into `sessionKey=`. +- CLI-side Claude token-account behavior: + - OAuth token account changes the effective source mode from `auto` to `oauth`, disables cookie mode, omits a + manual cookie header, and injects `CODEXBAR_CLAUDE_OAUTH_TOKEN`. + - Session-key or cookie-header account stays in cookie/manual mode. + +## Siloing and web-enrichment baseline + +Claude Web enrichment is cost-only when the primary source is OAuth or CLI: + +- Web extras may populate `providerCost` when it is missing. +- Web extras must not replace `accountEmail`, `accountOrganization`, or `loginMethod` from the primary source. +- Snapshot identity remains provider-scoped to Claude. + +This behavior is implemented in `Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift` +inside `applyWebExtrasIfNeeded`. + +## Documentation contract + +- [docs/claude.md](../claude.md) is the summary doc for contributors who want an overview. +- This file is the exact current-state baseline for contributor and refactor parity work. +- [claude-provider-vnext-locked.md](claude-provider-vnext-locked.md) + is the future refactor plan and should cite this file for present behavior. + +## Characterization coverage + +Stable automated coverage for this baseline lives in: + +- `Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift` +- `Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift` +- `Tests/CodexBarTests/ClaudeUsageTests.swift` +- `Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift` +- `Tests/CodexBarTests/SettingsStoreCoverageTests.swift` + +`ClaudeUsageTests.swift` now directly characterizes the reachable `ClaudeUsageFetcher(.auto)` branches for: + +- OAuth when OAuth, Web, and CLI all appear available, +- Web before CLI when OAuth is unavailable, + +The successful CLI-selected branch and the CLI-failure-to-OAuth fallback remain documented from code inspection plus +surrounding Claude probe/regression coverage, because the current CLI-availability decision is sourced from process-wide +binary discovery with no stable test seam that would keep RAT-107 in scope. diff --git a/docs/refactor/claude-provider-vnext-locked.md b/docs/refactor/claude-provider-vnext-locked.md new file mode 100644 index 0000000000..8576cf8ba6 --- /dev/null +++ b/docs/refactor/claude-provider-vnext-locked.md @@ -0,0 +1,356 @@ +--- +summary: "Locked implementation plan for Claude provider vNext: resolved source-selection contracts, typed credential rules, siloing guarantees, and phase gates." +supersedes: "Initial vNext draft (removed)" +created: "2026-02-18" +status: "Locked for implementation" +--- + +# Claude provider vNext (locked plan) + +This is the implementation-locked vNext plan. + +It preserves the original architecture direction, but removes ambiguity in behavior-critical areas before refactor work starts. + +Current-state parity reference for present behavior: + +- [docs/refactor/claude-current-baseline.md](claude-current-baseline.md) + +Use the baseline doc for present behavior. This vNext plan defines what the refactor should preserve and how it should +be staged; it is not the sole source of truth for current implementation details, and RAT-107 does not re-approve the +rest of the future architecture below. + +## Assessment snapshot + +- **Approach score:** `8.4/10`. +- **Why not 9+ yet:** the original plan left runtime ordering, token-account credential typing behavior, and compatibility mapping under-specified. +- **How this doc closes the gap:** explicit contracts + resolved decisions + phase exit gates + risk checklist. +- **Validated gap coverage in this version:** explicit `.auto` inconsistency handling, `ClaudeUsageFetcher` decomposition, stronger parity gates, TaskLocal-to-DI migration, and OAuth decomposition sub-phases. + +## Locked behavioral contracts + +These behaviors are **non-negotiable** during refactor unless this doc is explicitly updated. + +### 1) Runtime + source-mode contract + +`ClaudeSourcePlanner` must reproduce this matrix exactly: + +| Runtime | Selected mode | Ordered attempts | Fallback rules | +| --- | --- | --- | --- | +| app | auto | oauth -> cli -> web | oauth fallback allowed; cli fallback to web only when web available; web terminal | +| app | oauth | oauth | no fallback | +| app | cli | cli | no fallback | +| app | web | web | no fallback | +| cli | auto | web -> cli | web fallback allowed to cli; cli terminal | +| cli | oauth | oauth | no fallback | +| cli | cli | cli | no fallback | +| cli | web | web | no fallback | + +Notes: +- `sourceLabel` remains the final step label for successful fetch output. +- Planner diagnostics must include ordered steps and inclusion reasons. +- Planner output must feed the existing generic provider fetch pipeline; do not introduce a second Claude-only + execution stack alongside `ProviderFetchPlan` / `ProviderFetchPipeline`. + +### 1a) `.auto` inconsistency characterization contract (must-do before reconciliation) + +Current code has three `.auto` decision sites with inconsistent app ordering: + +- Strategy pipeline resolve order (app): `oauth -> cli -> web`. +- `resolveUsageStrategy` helper order: `oauth -> cli -> web -> cli fallback`. +- `ClaudeUsageFetcher.loadLatestUsage(.auto)` order: `oauth -> web -> cli -> oauth fallback`. + +Phase 0 must characterize these paths with tests where they are reachable through stable seams, and otherwise defer to +the baseline doc before deleting any path. +Phase 2 must reconcile this into planner-only source selection. + +### 2) Prompt/cooldown contract + +The planner must use one explicit `ClaudePromptDecision` equivalent, but outcome parity with current behavior is required: + +- User-initiated actions can clear prior keychain cooldown denial. +- Startup bootstrap prompt is only allowed when all are true: + - runtime is app + - interaction is background + - refresh phase is startup + - prompt mode is `onlyOnUserAction` + - no cached credentials +- Background delegated refresh is blocked when: + - prompt policy is `onlyOnUserAction` + - caller does not explicitly allow background delegated refresh +- Prompt mode `never` blocks delegated refresh attempts. + +### 3) Credential typing + routing contract + +Typed credentials must be introduced at the settings snapshot edge, with behavior parity: + +- `ClaudeManualCredential.sessionKey` +- `ClaudeManualCredential.cookieHeader` +- `ClaudeManualCredential.oauthAccessToken` + +Accepted Claude token-account inputs must continue to work: + +- Raw OAuth token (including `Bearer ...` input) +- Raw session key +- Full cookie header + +Routing parity requirements: + +- OAuth token account values must route to OAuth path (not cookie mode). +- Cookie/session-key account values must route to web cookie path. +- CLI token-account behavior must remain consistent in both app and `CodexBarCLI`. +- Scope note: current string heuristics are mostly edge-routing logic, not deep OAuth credential decoding internals. + +### 4) Ownership and refresh contract + +Credential owner behavior must remain identical: + +- `.claudeCLI` expired credentials: delegated refresh path. +- `.codexbar` expired credentials: direct refresh endpoint path. +- `.environment` expired credentials: no auto-refresh. + +Refresh failure-gate semantics must remain unchanged. + +### 5) Provider siloing + enrichment contract + +Hard invariant: + +- Never merge Claude Web identity into OAuth/CLI snapshots. +- Web extras may enrich **cost only**. +- Snapshot identity must always remain provider-scoped to `.claude` when persisted/displayed. + +### 6) Plan inference compatibility contract + +Canonical plan inference can live behind the existing `loginMethod` compatibility surface, but outward +compatibility must be preserved: + +- Existing detectable plans continue mapping to display strings: + - `Claude Max` + - `Claude Pro` + - `Claude Team` + - `Claude Enterprise` +- Subscription detection behavior must remain compatible with current UI logic, including existing `Ultra` detection + semantics until that behavior is explicitly changed. + +### 7) Documentation + diagnostics contract + +- During refactor, characterization coverage plus + [docs/refactor/claude-current-baseline.md](claude-current-baseline.md) + are the source of truth when docs and code disagree. +- `docs/claude.md` must be updated as part of Phase 0 after characterization lands so it no longer presents divergent + runtime ordering as settled behavior. +- Debug surfaces must consume planner-derived diagnostics instead of recomputing Claude source decisions separately. + +## Resolved decisions (from open questions) + +### Web identity fill-ins + +- **Decision:** do not use Web identity to fill missing OAuth/CLI identity fields. +- **Allowed:** Web cost enrichment only. + +### CLI runtime fallback ordering + +- **Decision:** keep current CLI ordering in `auto`: `web -> cli`. +- Planner must encode this explicitly and not rely on incidental strategy ordering. + +### Startup bootstrap prompt in `onlyOnUserAction` + +- **Decision:** keep support exactly under the startup bootstrap constraints listed above. +- Any expansion/restriction requires explicit doc update and tests. + +### Runtime policy unification timing + +- **Decision:** do not unify app and CLI `auto` ordering before this refactor. +- First consolidate to one planner implementation with current runtime-specific behavior preserved. +- Any runtime-policy unification is a separate, explicit behavior-change follow-up. + +### Planner integration timing + +- **Decision:** `ClaudeSourcePlanner` must be integrated into the existing provider descriptor / fetch pipeline rather + than added as a parallel orchestration layer. +- Reuse current `ProviderFetchContext` / `ProviderFetchPlan` plumbing where possible. + +### Dependency seam timing + +- **Decision:** introduce dependency-container seams for newly extracted planner/executor components as they are + created. +- Full TaskLocal cleanup can remain later, but new components should not deepen TaskLocal coupling. + +## Locked migration plan with exit gates + +### Phase 0: Baseline lock + +Deliverables: +- Add `docs/refactor/claude-current-baseline.md` as the current-state behavior reference. +- Add/refresh characterization tests for runtime/source matrix and prompt-decision parity. +- Add explicit characterization tests for existing `.auto` decision paths where they are reachable through stable seams, + and defer remaining current-state details to the baseline doc until later reconciliation. +- Update `docs/claude.md` after tests land so documented ordering matches characterized behavior. + +Exit gate: +- Behavior matrix tests pass for app and cli runtimes. +- `.auto` characterization coverage plus the baseline doc record current divergence explicitly without forcing new + production seams in Phase 0. +- `docs/claude.md` no longer contradicts characterized runtime/source behavior. + +### Phase 1: Canonical plan resolver + +Deliverables: +- Introduce `ClaudePlan` + one resolver used by OAuth/Web/CLI mapping and downstream UI consumers. + +Exit gate: +- Plan mapping tests cover tier/billing/status-derived hints, compatibility display strings, and current UI subscription + detection compatibility. + +### Phase 1b: Typed credentials at the snapshot edge + +Deliverables: +- Parse manual Claude credentials once at the app + CLI snapshot edges into a typed model. +- Remove duplicated edge-routing heuristics for OAuth-vs-cookie decisions across settings snapshots and token-account + CLI code. + +Exit gate: +- Token account parity tests pass for app + CLI. +- Snapshot-edge routing no longer duplicates Claude OAuth-token detection logic in multiple call sites. + +### Phase 2: Single source planner + +Deliverables: +- Introduce `ClaudeSourcePlanner` + explicit `ClaudeFetchPlan`. +- Integrate planner outputs into the existing provider pipeline / descriptor flow. +- Remove duplicate `.auto` policy branches from lower layers. +- Reconcile and remove `ClaudeUsageFetcher` internal `.auto` source selection. +- Move debug/diagnostic surfaces to planner-derived attempt ordering instead of helper-specific recomputation. + +Exit gate: +- One authoritative planner path for mode/runtime ordering. +- Fallback attempt logs still show expected sequence and source labels. +- No surviving `.auto` source-order logic outside planner. +- No surviving debug-only source-order recomputation outside planner diagnostics. +- Old-vs-new planner parity tests pass before old branches are removed. + +### Phase 2b: `ClaudeUsageFetcher` decomposition + +Deliverables: +- Split `ClaudeUsageFetcher` into smaller executor-focused components. +- Extract delegated OAuth retry/recovery flow into dedicated units. +- Remove embedded prompt-policy/source-selection ownership from fetcher; keep it execution-only. + +Exit gate: +- Fetcher no longer owns source-selection policy. +- Delegated OAuth retry behavior is covered by dedicated tests and remains parity-compatible. + +### Phase 3: Test injection cleanup + +Deliverables: +- Prefer dependency seams on extracted units instead of adding new TaskLocal-only override points. + - Avoid expanding TaskLocal-only test hooks while decomposition work lands. + +Exit gate: +- New fetcher tests rely on explicit dependency seams where practical. + +### Phase 4: OAuth decomposition + +Deliverables (sub-phases): + +- **Phase 4a (repository extraction):** + - Extract IO + caching + owner/source loading into repository surface. + - Keep prompt-gate semantics unchanged. +- **Phase 4b (refresher extraction):** + - Extract network refresh + failure gating to refresher component. + - Keep owner-based refresh behavior unchanged. +- **Phase 4c (delegated controller extraction):** + - Extract delegated CLI touch + keychain-change observation + cooldown behavior. + - Keep delegated retry outcomes unchanged. + +Exit gate: +- Existing OAuth delegated refresh / prompt policy / cooldown suites pass without behavior deltas at each sub-phase. +- Owner semantics parity remains intact across all sub-phases (`claudeCLI`, `codexbar`, `environment`). + +### Phase 5: Test injection migration (TaskLocal -> DI) + +Deliverables: +- Move test injection from TaskLocal-heavy overrides to `ClaudeFetchDependencies` and explicit protocol stubs. +- Keep compatibility shims temporarily where needed, then remove them. + +Exit gate: +- Core planner/executor tests run without TaskLocal injection dependencies. +- Legacy TaskLocal-only override surfaces are either removed or isolated to compatibility adapters with deletion TODOs. + +### Phase 6: Web decomposition (optional) + +Deliverables: +- Separate cookie acquisition from web usage client. +- Keep probe tooling isolated behind debug/tool surface. + +Exit gate: +- Web parsing and account mapping tests remain green. + +## Implementation PR plan (stacked) + +Use this sequence to keep each PR reviewable without turning the rollout into unnecessary PR overhead. + +| PR | Title | Scope | Primary risks | Must-pass gate before merge | +| --- | --- | --- | --- | --- | +| PR-01 | Baseline characterization + doc correction | Lock current matrix behavior, characterize `.auto` paths through stable seams, defer remaining lower-level current-state details to the baseline doc, characterize prompt bootstrap/cooldown and token-account routing, then update docs to match reality. | R1, R2, R5, R6, R10 | No production behavior changes; characterization suites green; docs no longer contradict tests or the baseline. | +| PR-02 | Canonical plan resolver | Introduce `ClaudePlan` and central resolver; map OAuth/Web/CLI/UI compatibility through one model while preserving current `loginMethod` projections. | R8 | Plan compatibility tests green (`Max/Pro/Team/Enterprise` + current subscription compatibility). | +| PR-03 | Typed credentials at the edge | Parse manual credentials once (`sessionKey`, `cookieHeader`, `oauthAccessToken`) in app + CLI snapshot shaping. | R6 | Token-account routing parity tests green in app + CLI contexts. | +| PR-04 | Source planner introduction + cutover | Add `ClaudeSourcePlanner`, prove parity against old path, then remove duplicate `.auto` selection branches once parity is proven. | R1, R5, R10 | One `.auto` authority remains; attempt/source-label diagnostics remain parity-compatible. | +| PR-05 | `ClaudeUsageFetcher` decomposition | Split fetcher into execution/retry-focused units; remove embedded source-selection ownership. | R2, R10 | Delegated OAuth retry/recovery tests green with no behavior deltas. | +| PR-06 | OAuth decomposition | Extract repository, refresher, and delegated-controller seams from `ClaudeOAuthCredentialsStore` while preserving owner semantics. | R3, R4, R7, R9 | Cache/fingerprint/prompt/owner suites green (`claudeCLI`, `codexbar`, `environment`). | +| PR-07 (optional) | TaskLocal -> DI migration | Move remaining tests and seams to `ClaudeFetchDependencies`, keep temporary compat adapters, then remove. | R9 | Core planner/executor tests run without TaskLocal globals. | +| PR-08 (optional) | Web decomposition | Split cookie acquisition from web usage client and keep tooling isolated. | R8, R10 | Web parsing/account mapping suites remain green. | + +Stacking rules: + +1. Keep each PR scoped to one risk cluster and one merge gate. +2. Do not remove old branches until a prior PR has old-vs-new parity tests in CI. +3. If a PR intentionally changes behavior, update this locked doc in the same PR and call it out in summary. +4. Prefer 6 core PRs unless parity risk forces a temporary split; do not fragment the rollout further without a + concrete rollback or reviewability reason. + +## Mandatory test additions + +Add these test groups before or during Phases 1-3, then extend for later phases: + +1. Planner matrix tests: + - `(runtime x selected mode x interaction x refresh phase x availability)` -> exact step order + fallback. +2. `.auto` divergence characterization tests: + - Lock current behavior of strategy pipeline vs `resolveUsageStrategy` helper vs fetcher-direct `.auto`. + - Use as guardrails while consolidating to planner-only logic. +3. Typed credential parsing tests: + - OAuth token, bearer token, session key, cookie header, malformed strings. +4. Cross-provider identity isolation tests: + - Ensure `.claude` identity does not leak via snapshot scoping/merging. +5. Source-label and attempt diagnostics tests: + - Validate final source label and attempt list parity. +6. CLI token-account parity tests: + - `TokenAccountCLIContext` and app settings snapshot behavior match for OAuth-vs-cookie routing. +7. Old-vs-new parity tests: + - Compare old path and planner path outputs before branch removals in Phase 2 and Phase 2b. +8. DI migration tests: + - Ensure new dependency container can drive planner/executor tests without TaskLocal globals. + +## Risk checklist (implementation review) + +Use these risk IDs in refactor PR checklists/reviews. + +| Risk ID | Severity | Risk | Detail | +| --- | --- | --- | --- | +| R1 | Critical | Auto-ordering reconciliation | Three `.auto` paths are inconsistent today. Characterize strategy pipeline vs `resolveUsageStrategy` helper vs fetcher-direct `.auto` before deleting any path. | +| R2 | High | Prompt policy consolidation | Prompt policy exists across strategy availability, fetcher flow, and credentials store gates. Preserve startup bootstrap constraints exactly to avoid prompt storms or silent OAuth suppression. | +| R3 | High | `ClaudeOAuthCredentialsStore` decomposition | Large lock-protected state + layered caches + fingerprint invalidation + security calls. Splits can break cache coherence, invalidation timing, or prompt gating order. | +| R4 | High | Owner semantics drift | Preserve exact owner-to-refresh mapping: `.claudeCLI` delegated, `.codexbar` direct refresh, `.environment` no refresh. | +| R5 | Medium | CLI runtime parity | Preserve runtime-specific policy: CLI `auto` remains `web -> cli`; OAuth is available only when explicitly selected as `sourceMode=.oauth`. Do not accidentally default CLI runtime to app ordering. | +| R6 | Medium | Token-account OAuth-vs-cookie misrouting | Keep routing parity for OAuth token vs session key vs full cookie header, including `Bearer sk-ant-oat...` normalization. | +| R7 | Medium | Cache invalidation regressions | Preserve credentials file/keychain fingerprint semantics and stale-cache guards during repository extraction. | +| R8 | Low-Medium | Plan inference heuristic drift | Preserve web-specific plan inference fallback (`billing_type` + `rate_limit_tier`) when unifying plan resolution. | +| R9 | Medium | Strict concurrency / `@Sendable` regressions | Maintain thread-safe behavior from current NSLock-based state while moving to DI/decomposed components under Swift 6 strict concurrency. | +| R10 | Low | Debug/diagnostic drift | Keep source labels, attempt sequences, and debug output aligned with real planner decisions after consolidation. | + +## Change-control rule + +Any refactor PR that intentionally changes one of the locked contracts above must: + +1. Update this document. +2. Add/adjust tests proving the new behavior. +3. Call out the behavior change explicitly in the PR summary. diff --git a/docs/refactor/cli.md b/docs/refactor/cli.md index 0eadaa7021..1e9ea37b86 100644 --- a/docs/refactor/cli.md +++ b/docs/refactor/cli.md @@ -73,7 +73,6 @@ read_when: - Config validation (bad region/source/apiKey field). - SettingsStore order/toggle invariants still pass. 8. **Verification** - - `swift test`, `swiftformat Sources Tests`, `swiftlint --strict`, `pnpm check`. + - `make test`, `swiftformat Sources Tests`, `swiftlint --strict`, `make check`. - `./Scripts/compile_and_run.sh`. - CLI e2e: `codexbar --json-only ...`, `codexbar config validate`. - diff --git a/docs/refactor/macros.md b/docs/refactor/macros.md deleted file mode 100644 index ce1a8439e4..0000000000 --- a/docs/refactor/macros.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -summary: "Provider macro refactor ideas and follow-ups." -read_when: - - Reviewing provider macro ergonomics - - Planning provider/descriptor refactors ---- - -# Macro refactor ideas - -1. Macro ergonomics + errors -- Emit compile-time errors when a macro is attached to the wrong target or missing `descriptor`/`init`. -- Add a member macro to generate `static let descriptor` from `makeDescriptor()` to remove boilerplate. - -2. Descriptor/data shape -- Split ProviderDescriptor into Descriptor + FetchPlan + Branding + CLI files for cleaner deps. -- Move source label into fetch results (strategy-specific). - -3. Fetching pipeline -- Return all attempted strategies + errors for debug UI and CLI `--verbose`. - -4. Registry order & stability -- Use ProviderDescriptorRegistry registration order for `all`; no sorting by rawValue. -- Use metadata flags (e.g. `isPrimaryProvider`) instead of hard-coded Codex/Claude. - -5. Account/identity -- Replace Codex UI special casing with a descriptor flag (e.g. `usesAccountFallback`). -- Enforce provider-keyed identity in snapshots to avoid cross-provider display. - -6. Settings + tokens -- Move Zai/Copilot token lookup into strategy-local resolvers (Keychain/env). -- Add optional per-provider settings fields in ProviderSettingsSnapshot. - -7. Docs + tests -- Add a minimal provider example in docs/provider.md. -- Add registry completeness + deterministic-order test. diff --git a/docs/refresh-loop.md b/docs/refresh-loop.md index 58cb7c5454..1426ce2425 100644 --- a/docs/refresh-loop.md +++ b/docs/refresh-loop.md @@ -15,6 +15,9 @@ read_when: - Background refresh runs off-main and updates `UsageStore` (usage + credits + optional web scrape). - Manual “Refresh now” always available in the menu. - Stale/error states dim the icon and surface status in-menu. +- Optional provider-storage scans run only when “Show provider storage usage” is enabled. They are scheduled in the + background, coalesced/throttled during automatic refreshes, and forced by manual refresh without blocking the usage + refresh path. ## Optional future - Auto-seed a log if none exists via `codex exec --skip-git-repo-check --json "ping"` (currently not executed). diff --git a/docs/releasing-homebrew.md b/docs/releasing-homebrew.md index 4bbfaf69c3..7aa73cbfdc 100644 --- a/docs/releasing-homebrew.md +++ b/docs/releasing-homebrew.md @@ -14,20 +14,30 @@ Homebrew is for the UI app via Cask. When installed via Homebrew, CodexBar disab - Access to the tap repo: `../homebrew-tap`. ## 1) Release CodexBar normally -Follow `docs/RELEASING.md` to publish `CodexBar-.zip` to GitHub Releases. +Follow `docs/RELEASING.md` to publish `CodexBar-macos-universal-.zip` to GitHub Releases. -## 2) Update the Homebrew tap cask -In `../homebrew-tap`, add/update the cask at `Casks/codexbar.rb`: -- `url` points at the GitHub release asset: `.../releases/download/v/CodexBar-.zip` +## 2) Let the Release CLI workflow update the tap +After the GitHub release is published, `.github/workflows/release-cli.yml` builds the standalone CLI assets and dispatches `steipete/homebrew-tap`'s `update-formula.yml`. That tap workflow updates both: +- `Casks/codexbar.rb` for the app zip. +- `Formula/codexbar.rb` for the standalone CLI tarballs. + +If dispatch fails or is rate-limited, update the files manually. + +## 2a) Manual cask update +In `../homebrew-tap`, update the cask at `Casks/codexbar.rb`: +- `url` points at the GitHub release asset: `.../releases/download/v/CodexBar-macos-universal-.zip` - Update `sha256` to match that zip. -- Keep `depends_on arch: :arm64` and `depends_on macos: ">= :sonoma"` (CodexBar is macOS 14+). +- Keep `depends_on macos: ">= :sonoma"` (CodexBar is macOS 14+). Do not add an architecture restriction; the app zip is universal. -## 2b) Update the Homebrew tap formula (Linux CLI) -In `../homebrew-tap`, add/update the formula at `Formula/codexbar.rb`: +## 2b) Manual formula update +In `../homebrew-tap`, update the formula at `Formula/codexbar.rb`: - `url` points at the GitHub release assets: - - `.../releases/download/v/CodexBarCLI-v-linux-aarch64.tar.gz` - - `.../releases/download/v/CodexBarCLI-v-linux-x86_64.tar.gz` -- Update both `sha256` values to match those tarballs. + - macOS: `.../releases/download/v/CodexBarCLI-v-macos-arm64.tar.gz` + - macOS: `.../releases/download/v/CodexBarCLI-v-macos-x86_64.tar.gz` + - Linux: `.../releases/download/v/CodexBarCLI-v-linux-aarch64.tar.gz` + - Linux: `.../releases/download/v/CodexBarCLI-v-linux-x86_64.tar.gz` +- Static musl tarballs are also published for manual Linux installs as `linux-musl-aarch64` and `linux-musl-x86_64`; keep the formula on the glibc assets unless intentionally changing its runtime contract. +- Update all `sha256` values to match those tarballs. ## 3) Verify install ```sh diff --git a/docs/session-keepalive-design.md b/docs/session-keepalive-design.md index c2f7d9d12f..545fb2beaf 100644 --- a/docs/session-keepalive-design.md +++ b/docs/session-keepalive-design.md @@ -1,3 +1,11 @@ +--- +summary: "Design notes for unified provider session keepalive scheduling and refresh control." +read_when: + - Planning provider session keepalive work + - Refactoring Augment keepalive behavior + - Designing background session refresh scheduling +--- + # Session Keepalive Enhancement Design **Date:** 2026-01-04 diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs new file mode 100644 index 0000000000..9964d07e2b --- /dev/null +++ b/docs/site-locales.mjs @@ -0,0 +1,1183 @@ +export const localeCatalog = [ + { + "code": "en", + "name": "English" + }, + { + "code": "zh-CN", + "name": "简体中文" + }, + { + "code": "zh-TW", + "name": "繁體中文" + }, + { + "code": "ja-JP", + "name": "日本語" + }, + { + "code": "es", + "name": "Español" + }, + { + "code": "pt-BR", + "name": "Português (BR)" + }, + { + "code": "ko", + "name": "한국어" + }, + { + "code": "de", + "name": "Deutsch" + }, + { + "code": "fr", + "name": "Français" + }, + { + "code": "ar", + "name": "العربية", + "direction": "rtl" + }, + { + "code": "it", + "name": "Italiano" + }, + { + "code": "vi", + "name": "Tiếng Việt" + }, + { + "code": "nl", + "name": "Nederlands" + }, + { + "code": "tr", + "name": "Türkçe" + }, + { + "code": "uk", + "name": "Українська" + }, + { + "code": "id", + "name": "Bahasa Indonesia" + }, + { + "code": "pl", + "name": "Polski" + }, + { + "code": "fa", + "name": "فارسی", + "direction": "rtl" + }, + { + "code": "th", + "name": "ไทย" + }, + { + "code": "ca", + "name": "Català" + }, + { + "code": "sv", + "name": "Svenska" + } +]; + +export const localeMessages = { + "en": { + "meta.title": "CodexBar — every AI coding limit in your menu bar", + "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 53 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", + "meta.ogDescription": "Track usage windows, credits, and resets across 53 AI coding providers from your macOS menu bar.", + "nav.primary": "Primary", + "nav.language": "Language", + "nav.docs": "Docs", + "nav.source": "Source", + "nav.download": "Download", + "theme.toLight": "Switch to light theme", + "theme.toDark": "Switch to dark theme", + "hero.prefix": "Every AI coding limit,", + "hero.accent": "in your menu bar.", + "hero.description": "CodexBar tracks usage windows, credit balances, and reset countdowns across the providers you actually pay for — one status item each, or merge them into one.", + "hero.download": "Download for macOS", + "hero.fineprint": "Free & open source · macOS 14+ · Universal via GitHub Releases and Homebrew", + "providers.title": "53 providers, one menu bar", + "providers.description": "Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.", + "providers.yourProvider": "Your provider", + "providers.authoringGuide": "Authoring guide", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar menu bar popover showing provider tiles with usage bars, reset countdowns, and credit details.", + "features.resetsTitle": "Resets you can plan around", + "features.resetsBody": "Per-provider session, weekly, and monthly windows with countdowns to the next reset — no more guessing whether to start a long task.", + "features.spendTitle": "Credits, spend, and cost scans", + "features.spendBody": "Credit balances and monthly spend where the provider exposes them, plus configurable local cost scans for Codex and Claude.", + "features.statusTitle": "Status & incidents", + "features.statusBody": "Provider status polling surfaces incident badges in the menu and an indicator overlay on the bar icon.", + "features.cliTitle": "CLI & widgets", + "features.cliBody": "WidgetKit widgets for supported providers and a bundled {codexbar} CLI for scripts and CI — macOS and Linux builds on every release.", + "detail.installTitle": "Install & updates", + "detail.installBody": "Universal app via {releases} with Sparkle auto-updates, or via {cask} (updates via {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "A bundled {codexbar} binary plus standalone tarballs for macOS and Linux on each release. On Linux: {linuxCommand}.", + "detail.privacyTitle": "Privacy", + "detail.privacyBody": "Reuses existing provider sessions — OAuth, device flow, API keys, browser cookies, local files — so no passwords are stored. Audit notes in {issue}.", + "detail.permissionsTitle": "macOS permissions", + "detail.permissionsBody": "Full Disk Access for Safari cookies, Keychain access for cookie decryption and OAuth flows, and Files & Folders prompts when provider helpers read project directories.", + "footer.builtBy": "Built by", + "footer.changelog": "Changelog", + "clipboard.copy": "Copy to clipboard", + "clipboard.copied": "Copied" + }, + "zh-CN": { + "meta.title": "CodexBar — 菜单栏中的每个 AI 编码限制", + "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 53 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", + "meta.ogDescription": "从您的 macOS 菜单栏跟踪 53 个 AI 编码提供商的使用窗口、积分和重置。", + "nav.primary": "基本的", + "nav.language": "语言", + "nav.docs": "文档", + "nav.source": "来源", + "nav.download": "下载", + "theme.toLight": "切换到浅色主题", + "theme.toDark": "切换到深色主题", + "hero.prefix": "每个AI编码限制,", + "hero.accent": "在你的菜单栏中。", + "hero.description": "CodexBar 跟踪您实际付费的提供商的使用窗口、信用余额和重置倒计时 - 每个状态项一项,或将它们合并为一项。", + "hero.download": "下载macOS", + "hero.fineprint": "免费开源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", + "providers.title": "53 个提供商,一个菜单栏", + "providers.description": "受欢迎的提供商成为状态项目,具有自己的使用窗口、重置倒计时、图表和提供商菜单。", + "providers.yourProvider": "您的提供商", + "providers.authoringGuide": "创作指南", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar 菜单栏弹出窗口显示提供商图块,其中包含使用栏、重置倒计时和信用详细信息。", + "features.resetsTitle": "您可以计划重置", + "features.resetsBody": "每个提供商的会话、每周和每月窗口,以及下一次重置的倒计时 - 不再猜测是否开始一项长期任务。", + "features.spendTitle": "积分、支出和成本扫描", + "features.spendBody": "提供商公开的信用余额和每月支出,以及 Codex 和 Claude 的可配置本地成本扫描。", + "features.statusTitle": "状态和事件", + "features.statusBody": "提供商状态轮询会在菜单中显示事件徽章,并在栏图标上覆盖一个指示器。", + "features.cliTitle": "CLI 和小部件", + "features.cliBody": "用于支持的提供程序的 WidgetKit 小部件以及用于脚本的捆绑 {codexbar} CLI 和 CI - macOS 和 Linux 构建在每个版本上。", + "detail.installTitle": "安装和更新", + "detail.installBody": "通过 {releases} 获取支持 Sparkle 自动更新的通用应用,或通过 {cask} 安装(使用 {upgrade} 更新)。", + "detail.cliTitle": "CLI", + "detail.cliBody": "每个版本的 {codexbar} 二进制文件以及 macOS 和 Linux 的独立 tarball。在 Linux 上:{linuxCommand}。", + "detail.privacyTitle": "隐私", + "detail.privacyBody": "重用现有的提供程序会话 - OAuth、设备流、API 密钥、浏览器 cookie、本地文件 - 因此不存储密码。 {issue} 中的审计注释。", + "detail.permissionsTitle": "macOS 权限", + "detail.permissionsBody": "Safari cookie 的完整磁盘访问权限、Cookie 解密和 OAuth 流的 Keychain 访问权限以及提供程序帮助程序读取项目目录时的文件和文件夹提示。", + "footer.builtBy": "建造者", + "footer.changelog": "变更日志", + "clipboard.copy": "复制到剪贴板", + "clipboard.copied": "已复制" + }, + "zh-TW": { + "meta.title": "CodexBar — 功能表列中的每個 AI 編碼限制", + "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 53 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", + "meta.ogDescription": "從您的 macOS 功能表列追蹤 53 個 AI 編碼提供者的使用視窗、積分和重設。", + "nav.primary": "基本的", + "nav.language": "語言", + "nav.docs": "文件", + "nav.source": "來源", + "nav.download": "下載", + "theme.toLight": "切換到淺色主題", + "theme.toDark": "切換到深色主題", + "hero.prefix": "每個AI編碼限制,", + "hero.accent": "在你的選單列中。", + "hero.description": "CodexBar 追蹤您實際付費的提供者的使用視窗、信用餘額和重設倒數計時 - 每個狀態項一項,或將它們合併為一項。", + "hero.download": "下載macOS", + "hero.fineprint": "免費開源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", + "providers.title": "53 個提供者,一個選單欄", + "providers.description": "受歡迎的提供者成為狀態項目,具有自己的使用視窗、重置倒數計時、圖表和提供者選單。", + "providers.yourProvider": "您的提供者", + "providers.authoringGuide": "創作指南", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar 功能表列彈出視窗顯示提供者圖塊,其中包含使用列、重設倒數計時和信用詳細資料。", + "features.resetsTitle": "您可以計劃重置", + "features.resetsBody": "每個提供者的會話、每周和每月窗口,以及下一次重置的倒數計時 - 不再猜測是否開始一項長期任務。", + "features.spendTitle": "積分、支出和成本掃描", + "features.spendBody": "提供者公開的信用餘額和每月支出,以及 Codex 和 Claude 的可設定本地成本掃描。", + "features.statusTitle": "狀態和事件", + "features.statusBody": "提供者狀態輪詢會在選單中顯示事件徽章,並在列圖示上覆蓋一個指示器。", + "features.cliTitle": "CLI 和小部件", + "features.cliBody": "用於支援的提供者的 WidgetKit 小部件以及用於腳本的捆綁 {codexbar} CLI 和 CI - macOS 和 Linux 建構在每個版本上。", + "detail.installTitle": "安裝和更新", + "detail.installBody": "透過 {releases} 取得支援 Sparkle 自動更新的通用應用程式,或透過 {cask} 安裝(使用 {upgrade} 更新)。", + "detail.cliTitle": "CLI", + "detail.cliBody": "每個版本的 {codexbar} 二進位檔案以及 macOS 和 Linux 的獨立 tarball。在 Linux 上:{linuxCommand}。", + "detail.privacyTitle": "隱私", + "detail.privacyBody": "重複使用現有的提供者會話 - OAuth、裝置流、API 金鑰、瀏覽器 cookie、本機檔案 - 因此不儲存密碼。 {issue} 中的審計註釋。", + "detail.permissionsTitle": "macOS 權限", + "detail.permissionsBody": "Safari cookie 的完整磁碟存取權限、Cookie 解密和 OAuth 流的 Keychain 存取權限以及提供者幫助程式讀取專案目錄時的檔案和資料夾提示。", + "footer.builtBy": "建造者", + "footer.changelog": "變更日誌", + "clipboard.copy": "複製到剪貼簿", + "clipboard.copied": "已複製" + }, + "ja-JP": { + "meta.title": "CodexBar — メニュー バーのすべての AI コーディング制限", + "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、53 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", + "meta.ogDescription": "macOS メニュー バーから、53 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", + "nav.primary": "主要な", + "nav.language": "言語", + "nav.docs": "ドキュメント", + "nav.source": "ソース", + "nav.download": "ダウンロード", + "theme.toLight": "ライトテーマに切り替える", + "theme.toDark": "ダークテーマに切り替える", + "hero.prefix": "AI コーディングのあらゆる制限、", + "hero.accent": "メニューバーにあります。", + "hero.description": "CodexBar は、実際に料金を支払っているプロバイダー全体の使用期間、クレジット残高、リセット カウントダウンを追跡します。ステータス項目を 1 つずつ、または 1 つに統合します。", + "hero.download": "macOS のダウンロード", + "hero.fineprint": "無料・オープンソース · macOS 14+ · GitHub Releases と Homebrew でユニバーサル版を提供", + "providers.title": "53 プロバイダー、1 つのメニュー バー", + "providers.description": "人気のあるプロバイダーは、独自の使用期間、リセット カウントダウン、グラフ、プロバイダー メニューを備えたステータス アイテムになります。", + "providers.yourProvider": "あなたのプロバイダー", + "providers.authoringGuide": "オーサリングガイド", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar メニュー バーのポップオーバー。使用量バー、リセット カウントダウン、クレジットの詳細を含むプロバイダー タイルが表示されます。", + "features.resetsTitle": "計画的にリセットできる", + "features.resetsBody": "プロバイダーごとのセッション、週次、月次のウィンドウに次のリセットまでのカウントダウンが表示されます。長いタスクを開始するかどうかを推測する必要はもうありません。", + "features.spendTitle": "クレジット、支出、コストのスキャン", + "features.spendBody": "プロバイダーが公開しているクレジット残高と毎月の支出に加え、Codex と Claude の構成可能なローカル コスト スキャン。", + "features.statusTitle": "ステータスとインシデント", + "features.statusBody": "プロバイダー ステータスのポーリングでは、メニューにインシデント バッジが表示され、バー アイコンにインジケーターがオーバーレイされます。", + "features.cliTitle": "CLI とウィジェット", + "features.cliBody": "サポートされているプロバイダー用の WidgetKit ウィジェットと、スクリプト用のバンドルされた {codexbar} CLI、および CI — macOS および Linux は各リリースでビルドされます。", + "detail.installTitle": "インストールとアップデート", + "detail.installBody": "Sparkle 自動更新を備えたユニバーサルアプリを {releases} から入手するか、{cask} でインストールできます({upgrade} で更新)。", + "detail.cliTitle": "CLI", + "detail.cliBody": "各リリースのバンドルされた {codexbar} バイナリと、macOS および Linux 用のスタンドアロン tarball。 Linux: {linuxCommand}。", + "detail.privacyTitle": "プライバシー", + "detail.privacyBody": "既存のプロバイダー セッション (OAuth、デバイス フロー、API キー、ブラウザー Cookie、ローカル ファイル) を再利用するため、パスワードは保存されません。 {issue} の監査メモ。", + "detail.permissionsTitle": "macOS 権限", + "detail.permissionsBody": "Safari Cookie に対するフルディスク アクセス、Cookie 復号化と OAuth フローに対する Keychain アクセス、プロバイダー ヘルパーがプロジェクト ディレクトリを読み取るときにファイルとフォルダーのプロンプトが表示されます。", + "footer.builtBy": "によって建てられました", + "footer.changelog": "変更履歴", + "clipboard.copy": "クリップボードにコピー", + "clipboard.copied": "コピーされました" + }, + "es": { + "meta.title": "CodexBar: cada límite de codificación de IA en tu barra de menú", + "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 53 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", + "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 53 proveedores de codificación de IA desde su barra de menú macOS.", + "nav.primary": "Primario", + "nav.language": "Idioma", + "nav.docs": "Documentos", + "nav.source": "Fuente", + "nav.download": "Descargar", + "theme.toLight": "Cambiar al tema claro", + "theme.toDark": "Cambiar al tema oscuro", + "hero.prefix": "Cada límite de codificación de IA,", + "hero.accent": "en su barra de menú.", + "hero.description": "CodexBar realiza un seguimiento de los períodos de uso, los saldos de crédito y restablece las cuentas regresivas de los proveedores por los que realmente paga: un elemento de estado para cada uno o los fusiona en uno solo.", + "hero.download": "Descargar para macOS", + "hero.fineprint": "Gratis y de código abierto · macOS 14+ · Universal mediante GitHub Releases y Homebrew", + "providers.title": "53 proveedores, una barra de menú", + "providers.description": "Los proveedores populares se convierten en elementos de estado con sus propias ventanas de uso, restablecen cuentas regresivas, gráficos y menús de proveedores.", + "providers.yourProvider": "Tu proveedor", + "providers.authoringGuide": "guía de autoría", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "Ventana emergente de la barra de menú CodexBar que muestra mosaicos de proveedores con barras de uso, restablecimiento de cuentas regresivas y detalles de crédito.", + "features.resetsTitle": "Restablecimientos que puedes planificar", + "features.resetsBody": "Sesión por proveedor, períodos semanales y mensuales con cuentas regresivas para el siguiente reinicio: ya no tendrá que adivinar si iniciar una tarea larga.", + "features.spendTitle": "Escaneos de créditos, gastos y costos", + "features.spendBody": "Saldos de crédito y gastos mensuales donde el proveedor los expone, además de escaneos de costos locales configurables para Codex y Claude.", + "features.statusTitle": "Estado e incidencias", + "features.statusBody": "El sondeo del estado del proveedor muestra insignias de incidentes en el menú y un indicador superpuesto en el ícono de la barra.", + "features.cliTitle": "CLI y widgets", + "features.cliBody": "Widgets WidgetKit para proveedores compatibles y {codexbar} CLI incluido para scripts y CI: macOS y Linux compilaciones en cada versión.", + "detail.installTitle": "Instalar y actualizar", + "detail.installBody": "Aplicación universal mediante {releases} con actualizaciones automáticas de Sparkle, o mediante {cask} (actualizaciones con {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Un paquete binario {codexbar} más archivos comprimidos independientes para macOS y Linux en cada versión. En Linux: {linuxCommand}.", + "detail.privacyTitle": "Privacidad", + "detail.privacyBody": "Reutiliza sesiones de proveedores existentes (OAuth, flujo de dispositivos, claves API, cookies del navegador, archivos locales) para que no se almacenen contraseñas. Notas de auditoría en {issue}.", + "detail.permissionsTitle": "permisos macOS", + "detail.permissionsBody": "Acceso total al disco para cookies Safari, acceso Keychain para descifrado de cookies y flujos OAuth, y avisos de Archivos y carpetas cuando los ayudantes del proveedor leen los directorios del proyecto.", + "footer.builtBy": "Construido por", + "footer.changelog": "Registro de cambios", + "clipboard.copy": "Copiar al portapapeles", + "clipboard.copied": "Copiado" + }, + "pt-BR": { + "meta.title": "CodexBar — todos os limites de codificação de IA na sua barra de menu", + "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 53 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", + "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 53 provedores de codificação de IA na barra de menu macOS.", + "nav.primary": "Primário", + "nav.language": "Linguagem", + "nav.docs": "Documentos", + "nav.source": "Fonte", + "nav.download": "Download", + "theme.toLight": "Mudar para tema claro", + "theme.toDark": "Mudar para tema escuro", + "hero.prefix": "Cada limite de codificação de IA,", + "hero.accent": "na sua barra de menu.", + "hero.description": "CodexBar rastreia janelas de uso, saldos de crédito e reinicia contagens regressivas nos provedores pelos quais você realmente paga - um item de status cada, ou mescla-os em um.", + "hero.download": "Baixar para macOS", + "hero.fineprint": "Gratuito e de código aberto · macOS 14+ · Universal via GitHub Releases e Homebrew", + "providers.title": "53 provedores, uma barra de menu", + "providers.description": "Provedores populares tornam-se itens de status com suas próprias janelas de uso, reiniciam contagens regressivas, gráficos e menus de provedores.", + "providers.yourProvider": "Seu provedor", + "providers.authoringGuide": "Guia de autoria", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "Popover da barra de menu CodexBar mostrando blocos de provedores com barras de uso, contagens regressivas de redefinição e detalhes de crédito.", + "features.resetsTitle": "Redefinições que você pode planejar", + "features.resetsBody": "Sessão por provedor, janelas semanais e mensais com contagem regressiva para a próxima redefinição — chega de ficar adivinhando se deve iniciar uma tarefa longa.", + "features.spendTitle": "Créditos, gastos e verificações de custos", + "features.spendBody": "Saldos de crédito e gastos mensais onde o provedor os expõe, além de verificações de custos locais configuráveis ​​para Codex e Claude.", + "features.statusTitle": "Status e incidentes", + "features.statusBody": "A pesquisa de status do provedor apresenta emblemas de incidentes no menu e uma sobreposição de indicador no ícone da barra.", + "features.cliTitle": "CLI e widgets", + "features.cliBody": "Widgets WidgetKit para provedores suportados e um pacote {codexbar} CLI para scripts e compilações CI — macOS e Linux em cada versão.", + "detail.installTitle": "Instalar e atualizações", + "detail.installBody": "Aplicativo universal via {releases} com atualizações automáticas do Sparkle ou via {cask} (atualizações com {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Um pacote binário {codexbar} mais tarballs independentes para macOS e Linux em cada versão. Em Linux: {linuxCommand}.", + "detail.privacyTitle": "Privacidade", + "detail.privacyBody": "Reutiliza sessões existentes do provedor — OAuth, fluxo do dispositivo, chaves API, cookies do navegador, arquivos locais — para que nenhuma senha seja armazenada. Notas de auditoria em {issue}.", + "detail.permissionsTitle": "macOS permissões", + "detail.permissionsBody": "Acesso total ao disco para cookies Safari, acesso Keychain para descriptografia de cookies e fluxos OAuth e prompts de arquivos e pastas quando ajudantes do provedor leem diretórios de projetos.", + "footer.builtBy": "Construído por", + "footer.changelog": "Registro de alterações", + "clipboard.copy": "Copiar para a área de transferência", + "clipboard.copied": "Copiado" + }, + "ko": { + "meta.title": "CodexBar — 메뉴 표시줄의 모든 AI 코딩 제한", + "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 53개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", + "meta.ogDescription": "macOS 메뉴 표시줄에서 53개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", + "nav.primary": "주요한", + "nav.language": "언어", + "nav.docs": "문서", + "nav.source": "원천", + "nav.download": "다운로드", + "theme.toLight": "밝은 테마로 전환", + "theme.toDark": "어두운 테마로 전환", + "hero.prefix": "모든 AI 코딩의 한계,", + "hero.accent": "메뉴 표시줄에", + "hero.description": "CodexBar는 귀하가 실제로 비용을 지불한 제공업체 전반에 걸쳐 사용 기간, 크레딧 잔액 및 재설정 카운트다운을 추적합니다. 즉, 각각 하나의 상태 항목 또는 하나로 병합됩니다.", + "hero.download": "macOS 동안 다운로드", + "hero.fineprint": "무료 오픈 소스 · macOS 14+ · GitHub Releases와 Homebrew에서 유니버설 제공", + "providers.title": "공급자 53개, 메뉴 표시줄 1개", + "providers.description": "인기 있는 제공업체는 자체 사용 창, 재설정 카운트다운, 차트 및 제공업체 메뉴를 갖춘 상태 항목이 됩니다.", + "providers.yourProvider": "귀하의 제공자", + "providers.authoringGuide": "저작 가이드", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar 사용량 표시줄, 재설정 카운트다운 및 신용 세부정보가 포함된 제공업체 타일을 표시하는 메뉴 표시줄 팝오버입니다.", + "features.resetsTitle": "계획할 수 있는 재설정", + "features.resetsBody": "다음 재설정까지 카운트다운이 가능한 공급자별 세션, 주간 및 월간 기간으로 더 이상 긴 작업을 시작할지 여부를 추측할 필요가 없습니다.", + "features.spendTitle": "크레딧, 지출, 비용 스캔", + "features.spendBody": "공급자가 노출하는 신용 ​​잔액 및 월별 지출과 Codex 및 Claude에 대한 구성 가능한 지역 비용 스캔.", + "features.statusTitle": "현황 및 사건", + "features.statusBody": "공급자 상태 폴링은 메뉴에 사건 배지를 표시하고 막대 아이콘에 표시기 오버레이를 표시합니다.", + "features.cliTitle": "CLI 및 위젯", + "features.cliBody": "지원되는 공급자를 위한 WidgetKit 위젯과 스크립트 및 CI용 번들 {codexbar} CLI — macOS 및 Linux는 모든 릴리스에서 빌드됩니다.", + "detail.installTitle": "설치 및 업데이트", + "detail.installBody": "Sparkle 자동 업데이트가 포함된 유니버설 앱은 {releases}에서, 또는 {cask}로 설치할 수 있습니다({upgrade}로 업데이트).", + "detail.cliTitle": "CLI", + "detail.cliBody": "각 릴리스의 macOS 및 Linux에 대한 번들 {codexbar} 바이너리와 독립 실행형 tarball입니다. Linux에: {linuxCommand}.", + "detail.privacyTitle": "은둔", + "detail.privacyBody": "기존 공급자 세션(OAuth, 장치 흐름, API 키, 브라우저 쿠키, 로컬 파일)을 재사용하므로 비밀번호가 저장되지 않습니다. {issue}의 감사 메모입니다.", + "detail.permissionsTitle": "macOS 권한", + "detail.permissionsBody": "Safari 쿠키에 대한 전체 디스크 액세스, 쿠키 암호 해독 및 OAuth 흐름에 대한 Keychain 액세스, 공급자 도우미가 프로젝트 디렉터리를 읽을 때 파일 및 폴더 메시지가 표시됩니다.", + "footer.builtBy": "제작사", + "footer.changelog": "변경 내역", + "clipboard.copy": "클립보드에 복사", + "clipboard.copied": "복사됨" + }, + "de": { + "meta.title": "CodexBar – alle Limits Ihrer KI-Coding-Tools in der Menüleiste", + "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 53 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", + "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 53 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", + "nav.primary": "Primär", + "nav.language": "Sprache", + "nav.docs": "Dokumente", + "nav.source": "Quelle", + "nav.download": "Herunterladen", + "theme.toLight": "Wechseln Sie zum Lichtthema", + "theme.toDark": "Wechseln Sie zum dunklen Thema", + "hero.prefix": "Alle Limits Ihrer KI-Coding-Tools –", + "hero.accent": "direkt in Ihrer Menüleiste.", + "hero.description": "CodexBar verfolgt Nutzungsfenster, Guthaben und Reset-Countdowns bei den Anbietern, für die Sie tatsächlich bezahlen – jeweils ein Statuselement oder sie werden zu einem zusammengeführt.", + "hero.download": "Herunterladen für macOS", + "hero.fineprint": "Kostenlos und Open Source · macOS 14+ · Universal über GitHub Releases und Homebrew", + "providers.title": "53 Anbieter, eine Menüleiste", + "providers.description": "Beliebte Anbieter werden zu Statuselementen mit eigenen Nutzungsfenstern, Reset-Countdowns, Diagrammen und Anbietermenüs.", + "providers.yourProvider": "Ihr Anbieter", + "providers.authoringGuide": "Autorenleitfaden", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar-Menüleisten-Popover mit Anbieterkacheln mit Nutzungsbalken, Reset-Countdowns und Kreditdetails.", + "features.resetsTitle": "Resets, die Sie planen können", + "features.resetsBody": "Sitzung pro Anbieter, wöchentliche und monatliche Fenster mit Countdowns bis zum nächsten Zurücksetzen – kein Raten mehr, ob eine lange Aufgabe gestartet werden soll.", + "features.spendTitle": "Gutschriften-, Ausgaben- und Kostenscans", + "features.spendBody": "Guthaben und monatliche Ausgaben dort, wo der Anbieter sie offenlegt, plus konfigurierbare lokale Kostenscans für Codex und Claude.", + "features.statusTitle": "Status und Vorfälle", + "features.statusBody": "Bei der Abfrage des Anbieterstatus werden Vorfall-Badges im Menü und ein Indikator-Overlay auf dem Balkensymbol angezeigt.", + "features.cliTitle": "CLI und Widgets", + "features.cliBody": "WidgetKit-Widgets für unterstützte Anbieter und eine gebündelte {codexbar} CLI für Skripte und CI – macOS und Linux bauen auf jeder Version auf.", + "detail.installTitle": "Installieren und aktualisieren", + "detail.installBody": "Universelle App über {releases} mit automatischen Sparkle-Updates oder über {cask} (Updates mit {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Eine gebündelte {codexbar}-Binärdatei plus eigenständige Tarballs für macOS und Linux in jeder Version. Auf Linux: {linuxCommand}.", + "detail.privacyTitle": "Privatsphäre", + "detail.privacyBody": "Verwendet vorhandene Anbietersitzungen – OAuth, Gerätefluss, API-Schlüssel, Browser-Cookies, lokale Dateien – wieder, sodass keine Passwörter gespeichert werden. Prüfnotizen in {issue}.", + "detail.permissionsTitle": "macOS-Berechtigungen", + "detail.permissionsBody": "Vollständiger Festplattenzugriff für Safari-Cookies, Keychain-Zugriff für Cookie-Entschlüsselung und OAuth-Abläufe sowie Eingabeaufforderungen für Dateien und Ordner, wenn Anbieterhelfer Projektverzeichnisse lesen.", + "footer.builtBy": "Gebaut von", + "footer.changelog": "Änderungsprotokoll", + "clipboard.copy": "In die Zwischenablage kopieren", + "clipboard.copied": "Kopiert" + }, + "fr": { + "meta.title": "CodexBar — chaque limite de codage IA dans votre barre de menus", + "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 53 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", + "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 53 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", + "nav.primary": "Primaire", + "nav.language": "Langue", + "nav.docs": "Documents", + "nav.source": "Source", + "nav.download": "Télécharger", + "theme.toLight": "Passer au thème clair", + "theme.toDark": "Passer au thème sombre", + "hero.prefix": "Chaque limite de codage AI,", + "hero.accent": "dans votre barre de menu.", + "hero.description": "CodexBar suit les fenêtres d'utilisation, les soldes créditeurs et réinitialise les comptes à rebours pour les fournisseurs pour lesquels vous payez réellement - un élément de statut chacun, ou les fusionne en un seul.", + "hero.download": "Télécharger pour macOS", + "hero.fineprint": "Gratuit et open source · macOS 14+ · Universel via GitHub Releases et Homebrew", + "providers.title": "53 fournisseurs, une barre de menu", + "providers.description": "Les fournisseurs populaires deviennent des éléments de statut avec leurs propres fenêtres d'utilisation, réinitialisent les comptes à rebours, les graphiques et les menus des fournisseurs.", + "providers.yourProvider": "Votre fournisseur", + "providers.authoringGuide": "Guide de création", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "Fenêtre contextuelle de la barre de menu CodexBar affichant les vignettes du fournisseur avec des barres d'utilisation, des comptes à rebours de réinitialisation et des détails de crédit.", + "features.resetsTitle": "Des réinitialisations que vous pouvez planifier", + "features.resetsBody": "Session par fournisseur, fenêtres hebdomadaires et mensuelles avec comptes à rebours jusqu'à la prochaine réinitialisation : plus besoin de deviner s'il faut démarrer une longue tâche.", + "features.spendTitle": "Analyses des crédits, des dépenses et des coûts", + "features.spendBody": "Soldes créditeurs et dépenses mensuelles là où le fournisseur les expose, ainsi que des analyses de coûts locales configurables pour Codex et Claude.", + "features.statusTitle": "Statut et incidents", + "features.statusBody": "L'interrogation de l'état du fournisseur fait apparaître des badges d'incident dans le menu et un indicateur superposé sur l'icône de la barre.", + "features.cliTitle": "CLI et widgets", + "features.cliBody": "Des widgets WidgetKit pour les fournisseurs pris en charge et un {codexbar} CLI fourni pour les scripts et CI — macOS et Linux s'appuient sur chaque version.", + "detail.installTitle": "Installation et mises à jour", + "detail.installBody": "Application universelle via {releases} avec mises à jour automatiques Sparkle, ou via {cask} (mises à jour avec {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Un binaire {codexbar} fourni ainsi que des archives tar autonomes pour macOS et Linux sur chaque version. Sur Linux : {linuxCommand}.", + "detail.privacyTitle": "Confidentialité", + "detail.privacyBody": "Réutilise les sessions du fournisseur existantes (OAuth, flux de périphérique, clés API, cookies du navigateur, fichiers locaux) afin qu'aucun mot de passe ne soit stocké. Notes d'audit dans {issue}.", + "detail.permissionsTitle": "autorisations macOS", + "detail.permissionsBody": "Accès complet au disque pour les cookies Safari, accès Keychain pour le décryptage des cookies et les flux OAuth, et fichiers et dossiers vous invitent lorsque les assistants du fournisseur lisent les répertoires du projet.", + "footer.builtBy": "Construit par", + "footer.changelog": "Journal des modifications", + "clipboard.copy": "Copier dans le presse-papier", + "clipboard.copied": "Copié" + }, + "ar": { + "meta.title": "CodexBar — كل حد لترميز الذكاء الاصطناعي في شريط القائمة", + "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 53 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", + "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 53 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", + "nav.primary": "أساسي", + "nav.language": "لغة", + "nav.docs": "المستندات", + "nav.source": "مصدر", + "nav.download": "تحميل", + "theme.toLight": "التبديل إلى موضوع الضوء", + "theme.toDark": "التبديل إلى المظهر الداكن", + "hero.prefix": "كل حدود ترميز الذكاء الاصطناعي،", + "hero.accent": "في شريط القائمة الخاص بك.", + "hero.description": "يتتبع CodexBar فترات الاستخدام والأرصدة الائتمانية وإعادة تعيين العد التنازلي عبر مقدمي الخدمة الذين تدفع مقابلهم فعليًا — عنصر حالة واحد لكل منهم، أو دمجهم في عنصر واحد.", + "hero.download": "التنزيل لمدة macOS", + "hero.fineprint": "مجاني ومفتوح المصدر · macOS 14+ · إصدار شامل عبر GitHub Releases وHomebrew", + "providers.title": "53 مزودًا، وشريط قوائم واحد", + "providers.description": "يصبح الموفرون المشهورون عناصر حالة مع نوافذ الاستخدام الخاصة بهم، وعمليات إعادة تعيين العد التنازلي، والمخططات، وقوائم الموفر.", + "providers.yourProvider": "المزود الخاص بك", + "providers.authoringGuide": "دليل التأليف", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar القائمة المنبثقة لشريط القائمة تعرض مربعات الموفر مع أشرطة الاستخدام، وعمليات إعادة التعيين التنازلية، وتفاصيل الائتمان.", + "features.resetsTitle": "إعادة تعيين يمكنك التخطيط حولها", + "features.resetsBody": "جلسة لكل مزود، ونوافذ أسبوعية وشهرية مع العد التنازلي لإعادة التعيين التالية - لم تعد هناك حاجة للتخمين بشأن بدء مهمة طويلة.", + "features.spendTitle": "عمليات فحص الاعتمادات والإنفاق والتكلفة", + "features.spendBody": "الأرصدة الدائنة والإنفاق الشهري حيث يعرضها مقدم الخدمة، بالإضافة إلى عمليات فحص التكلفة المحلية القابلة للتكوين لـ Codex وClaude.", + "features.statusTitle": "الحالة والحوادث", + "features.statusBody": "يعرض استقصاء حالة الموفر شارات الحوادث في القائمة وتراكب المؤشر على أيقونة الشريط.", + "features.cliTitle": "CLI والحاجيات", + "features.cliBody": "أدوات WidgetKit للموفرين المدعومين و{codexbar} CLI المجمعة للنصوص البرمجية وCI - يعتمد macOS وLinux على كل إصدار.", + "detail.installTitle": "التثبيت والتحديثات", + "detail.installBody": "تطبيق شامل عبر {releases} مع تحديثات تلقائية لـ Sparkle، أو عبر {cask} (التحديثات باستخدام {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "ملف ثنائي {codexbar} مجمع بالإضافة إلى كرات قطران مستقلة لـ macOS وLinux في كل إصدار. في Linux: {linuxCommand}.", + "detail.privacyTitle": "خصوصية", + "detail.privacyBody": "يعيد استخدام جلسات الموفر الحالية - OAuth، وتدفق الجهاز، ومفاتيح API، وملفات تعريف الارتباط للمتصفح، والملفات المحلية - لذلك لا يتم تخزين كلمات المرور. ملاحظات التدقيق في {issue}.", + "detail.permissionsTitle": "أذونات macOS.", + "detail.permissionsBody": "الوصول الكامل إلى القرص لملفات تعريف الارتباط Safari، والوصول Keychain لفك تشفير ملفات تعريف الارتباط وتدفقات OAuth، ومطالبة الملفات والمجلدات عندما يقرأ مساعدو الموفر أدلة المشروع.", + "footer.builtBy": "بنيت من قبل", + "footer.changelog": "سجل التغيير", + "clipboard.copy": "نسخ إلى الحافظة", + "clipboard.copied": "منقول" + }, + "it": { + "meta.title": "CodexBar: ogni limite di codifica AI nella barra dei menu", + "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 53 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", + "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 53 fornitori di codifica AI dalla barra dei menu macOS.", + "nav.primary": "Primario", + "nav.language": "Lingua", + "nav.docs": "Documenti", + "nav.source": "Fonte", + "nav.download": "Scaricamento", + "theme.toLight": "Passa al tema chiaro", + "theme.toDark": "Passa al tema scuro", + "hero.prefix": "Ogni limite di codifica AI,", + "hero.accent": "nella barra dei menu.", + "hero.description": "CodexBar tiene traccia delle finestre di utilizzo, dei saldi del credito e reimposta i conti alla rovescia tra i fornitori per cui paghi effettivamente: un elemento di stato ciascuno o uniscili in uno solo.", + "hero.download": "Scarica per macOS", + "hero.fineprint": "Gratuito e open source · macOS 14+ · Universale tramite GitHub Releases e Homebrew", + "providers.title": "53 fornitori, una barra dei menu", + "providers.description": "I fornitori più popolari diventano elementi di stato con le proprie finestre di utilizzo, reimpostano i conti alla rovescia, i grafici e i menu dei fornitori.", + "providers.yourProvider": "Il tuo fornitore", + "providers.authoringGuide": "Guida all'autore", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "Popover della barra dei menu CodexBar che mostra i riquadri del fornitore con barre di utilizzo, reimpostazione del conto alla rovescia e dettagli del credito.", + "features.resetsTitle": "Ripristini che puoi pianificare", + "features.resetsBody": "Sessione per fornitore, finestre settimanali e mensili con conto alla rovescia fino al ripristino successivo: non dovrai più indovinare se avviare un'attività lunga.", + "features.spendTitle": "Crediti, spesa e scansioni dei costi", + "features.spendBody": "Saldi dei crediti e spesa mensile laddove il fornitore li espone, oltre a scansioni dei costi locali configurabili per Codex e Claude.", + "features.statusTitle": "Stato e incidenti", + "features.statusBody": "Il sondaggio sullo stato del fornitore mostra i badge degli incidenti nel menu e un indicatore in sovrapposizione sull'icona della barra.", + "features.cliTitle": "CLI e widget", + "features.cliBody": "Widget WidgetKit per i provider supportati e un {codexbar} CLI in bundle per gli script e CI: macOS e Linux si basano su ogni versione.", + "detail.installTitle": "Installa e aggiorna", + "detail.installBody": "App universale tramite {releases} con aggiornamenti automatici di Sparkle o tramite {cask} (aggiornamenti con {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Un pacchetto binario {codexbar} più tarball indipendenti per macOS e Linux su ogni versione. Su Linux: {linuxCommand}.", + "detail.privacyTitle": "Privacy", + "detail.privacyBody": "Riutilizza le sessioni del provider esistenti (OAuth, flusso del dispositivo, chiavi API, cookie del browser, file locali), quindi non viene memorizzata alcuna password. Note di controllo in {issue}.", + "detail.permissionsTitle": "macOS autorizzazioni", + "detail.permissionsBody": "Accesso completo al disco per cookie Safari, accesso Keychain per la decrittografia dei cookie e flussi OAuth e richieste di File e cartelle quando gli aiutanti del provider leggono le directory del progetto.", + "footer.builtBy": "Costruito da", + "footer.changelog": "Registro delle modifiche", + "clipboard.copy": "Copia negli appunti", + "clipboard.copied": "Copiato" + }, + "vi": { + "meta.title": "CodexBar — mọi giới hạn mã hóa AI trong thanh menu của bạn", + "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 53 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", + "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 53 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", + "nav.primary": "Sơ đẳng", + "nav.language": "Ngôn ngữ", + "nav.docs": "Tài liệu", + "nav.source": "Nguồn", + "nav.download": "Tải xuống", + "theme.toLight": "Chuyển sang giao diện nhẹ nhàng", + "theme.toDark": "Chuyển sang chủ đề tối", + "hero.prefix": "Mọi giới hạn mã hóa AI,", + "hero.accent": "trong thanh menu của bạn.", + "hero.description": "CodexBar theo dõi khoảng thời gian sử dụng, số dư tín dụng và đếm ngược đặt lại trên các nhà cung cấp mà bạn thực sự thanh toán — mỗi mục một trạng thái hoặc hợp nhất chúng thành một.", + "hero.download": "Tải xuống cho macOS", + "hero.fineprint": "Miễn phí và nguồn mở · macOS 14+ · Bản universal qua GitHub Releases và Homebrew", + "providers.title": "53 nhà cung cấp, một thanh menu", + "providers.description": "Các nhà cung cấp phổ biến trở thành các mục trạng thái với cửa sổ sử dụng của riêng họ, đặt lại bộ đếm ngược, biểu đồ và menu nhà cung cấp.", + "providers.yourProvider": "Nhà cung cấp của bạn", + "providers.authoringGuide": "Hướng dẫn soạn thảo", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "Cửa sổ bật lên thanh menu CodexBar hiển thị các ô nhà cung cấp có thanh sử dụng, đặt lại đếm ngược và chi tiết tín dụng.", + "features.resetsTitle": "Đặt lại bạn có thể lập kế hoạch xung quanh", + "features.resetsBody": "Phiên dành cho mỗi nhà cung cấp, khoảng thời gian hàng tuần và hàng tháng có đếm ngược đến lần đặt lại tiếp theo — không cần phải đoán xem có nên bắt đầu một nhiệm vụ dài hay không.", + "features.spendTitle": "Tín dụng, chi tiêu và quét chi phí", + "features.spendBody": "Số dư tín dụng và chi tiêu hàng tháng mà nhà cung cấp hiển thị, cùng với tính năng quét chi phí địa phương có thể định cấu hình cho Codex và Claude.", + "features.statusTitle": "Tình trạng & sự cố", + "features.statusBody": "Cuộc thăm dò trạng thái nhà cung cấp hiển thị các huy hiệu sự cố trong menu và lớp phủ chỉ báo trên biểu tượng thanh.", + "features.cliTitle": "CLI & tiện ích", + "features.cliBody": "Tiện ích WidgetKit dành cho các nhà cung cấp được hỗ trợ và một gói {codexbar} CLI dành cho tập lệnh và CI — macOS và Linux được xây dựng trên mỗi bản phát hành.", + "detail.installTitle": "Cài đặt và cập nhật", + "detail.installBody": "Ứng dụng universal qua {releases} với cập nhật Sparkle tự động, hoặc qua {cask} (cập nhật bằng {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Một gói nhị phân {codexbar} cộng với các tarball độc lập dành cho macOS và Linux trên mỗi bản phát hành. Trên Linux: {linuxCommand}.", + "detail.privacyTitle": "Sự riêng tư", + "detail.privacyBody": "Sử dụng lại các phiên nhà cung cấp hiện có — OAuth, luồng thiết bị, khóa API, cookie trình duyệt, tệp cục bộ — nên không có mật khẩu nào được lưu trữ. Ghi chú kiểm tra trong {issue}.", + "detail.permissionsTitle": "quyền macOS", + "detail.permissionsBody": "Quyền truy cập toàn bộ ổ đĩa cho cookie Safari, quyền truy cập Keychain để giải mã cookie và luồng OAuth cũng như lời nhắc về Tệp & Thư mục khi người trợ giúp nhà cung cấp đọc thư mục dự án.", + "footer.builtBy": "Được xây dựng bởi", + "footer.changelog": "Nhật ký thay đổi", + "clipboard.copy": "Sao chép vào bảng nhớ tạm", + "clipboard.copied": "Đã sao chép" + }, + "nl": { + "meta.title": "CodexBar — elke AI-coderingslimiet in uw menubalk", + "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 53 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", + "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 53 leveranciers van AI-codering via uw macOS-menubalk.", + "nav.primary": "Primair", + "nav.language": "Taal", + "nav.docs": "Documenten", + "nav.source": "Bron", + "nav.download": "Downloaden", + "theme.toLight": "Schakel over naar het lichtthema", + "theme.toDark": "Schakel over naar een donker thema", + "hero.prefix": "Elke AI-coderingslimiet,", + "hero.accent": "in uw menubalk.", + "hero.description": "CodexBar houdt gebruiksperioden, tegoeden en reset-countdowns bij voor de providers waarvoor u daadwerkelijk betaalt: elk één statusitem, of u kunt ze samenvoegen tot één statusitem.", + "hero.download": "Downloaden voor macOS", + "hero.fineprint": "Gratis en open source · macOS 14+ · Universeel via GitHub Releases en Homebrew", + "providers.title": "53 aanbieders, één menubalk", + "providers.description": "Populaire providers worden statusitems met hun eigen gebruiksvensters, resetcountdowns, grafieken en providermenu's.", + "providers.yourProvider": "Uw aanbieder", + "providers.authoringGuide": "Handleiding voor het schrijven", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar menubalkpopover met providertegels met gebruiksbalken, aftellingen voor resetten en kredietgegevens.", + "features.resetsTitle": "Resets die u kunt plannen", + "features.resetsBody": "Per sessie, wekelijkse en maandelijkse vensters per provider met aftelling naar de volgende reset - u hoeft niet meer te raden of u aan een lange taak moet beginnen.", + "features.spendTitle": "Credits, uitgaven en kostenscans", + "features.spendBody": "Tegoeden en maandelijkse uitgaven waar de provider deze openbaar maakt, plus configureerbare lokale kostenscans voor Codex en Claude.", + "features.statusTitle": "Status & incidenten", + "features.statusBody": "Bij het peilen van de providerstatus worden incidentbadges in het menu en een indicator-overlay op het balkpictogram weergegeven.", + "features.cliTitle": "CLI en widgets", + "features.cliBody": "WidgetKit widgets voor ondersteunde providers en een gebundelde {codexbar} CLI voor scripts en CI — macOS en Linux bouwt voort op elke release.", + "detail.installTitle": "Installeren en bijwerken", + "detail.installBody": "Universele app via {releases} met automatische Sparkle-updates, of via {cask} (updates met {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Een gebundeld binair {codexbar}-bestand plus zelfstandige tarballs voor macOS en Linux bij elke release. Op Linux: {linuxCommand}.", + "detail.privacyTitle": "Privacy", + "detail.privacyBody": "Hergebruik bestaande providersessies (OAuth, apparaatstroom, API-sleutels, browsercookies, lokale bestanden) zodat er geen wachtwoorden worden opgeslagen. Controlenotities in {issue}.", + "detail.permissionsTitle": "macOS rechten", + "detail.permissionsBody": "Volledige schijftoegang voor Safari-cookies, Keychain-toegang voor het decoderen van cookies en OAuth-stromen, en aanwijzingen voor Bestanden en mappen wanneer helpers van de provider projectmappen lezen.", + "footer.builtBy": "Gebouwd door", + "footer.changelog": "Wijzigingslog", + "clipboard.copy": "Kopiëren naar klembord", + "clipboard.copied": "Gekopieerd" + }, + "tr": { + "meta.title": "CodexBar — menü çubuğunuzdaki tüm AI kodlama limitleri", + "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 53 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", + "meta.ogDescription": "macOS menü çubuğunu kullanarak 53 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", + "nav.primary": "Öncelik", + "nav.language": "Dil", + "nav.docs": "Dokümanlar", + "nav.source": "Kaynak", + "nav.download": "İndirmek", + "theme.toLight": "Açık temaya geç", + "theme.toDark": "Koyu temaya geç", + "hero.prefix": "Her AI kodlama sınırı,", + "hero.accent": "Menü çubuğunuzda.", + "hero.description": "CodexBar, gerçekte ödeme yaptığınız sağlayıcılar genelinde kullanım pencerelerini, kredi bakiyelerini ve geri sayımları sıfırlar (her biri bir durum öğesi olacak şekilde) izler veya bunları tek bir öğede birleştirir.", + "hero.download": "macOS için indirin", + "hero.fineprint": "Ücretsiz ve açık kaynak · macOS 14+ · GitHub Releases ve Homebrew ile evrensel sürüm", + "providers.title": "53 sağlayıcı, bir menü çubuğu", + "providers.description": "Popüler sağlayıcılar, kendi kullanım pencereleri, sıfırlama geri sayımları, çizelgeleri ve sağlayıcı menüleriyle durum öğeleri haline gelir.", + "providers.yourProvider": "Sağlayıcınız", + "providers.authoringGuide": "Yazma kılavuzu", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "Kullanım çubukları, sıfırlama geri sayımları ve kredi ayrıntılarını içeren sağlayıcı kutucuklarını gösteren CodexBar menü çubuğu açılır penceresi.", + "features.resetsTitle": "Planlayabileceğiniz sıfırlamalar", + "features.resetsBody": "Sağlayıcı başına oturum, haftalık ve aylık pencereler ile bir sonraki sıfırlama için geri sayımlar; artık uzun bir göreve başlayıp başlamayacağınızı tahmin etmenize gerek yok.", + "features.spendTitle": "Krediler, harcama ve maliyet taramaları", + "features.spendBody": "Sağlayıcının sunduğu kredi bakiyeleri ve aylık harcamaların yanı sıra Codex ve Claude için yapılandırılabilir yerel maliyet taramaları.", + "features.statusTitle": "Durum ve olaylar", + "features.statusBody": "Sağlayıcı durum anketi, menüdeki olay rozetlerini ve çubuk simgesinin üzerinde bir gösterge katmanını gösterir.", + "features.cliTitle": "CLI ve widget'lar", + "features.cliBody": "Desteklenen sağlayıcılar için WidgetKit widget'ları ve komut dosyaları için paket halinde bir {codexbar} CLI ve her sürümde CI — macOS ve Linux derlemeleri.", + "detail.installTitle": "Yükleme ve güncellemeler", + "detail.installBody": "Sparkle otomatik güncellemeli evrensel uygulamayı {releases} üzerinden veya {cask} ile kurun ({upgrade} ile güncellenir).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Her sürümde macOS ve Linux için paketlenmiş bir {codexbar} ikili programı ve bağımsız tarball'lar. Linux üzerinde: {linuxCommand}.", + "detail.privacyTitle": "Mahremiyet", + "detail.privacyBody": "Mevcut sağlayıcı oturumlarını (OAuth, cihaz akışı, API anahtarları, tarayıcı çerezleri, yerel dosyalar) yeniden kullanır, böylece hiçbir şifre saklanmaz. {issue} içindeki denetim notları.", + "detail.permissionsTitle": "macOS izinleri", + "detail.permissionsBody": "Safari çerezleri için Tam Disk Erişimi, çerez şifre çözme ve OAuth akışları için Keychain erişimi ve Dosyalar ve Klasörler, sağlayıcı yardımcıları proje dizinlerini okuduğunda istemde bulunur.", + "footer.builtBy": "Tarafından inşa edildi", + "footer.changelog": "Değişiklik günlüğü", + "clipboard.copy": "Panoya kopyala", + "clipboard.copied": "Kopyalandı" + }, + "uk": { + "meta.title": "CodexBar — кожне обмеження кодування AI у вашій панелі меню", + "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 53 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", + "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 53 постачальників кодування ШІ за допомогою панелі меню macOS.", + "nav.primary": "Первинний", + "nav.language": "Мова", + "nav.docs": "документи", + "nav.source": "Джерело", + "nav.download": "Завантажити", + "theme.toLight": "Переключитися на світлу тему", + "theme.toDark": "Переключитися на темну тему", + "hero.prefix": "Кожен ліміт кодування ШІ,", + "hero.accent": "у панелі меню.", + "hero.description": "CodexBar відстежує вікна використання, кредитні баланси та скидає зворотний відлік для постачальників, за яких ви фактично платите, — по одному статусу для кожного або об’єднує їх в один.", + "hero.download": "Завантажити для macOS", + "hero.fineprint": "Безкоштовний і відкритий код · macOS 14+ · Універсальна версія через GitHub Releases і Homebrew", + "providers.title": "53 провайдерів, одна панель меню", + "providers.description": "Популярні постачальники стають елементами статусу з власними вікнами використання, скиданням зворотного відліку, діаграмами та меню постачальників.", + "providers.yourProvider": "Ваш провайдер", + "providers.authoringGuide": "Авторський посібник", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar спливаюче вікно панелі меню, що показує плитки постачальника зі смужками використання, зворотним відліком скидання та деталями кредиту.", + "features.resetsTitle": "Скидання, які ви можете планувати", + "features.resetsBody": "Сеанси для кожного постачальника, тижневі та місячні вікна зі зворотним відліком до наступного скидання — більше не потрібно гадати, чи починати довге завдання.", + "features.spendTitle": "Сканування кредитів, витрат і витрат", + "features.spendBody": "Кредитні баланси та щомісячні витрати там, де їх показує постачальник, а також настроювані локальні сканування витрат для Codex і Claude.", + "features.statusTitle": "Статус та інциденти", + "features.statusBody": "Поверхні для опитування статусу постачальника показують значки в меню та накладення індикатора на піктограму панелі.", + "features.cliTitle": "CLI і віджети", + "features.cliBody": "Віджети WidgetKit для підтримуваних постачальників і пакет {codexbar} CLI для сценаріїв і CI — macOS і Linux будуються на кожному випуску.", + "detail.installTitle": "Встановлення та оновлення", + "detail.installBody": "Універсальний додаток через {releases} з автоматичними оновленнями Sparkle або через {cask} (оновлення за допомогою {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Двійковий файл {codexbar} у комплекті, а також окремі архіви для macOS і Linux у кожному випуску. На Linux: {linuxCommand}.", + "detail.privacyTitle": "Конфіденційність", + "detail.privacyBody": "Повторно використовує існуючі сеанси постачальника — OAuth, потік пристрою, ключі API, файли cookie браузера, локальні файли — тому паролі не зберігаються. Аудиторські примітки в {issue}.", + "detail.permissionsTitle": "macOS дозволи", + "detail.permissionsBody": "Повний доступ до диска для файлів cookie Safari, доступ Keychain для розшифровки файлів cookie та потоків OAuth, а також підказки щодо файлів і папок, коли помічники постачальника читають каталоги проекту.", + "footer.builtBy": "Побудовано", + "footer.changelog": "Журнал змін", + "clipboard.copy": "Копіювати в буфер обміну", + "clipboard.copied": "Скопійовано" + }, + "id": { + "meta.title": "CodexBar — setiap batas pengkodean AI di bilah menu Anda", + "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 53 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", + "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 53 penyedia pengkodean AI dari bilah menu macOS Anda.", + "nav.primary": "Utama", + "nav.language": "Bahasa", + "nav.docs": "dokumen", + "nav.source": "Sumber", + "nav.download": "Unduh", + "theme.toLight": "Beralih ke tema terang", + "theme.toDark": "Beralih ke tema gelap", + "hero.prefix": "Setiap batas pengkodean AI,", + "hero.accent": "di bilah menu Anda.", + "hero.description": "CodexBar melacak jangka waktu penggunaan, saldo kredit, dan hitungan mundur penyetelan ulang di seluruh penyedia yang sebenarnya Anda bayar — masing-masing satu item status, atau gabungkan menjadi satu.", + "hero.download": "Unduh untuk macOS", + "hero.fineprint": "Gratis dan sumber terbuka · macOS 14+ · Universal melalui GitHub Releases dan Homebrew", + "providers.title": "53 penyedia, satu bilah menu", + "providers.description": "Penyedia populer menjadi item status dengan jendela penggunaannya sendiri, hitung mundur pengaturan ulang, bagan, dan menu penyedia.", + "providers.yourProvider": "Penyedia Anda", + "providers.authoringGuide": "Panduan penulisan", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar popover bilah menu menampilkan ubin penyedia dengan bilah penggunaan, hitungan mundur penyetelan ulang, dan detail kredit.", + "features.resetsTitle": "Penyetelan ulang dapat Anda rencanakan", + "features.resetsBody": "Sesi per penyedia, jendela mingguan, dan bulanan dengan hitungan mundur ke pengaturan ulang berikutnya — tidak perlu lagi menebak-nebak apakah akan memulai tugas yang panjang.", + "features.spendTitle": "Pemindaian kredit, pembelanjaan, dan biaya", + "features.spendBody": "Saldo kredit dan pembelanjaan bulanan di mana penyedia memaparkannya, ditambah pemindaian biaya lokal yang dapat dikonfigurasi untuk Codex dan Claude.", + "features.statusTitle": "Status & insiden", + "features.statusBody": "Jajak pendapat status penyedia menampilkan lencana insiden di menu dan hamparan indikator pada ikon bilah.", + "features.cliTitle": "CLI & widget", + "features.cliBody": "Widget WidgetKit untuk penyedia yang didukung dan paket {codexbar} CLI untuk skrip dan CI — macOS dan Linux dibuat pada setiap rilis.", + "detail.installTitle": "Instal & perbarui", + "detail.installBody": "Aplikasi universal melalui {releases} dengan pembaruan otomatis Sparkle, atau melalui {cask} (pembaruan dengan {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Biner {codexbar} yang dibundel ditambah tarbal mandiri untuk macOS dan Linux pada setiap rilis. Pada Linux: {linuxCommand}.", + "detail.privacyTitle": "Pribadi", + "detail.privacyBody": "Menggunakan kembali sesi penyedia yang ada — OAuth, alur perangkat, kunci API, cookie browser, file lokal — sehingga tidak ada kata sandi yang disimpan. Catatan audit di {issue}.", + "detail.permissionsTitle": "izin macOS", + "detail.permissionsBody": "Akses Disk Penuh untuk cookie Safari, akses Keychain untuk dekripsi cookie dan alur OAuth, serta permintaan File & Folder saat pembantu penyedia membaca direktori proyek.", + "footer.builtBy": "Dibangun oleh", + "footer.changelog": "log perubahan", + "clipboard.copy": "Salin ke papan klip", + "clipboard.copied": "Disalin" + }, + "pl": { + "meta.title": "CodexBar — każdy limit kodowania AI na pasku menu", + "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 53 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", + "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 53 dostawców kodowania AI za pomocą paska menu macOS.", + "nav.primary": "Podstawowy", + "nav.language": "Język", + "nav.docs": "Dokumenty", + "nav.source": "Źródło", + "nav.download": "Pobierać", + "theme.toLight": "Przełącz na jasny motyw", + "theme.toDark": "Przełącz na ciemny motyw", + "hero.prefix": "Każdy limit kodowania AI,", + "hero.accent": "na pasku menu.", + "hero.description": "CodexBar śledzi okna użytkowania, salda kredytów i resetuje odliczanie u dostawców, za których faktycznie płacisz — po jednym statusie dla każdego lub połącz je w jeden.", + "hero.download": "Pobierz dla macOS", + "hero.fineprint": "Darmowe i otwarte oprogramowanie · macOS 14+ · Wersja uniwersalna przez GitHub Releases i Homebrew", + "providers.title": "53 dostawców, jeden pasek menu", + "providers.description": "Popularni dostawcy stają się elementami statusu z własnymi oknami użytkowania, resetowaniem odliczania, wykresami i menu dostawców.", + "providers.yourProvider": "Twój dostawca", + "providers.authoringGuide": "Przewodnik autorski", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "Wyskakujący pasek menu CodexBar pokazujący kafelki dostawców z paskami użytkowania, resetowaniem odliczania i szczegółami kredytu.", + "features.resetsTitle": "Resety, które możesz zaplanować", + "features.resetsBody": "Sesja dla każdego dostawcy, okna tygodniowe i miesięczne z odliczaniem do następnego resetu — koniec z zastanawianiem się, czy rozpocząć długie zadanie.", + "features.spendTitle": "Skanowanie kredytów, wydatków i kosztów", + "features.spendBody": "Salda kredytowe i miesięczne wydatki tam, gdzie dostawca je udostępnia, a także konfigurowalne lokalne skanowanie kosztów dla Codexu i Claude.", + "features.statusTitle": "Stan i zdarzenia", + "features.statusBody": "Odpytywanie stanu dostawcy wyświetla w menu plakietki zdarzeń oraz nakładkę wskaźnika na ikonie paska.", + "features.cliTitle": "CLI i widżety", + "features.cliBody": "Widżety WidgetKit dla obsługiwanych dostawców oraz pakiet {codexbar} CLI dla skryptów i kompilacji CI — macOS i Linux w każdej wersji.", + "detail.installTitle": "Zainstaluj i aktualizuj", + "detail.installBody": "Aplikacja uniwersalna przez {releases} z automatycznymi aktualizacjami Sparkle lub przez {cask} (aktualizacje za pomocą {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Dołączony plik binarny {codexbar} oraz samodzielne archiwum tar dla macOS i Linux w każdym wydaniu. W Linux: {linuxCommand}.", + "detail.privacyTitle": "Prywatność", + "detail.privacyBody": "Wykorzystuje ponownie istniejące sesje dostawcy — OAuth, przepływ urządzenia, klucze API, pliki cookie przeglądarki, pliki lokalne — dzięki czemu nie są przechowywane żadne hasła. Notatki z audytu w {issue}.", + "detail.permissionsTitle": "uprawnienia macOS", + "detail.permissionsBody": "Pełny dostęp do dysku dla plików cookie Safari, dostęp Keychain do odszyfrowywania plików cookie i przepływów OAuth oraz monit o pliki i foldery, gdy pomocnicy dostawcy czytają katalogi projektu.", + "footer.builtBy": "Zbudowany przez", + "footer.changelog": "Dziennik zmian", + "clipboard.copy": "Skopiuj do schowka", + "clipboard.copied": "Skopiowano" + }, + "fa": { + "meta.title": "CodexBar - هر محدودیت کدنویسی هوش مصنوعی در نوار منو شما", + "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 53 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", + "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 53 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", + "nav.primary": "اولیه", + "nav.language": "زبان", + "nav.docs": "اسناد", + "nav.source": "منبع", + "nav.download": "دانلود کنید", + "theme.toLight": "به طرح زمینه روشن تغییر دهید", + "theme.toDark": "به طرح زمینه تیره بروید", + "hero.prefix": "هر محدودیت کدنویسی هوش مصنوعی،", + "hero.accent": "در نوار منوی شما", + "hero.description": "CodexBar پنجره‌های استفاده، مانده اعتبار و بازنشانی شمارش معکوس را در سراسر ارائه‌دهندگانی که واقعاً برایشان پول پرداخت می‌کنید ردیابی می‌کند - هر کدام یک مورد وضعیت، یا آنها را در یکی ادغام کنید.", + "hero.download": "دانلود برای macOS", + "hero.fineprint": "رایگان و متن‌باز · macOS 14+ · نسخهٔ یونیورسال از GitHub Releases و Homebrew", + "providers.title": "53 ارائه دهنده، یک نوار منو", + "providers.description": "ارائه‌دهندگان محبوب با پنجره‌های استفاده خاص خود، شمارش معکوس، نمودارها و منوهای ارائه‌دهنده را بازنشانی می‌کنند.", + "providers.yourProvider": "ارائه دهنده شما", + "providers.authoringGuide": "راهنمای نگارش", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "پاپاور نوار منو CodexBar که کاشی‌های ارائه‌دهنده را با نوارهای استفاده، شمارش معکوس بازنشانی، و جزئیات اعتبار نشان می‌دهد.", + "features.resetsTitle": "بازنشانی می کند که می توانید برای آن برنامه ریزی کنید", + "features.resetsBody": "هر جلسه ارائه دهنده، پنجره های هفتگی و ماهانه با شمارش معکوس برای تنظیم مجدد بعدی - دیگر حدس نمی زنید که آیا یک کار طولانی را شروع کنید یا خیر.", + "features.spendTitle": "اسکن اعتبار، هزینه و هزینه", + "features.spendBody": "موجودی اعتبار و هزینه‌های ماهانه در جایی که ارائه‌دهنده آن‌ها را نشان می‌دهد، به‌علاوه اسکن‌های هزینه محلی قابل تنظیم برای Codex و Claude.", + "features.statusTitle": "وضعیت و حوادث", + "features.statusBody": "نشان‌های برخورد سطوح نظرسنجی وضعیت ارائه‌دهنده در منو و یک پوشش نشانگر روی نماد نوار.", + "features.cliTitle": "CLI و ویجت‌ها", + "features.cliBody": "ویجت‌های WidgetKit برای ارائه‌دهندگان پشتیبانی‌شده و یک {codexbar} CLI برای اسکریپت‌ها و CI — macOS و Linux در هر نسخه ساخته می‌شود.", + "detail.installTitle": "نصب و به روز رسانی", + "detail.installBody": "برنامهٔ یونیورسال از طریق {releases} با به‌روزرسانی خودکار Sparkle، یا از طریق {cask} (به‌روزرسانی با {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "یک {codexbar} باینری به همراه تاربال‌های مستقل برای macOS و Linux در هر نسخه. در Linux: {linuxCommand}.", + "detail.privacyTitle": "حریم خصوصی", + "detail.privacyBody": "از جلسات ارائه‌دهنده موجود - OAuth، جریان دستگاه، کلیدهای API، کوکی‌های مرورگر، فایل‌های محلی - مجدداً استفاده می‌کند - بنابراین هیچ گذرواژه‌ای ذخیره نمی‌شود. یادداشت های حسابرسی در {issue}.", + "detail.permissionsTitle": "مجوزهای macOS", + "detail.permissionsBody": "دسترسی کامل به دیسک برای کوکی‌های Safari، دسترسی Keychain برای رمزگشایی کوکی‌ها و جریان‌های OAuth، و هنگامی که کمک‌کنندگان ارائه‌دهنده دایرکتوری‌های پروژه را می‌خوانند، از Files & Folders درخواست می‌کند.", + "footer.builtBy": "ساخته شده توسط", + "footer.changelog": "تغییرات", + "clipboard.copy": "در کلیپ بورد کپی کنید", + "clipboard.copied": "کپی شده" + }, + "th": { + "meta.title": "CodexBar — ทุกขีดจำกัดการเข้ารหัส AI ในแถบเมนูของคุณ", + "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 53 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", + "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 53 รายจากแถบเมนู macOS", + "nav.primary": "หลัก", + "nav.language": "ภาษา", + "nav.docs": "เอกสาร", + "nav.source": "แหล่งที่มา", + "nav.download": "ดาวน์โหลด", + "theme.toLight": "เปลี่ยนเป็นธีมสว่าง", + "theme.toDark": "เปลี่ยนเป็นธีมสีเข้ม", + "hero.prefix": "ทุกขีดจำกัดการเข้ารหัส AI", + "hero.accent": "ในแถบเมนูของคุณ", + "hero.description": "CodexBar ติดตามกรอบเวลาการใช้งาน ยอดเครดิต และรีเซ็ตการนับถอยหลังของผู้ให้บริการที่คุณชำระเงินจริง — รายการสถานะแต่ละรายการ หรือรวมรายการเหล่านั้นเป็นรายการเดียว", + "hero.download": "ดาวน์โหลดสำหรับ macOS", + "hero.fineprint": "ฟรีและโอเพ่นซอร์ส · macOS 14+ · รุ่น Universal ผ่าน GitHub Releases และ Homebrew", + "providers.title": "ผู้ให้บริการ 53 ราย หนึ่งแถบเมนู", + "providers.description": "ผู้ให้บริการยอดนิยมจะกลายเป็นรายการสถานะที่มีหน้าต่างการใช้งานของตนเอง รีเซ็ตการนับถอยหลัง แผนภูมิ และเมนูของผู้ให้บริการ", + "providers.yourProvider": "ผู้ให้บริการของคุณ", + "providers.authoringGuide": "คู่มือการเขียน", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "ป๊อปโอเวอร์แถบเมนู CodexBar แสดงไทล์ผู้ให้บริการพร้อมแถบการใช้งาน การรีเซ็ตการนับถอยหลัง และรายละเอียดเครดิต", + "features.resetsTitle": "รีเซ็ตคุณสามารถวางแผนได้", + "features.resetsBody": "เซสชันต่อผู้ให้บริการ รายสัปดาห์และรายเดือนพร้อมการนับถอยหลังสู่การรีเซ็ตครั้งถัดไป — ไม่ต้องเดาอีกต่อไปว่าจะเริ่มงานที่ยาวนานหรือไม่", + "features.spendTitle": "เครดิต การใช้จ่าย และการสแกนต้นทุน", + "features.spendBody": "ยอดเครดิตและการใช้จ่ายรายเดือนที่ผู้ให้บริการเปิดเผย รวมถึงการสแกนต้นทุนในท้องถิ่นที่กำหนดค่าได้สำหรับ Codex และ Claude", + "features.statusTitle": "สถานะและเหตุการณ์ที่เกิดขึ้น", + "features.statusBody": "การสำรวจสถานะผู้ให้บริการจะแสดงป้ายเหตุการณ์ในเมนูและมีตัวบ่งชี้ซ้อนทับบนไอคอนแถบ", + "features.cliTitle": "CLI & วิดเจ็ต", + "features.cliBody": "วิดเจ็ต WidgetKit สำหรับผู้ให้บริการที่รองรับและ {codexbar} CLI แบบรวมสำหรับสคริปต์และ CI — macOS และ Linux บิวด์ในทุกรีลีส", + "detail.installTitle": "ติดตั้งและอัพเดต", + "detail.installBody": "แอป Universal ผ่าน {releases} พร้อมการอัปเดตอัตโนมัติ Sparkle หรือผ่าน {cask} (อัปเดตด้วย {upgrade})", + "detail.cliTitle": "CLI", + "detail.cliBody": "ไบนารี {codexbar} ที่รวมเข้าด้วยกันพร้อม tarball แบบสแตนด์อโลนสำหรับ macOS และ Linux ในแต่ละรุ่น ใน Linux: {linuxCommand}", + "detail.privacyTitle": "ความเป็นส่วนตัว", + "detail.privacyBody": "ใช้เซสชันของผู้ให้บริการที่มีอยู่ซ้ำ — OAuth, การไหลเวียนของอุปกรณ์, คีย์ API, คุกกี้ของเบราว์เซอร์, ไฟล์ในเครื่อง — ดังนั้นจึงไม่มีการจัดเก็บรหัสผ่าน บันทึกการตรวจสอบใน {issue}", + "detail.permissionsTitle": "สิทธิ์ macOS", + "detail.permissionsBody": "การเข้าถึงดิสก์โดยสมบูรณ์สำหรับคุกกี้ Safari, การเข้าถึง Keychain สำหรับการถอดรหัสคุกกี้และโฟลว์ OAuth และไฟล์และโฟลเดอร์แจ้งเตือนเมื่อผู้ช่วยผู้ให้บริการอ่านไดเรกทอรีโปรเจ็กต์", + "footer.builtBy": "สร้างโดย", + "footer.changelog": "บันทึกการเปลี่ยนแปลง", + "clipboard.copy": "คัดลอกไปยังคลิปบอร์ด", + "clipboard.copied": "คัดลอกแล้ว" + }, + "ca": { + "meta.title": "CodexBar: tots els límits de la programació amb IA a la barra de menús", + "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 53 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", + "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 53 proveïdors de programació amb IA des de la barra de menús del macOS.", + "nav.primary": "Navegació principal", + "nav.language": "Llengua", + "nav.docs": "Documentació", + "nav.source": "Codi font", + "nav.download": "Baixeu", + "theme.toLight": "Canvia al tema clar", + "theme.toDark": "Canvia al tema fosc", + "hero.prefix": "Cada límit de la programació amb IA,", + "hero.accent": "a la barra de menús.", + "hero.description": "El CodexBar fa el seguiment de les finestres d'ús, els saldos de crèdit i els comptes enrere fins al restabliment dels proveïdors pels quals realment pagueu: un element d'estat per a cadascun, o combineu-los tots en un de sol.", + "hero.download": "Baixeu per al macOS", + "hero.fineprint": "Gratuït i de codi obert · macOS 14+ · Universal mitjançant GitHub Releases i Homebrew", + "providers.title": "53 proveïdors, una barra de menús", + "providers.description": "Els proveïdors populars es converteixen en elements d'estat amb les seves pròpies finestres d'ús, comptes enrere de restabliment, gràfics i menús de proveïdor.", + "providers.yourProvider": "El vostre proveïdor", + "providers.authoringGuide": "Guia de creació", + "auth.cookies": "Galetes", + "auth.cookiesGo": "Galetes + Go", + "auth.deviceFlow": "Flux de dispositiu", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "Clau d'API", + "auth.token": "Token", + "auth.legacyApi": "API antiga", + "auth.local": "Local", + "auth.apiKeyCookies": "Clau d'API + galetes", + "auth.virtualKey": "Clau virtual", + "showcase.alt": "Finestra emergent del CodexBar a la barra de menús que mostra les targetes dels proveïdors amb barres d'ús, comptes enrere de restabliment i detalls de crèdit.", + "features.resetsTitle": "Restabliments que podeu planificar", + "features.resetsBody": "Sessió per proveïdor, finestres setmanals i mensuals amb comptes enrere fins al restabliment següent; sense haver d'endevinar si podeu iniciar una tasca llarga.", + "features.spendTitle": "Crèdits, despesa i anàlisis de costos", + "features.spendBody": "Saldos de crèdit i despesa mensual allà on el proveïdor els exposa, més anàlisis de costos locals configurables per a Codex i Claude.", + "features.statusTitle": "Estat i incidències", + "features.statusBody": "El sondeig d'estat dels proveïdors mostra insígnies d'incidències al menú i una superposició d'indicadors a la icona de la barra.", + "features.cliTitle": "CLI i ginys", + "features.cliBody": "Ginys de WidgetKit per als proveïdors compatibles i una CLI {codexbar} inclosa per a scripts i CI: el macOS i el Linux es publiquen a cada versió.", + "detail.installTitle": "Instal·lació i actualitzacions", + "detail.installBody": "Aplicació universal mitjançant {releases} amb actualitzacions automàtiques de Sparkle o mitjançant {cask} (actualitzacions amb {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "Un binari {codexbar} inclòs, més tarballs autònoms per al macOS i el Linux a cada versió. Al Linux: {linuxCommand}.", + "detail.privacyTitle": "Privadesa", + "detail.privacyBody": "Reutilitza les sessions existents dels proveïdors (OAuth, flux de dispositiu, claus d'API, galetes del navegador, fitxers locals), de manera que no s'emmagatzema cap contrasenya. Notes d'auditoria a {issue}.", + "detail.permissionsTitle": "Permisos del macOS", + "detail.permissionsBody": "Accés complet al disc per a les galetes de Safari, accés al Clauer per desxifrar les galetes i gestionar els fluxos OAuth, i sol·licituds d'accés a Fitxers i carpetes quan els processos auxiliars dels proveïdors llegeixen els directoris dels projectes.", + "footer.builtBy": "Creat per", + "footer.changelog": "Registre de canvis", + "clipboard.copy": "Copia al porta-retalls", + "clipboard.copied": "Copiat" + }, + "sv": { + "meta.title": "CodexBar — varje AI-kodningsgräns i din menyrad", + "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 53 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", + "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 53 AI-kodningsleverantörer från din macOS-menyrad.", + "nav.primary": "Primär", + "nav.language": "Språk", + "nav.docs": "Dokument", + "nav.source": "Källa", + "nav.download": "Ladda ner", + "theme.toLight": "Byt till ljustema", + "theme.toDark": "Byt till mörkt tema", + "hero.prefix": "Varje AI-kodningsgräns,", + "hero.accent": "i din menyrad.", + "hero.description": "CodexBar spårar användningsfönster, kreditsaldon och återställningsnedräkningar för de leverantörer du faktiskt betalar för – en statuspost var, eller slå samman dem till en.", + "hero.download": "Ladda ner för macOS", + "hero.fineprint": "Gratis och öppen källkod · macOS 14+ · Universal via GitHub Releases och Homebrew", + "providers.title": "53 leverantörer, en menyrad", + "providers.description": "Populära leverantörer blir statusobjekt med sina egna användningsfönster, återställer nedräkningar, diagram och leverantörsmenyer.", + "providers.yourProvider": "Din leverantör", + "providers.authoringGuide": "Författarguide", + "auth.cookies": "Cookies", + "auth.cookiesGo": "Cookies + Go", + "auth.deviceFlow": "Device flow", + "auth.bearerLocal": "Bearer / Local", + "auth.apiKey": "API key", + "auth.token": "Token", + "auth.legacyApi": "Legacy API", + "auth.local": "Local", + "auth.apiKeyCookies": "API key + Cookies", + "auth.virtualKey": "Virtual key", + "showcase.alt": "CodexBar menyrad popover som visar leverantörsrutor med användningsfält, återställda nedräkningar och kreditinformation.", + "features.resetsTitle": "Återställningar du kan planera runt", + "features.resetsBody": "Session per leverantör, vecko- och månadsfönster med nedräkningar till nästa återställning – du slipper gissa om du ska påbörja en lång uppgift.", + "features.spendTitle": "Krediter, utgifter och kostnadsskanningar", + "features.spendBody": "Kreditsaldon och månatliga utgifter där leverantören exponerar dem, plus konfigurerbara lokala kostnadsskanningar för Codex och Claude.", + "features.statusTitle": "Status & incidenter", + "features.statusBody": "Leverantörsstatusundersökning visar incidentmärken i menyn och en indikatoröverlagring på fältikonen.", + "features.cliTitle": "CLI och widgets", + "features.cliBody": "WidgetKit-widgets för leverantörer som stöds och en medföljande {codexbar} CLI för skript och CI — macOS och Linux bygger på varje version.", + "detail.installTitle": "Installera och uppdatera", + "detail.installBody": "Universell app via {releases} med automatiska Sparkle-uppdateringar, eller via {cask} (uppdateringar med {upgrade}).", + "detail.cliTitle": "CLI", + "detail.cliBody": "En medföljande binär {codexbar} plus fristående tarballs för macOS och Linux på varje version. På Linux: {linuxCommand}.", + "detail.privacyTitle": "Privatliv", + "detail.privacyBody": "Återanvänder befintliga leverantörssessioner – OAuth, enhetsflöde, API-nycklar, webbläsarcookies, lokala filer – så inga lösenord lagras. Granskningsanteckningar i {issue}.", + "detail.permissionsTitle": "macOS behörigheter", + "detail.permissionsBody": "Full diskåtkomst för Safari-cookies, Keychain-åtkomst för cookie-dekryptering och OAuth-flöden och filer och mappar när leverantörshjälpare läser projektkataloger.", + "footer.builtBy": "Byggd av", + "footer.changelog": "Ändringslogg", + "clipboard.copy": "Kopiera till urklipp", + "clipboard.copied": "Kopierade" + } +}; diff --git a/docs/site.css b/docs/site.css index bf335d072a..f76fdf1e18 100644 --- a/docs/site.css +++ b/docs/site.css @@ -1,505 +1,1010 @@ :root { - color-scheme: light dark; - --bg: #070b12; - --panel: rgba(14, 18, 28, 0.78); - --panel-solid: #0e121c; - --text: rgba(241, 246, 255, 0.92); - --muted: rgba(187, 198, 218, 0.78); - --line: rgba(255, 255, 255, 0.1); - --accent: #3ff0d6; - --accent2: #2d5bff; - --shadow: rgba(0, 0, 0, 0.42); - --radius: 18px; - --radius-sm: 14px; -} - -@media (prefers-color-scheme: light) { - :root { - --bg: #f8fbff; - --panel: rgba(255, 255, 255, 0.74); - --panel-solid: #ffffff; - --text: rgba(11, 16, 22, 0.92); - --muted: rgba(56, 70, 88, 0.75); - --line: rgba(11, 16, 22, 0.1); - --shadow: rgba(11, 16, 22, 0.12); + color-scheme: light; + --bg: #fbfbfc; + --bg-elev: #ffffff; + --bg-elev-2: #f3f4f6; + --ink-1: #0a0a0c; + --ink-2: #5b6068; + --ink-3: #8a8f97; + --line: rgba(0, 0, 0, 0.08); + --line-strong: rgba(0, 0, 0, 0.18); + --link: #0066cc; + --structure-line: rgba(10, 10, 12, 0.045); + --structure-line-strong: rgba(10, 10, 12, 0.1); + --structure-accent-hot: rgba(255, 106, 0, 0.12); + --structure-accent-cool: rgba(0, 191, 165, 0.1); + --logo-chip-bg: #ffffff; + --logo-chip-line: rgba(0, 0, 0, 0.08); + --logo-image-filter: none; + --logo-chip-shadow: 0 1px 0 rgba(255, 255, 255, 0.34), inset 0 0 0 1px rgba(0, 0, 0, 0.04); + --theme-toggle-bg: #101116; + --theme-toggle-ink: #ffffff; + --radius: 8px; + --radius-sm: 7px; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #0a0a0c; + --bg-elev: #131418; + --bg-elev-2: #1c1d22; + --ink-1: #f0f0f3; + --ink-2: #97a0aa; + --ink-3: #6b7280; + --line: rgba(255, 255, 255, 0.09); + --line-strong: rgba(255, 255, 255, 0.2); + --link: #6ea8ff; + --structure-line: rgba(255, 255, 255, 0.035); + --structure-line-strong: rgba(255, 255, 255, 0.095); + --structure-accent-hot: rgba(255, 106, 0, 0.14); + --structure-accent-cool: rgba(0, 191, 165, 0.12); + --logo-chip-bg: #101116; + --logo-chip-line: rgba(255, 255, 255, 0.14); + --logo-image-filter: brightness(0) invert(1) saturate(0.86) contrast(1.12); + --logo-chip-shadow: 0 1px 0 rgba(255, 255, 255, 0.08), inset 0 0 0 1px rgba(255, 255, 255, 0.08); + --theme-toggle-bg: #ffffff; + --theme-toggle-ink: #0a0a0c; } } +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #0a0a0c; + --bg-elev: #131418; + --bg-elev-2: #1c1d22; + --ink-1: #f0f0f3; + --ink-2: #97a0aa; + --ink-3: #6b7280; + --line: rgba(255, 255, 255, 0.09); + --line-strong: rgba(255, 255, 255, 0.2); + --link: #6ea8ff; + --structure-line: rgba(255, 255, 255, 0.035); + --structure-line-strong: rgba(255, 255, 255, 0.095); + --structure-accent-hot: rgba(255, 106, 0, 0.14); + --structure-accent-cool: rgba(0, 191, 165, 0.12); + --logo-chip-bg: #101116; + --logo-chip-line: rgba(255, 255, 255, 0.14); + --logo-image-filter: brightness(0) invert(1) saturate(0.86) contrast(1.12); + --logo-chip-shadow: 0 1px 0 rgba(255, 255, 255, 0.08), inset 0 0 0 1px rgba(255, 255, 255, 0.08); + --theme-toggle-bg: #ffffff; + --theme-toggle-ink: #0a0a0c; +} + +:root[data-theme="light"] { + color-scheme: light; +} + * { box-sizing: border-box; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + html, body { min-height: 100%; } +html { + background: var(--bg); +} + body { margin: 0; - font: 16px/1.55 ui-sans-serif, "Avenir Next", Avenir, "Segoe UI", "Helvetica Neue", sans-serif; - color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "Helvetica Neue", sans-serif; + font-size: 16px; + line-height: 1.5; + color: var(--ink-1); + background: var(--bg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + position: relative; + overflow-x: hidden; +} + +h1, +h2, +h3, +p { + overflow-wrap: break-word; +} + +.backdrop { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + contain: strict; +} + +.backdrop::before { + content: ""; + position: absolute; + inset: -20% -10%; background: - radial-gradient(1100px 800px at 15% 10%, rgba(63, 240, 214, 0.18), transparent 58%), - radial-gradient(950px 780px at 85% 20%, rgba(45, 91, 255, 0.18), transparent 60%), - radial-gradient(900px 700px at 40% 95%, rgba(63, 240, 214, 0.06), transparent 60%), - repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.04) 1px, transparent 1px, transparent 72px), - var(--bg); + linear-gradient(115deg, transparent 0 42%, var(--structure-accent-hot) 42% 42.35%, transparent 42.35% 100%), + linear-gradient(115deg, transparent 0 58%, var(--structure-accent-cool) 58% 58.3%, transparent 58.3% 100%); + opacity: 0.42; + transform: skewY(-4deg); } -html { +.backdrop .plane, +.backdrop .trace { + position: absolute; + display: block; +} + +.backdrop .plane { + border: 1px solid var(--structure-line-strong); + border-radius: 18px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + opacity: 0.32; +} + +.backdrop .p1 { + width: 780px; + height: 300px; + left: max(24px, calc(50% - 720px)); + top: 128px; + transform: rotate(-2deg); +} + +.backdrop .p2 { + width: 580px; + height: 360px; + right: max(24px, calc(50% - 760px)); + top: 460px; + transform: rotate(3deg); +} + +.backdrop .trace { + height: 1px; + background: linear-gradient(90deg, transparent, var(--structure-line-strong), transparent); + opacity: 0.36; +} + +.backdrop .trace::before, +.backdrop .trace::after { + content: ""; + position: absolute; + top: -3px; + width: 7px; + height: 7px; + border: 1px solid var(--structure-line-strong); + border-radius: 2px; background: var(--bg); } -a, -a:visited { - color: inherit; +.backdrop .trace::before { + left: 18%; +} + +.backdrop .trace::after { + right: 12%; +} + +.backdrop .t1 { + width: 58vw; + left: -6vw; + top: 260px; + transform: rotate(-12deg); +} + +.backdrop .t2 { + width: 44vw; + right: -4vw; + top: 210px; + transform: rotate(14deg); +} + +.backdrop .t3 { + width: 52vw; + left: 10vw; + top: 660px; + transform: rotate(7deg); +} + +.backdrop .t4 { + width: 38vw; + right: 10vw; + top: 910px; + transform: rotate(-9deg); +} + +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(var(--structure-line) 1px, transparent 1px), + linear-gradient(90deg, var(--structure-line) 1px, transparent 1px), + linear-gradient(var(--structure-line-strong) 1px, transparent 1px), + linear-gradient(90deg, var(--structure-line-strong) 1px, transparent 1px); + background-size: 32px 32px, 32px 32px, 128px 128px, 128px 128px; + background-position: 50% 0, 50% 0, 50% 0, 50% 0; + -webkit-mask-image: linear-gradient(180deg, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0.7) 70%, rgba(0, 0, 0, 0.45)); + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0.7) 70%, rgba(0, 0, 0, 0.45)); +} + +body::after { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + repeating-linear-gradient(135deg, transparent 0 56px, var(--structure-line) 56px 57px, transparent 57px 112px), + radial-gradient(circle at center, var(--structure-line-strong) 1px, transparent 1.6px); + background-size: auto, 96px 96px; + background-position: 0 0, 50% 0; + -webkit-mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.48), rgba(0, 0, 0, 0.24) 78%, transparent); + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.48), rgba(0, 0, 0, 0.24) 78%, transparent); } a { + color: inherit; text-decoration: none; } a:hover { text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; } code { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; - font-size: 0.95em; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.92em; } -.wrap { - max-width: 1020px; - margin: 0 auto; - padding: 46px 22px 34px; +.masthead, +.lede, +.providers, +.showcase, +.detail, +.foot { + position: relative; + z-index: 1; + width: 100%; + max-width: 1080px; + margin-left: auto; + margin-right: auto; + padding-left: 24px; + padding-right: 24px; } -.top { +.masthead { display: flex; align-items: center; justify-content: space-between; - gap: 18px; - margin-bottom: 28px; + padding-top: 22px; + padding-bottom: 22px; } .brand { - display: flex; + display: inline-flex; align-items: center; - gap: 14px; - min-width: 240px; + gap: 10px; + font-weight: 600; + font-size: 16px; + letter-spacing: 0; +} + +.brand:hover { + text-decoration: none; +} + +.mark { + width: 28px; + height: 28px; + border-radius: 7px; } -.name { +.nav { display: flex; - flex-direction: column; - gap: 2px; + align-items: center; + gap: 22px; + font-size: 14px; } -.title { - margin: 0; - font-family: ui-serif, "New York", "Iowan Old Style", Palatino, serif; - font-size: 22px; - letter-spacing: 0.1px; +.nav a { + color: var(--ink-2); } -.tagline { - margin: 0; - color: var(--muted); - font-size: 13.5px; +.nav a:hover { + color: var(--ink-1); + text-decoration: none; } -.mark { - width: 44px; - height: 44px; - border-radius: 13px; - border: 0; - box-shadow: none; - background: transparent; - object-fit: cover; - filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.35)); +.language-control { + position: relative; + display: inline-flex; + align-items: center; + color: var(--ink-2); } -.brand:hover .mark { - filter: drop-shadow(0 16px 34px rgba(0, 0, 0, 0.42)); +.language-control svg { + position: absolute; + right: 10px; + pointer-events: none; } -.links { - display: flex; - gap: 10px; +.language-select { + max-width: 136px; + height: 36px; + padding: 0 30px 0 12px; + border: 1px solid var(--line-strong); + border-radius: 999px; + color: var(--ink-1); + background: var(--bg-elev); + font: inherit; + font-size: 13px; + cursor: pointer; + appearance: none; + transition: border-color 140ms ease, background 140ms ease; } -.btn { - padding: 10px 14px; - border-radius: 13px; - border: 0; - background: color-mix(in srgb, var(--panel-solid) 32%, transparent); - box-shadow: 0 10px 28px var(--shadow); - transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease; - white-space: nowrap; +.language-select:hover, +.language-select:focus-visible { + border-color: var(--ink-2); + outline: none; } -.btn:hover { +[dir="rtl"] .language-control svg { + right: auto; + left: 10px; +} + +[dir="rtl"] .language-select { + padding-right: 12px; + padding-left: 30px; +} + +.theme-toggle { + width: 36px; + height: 36px; + padding: 0; + border: 1px solid var(--line-strong); + border-radius: 999px; + display: inline-grid; + place-items: center; + color: var(--theme-toggle-ink); + background: var(--theme-toggle-bg); + cursor: pointer; + transition: transform 140ms ease, border-color 140ms ease, background 140ms ease, color 140ms ease; +} + +.theme-toggle:hover { transform: translateY(-1px); - text-decoration: none; - background: color-mix(in srgb, var(--panel-solid) 52%, transparent); - box-shadow: 0 14px 34px var(--shadow); + border-color: var(--ink-2); } -.btn.primary { - background: linear-gradient(135deg, rgba(63, 240, 214, 0.65), rgba(45, 91, 255, 0.62)); - background-clip: padding-box; - box-shadow: 0 12px 30px color-mix(in srgb, var(--accent) 25%, var(--shadow)); +.theme-toggle .sun, +.theme-toggle .moon { + grid-area: 1 / 1; + transition: opacity 140ms ease, transform 140ms ease; } -.btn.ghost { - background: transparent; - box-shadow: none; - color: color-mix(in srgb, var(--text) 86%, var(--muted)); +.theme-toggle .sun { + opacity: 1; + transform: scale(1); +} + +.theme-toggle .moon { + opacity: 0; + transform: scale(0.72) rotate(-12deg); } -.btn.ghost:hover { - background: color-mix(in srgb, var(--panel-solid) 28%, transparent); - box-shadow: 0 10px 24px var(--shadow); +:root[data-theme="dark"] .theme-toggle .sun, +:root:not([data-theme]) .theme-toggle[aria-pressed="true"] .sun { + opacity: 0; + transform: scale(0.72) rotate(12deg); } -.hero { - display: grid; - grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.9fr); - gap: 26px; +:root[data-theme="dark"] .theme-toggle .moon, +:root:not([data-theme]) .theme-toggle[aria-pressed="true"] .moon { + opacity: 1; + transform: scale(1); +} + +.btn { + display: inline-flex; align-items: center; - padding: 24px 26px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--panel); - backdrop-filter: blur(12px); - box-shadow: 0 18px 60px var(--shadow); - position: relative; - overflow: hidden; + justify-content: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid var(--line-strong); + background: var(--bg-elev); + font-size: 14px; + font-weight: 500; + color: var(--ink-1); + white-space: nowrap; + transition: background 140ms ease, border-color 140ms ease, transform 140ms ease, color 140ms ease; } -.copy { - position: relative; - z-index: 1; +.btn:hover { + background: var(--bg-elev-2); + border-color: var(--ink-2); + text-decoration: none; } -.hero-heading { - display: flex; - gap: 14px; - align-items: flex-start; +.btn.primary { + background: var(--ink-1); + color: var(--bg); + border-color: var(--ink-1); } -.hero-icon { - width: 40px; - height: 40px; - border-radius: 12px; - filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.35)); +.btn.primary:hover { + background: color-mix(in srgb, var(--ink-1) 86%, transparent); + border-color: var(--ink-1); + color: var(--bg); } -.hero::before { - content: ""; - position: absolute; - inset: -1px; - background: - radial-gradient(600px 320px at 10% 15%, rgba(63, 240, 214, 0.18), transparent 55%), - radial-gradient(520px 300px at 92% 18%, rgba(45, 91, 255, 0.18), transparent 55%); - pointer-events: none; - opacity: 0.9; +.btn.lg { + padding: 11px 20px; + font-size: 15px; } -.copy h2 { - margin: 0 0 8px; - font-family: ui-serif, "New York", "Iowan Old Style", Palatino, serif; - font-size: 34px; - letter-spacing: -0.3px; - line-height: 1.12; - position: relative; +.lede { + padding-top: 56px; + padding-bottom: 64px; } -.copy p { - margin: 0 0 10px; - color: var(--muted); +.lede h1 { + margin: 0 0 18px; + font-size: 60px; + line-height: 1.04; + letter-spacing: -0.01em; + font-weight: 600; + max-width: 18ch; } -.requirement { - margin: 6px 0 0; - color: color-mix(in srgb, var(--text) 92%, var(--muted)); - font-size: 13.5px; +.lede h1 .grad { + background-image: linear-gradient(100deg, #ff6a00 0%, #ff3399 32%, #6e5aff 62%, #00bfa5 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + background-size: 200% 100%; + animation: hue-slide 14s ease-in-out infinite; } -.badges { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 10px; - position: relative; +@keyframes hue-slide { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } } -.badge { - font-size: 12px; - padding: 6px 10px; - border-radius: 999px; - border: 1px solid var(--line); - background: color-mix(in srgb, var(--panel-solid) 14%, transparent); - color: color-mix(in srgb, var(--text) 86%, var(--muted)); - letter-spacing: 0.2px; +.lede .dek { + margin: 0; + font-size: 19px; + line-height: 1.5; + color: var(--ink-2); + max-width: 60ch; } -.cta { +.lede .actions { display: flex; - gap: 10px; flex-wrap: wrap; - margin-top: 14px; - position: relative; + gap: 12px; + margin: 28px 0 14px; + align-items: center; } -.brew-line { - display: flex; +.brew { + display: inline-flex; align-items: center; - gap: 8px; - flex-wrap: wrap; + gap: 4px; + padding: 6px 6px 6px 14px; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: var(--bg-elev); + font-size: 13.5px; + max-width: 100%; + overflow: hidden; } -.brew-line code { - background: color-mix(in srgb, var(--panel-solid) 40%, transparent); - padding: 4px 8px; - border-radius: 6px; - border: 1px solid var(--line); +.brew code { + background: none; + padding: 0; + direction: ltr; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + unicode-bidi: isolate; } -.copy-btn { +.brew-copy { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; - border: 1px solid var(--line); - border-radius: 6px; - background: color-mix(in srgb, var(--panel-solid) 30%, transparent); - color: var(--muted); + margin: 0; + border: 0; + background: transparent; + border-radius: 999px; + color: var(--ink-2); cursor: pointer; - transition: background 160ms ease, color 160ms ease, border-color 160ms ease; + flex-shrink: 0; + transition: background 140ms ease, color 140ms ease; } -.copy-btn:hover { - background: color-mix(in srgb, var(--panel-solid) 60%, transparent); - color: var(--text); - border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); +.brew-copy:hover { + background: var(--bg-elev-2); + color: var(--ink-1); } -.copy-btn .icon-check { +.brew-copy .ok { display: none; - color: var(--accent); } -.copy-btn.copied .icon-copy { +.brew-copy.copied .ic { display: none; } -.copy-btn.copied .icon-check { - display: block; +.brew-copy.copied .ok { + display: inline; + color: #16a34a; } -.copy-btn.copied { - border-color: var(--accent); - color: var(--accent); +.fineprint { + margin: 6px 0 0; + font-size: 13px; + color: var(--ink-3); } -.bullets { - margin: 14px 0 0; - padding: 0 0 0 18px; - color: var(--muted); - font-size: 13.5px; - position: relative; +.section-head { + margin: 0 0 22px; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 18px; + flex-wrap: wrap; } -.bullets li { - margin: 6px 0; +.section-head h2 { + margin: 0; + font-size: 28px; + font-weight: 600; + letter-spacing: 0; } -.note { - margin: 10px 0 0; - color: var(--muted); - font-size: 13px; +.section-head p { + margin: 0; + color: var(--ink-2); + font-size: 14.5px; + max-width: 48ch; +} + +.providers { + padding-bottom: 64px; } -.shot { +.grid { + list-style: none; margin: 0; - position: relative; - z-index: 1; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 8px; } -.shot img { - width: 100%; - height: auto; - border-radius: var(--radius-sm); +.grid > li { + margin: 0; +} + +.provider { + --ink: #ffffff; + position: relative; + display: flex; + align-items: center; + gap: 12px; + padding: 12px; border: 1px solid var(--line); - box-shadow: 0 18px 60px var(--shadow); - background: color-mix(in srgb, var(--panel-solid) 92%, transparent); - transform: perspective(900px) rotateY(-6deg) rotateX(2deg); - transition: transform 220ms ease, box-shadow 220ms ease; + border-radius: var(--radius); + background: color-mix(in srgb, var(--bg-elev) 82%, transparent); + backdrop-filter: blur(8px) saturate(140%); + -webkit-backdrop-filter: blur(8px) saturate(140%); + text-decoration: none; + color: var(--ink-1); + min-height: 64px; + transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; } -.hero-shot { - margin: 0; - justify-self: end; +.provider:hover { + text-decoration: none; + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--brand, var(--line-strong)) 55%, var(--line-strong)); + box-shadow: 0 6px 18px color-mix(in srgb, var(--brand, transparent) 18%, transparent); } -.hero-shot img { - max-width: 300px; - margin-left: auto; +.provider:has(.chip-logo)::before { + content: ""; + width: 38px; + height: 38px; + border-radius: var(--radius-sm); + flex: 0 0 38px; + background: var(--logo-chip-bg); + border: 1px solid var(--logo-chip-line); + box-shadow: var(--logo-chip-shadow); + transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease; } -.hero-shot figcaption { - max-width: 300px; - text-align: center; - margin-left: auto; +.provider.logo-dark::before { + background: #101116; + border-color: rgba(0, 0, 0, 0.14); } -.shot figcaption { - margin-top: 8px; - color: var(--muted); - font-size: 12.5px; - text-align: center; +:root[data-theme="dark"] .provider.logo-dark::before { + background: var(--logo-chip-bg); + border-color: var(--logo-chip-line); } -.shot img:hover { - transform: perspective(900px) rotateY(-2deg) rotateX(0deg) translateY(-2px); - box-shadow: 0 24px 80px var(--shadow); +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .provider.logo-dark::before { + background: var(--logo-chip-bg); + border-color: var(--logo-chip-line); + } } -.details { +.provider .chip { + width: 38px; + height: 38px; + border-radius: var(--radius-sm); display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 14px; - margin-top: 14px; + place-items: center; + background: var(--brand, var(--bg-elev-2)); + color: var(--ink, #fff); + font-weight: 700; + font-size: 17px; + letter-spacing: 0; + flex-shrink: 0; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.18), + inset 0 -1px 0 rgba(0, 0, 0, 0.12); +} + +.provider .chip-logo { + position: absolute; + left: 18px; + top: 50%; + width: 26px; + height: 26px; + transform: translateY(-50%); + border-radius: var(--radius-sm); + flex-shrink: 0; + object-fit: contain; + background: transparent; + border: 0; + filter: var(--logo-image-filter); + padding: 0; + box-shadow: none; + transition: filter 140ms ease; } -.details.permissions { - grid-template-columns: 1fr; +.provider .chip-logo.no-bg { + background: transparent; + padding: 0; } -.card { - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 14px 14px 12px; - background: var(--panel); - backdrop-filter: blur(10px); - box-shadow: 0 14px 40px var(--shadow); +.provider .chip-logo.preserve-color { + filter: none; } -.card h3 { - margin: 0 0 6px; - font-size: 13px; - letter-spacing: 0.2px; - text-transform: uppercase; - color: color-mix(in srgb, var(--text) 88%, var(--muted)); +.provider .meta { + display: flex; + flex-direction: column; + min-width: 0; } -.card p { - margin: 0; - color: var(--muted); - font-size: 13.5px; +.provider .name { + font-size: 14px; + font-weight: 500; + line-height: 1.2; + color: var(--ink-1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.foot { - margin-top: 22px; - text-align: center; - color: var(--muted); - font-size: 13px; +.provider .auth { + font-size: 12px; + color: var(--ink-2); + margin-top: 3px; + letter-spacing: 0; } -.links-row { - margin-top: 14px; - display: flex; - gap: 10px; - flex-wrap: wrap; - justify-content: center; +[dir="rtl"] .provider .chip-logo { + left: auto; + right: 18px; } -.link-pill { - padding: 8px 12px; - border-radius: 999px; +.provider.add { + border-style: dashed; + background: transparent; +} + +.provider.add:hover { + border-color: var(--ink-2); + box-shadow: none; +} + +.provider.add .chip { + background: transparent; + color: var(--ink-2); + box-shadow: inset 0 0 0 1px var(--line-strong); + font-weight: 400; + font-size: 22px; +} + +.showcase { + display: grid; + grid-template-columns: 1.05fr 1fr; + gap: 48px; + align-items: center; + padding-top: 8px; + padding-bottom: 64px; +} + +.showcase .shot { + margin: 0; +} + +.showcase .shot img { + width: 100%; + height: auto; + border-radius: var(--radius); border: 1px solid var(--line); - background: color-mix(in srgb, var(--panel-solid) 24%, transparent); - font-size: 13px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.18); + background: var(--bg-elev); } -.foot a { - text-decoration: underline; - text-decoration-color: color-mix(in srgb, var(--muted) 45%, transparent); +.showcase .features { + display: grid; + grid-template-columns: 1fr; + gap: 20px; } -[data-animate] { - opacity: 0; - transform: translateY(10px); - animation: rise 680ms cubic-bezier(0.2, 0.9, 0.2, 1) forwards; - animation-delay: calc(var(--d, 0) * 90ms); +.showcase .features article { + padding-left: 14px; + border-left: 2px solid var(--line); } -[data-animate="1"] { - --d: 1; +[dir="rtl"] .showcase .features article { + padding-right: 14px; + padding-left: 0; + border-right: 2px solid var(--line); + border-left: 0; } -[data-animate="2"] { - --d: 2; +.showcase .features article h3 { + margin: 0 0 4px; + font-size: 15px; + font-weight: 600; } -[data-animate="3"] { - --d: 3; +.showcase .features article p { + margin: 0; + color: var(--ink-2); + font-size: 14.5px; + line-height: 1.5; } -[data-animate="4"] { - --d: 4; +.detail { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + padding-bottom: 56px; } -[data-animate="5"] { - --d: 5; +.detail .block { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 18px; + background: color-mix(in srgb, var(--bg-elev) 82%, transparent); + backdrop-filter: blur(8px) saturate(140%); + -webkit-backdrop-filter: blur(8px) saturate(140%); } -[data-animate="6"] { - --d: 6; +.detail .block h3 { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; } -[data-animate="7"] { - --d: 7; +.detail .block p { + margin: 0; + color: var(--ink-2); + font-size: 14px; + line-height: 1.5; } -[data-animate="8"] { - --d: 8; +.detail .block code, +.lede .brew code { + background: var(--bg-elev-2); + padding: 2px 6px; + border-radius: 5px; } -@keyframes rise { - to { - opacity: 1; - transform: translateY(0); - } +.lede .brew code { + background: none; + padding: 0; } -@media (prefers-reduced-motion: reduce) { - [data-animate] { - animation: none; - opacity: 1; - transform: none; - } - .btn, - .shot img { - transition: none; - } +.lede a, +.showcase a, +.detail a, +.foot a { + color: var(--link); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--link) 35%, transparent); + text-underline-offset: 2px; +} + +.lede a:hover, +.showcase a:hover, +.detail a:hover, +.foot a:hover { + text-decoration-color: var(--link); +} + +.foot { + padding-top: 22px; + padding-bottom: 36px; + margin-top: 8px; + border-top: 1px solid var(--line); + text-align: center; + color: var(--ink-3); + font-size: 13px; +} + +.foot p { + margin: 0; } -@media (max-width: 860px) { - .top { +@media (max-width: 720px) { + .masthead, + .lede, + .providers, + .showcase, + .detail, + .foot { + padding-left: 18px; + padding-right: 18px; + } + .masthead { + padding-top: 16px; + padding-bottom: 16px; + } + .nav { + gap: 8px; + } + .language-select { + width: 44px; + padding-left: 12px; + color: transparent; + } + .language-control::before { + content: "Aa"; + position: absolute; + left: 12px; + z-index: 1; + color: var(--ink-1); + font-size: 13px; + pointer-events: none; + } + [dir="rtl"] .language-select { + padding-right: 12px; + padding-left: 12px; + } + [dir="rtl"] .language-control::before { + right: 12px; + left: auto; + } + .language-control svg { + display: none; + } + .nav-link { + display: none; + } + .theme-toggle { + width: 34px; + height: 34px; + } + .nav .btn { + padding-left: 12px; + padding-right: 12px; + } + .lede { + padding-top: 28px; + padding-bottom: 40px; + } + .lede h1 { + font-size: 32px; + max-width: 11ch; + } + .lede .dek { + font-size: 16px; + max-width: 30ch; + } + .section-head h2 { + font-size: 23px; + } + .section-head p { + max-width: 30ch; + } + .lede .actions { flex-direction: column; - align-items: flex-start; + align-items: stretch; + min-width: 0; + width: 100%; + } + .lede .actions .btn { + max-width: 100%; + min-width: 0; + width: 100%; + } + .brew { + min-width: 0; + width: 100%; + } + .brew code { + flex: 1 1 auto; + } + .brew-copy { + flex: 0 0 28px; } - .hero { + .grid { grid-template-columns: 1fr; } - .details { + .showcase { grid-template-columns: 1fr; + gap: 28px; + } + .detail { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .masthead { + gap: 12px; } - .shot img { + .nav { + flex: 0 1 auto; + min-width: 0; + } + .nav .btn { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .btn, + .provider { + transition: none; + } + .provider:hover, + .btn:hover { transform: none; } + .lede h1 .grad { + animation: none; + } } diff --git a/docs/site.js b/docs/site.js new file mode 100644 index 0000000000..6887ab3375 --- /dev/null +++ b/docs/site.js @@ -0,0 +1,208 @@ +import { localeCatalog, localeMessages } from "./site-locales.mjs"; + +const root = document.documentElement; +const toggle = document.querySelector(".theme-toggle"); +const languageSelect = document.querySelector(".language-select"); +const metaTheme = document.querySelector('meta[name="theme-color"]'); +const metaDescription = document.querySelector('meta[name="description"]'); +const metaOgDescription = document.querySelector('meta[property="og:description"]'); +const media = window.matchMedia("(prefers-color-scheme: dark)"); +const supportedLocales = new Set(localeCatalog.map((locale) => locale.code)); +const rtlLocales = new Set(localeCatalog.filter((locale) => locale.direction === "rtl").map((locale) => locale.code)); +let activeMessages = localeMessages.en; + +function normalizeLocale(value) { + if (!value) return null; + + const lower = value.toLowerCase(); + const aliases = { + "zh-cn": "zh-CN", + "zh-hans": "zh-CN", + "zh-hant": "zh-TW", + "zh-hk": "zh-TW", + "zh-tw": "zh-TW", + ja: "ja-JP", + pt: "pt-BR", + "pt-br": "pt-BR", + }; + if (aliases[lower]) return aliases[lower]; + + return localeCatalog.find((locale) => + locale.code.toLowerCase() === lower || lower.startsWith(`${locale.code.toLowerCase()}-`))?.code ?? null; +} + +function selectedLocale() { + const fromDocument = normalizeLocale(root.dataset.locale); + if (fromDocument) return fromDocument; + + try { + const queryLocale = normalizeLocale(new URLSearchParams(location.search).get("lang")); + if (queryLocale) return queryLocale; + const storedLocale = normalizeLocale(localStorage.getItem("codexbar-language")); + if (storedLocale) return storedLocale; + } catch (_) {} + + for (const language of navigator.languages || [navigator.language]) { + const locale = normalizeLocale(language); + if (locale) return locale; + } + return "en"; +} + +function message(key) { + return activeMessages[key] || localeMessages.en[key] || key; +} + +function applyLocale(locale) { + const resolved = supportedLocales.has(locale) ? locale : "en"; + activeMessages = { ...localeMessages.en, ...localeMessages[resolved] }; + + root.lang = resolved; + root.dir = rtlLocales.has(resolved) ? "rtl" : "ltr"; + root.dataset.locale = resolved; + if (languageSelect) languageSelect.value = resolved; + + document.title = message("meta.title"); + metaDescription?.setAttribute("content", message("meta.description")); + metaOgDescription?.setAttribute("content", message("meta.ogDescription")); + + document.querySelectorAll("[data-i18n]").forEach((element) => { + element.textContent = message(element.dataset.i18n); + }); + document.querySelectorAll("[data-i18n-rich]").forEach((element) => { + renderRichMessage(element, message(element.dataset.i18nRich)); + }); + applyAttributeMessages("data-i18n-aria-label", "aria-label"); + applyAttributeMessages("data-i18n-title", "title"); + applyAttributeMessages("data-i18n-alt", "alt"); + updateThemeUI(); +} + +function applyAttributeMessages(dataAttribute, targetAttribute) { + document.querySelectorAll(`[${dataAttribute}]`).forEach((element) => { + element.setAttribute(targetAttribute, message(element.getAttribute(dataAttribute))); + }); +} + +function renderRichMessage(element, value) { + const fragment = document.createDocumentFragment(); + const tokenPattern = /\{([a-zA-Z][a-zA-Z0-9]*)\}/g; + let cursor = 0; + let match; + + while ((match = tokenPattern.exec(value)) !== null) { + fragment.append(document.createTextNode(value.slice(cursor, match.index))); + fragment.append(richToken(match[1])); + cursor = match.index + match[0].length; + } + fragment.append(document.createTextNode(value.slice(cursor))); + element.replaceChildren(fragment); +} + +function richToken(name) { + const codeTokens = { + cask: "brew install --cask steipete/tap/codexbar", + codexbar: "codexbar", + linuxCommand: "brew install steipete/tap/codexbar", + upgrade: "brew upgrade", + }; + if (codeTokens[name]) { + const code = document.createElement("code"); + code.textContent = codeTokens[name]; + return code; + } + if (name === "releases") { + const link = document.createElement("a"); + link.href = "https://github.com/steipete/CodexBar/releases/latest"; + link.textContent = "GitHub Releases"; + return link; + } + if (name === "issue") { + const link = document.createElement("a"); + link.href = "https://github.com/steipete/CodexBar/issues/12"; + link.textContent = "issue #12"; + return link; + } + + return document.createTextNode(`{${name}}`); +} + +function queryTheme() { + try { + const value = new URLSearchParams(location.search).get("theme"); + return value === "light" || value === "dark" ? value : null; + } catch (_) { + return null; + } +} + +function storedTheme() { + try { + return localStorage.getItem("codexbar-theme"); + } catch (_) { + return null; + } +} + +function effectiveTheme() { + return root.dataset.theme || (media.matches ? "dark" : "light"); +} + +function updateThemeImages(theme) { + document.querySelectorAll("img[data-dark-src]").forEach((image) => { + if (!image.dataset.lightSrc) image.dataset.lightSrc = image.getAttribute("src"); + image.setAttribute("src", theme === "dark" ? image.dataset.darkSrc : image.dataset.lightSrc); + }); +} + +function updateThemeUI() { + const theme = effectiveTheme(); + updateThemeImages(theme); + if (toggle) { + const label = message(theme === "dark" ? "theme.toLight" : "theme.toDark"); + toggle.setAttribute("aria-pressed", theme === "dark" ? "true" : "false"); + toggle.setAttribute("aria-label", label); + toggle.title = label; + } + metaTheme?.setAttribute("content", theme === "dark" ? "#0a0a0c" : "#fbfbfc"); +} + +toggle?.addEventListener("click", () => { + const next = effectiveTheme() === "dark" ? "light" : "dark"; + root.dataset.theme = next; + try { + localStorage.setItem("codexbar-theme", next); + } catch (_) {} + updateThemeUI(); +}); + +media.addEventListener?.("change", () => { + if (!queryTheme() && !storedTheme()) updateThemeUI(); +}); + +languageSelect?.addEventListener("change", () => { + const locale = normalizeLocale(languageSelect.value) || "en"; + try { + localStorage.setItem("codexbar-language", locale); + const url = new URL(location.href); + url.searchParams.set("lang", locale); + history.replaceState(null, "", url); + } catch (_) {} + applyLocale(locale); +}); + +const copyButton = document.querySelector(".brew-copy"); +copyButton?.addEventListener("click", () => { + const text = document.getElementById("brew-cmd")?.textContent; + if (!text || !navigator.clipboard) return; + navigator.clipboard.writeText(text).then(() => { + copyButton.classList.add("copied"); + copyButton.setAttribute("aria-label", message("clipboard.copied")); + setTimeout(() => { + copyButton.classList.remove("copied"); + copyButton.setAttribute("aria-label", message("clipboard.copy")); + }, 1500); + }); +}); + +applyLocale(selectedLocale()); diff --git a/docs/social.html b/docs/social.html new file mode 100644 index 0000000000..27ff60a245 --- /dev/null +++ b/docs/social.html @@ -0,0 +1,242 @@ + + + + + CodexBar social card + + + +
+ + + + +
+
+
+ +
+
+
+ + CodexBar +
+
codexbar.app
+
+ +
+

Every AI coding limit, in your menu bar.

+

53 providers·usage windows, credits, resets·one status item each, or merged.

+
+ +
    +
  • C
    Codex
  • +
  • C
    Claude
  • +
  • C
    Cursor
  • +
  • O
    OpenCode
  • +
  • A
    Alibaba
  • +
  • G
    Gemini
  • +
  • A
    Antigravity
  • + +
  • D
    Droid
  • +
  • C
    Copilot
  • +
  • z
    z.ai
  • +
  • M
    MiniMax
  • +
  • K
    Kimi
  • +
  • K
    Kimi K2 Legacy
  • +
  • K
    Kilo
  • +
  • K
    Kiro
  • + +
  • V
    Vertex AI
  • +
  • A
    Augment
  • +
  • A
    Amp
  • +
  • O
    Ollama
  • +
  • S
    Synthetic
  • +
  • J
    JetBrains AI
  • +
  • W
    Warp
  • +
  • E
    ElevenLabs
  • +
  • O
    OpenRouter
  • + +
  • P
    Perplexity
  • +
  • A
    Abacus AI
  • +
  • M
    Mistral
  • +
  • D
    DeepSeek
  • +
  • C
    Codebuff
  • +
  • LL
    LiteLLM
  • +
+
+ + diff --git a/docs/social.png b/docs/social.png new file mode 100644 index 0000000000..01032c364c Binary files /dev/null and b/docs/social.png differ diff --git a/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md b/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md new file mode 100644 index 0000000000..bba17d53f3 --- /dev/null +++ b/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md @@ -0,0 +1,72 @@ +--- +module: CodexBar +date: 2026-03-07 +summary: "OpenAI web extras battery-drain fix: default optional hidden WebView work off." +read_when: + - Investigating OpenAI web extras battery or energy impact + - Changing Codex OpenAI web extras defaults + - Reviewing hidden WebView performance safeguards +problem_type: performance_issue +component: tooling +symptoms: + - "Hidden chatgpt.com web content could spike to extremely high Energy Impact values in Activity Monitor" + - "CodexBar battery usage stayed abnormally high even when the app appeared idle" + - "Users did not realize optional OpenAI web extras were enabled by default" +root_cause: wrong_api +resolution_type: config_change +severity: high +tags: [codexbar, battery-drain, openai-web, webview, chatgpt, defaults] +--- + +# Troubleshooting: Default OpenAI Web Extras Off + +## Problem +CodexBar exposed optional OpenAI dashboard extras through a hidden `chatgpt.com` WebView, but the feature was enabled by default. That created a mismatch between user expectations for a lightweight menu bar app and the real cost of running a hidden single-page web app in the background. + +## Environment +- Module: CodexBar +- Affected component: Codex OpenAI web extras +- Date: 2026-03-07 + +## Symptoms +- Activity Monitor showed extreme energy usage attributed to `https://chatgpt.com` under the CodexBar process tree. +- Users observed battery drain that was out of proportion to the visible work the app was doing. +- The optional setting existed, but it was easy to miss, so affected users often did not know they could disable it. + +## What Didn't Work + +**Attempted solution 1:** Throttle failed OpenAI dashboard refresh attempts and evict cached WebViews more aggressively. +- **Why it failed:** This reduced the runaway failure loop, but it did not change the product default. Users could still pay the cost of a hidden ChatGPT dashboard without explicitly opting into it. + +**Attempted solution 2:** Keep the feature enabled by default and rely on a visible opt-out toggle. +- **Why it failed:** The battery and network cost was too high for a background utility. An opt-out-only design still left many users exposed to behavior they did not expect or understand. + +## Solution +Change OpenAI web extras to be off by default for new installs while preserving existing explicit configurations. + +**Code changes** +- `SettingsStore` now defaults `openAIWebAccessEnabled` to `false` when no prior preference exists. +- `SettingsStore` now defaults `openAIWebBatterySaverEnabled` to `false`; users can still opt into reduced routine OpenAI web refreshes separately. +- Existing users with an explicit Codex cookie configuration are inferred as enabled so upgrades do not silently break working setups. +- The Codex settings copy now describes the feature as optional and warns about battery and network cost. +- Documentation now labels the OpenAI web dashboard path as optional and off by default. + +## Why This Works +The root problem was not that the app had a toggle. The root problem was that an optional feature with heavyweight implementation details was enabled by default. + +The OpenAI web extras path uses a hidden `WKWebView` against `chatgpt.com` to gather dashboard-only data. That mechanism is fundamentally more expensive than the main Codex data paths, which already provide the normal information users expect from the app: session usage, weekly usage, reset timers, account identity, plan label, and normal credits remaining. + +Making the feature opt-in aligns the default behavior with the actual technical cost: +1. The normal Codex card continues to work without the hidden ChatGPT dashboard. +2. Users only incur the WebView cost if they deliberately choose the extra dashboard data. +3. Existing users with a configured Codex web setup keep their behavior on upgrade instead of being silently broken. + +## Prevention +- Do not default-enable optional features that load heavyweight hidden web content in a background utility. +- If a feature depends on a hidden SPA or WebView, require explicit user opt-in unless it is essential to core functionality. +- Prefer direct API or cookie-backed HTTP requests over hidden browser automation for background data collection. +- Surface the operational cost of optional features in the settings copy, not only in debug notes or issue threads. + +## Related Issues +- See also: [perf-energy-issue-139-simulation-report-2026-02-19.md](../../perf-energy-issue-139-simulation-report-2026-02-19.md) +- See also: [perf-energy-issue-139-main-fix-validation-2026-02-19.md](../../perf-energy-issue-139-main-fix-validation-2026-02-19.md) diff --git a/docs/sparkle.md b/docs/sparkle.md index 5aeed10d1e..3f43c0bc33 100644 --- a/docs/sparkle.md +++ b/docs/sparkle.md @@ -17,7 +17,7 @@ read_when: - Channels: stable vs beta are served from the same appcast. Beta items are tagged with `sparkle:channel="beta"`; About → Update Channel controls `allowedChannels`. ## Release flow -1) Build & notarize as usual (`./Scripts/sign-and-notarize.sh`), producing notarized `CodexBar-.zip`. +1) Build & notarize as usual (`./Scripts/sign-and-notarize.sh`), producing notarized `CodexBar-macos-universal-.zip`. 2) Generate appcast entry with Sparkle `generate_appcast` using the Ed25519 private key; HTML release notes come from `CHANGELOG.md` via `Scripts/changelog-to-html.sh`. For beta releases: set `SPARKLE_CHANNEL=beta` to tag the entry. 3) Upload `appcast.xml` + zip to GitHub Releases (feed URL stays stable). 4) Tag/release. diff --git a/docs/stepfun.md b/docs/stepfun.md new file mode 100644 index 0000000000..cba78d33d2 --- /dev/null +++ b/docs/stepfun.md @@ -0,0 +1,57 @@ +--- +summary: "StepFun provider data sources: username + password login for Step Plan rate limits and subscription plan name." +read_when: + - Adding or tweaking StepFun rate limit parsing + - Updating StepFun login flow + - Documenting new provider behavior +--- + +# StepFun provider + +StepFun (阶跃星辰) is a web-based provider. Usage data comes from the Step Plan rate limit API, +authenticated via an Oasis-Token obtained through a username + password login flow. + +## Data sources + +1. **Authentication** — Three methods (in priority order): + - **Auto mode**: Username + password entered in Settings → Providers → StepFun. + CodexBar performs a 3-step login flow to obtain an Oasis-Token: + 1. `GET https://platform.stepfun.com` → `INGRESSCOOKIE` + 2. `POST …/RegisterDevice` → anonymous token + 3. `POST …/SignInByPassword` → authenticated Oasis-Token + The token is cached in Keychain-backed `CookieHeaderCache` and reused until it expires. + - **Manual mode**: Paste an Oasis-Token directly in Settings → Providers → StepFun. + - **Environment variables**: `STEPFUN_USERNAME` + `STEPFUN_PASSWORD`, or `STEPFUN_TOKEN`. + +2. **Rate limit endpoint** + - `POST https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/QueryStepPlanRateLimit` + - Request headers: `Cookie: Oasis-Token=`, `Content-Type: application/json` + - Response fields: + - `five_hour_usage_left_rate` — remaining fraction for the 5-hour window (e.g. `0.99781543`) + - `weekly_usage_left_rate` — remaining fraction for the weekly window + - `five_hour_usage_reset_time` — reset timestamp (string or integer) + - `weekly_usage_reset_time` — reset timestamp (string or integer) + +3. **Plan status endpoint** + - `POST https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/GetStepPlanStatus` + - Same auth headers as above + - Response → `subscription.name` → plan name (e.g. "Plus", "Mini") + - If this request fails, usage data is still displayed without a plan name. + +## Usage details + +- **Primary window** (top bar): 5-hour rate limit (300 minutes). +- **Secondary window** (bottom bar): weekly rate limit (10 080 minutes). +- `usedPercent` is computed as `(1.0 - left_rate) × 100`. +- Plan name is shown as the `loginMethod` label in the menu card (e.g. "Plus"). +- When auth source is set to **Off**, no background refreshes occur. +- Token expiry triggers automatic re-login (cache is cleared and the 3-step flow runs again). + +## Key files + +- `Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift` (descriptor + web fetch strategy) +- `Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift` (login flow + HTTP client + JSON parser) +- `Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift` (env var resolution) +- `Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift` (settings fields + activation logic) +- `Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift` (SettingsStore extension) +- `Tests/CodexBarTests/StepFunUsageFetcherTests.swift` (22 test cases) diff --git a/docs/superpowers/plans/2026-05-11-kilo-organization-selection.md b/docs/superpowers/plans/2026-05-11-kilo-organization-selection.md new file mode 100644 index 0000000000..96542a6ff2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-kilo-organization-selection.md @@ -0,0 +1,1490 @@ +--- +summary: "Implementation plan for Kilo organization selection and stacked organization usage cards." +read_when: + - Implementing Kilo organization selection + - Updating Kilo organization-scoped usage fetching + - Planning stacked Kilo personal and organization menu cards +--- + +# Kilo Organization Selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let CodexBar users opt in to one or more Kilo organizations from Preferences → Providers → Kilo. Enabled orgs render as stacked cards alongside their personal account in the Kilo menu. + +**Architecture:** Add `KiloUsageScope` and `KiloOrganization` types in `CodexBarCore`. Inject `X-KILOCODE-ORGANIZATIONID` header in `KiloUsageFetcher` when a scope is `.organization`. Persist known orgs and enabled-ids in `ProviderConfig` JSON. Mirror the existing tokenAccounts pattern in `UsageStore` to fan out a fetch per enabled scope and store stacked snapshots. Render via the existing stacked-snapshot menu pipeline by surfacing a Kilo-scoped accounts adapter. + +**Tech Stack:** Swift 6, SwiftUI, Swift Testing (`@Test`), `swift build` / `swift test` / `make check`, GitHub CLI for PR. + +**Spec:** `docs/superpowers/specs/2026-05-11-kilo-organization-selection-design.md` + +--- + +## Pre-flight (lead-only — do BEFORE dispatching tasks) + +- [ ] **Step 0.1: Confirm clean working tree on `main`** + +```bash +git status +``` + +Expected output: `nothing to commit, working tree clean` on branch `main` (the spec commit `c24e58a4` is already in). + +- [ ] **Step 0.2: Create feature branch** + +```bash +git switch -c feat/kilo-organization-selection +``` + +- [ ] **Step 0.3: Verify Swift toolchain and tests baseline** + +```bash +swift build 2>&1 | tail -5 +swift test --filter KiloUsageFetcherTests 2>&1 | tail -10 +``` + +Expected: build succeeds, KiloUsageFetcher tests pass. + +--- + +## Task 1: Add `KiloOrganization` data type + +**Files:** +- Create: `Sources/CodexBarCore/Providers/Kilo/KiloOrganization.swift` +- Create: `Tests/CodexBarTests/KiloOrganizationTests.swift` + +- [ ] **Step 1.1: Write the failing test** + +Create `Tests/CodexBarTests/KiloOrganizationTests.swift`: + +```swift +import Foundation +import Testing +@testable import CodexBarCore + +struct KiloOrganizationTests { + @Test + func `decodes from canonical Kilo profile payload`() throws { + let json = #""" + { "id": "org_123", "name": "Acme Corp", "role": "owner" } + """# + let data = Data(json.utf8) + let org = try JSONDecoder().decode(KiloOrganization.self, from: data) + #expect(org.id == "org_123") + #expect(org.name == "Acme Corp") + #expect(org.role == "owner") + } + + @Test + func `decodes when role missing`() throws { + let json = #""" + { "id": "org_xyz", "name": "No Role Org" } + """# + let data = Data(json.utf8) + let org = try JSONDecoder().decode(KiloOrganization.self, from: data) + #expect(org.role == nil) + } + + @Test + func `equality covers all stored fields`() { + let a = KiloOrganization(id: "org_1", name: "A", role: "member") + let b = KiloOrganization(id: "org_1", name: "A", role: "member") + let differentRole = KiloOrganization(id: "org_1", name: "A", role: "owner") + #expect(a == b) + #expect(a != differentRole) + } +} +``` + +- [ ] **Step 1.2: Run test to verify it fails** + +```bash +swift test --filter KiloOrganizationTests 2>&1 | tail -10 +``` + +Expected: compile error "cannot find 'KiloOrganization' in scope". + +- [ ] **Step 1.3: Create the type** + +Create `Sources/CodexBarCore/Providers/Kilo/KiloOrganization.swift`: + +```swift +import Foundation + +public struct KiloOrganization: Codable, Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let name: String + public let role: String? + + public init(id: String, name: String, role: String? = nil) { + self.id = id + self.name = name + self.role = role + } +} +``` + +- [ ] **Step 1.4: Run tests, verify pass** + +```bash +swift test --filter KiloOrganizationTests 2>&1 | tail -10 +``` + +Expected: all 3 tests pass. + +- [ ] **Step 1.5: Commit** + +```bash +git add Sources/CodexBarCore/Providers/Kilo/KiloOrganization.swift \ + Tests/CodexBarTests/KiloOrganizationTests.swift +git commit -m "feat(kilo): add KiloOrganization model" +``` + +--- + +## Task 2: Add `KiloUsageScope` enum + +**Files:** +- Create: `Sources/CodexBarCore/Providers/Kilo/KiloUsageScope.swift` +- Modify: `Tests/CodexBarTests/KiloOrganizationTests.swift` + +- [ ] **Step 2.1: Append failing tests** + +Append to `Tests/CodexBarTests/KiloOrganizationTests.swift`: + +```swift +struct KiloUsageScopeTests { + @Test + func `personal scope identifier is stable`() { + let scope: KiloUsageScope = .personal + #expect(scope.scopeIdentifier == "personal") + } + + @Test + func `organization scope identifier prefixes id`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.scopeIdentifier == "org:org_42") + } + + @Test + func `organizationID is nil for personal`() { + #expect(KiloUsageScope.personal.organizationID == nil) + } + + @Test + func `organizationID returns id for organization`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.organizationID == "org_42") + } + + @Test + func `displayName falls back to Personal for personal`() { + #expect(KiloUsageScope.personal.displayName == "Personal") + } + + @Test + func `displayName uses org name for organization`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.displayName == "Acme") + } +} +``` + +- [ ] **Step 2.2: Run test, verify it fails** + +```bash +swift test --filter KiloUsageScopeTests 2>&1 | tail -10 +``` + +Expected: compile error "cannot find 'KiloUsageScope' in scope". + +- [ ] **Step 2.3: Create the type** + +Create `Sources/CodexBarCore/Providers/Kilo/KiloUsageScope.swift`: + +```swift +import Foundation + +public enum KiloUsageScope: Sendable, Hashable, Equatable { + case personal + case organization(id: String, name: String) + + public var scopeIdentifier: String { + switch self { + case .personal: + "personal" + case let .organization(id, _): + "org:\(id)" + } + } + + public var organizationID: String? { + switch self { + case .personal: + nil + case let .organization(id, _): + id + } + } + + public var displayName: String { + switch self { + case .personal: + "Personal" + case let .organization(_, name): + name + } + } +} +``` + +- [ ] **Step 2.4: Run tests, verify pass** + +```bash +swift test --filter KiloUsageScopeTests 2>&1 | tail -10 +``` + +Expected: all 6 tests pass. + +- [ ] **Step 2.5: Commit** + +```bash +git add Sources/CodexBarCore/Providers/Kilo/KiloUsageScope.swift \ + Tests/CodexBarTests/KiloOrganizationTests.swift +git commit -m "feat(kilo): add KiloUsageScope enum" +``` + +--- + +## Task 3: Inject org header in `KiloUsageFetcher.fetchUsage` + +**Files:** +- Modify: `Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift` +- Modify: `Tests/CodexBarTests/KiloUsageFetcherTests.swift` + +- [ ] **Step 3.1: Append failing test for header injection** + +Append the following inside `struct KiloUsageFetcherTests` in `Tests/CodexBarTests/KiloUsageFetcherTests.swift`: + +```swift + @Test + func `request builder adds org header for organization scope`() throws { + let baseURL = try #require(URL(string: "https://kilo.example/trpc")) + let request = try KiloUsageFetcher._buildRequestForTesting( + baseURL: baseURL, + apiKey: "test-token", + scope: .organization(id: "org_42", name: "Acme")) + #expect(request.value(forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") == "org_42") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + } + + @Test + func `request builder omits org header for personal scope`() throws { + let baseURL = try #require(URL(string: "https://kilo.example/trpc")) + let request = try KiloUsageFetcher._buildRequestForTesting( + baseURL: baseURL, + apiKey: "test-token", + scope: .personal) + #expect(request.value(forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") == nil) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + } +``` + +- [ ] **Step 3.2: Run test, verify it fails** + +```bash +swift test --filter KiloUsageFetcherTests 2>&1 | tail -15 +``` + +Expected: compile error — `_buildRequestForTesting` not found. + +- [ ] **Step 3.3: Refactor `KiloUsageFetcher` to accept scope and extract request builder** + +In `Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift`: + +Replace the existing `public static func fetchUsage(apiKey:environment:)` signature (around line 258) with the scoped version, and extract the request building into a testable helper. The new code: + +```swift + public static func fetchUsage( + apiKey: String, + scope: KiloUsageScope = .personal, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> KiloUsageSnapshot + { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw KiloUsageError.missingCredentials + } + + let baseURL = KiloSettingsReader.apiURL(environment: environment) + let request = try self.makeRequest(baseURL: baseURL, apiKey: apiKey, scope: scope) + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw KiloUsageError.networkError(error.localizedDescription) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw KiloUsageError.networkError("Invalid response") + } + + if let mapped = self.statusError(for: httpResponse.statusCode) { + throw mapped + } + + guard httpResponse.statusCode == 200 else { + throw KiloUsageError.apiError(httpResponse.statusCode) + } + + return try self.parseSnapshot(data: data) + } + + static func _buildRequestForTesting( + baseURL: URL, + apiKey: String, + scope: KiloUsageScope) throws -> URLRequest + { + try self.makeRequest(baseURL: baseURL, apiKey: apiKey, scope: scope) + } + + private static func makeRequest( + baseURL: URL, + apiKey: String, + scope: KiloUsageScope) throws -> URLRequest + { + let batchURL = try self.makeBatchURL(baseURL: baseURL) + var request = URLRequest(url: batchURL) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let orgId = scope.organizationID { + request.setValue(orgId, forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") + } + return request + } +``` + +Then delete the old inline `var request = URLRequest(url: batchURL)` setup that lived in `fetchUsage` (it's now in `makeRequest`). + +- [ ] **Step 3.4: Run tests, verify pass** + +```bash +swift test --filter KiloUsageFetcherTests 2>&1 | tail -15 +``` + +Expected: all KiloUsageFetcher tests pass (existing + 2 new). + +- [ ] **Step 3.5: Commit** + +```bash +git add Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift \ + Tests/CodexBarTests/KiloUsageFetcherTests.swift +git commit -m "feat(kilo): scope KiloUsageFetcher.fetchUsage with org header" +``` + +--- + +## Task 4: Add `fetchOrganizations` to `KiloUsageFetcher` + +**Files:** +- Modify: `Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift` +- Modify: `Tests/CodexBarTests/KiloUsageFetcherTests.swift` + +- [ ] **Step 4.1: Append failing parse tests** + +Append inside `struct KiloUsageFetcherTests`: + +```swift + @Test + func `parseOrganizations decodes tRPC array shape`() throws { + let json = #""" + [ + { + "result": { + "data": { + "json": [ + { "id": "org_1", "name": "Alpha", "role": "owner" }, + { "id": "org_2", "name": "Beta", "role": "member" } + ] + } + } + } + ] + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.count == 2) + #expect(orgs[0].id == "org_1") + #expect(orgs[0].name == "Alpha") + #expect(orgs[0].role == "owner") + #expect(orgs[1].id == "org_2") + #expect(orgs[1].role == "member") + } + + @Test + func `parseOrganizations decodes profile REST shape`() throws { + let json = #""" + { + "user": { "email": "test@example.com" }, + "organizations": [ + { "id": "org_42", "name": "Gamma" } + ] + } + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.count == 1) + #expect(orgs[0].id == "org_42") + #expect(orgs[0].role == nil) + } + + @Test + func `parseOrganizations returns empty for no orgs`() throws { + let json = #""" + { "user": { "email": "x@y" }, "organizations": [] } + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.isEmpty) + } +``` + +- [ ] **Step 4.2: Run, verify fail** + +```bash +swift test --filter KiloUsageFetcherTests 2>&1 | tail -10 +``` + +Expected: compile error — `_parseOrganizationsForTesting` undefined. + +- [ ] **Step 4.3: Add organization fetching logic** + +Append to `Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift` (inside the `KiloUsageFetcher` struct): + +```swift + public static func fetchOrganizations( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> [KiloOrganization] + { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw KiloUsageError.missingCredentials + } + + let baseURL = KiloSettingsReader.apiURL(environment: environment) + let trpcRequest = try self.makeOrgListTRPCRequest(baseURL: baseURL, apiKey: apiKey) + + do { + let (data, response) = try await URLSession.shared.data(for: trpcRequest) + guard let httpResponse = response as? HTTPURLResponse else { + throw KiloUsageError.networkError("Invalid response") + } + if httpResponse.statusCode == 404 { + return try await self.fetchOrganizationsRESTFallback(apiKey: apiKey) + } + if let mapped = self.statusError(for: httpResponse.statusCode) { + throw mapped + } + return try self.parseOrganizations(data: data) + } catch let error as KiloUsageError { + throw error + } catch { + throw KiloUsageError.networkError(error.localizedDescription) + } + } + + static func _parseOrganizationsForTesting(_ data: Data) throws -> [KiloOrganization] { + try self.parseOrganizations(data: data) + } + + private static func makeOrgListTRPCRequest( + baseURL: URL, + apiKey: String) throws -> URLRequest + { + let endpoint = baseURL.appendingPathComponent("user.getOrganizations") + let inputData = try JSONSerialization.data( + withJSONObject: ["0": ["json": NSNull()]] as [String: Any]) + guard let inputString = String(data: inputData, encoding: .utf8), + var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else { + throw KiloUsageError.parseFailed("Invalid org list endpoint") + } + components.queryItems = [ + URLQueryItem(name: "batch", value: "1"), + URLQueryItem(name: "input", value: inputString), + ] + guard let url = components.url else { + throw KiloUsageError.parseFailed("Invalid org list endpoint") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + return request + } + + private static func fetchOrganizationsRESTFallback(apiKey: String) async throws -> [KiloOrganization] { + guard let url = URL(string: "https://api.kilo.ai/api/profile") else { + throw KiloUsageError.parseFailed("Invalid REST fallback URL") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw KiloUsageError.networkError("Invalid response") + } + if let mapped = self.statusError(for: httpResponse.statusCode) { + throw mapped + } + guard httpResponse.statusCode == 200 else { + throw KiloUsageError.apiError(httpResponse.statusCode) + } + return try self.parseOrganizations(data: data) + } + + private static func parseOrganizations(data: Data) throws -> [KiloOrganization] { + guard let root = try? JSONSerialization.jsonObject(with: data) else { + throw KiloUsageError.parseFailed("Invalid JSON") + } + + // tRPC batch shape: [ { result: { data: { json: [orgs] } } } ] + if let entries = root as? [[String: Any]], + let first = entries.first, + let resultObject = first["result"] as? [String: Any] + { + if let dataObject = resultObject["data"] as? [String: Any], + let payload = dataObject["json"] as? [[String: Any]] + { + return self.decodeOrganizations(payload) + } + if let payload = resultObject["data"] as? [[String: Any]] { + return self.decodeOrganizations(payload) + } + } + + // REST profile shape: { user: ..., organizations: [orgs] } + if let dictionary = root as? [String: Any] { + if let orgs = dictionary["organizations"] as? [[String: Any]] { + return self.decodeOrganizations(orgs) + } + // Some single-procedure tRPC shapes flatten to { result: { data: { json: { organizations: [...] }}}} + if let resultObject = dictionary["result"] as? [String: Any], + let dataObject = resultObject["data"] as? [String: Any] + { + if let payload = dataObject["json"] as? [[String: Any]] { + return self.decodeOrganizations(payload) + } + if let payload = dataObject["json"] as? [String: Any], + let orgs = payload["organizations"] as? [[String: Any]] + { + return self.decodeOrganizations(orgs) + } + } + } + + return [] + } + + private static func decodeOrganizations(_ raw: [[String: Any]]) -> [KiloOrganization] { + raw.compactMap { item -> KiloOrganization? in + guard let id = item["id"] as? String, !id.isEmpty else { return nil } + let name = (item["name"] as? String).map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } ?? id + let role = (item["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedRole = (role?.isEmpty ?? true) ? nil : role + return KiloOrganization(id: id, name: name.isEmpty ? id : name, role: normalizedRole) + } + } +``` + +- [ ] **Step 4.4: Run tests, verify pass** + +```bash +swift test --filter KiloUsageFetcherTests 2>&1 | tail -15 +``` + +Expected: all 3 new parse tests pass plus prior tests. + +- [ ] **Step 4.5: Commit** + +```bash +git add Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift \ + Tests/CodexBarTests/KiloUsageFetcherTests.swift +git commit -m "feat(kilo): add fetchOrganizations with REST fallback" +``` + +--- + +## Task 5: Extend `ProviderConfig` with `kiloOrganizations` + +**Files:** +- Modify: `Sources/CodexBarCore/Config/CodexBarConfig.swift` +- Tests: covered indirectly via Task 6 SettingsStore tests. + +- [ ] **Step 5.1: Add fields to `ProviderConfig`** + +In `Sources/CodexBarCore/Config/CodexBarConfig.swift`, inside `public struct ProviderConfig`: + +After the existing `public var quotaWarnings: QuotaWarningConfig?` line, add: + +```swift + public var kiloKnownOrganizations: [KiloOrganization]? + public var kiloEnabledOrganizationIDs: [String]? +``` + +Then extend the `init` signature with these matching parameters (defaulted to `nil`) and assign them in the body. Match the existing init ordering pattern — add the two new parameters at the bottom of the init parameter list: + +```swift + public init( + id: UsageProvider, + enabled: Bool? = nil, + source: ProviderSourceMode? = nil, + extrasEnabled: Bool? = nil, + apiKey: String? = nil, + cookieHeader: String? = nil, + cookieSource: ProviderCookieSource? = nil, + region: String? = nil, + workspaceID: String? = nil, + enterpriseHost: String? = nil, + tokenAccounts: ProviderTokenAccountData? = nil, + codexActiveSource: CodexActiveSource? = nil, + quotaWarnings: QuotaWarningConfig? = nil, + kiloKnownOrganizations: [KiloOrganization]? = nil, + kiloEnabledOrganizationIDs: [String]? = nil) + { + self.id = id + self.enabled = enabled + self.source = source + self.extrasEnabled = extrasEnabled + self.apiKey = apiKey + self.cookieHeader = cookieHeader + self.cookieSource = cookieSource + self.region = region + self.workspaceID = workspaceID + self.enterpriseHost = enterpriseHost + self.tokenAccounts = tokenAccounts + self.codexActiveSource = codexActiveSource + self.quotaWarnings = quotaWarnings + self.kiloKnownOrganizations = kiloKnownOrganizations + self.kiloEnabledOrganizationIDs = kiloEnabledOrganizationIDs + } +``` + +The implicit `Codable` conformance will pick up the new optional fields automatically. + +- [ ] **Step 5.2: Build to verify schema compiles** + +```bash +swift build 2>&1 | tail -5 +``` + +Expected: build succeeds. + +- [ ] **Step 5.3: Commit** + +```bash +git add Sources/CodexBarCore/Config/CodexBarConfig.swift +git commit -m "feat(kilo): persist kilo organizations in ProviderConfig" +``` + +--- + +## Task 6: Extend `SettingsStore` with Kilo orgs accessors + +**Files:** +- Modify: `Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift` +- Create: `Tests/CodexBarTests/KiloSettingsStoreTests.swift` + +- [ ] **Step 6.1: Write failing tests** + +Create `Tests/CodexBarTests/KiloSettingsStoreTests.swift`: + +```swift +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct KiloSettingsStoreTests { + private func makeSettings() -> SettingsStore { + let env = ProviderConfigEnvironment( + configFileURL: FileManager.default.temporaryDirectory.appendingPathComponent( + "kilo-org-settings-test-\(UUID().uuidString).json"), + environment: [:]) + return SettingsStore(providerConfigEnvironment: env) + } + + @Test + func `defaults to empty known organizations and empty enabled ids`() { + let settings = self.makeSettings() + #expect(settings.kiloKnownOrganizations.isEmpty) + #expect(settings.kiloEnabledOrganizationIDs.isEmpty) + } + + @Test + func `setting known organizations persists them`() { + let settings = self.makeSettings() + let orgs = [ + KiloOrganization(id: "org_1", name: "Alpha", role: "owner"), + KiloOrganization(id: "org_2", name: "Beta", role: "member"), + ] + settings.kiloKnownOrganizations = orgs + #expect(settings.kiloKnownOrganizations == orgs) + } + + @Test + func `setting enabled org ids persists them`() { + let settings = self.makeSettings() + settings.kiloEnabledOrganizationIDs = ["org_1", "org_2"] + #expect(settings.kiloEnabledOrganizationIDs == ["org_1", "org_2"]) + } + + @Test + func `setKiloKnownOrganizations prunes stale enabled ids`() { + let settings = self.makeSettings() + settings.kiloKnownOrganizations = [ + KiloOrganization(id: "org_1", name: "Alpha", role: nil), + KiloOrganization(id: "org_2", name: "Beta", role: nil), + ] + settings.kiloEnabledOrganizationIDs = ["org_1", "org_2"] + settings.setKiloKnownOrganizationsPruningEnabled( + [KiloOrganization(id: "org_2", name: "Beta", role: nil)]) + #expect(settings.kiloKnownOrganizations.map(\.id) == ["org_2"]) + #expect(settings.kiloEnabledOrganizationIDs == ["org_2"]) + } +} +``` + +- [ ] **Step 6.2: Run, verify fail** + +```bash +swift test --filter KiloSettingsStoreTests 2>&1 | tail -10 +``` + +Expected: compile error — missing properties. + +- [ ] **Step 6.3: Add accessors to `KiloSettingsStore`** + +Append to `Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift`: + +```swift +extension SettingsStore { + var kiloKnownOrganizations: [KiloOrganization] { + get { self.configSnapshot.providerConfig(for: .kilo)?.kiloKnownOrganizations ?? [] } + set { + self.updateProviderConfig(provider: .kilo) { entry in + entry.kiloKnownOrganizations = newValue.isEmpty ? nil : newValue + } + } + } + + var kiloEnabledOrganizationIDs: [String] { + get { self.configSnapshot.providerConfig(for: .kilo)?.kiloEnabledOrganizationIDs ?? [] } + set { + let cleaned = Array(LinkedHashSet(newValue + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty })) + self.updateProviderConfig(provider: .kilo) { entry in + entry.kiloEnabledOrganizationIDs = cleaned.isEmpty ? nil : cleaned + } + self.logProviderModeChange( + provider: .kilo, + field: "enabledOrganizations", + value: cleaned.joined(separator: ",")) + } + } + + func setKiloKnownOrganizationsPruningEnabled(_ orgs: [KiloOrganization]) { + self.kiloKnownOrganizations = orgs + let validIDs = Set(orgs.map(\.id)) + let pruned = self.kiloEnabledOrganizationIDs.filter { validIDs.contains($0) } + if pruned != self.kiloEnabledOrganizationIDs { + self.kiloEnabledOrganizationIDs = pruned + } + } + + func kiloIsOrganizationEnabled(_ orgID: String) -> Bool { + self.kiloEnabledOrganizationIDs.contains(orgID) + } + + func setKiloOrganization(_ orgID: String, enabled: Bool) { + var current = self.kiloEnabledOrganizationIDs + if enabled { + guard !current.contains(orgID) else { return } + current.append(orgID) + } else { + current.removeAll { $0 == orgID } + } + self.kiloEnabledOrganizationIDs = current + } +} + +// Small order-preserving set used to dedupe enabled IDs without sorting. +private struct LinkedHashSet: Sequence { + private var seen: Set = [] + private var ordered: [Element] = [] + + init(_ sequence: S) where S.Element == Element { + for element in sequence where self.seen.insert(element).inserted { + self.ordered.append(element) + } + } + + func makeIterator() -> IndexingIterator<[Element]> { + self.ordered.makeIterator() + } +} +``` + +- [ ] **Step 6.4: Run tests, verify pass** + +```bash +swift test --filter KiloSettingsStoreTests 2>&1 | tail -10 +``` + +Expected: all 4 tests pass. + +- [ ] **Step 6.5: Commit** + +```bash +git add Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift \ + Tests/CodexBarTests/KiloSettingsStoreTests.swift +git commit -m "feat(kilo): settings accessors for known + enabled organizations" +``` + +--- + +## Task 7: Wire scoped fetching into Kilo strategies + +**Files:** +- Modify: `Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift` +- Modify: `Sources/CodexBar/UsageStore+Refresh.swift` +- Create: `Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift` + +The `ProviderFetchStrategy` returns one `UsageSnapshot`, so we keep the existing strategy returning the personal scope. Org snapshots are fanned out at the UsageStore layer, mirroring `refreshTokenAccounts`. + +- [ ] **Step 7.1: Add scope-aware overload to the AP strategies (no-op call path so they still build)** + +This task does NOT change `KiloAPIFetchStrategy.fetch`. The strategy continues to fetch the personal scope. The fan-out is added at the UsageStore layer below. Skip directly to step 7.2. + +- [ ] **Step 7.2: Add a Kilo scope account adapter** + +The codebase already has `TokenAccountUsageSnapshot` for stacked rendering. We mirror that for Kilo scopes. + +Create `Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift`: + +```swift +import CodexBarCore +import Foundation + +struct KiloScopeSnapshot: Identifiable, Equatable { + let id: String // KiloUsageScope.scopeIdentifier + let scope: KiloUsageScope + let snapshot: UsageSnapshot? + let errorMessage: String? + let sourceLabel: String? + + static func == (lhs: KiloScopeSnapshot, rhs: KiloScopeSnapshot) -> Bool { + lhs.id == rhs.id + && lhs.snapshot?.updatedAt == rhs.snapshot?.updatedAt + && lhs.errorMessage == rhs.errorMessage + && lhs.sourceLabel == rhs.sourceLabel + } +} + +extension UsageStore { + var kiloEnabledScopes: [KiloUsageScope] { + var scopes: [KiloUsageScope] = [.personal] + let enabled = self.settings.kiloEnabledOrganizationIDs + guard !enabled.isEmpty else { return scopes } + let knownByID = Dictionary( + uniqueKeysWithValues: self.settings.kiloKnownOrganizations.map { ($0.id, $0) }) + for id in enabled { + if let org = knownByID[id] { + scopes.append(.organization(id: org.id, name: org.name)) + } + } + return scopes + } + + func shouldFanOutKiloScopes() -> Bool { + self.kiloEnabledScopes.count > 1 + } + + func refreshKiloScopes() async { + let scopes = self.kiloEnabledScopes + guard scopes.count > 1 else { + await MainActor.run { self.kiloScopeSnapshots = [] } + return + } + let apiKey = self.settings.configSnapshot.providerConfig(for: .kilo)?.sanitizedAPIKey + ?? ProcessInfo.processInfo.environment[KiloSettingsReader.apiTokenKey] + guard let resolvedKey = apiKey, !resolvedKey.isEmpty else { + await MainActor.run { + self.kiloScopeSnapshots = scopes.map { + KiloScopeSnapshot( + id: $0.scopeIdentifier, + scope: $0, + snapshot: nil, + errorMessage: "Kilo API credentials missing.", + sourceLabel: nil) + } + } + return + } + + let env = ProcessInfo.processInfo.environment + let results: [KiloScopeSnapshot] = await withTaskGroup(of: KiloScopeSnapshot.self) { group in + for scope in scopes { + group.addTask { + do { + let raw = try await KiloUsageFetcher.fetchUsage( + apiKey: resolvedKey, + scope: scope, + environment: env) + var snapshot = raw.toUsageSnapshot() + snapshot = snapshot.replacingIdentityOrganization(scope.displayName) + return KiloScopeSnapshot( + id: scope.scopeIdentifier, + scope: scope, + snapshot: snapshot, + errorMessage: nil, + sourceLabel: "api") + } catch { + return KiloScopeSnapshot( + id: scope.scopeIdentifier, + scope: scope, + snapshot: nil, + errorMessage: (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription, + sourceLabel: nil) + } + } + } + var collected: [KiloScopeSnapshot] = [] + for await result in group { + collected.append(result) + } + return collected + } + + // Preserve the order from `scopes` (personal first, then enabled orgs in order). + let resultByID = Dictionary(uniqueKeysWithValues: results.map { ($0.id, $0) }) + let ordered = scopes.compactMap { resultByID[$0.scopeIdentifier] } + + await MainActor.run { + self.kiloScopeSnapshots = ordered + } + } +} + +extension UsageSnapshot { + fileprivate func replacingIdentityOrganization(_ org: String) -> UsageSnapshot { + let baseIdentity = self.identity + let newIdentity = ProviderIdentitySnapshot( + providerID: baseIdentity?.providerID ?? .kilo, + accountEmail: baseIdentity?.accountEmail, + accountOrganization: org, + loginMethod: baseIdentity?.loginMethod) + return UsageSnapshot( + primary: self.primary, + secondary: self.secondary, + tertiary: self.tertiary, + providerCost: self.providerCost, + updatedAt: self.updatedAt, + identity: newIdentity) + } +} +``` + +- [ ] **Step 7.3: Add the `kiloScopeSnapshots` stored property to `UsageStore`** + +In `Sources/CodexBar/UsageStore.swift`, find the closest place where Codex-related published properties live (e.g. near `codexAccountSnapshots`) and add: + +```swift + @Published var kiloScopeSnapshots: [KiloScopeSnapshot] = [] +``` + +Choose a location adjacent to existing per-provider stacked snapshot arrays. The exact line is around `codexAccountSnapshots: [CodexAccountUsageSnapshot] = []` — add directly below it. + +- [ ] **Step 7.4: Invoke fan-out from `refreshProvider`** + +In `Sources/CodexBar/UsageStore+Refresh.swift`, find the existing `tokenAccounts` fan-out block in `refreshProvider`: + +```swift + let tokenAccounts = self.tokenAccounts(for: provider) + if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { + await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) + return + } +``` + +Insert directly above it (before line 55): + +```swift + if provider == .kilo, self.shouldFanOutKiloScopes() { + await self.refreshKiloScopes() + // Continue to also fetch the personal snapshot through the regular path + // so the existing single-card render keeps working when only personal is shown. + // The presence of multi-element kiloScopeSnapshots triggers stacked rendering. + } +``` + +- [ ] **Step 7.5: Reset `kiloScopeSnapshots` when Kilo is disabled or single-scope** + +Inside the existing disabled-provider branch of `refreshProvider` (the block that runs when `!spec.isEnabled()`), add inside the `MainActor.run`: + +```swift + if provider == .kilo { + self.kiloScopeSnapshots = [] + } +``` + +Also at the start of the regular fetch path (just before `let fetchContext = spec.makeFetchContext()`), add: + +```swift + if provider == .kilo, !self.shouldFanOutKiloScopes() { + await MainActor.run { self.kiloScopeSnapshots = [] } + } +``` + +- [ ] **Step 7.6: Build to confirm wiring compiles** + +```bash +swift build 2>&1 | tail -15 +``` + +Expected: succeeds. If `UsageSnapshot.replacingIdentityOrganization` clashes with an existing extension, rename to `withAccountOrganization` and update the call site. + +- [ ] **Step 7.7: Commit** + +```bash +git add Sources/CodexBar/UsageStore.swift \ + Sources/CodexBar/UsageStore+Refresh.swift \ + Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift +git commit -m "feat(kilo): fan out usage fetch per enabled scope" +``` + +--- + +## Task 8: Surface scope snapshots in menu rendering + +**Files:** +- Modify: `Sources/CodexBar/StatusItemController+MenuCardModel.swift` (or the file that produces Kilo menu rows) +- Modify: `Sources/CodexBar/MenuDescriptor.swift` if needed + +The menu currently renders one Kilo card. Add a branch: when `kiloScopeSnapshots` has 2+ entries, render one card per scope. + +- [ ] **Step 8.1: Locate the Kilo menu-row producer** + +```bash +grep -n "case \\.kilo" Sources/CodexBar/StatusItemController*.swift Sources/CodexBar/Menu*.swift 2>&1 | head -15 +``` + +This points at the rendering site. Open whichever file produces the per-provider row group for Kilo. + +- [ ] **Step 8.2: Insert scope-fan-out in the Kilo row builder** + +At the location that produces the Kilo `MenuCardModel` (or the equivalent NSMenu items), guard: + +```swift +if !self.kiloScopeSnapshots.isEmpty, self.kiloScopeSnapshots.count > 1 { + return self.kiloScopeSnapshots.map { scope -> MenuCardModel in + self.makeKiloMenuCard( + snapshot: scope.snapshot, + errorMessage: scope.errorMessage, + sourceLabel: scope.sourceLabel, + scopeName: scope.scope.displayName) + } +} +``` + +If a helper named `makeKiloMenuCard(...)` does not exist, factor the existing inline Kilo card construction into one, taking the four parameters above. Reuse the same code path used by Claude's stacked tokenAccount rendering as a structural reference. + +- [ ] **Step 8.3: Verify visually using CLI snapshot test (no UI required)** + +```bash +swift test --filter CLIRendererTests 2>&1 | tail -15 +swift test --filter MenuCardModelTests 2>&1 | tail -15 +``` + +Existing tests must continue to pass. + +- [ ] **Step 8.4: Build** + +```bash +swift build 2>&1 | tail -5 +``` + +Expected: success. + +- [ ] **Step 8.5: Commit** + +```bash +git add Sources/CodexBar/StatusItemController+MenuCardModel.swift \ + Sources/CodexBar/MenuDescriptor.swift +git commit -m "feat(kilo): render one menu card per enabled scope" +``` + +--- + +## Task 9: Preferences pane — Organizations section + +**Files:** +- Modify: `Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift` +- Possibly modify: `Sources/CodexBar/PreferencesProviderDetailView.swift` and `Sources/CodexBarCore/Providers/ProviderDescriptor.swift` if a new descriptor variant is needed. + +If a multi-toggle descriptor is not yet supported, surface the org list using a `ProviderSettingsFieldDescriptor.kind = .info`-style wrapper combined with action buttons, OR add a new descriptor variant. Pick the minimum needed. + +- [ ] **Step 9.1: Add an org-section descriptor type** + +Search first to see whether the existing `ProviderSettingsFieldDescriptor` already has a list/toggle kind: + +```bash +grep -n "enum Kind\|case info\|case toggleList\|case checkboxList" \ + Sources/CodexBarCore/Providers/ProviderDescriptor.swift \ + Sources/CodexBar/PreferencesProviderDetailView.swift 2>&1 | head -20 +``` + +If a toggle-list kind exists, reuse it. Otherwise, add a new `ProviderSettingsOrganizationsDescriptor` in `Sources/CodexBarCore/Providers/ProviderDescriptor.swift`: + +```swift +public struct ProviderSettingsOrganizationsDescriptor: Sendable { + public let id: String + public let title: String + public let subtitle: String? + public let entries: () -> [Entry] + public let onToggle: @MainActor (String, Bool) -> Void + public let onRefresh: @MainActor () async -> RefreshOutcome + public let canRefresh: () -> Bool + + public struct Entry: Sendable, Identifiable { + public let id: String + public let title: String + public let subtitle: String? + public let isEnabled: Bool + public let isLocked: Bool + + public init(id: String, title: String, subtitle: String?, isEnabled: Bool, isLocked: Bool) { + self.id = id + self.title = title + self.subtitle = subtitle + self.isEnabled = isEnabled + self.isLocked = isLocked + } + } + + public struct RefreshOutcome: Sendable { + public let success: Bool + public let errorMessage: String? + + public init(success: Bool, errorMessage: String? = nil) { + self.success = success + self.errorMessage = errorMessage + } + } + + public init( + id: String, + title: String, + subtitle: String?, + entries: @escaping () -> [Entry], + onToggle: @escaping @MainActor (String, Bool) -> Void, + onRefresh: @escaping @MainActor () async -> RefreshOutcome, + canRefresh: @escaping () -> Bool) + { + self.id = id + self.title = title + self.subtitle = subtitle + self.entries = entries + self.onToggle = onToggle + self.onRefresh = onRefresh + self.canRefresh = canRefresh + } +} +``` + +Then add an optional `settingsOrganizations:` slot to whatever protocol `ProviderImplementation` exposes (e.g. add `func settingsOrganizations(context: ProviderSettingsContext) -> ProviderSettingsOrganizationsDescriptor?`). Default the protocol method to `nil`. + +- [ ] **Step 9.2: Implement `settingsOrganizations` in `KiloProviderImplementation`** + +In `Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift`: + +```swift + @MainActor + func settingsOrganizations( + context: ProviderSettingsContext) -> ProviderSettingsOrganizationsDescriptor? + { + ProviderSettingsOrganizationsDescriptor( + id: "kilo-organizations", + title: "Organizations", + subtitle: "Show usage for organizations you belong to. Personal account is always shown.", + entries: { + var entries: [ProviderSettingsOrganizationsDescriptor.Entry] = [ + .init( + id: "personal", + title: "Personal account", + subtitle: nil, + isEnabled: true, + isLocked: true), + ] + for org in context.settings.kiloKnownOrganizations { + entries.append( + .init( + id: org.id, + title: org.name, + subtitle: org.role, + isEnabled: context.settings.kiloIsOrganizationEnabled(org.id), + isLocked: false)) + } + return entries + }, + onToggle: { orgID, enabled in + guard orgID != "personal" else { return } + context.settings.setKiloOrganization(orgID, enabled: enabled) + }, + onRefresh: { + let apiKey = context.settings.kiloAPIToken.isEmpty + ? ProcessInfo.processInfo.environment[KiloSettingsReader.apiTokenKey] ?? "" + : context.settings.kiloAPIToken + guard !apiKey.isEmpty else { + return .init(success: false, + errorMessage: "Set the Kilo API key first.") + } + do { + let orgs = try await KiloUsageFetcher.fetchOrganizations(apiKey: apiKey) + context.settings.setKiloKnownOrganizationsPruningEnabled(orgs) + return .init(success: true) + } catch let error as LocalizedError { + return .init(success: false, + errorMessage: error.errorDescription ?? "Failed to load organizations.") + } catch { + return .init(success: false, + errorMessage: error.localizedDescription) + } + }, + canRefresh: { + !context.settings.kiloAPIToken.isEmpty + || !(ProcessInfo.processInfo.environment[KiloSettingsReader.apiTokenKey] ?? "").isEmpty + }) + } +``` + +- [ ] **Step 9.3: Render the new descriptor in `PreferencesProviderDetailView`** + +In `Sources/CodexBar/PreferencesProviderDetailView.swift`, follow the existing pattern used by `settingsTokenAccounts`. Add a stored property `settingsOrganizations: ProviderSettingsOrganizationsDescriptor?`, source it from the provider implementation, and render it as a SwiftUI section under the API key: + +```swift +if let descriptor = self.settingsOrganizations { + Section(descriptor.title) { + if let subtitle = descriptor.subtitle { + Text(subtitle).font(.caption).foregroundStyle(.secondary) + } + ForEach(descriptor.entries(), id: \.id) { entry in + Toggle(isOn: Binding( + get: { entry.isEnabled }, + set: { newValue in descriptor.onToggle(entry.id, newValue) })) + { + VStack(alignment: .leading, spacing: 2) { + Text(entry.title) + if let subtitle = entry.subtitle { + Text(subtitle).font(.caption).foregroundStyle(.secondary) + } + } + } + .disabled(entry.isLocked) + } + HStack { + Button("Refresh organizations") { + Task { + let result = await descriptor.onRefresh() + if !result.success, let message = result.errorMessage { + self.kiloOrganizationsErrorMessage = message + } else { + self.kiloOrganizationsErrorMessage = nil + } + } + } + .disabled(!descriptor.canRefresh()) + Spacer() + if let message = self.kiloOrganizationsErrorMessage { + Text(message).font(.caption).foregroundStyle(.red) + } + } + } +} +``` + +Add `@State private var kiloOrganizationsErrorMessage: String?` near other `@State` properties in the view. + +- [ ] **Step 9.4: Build and verify** + +```bash +swift build 2>&1 | tail -10 +``` + +Expected: success. If type mismatches arise, follow them and align signatures. + +- [ ] **Step 9.5: Commit** + +```bash +git add Sources/CodexBarCore/Providers/ProviderDescriptor.swift \ + Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift \ + Sources/CodexBar/PreferencesProviderDetailView.swift +git commit -m "feat(kilo): Preferences organizations section with refresh + toggles" +``` + +--- + +## Task 10: Update Kilo docs + +**Files:** +- Modify: `docs/kilo.md` + +- [ ] **Step 10.1: Add a "Organizations" section** + +Append to `docs/kilo.md`: + +```markdown +## Organizations + +CodexBar can show usage for any Kilo organization the API key belongs to. + +- Open Preferences → Providers → Kilo, set the API key, then click **Refresh + organizations**. +- Toggle the organizations you want to display alongside Personal. Personal is + always shown. +- When at least one organization is enabled, the menu renders one Kilo card per + enabled scope. +- The CodexBar fetcher sends the standard `X-KILOCODE-ORGANIZATIONID` header on + every usage call to scope the response to that organization. +- CLI source mode (`auth.json`): the header is applied to CLI-resolved tokens + as well. If a CLI token isn't authorized for the chosen organization, that + card surfaces an unauthorized error while Personal and other enabled scopes + continue to render normally. +``` + +- [ ] **Step 10.2: Commit** + +```bash +git add docs/kilo.md +git commit -m "docs(kilo): document organization selection" +``` + +--- + +## Task 11: Repo-wide validation + +- [ ] **Step 11.1: Run the full unit test suite** + +```bash +swift test 2>&1 | tail -30 +``` + +Expected: all tests pass. If new failures appear in unrelated tests (network-flaky, etc.), record and rerun. + +- [ ] **Step 11.2: Run `make check`** + +```bash +make check 2>&1 | tail -30 +``` + +Expected: swiftformat + swiftlint clean. Fix any reported issues by applying suggestions inline and re-running. + +- [ ] **Step 11.3: Build release config to ensure no debug-only types leaked** + +```bash +swift build -c release 2>&1 | tail -10 +``` + +Expected: success. + +- [ ] **Step 11.4: Commit any lint/format fixups** + +```bash +git status +git diff --stat +git add -A +git commit -m "chore: swiftformat/swiftlint fixups for kilo orgs work" +``` + +(Skip if no diff.) + +--- + +## Task 12: Open the PR (lead-only — runs after all build tasks land) + +- [ ] **Step 12.1: Ensure a fork exists for `noefabris`** + +```bash +gh repo view noefabris/CodexBar --json url 2>&1 | head -5 +``` + +If 404, fork: + +```bash +gh repo fork steipete/CodexBar --remote=false --clone=false +``` + +- [ ] **Step 12.2: Add fork as a remote (if missing) and push** + +```bash +git remote get-url fork 2>/dev/null || git remote add fork https://github.com/noefabris/CodexBar.git +git push -u fork feat/kilo-organization-selection +``` + +- [ ] **Step 12.3: Create the PR** + +```bash +gh pr create \ + --repo steipete/CodexBar \ + --base main \ + --head noefabris:feat/kilo-organization-selection \ + --title "Add Kilo organization selection (usage stacking)" \ + --body "$(cat <<'EOF' +## Summary +- Adds Kilo organization selection to Preferences → Providers → Kilo. +- Refresh button fetches `user.getOrganizations` (with `/api/profile` REST fallback). +- Each enabled organization is fetched in parallel with the personal account using the standard `X-KILOCODE-ORGANIZATIONID` header. +- The Kilo menu now stacks one card per enabled scope (Personal + each chosen org), reusing the existing multi-snapshot rendering pattern. + +## Design doc +- `docs/superpowers/specs/2026-05-11-kilo-organization-selection-design.md` +- `docs/superpowers/plans/2026-05-11-kilo-organization-selection.md` + +## Test plan +- [x] `swift test` passes +- [x] `make check` clean +- [x] `swift build -c release` succeeds +- [ ] Manual: launch app, set Kilo API key, hit Refresh organizations, toggle orgs, observe stacked menu cards +- [ ] Manual: revoke org permission and confirm only that scope errors out + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Capture the printed PR URL. + +- [ ] **Step 12.4: Report the PR URL back to the user** + +--- + +## Self-review checklist + +- [x] Each spec section has at least one task implementing it. +- [x] Task 4 covers both tRPC and REST org-discovery shapes (spec §2). +- [x] Task 7 fan-out covers both API and CLI source modes — the strategy resolves the token, then `refreshKiloScopes` reuses the same API key transport. +- [x] Persistence covered by Task 5 + 6. +- [x] UI section covered by Task 9. +- [x] Menu rendering covered by Task 8. +- [x] Tests written before implementation per TDD in Tasks 1–6. +- [x] No placeholders ("TBD", "fill in later", etc.). +- [x] Types stay consistent: `KiloOrganization`, `KiloUsageScope`, `KiloScopeSnapshot` referenced consistently across tasks. +- [x] Out-of-scope items from spec (menu switcher, multi-key auth, widget) intentionally absent. + +If during execution any task uncovers an actual gap not covered above (e.g. existing tests that mock `KiloUsageFetcher.fetchUsage(apiKey:environment:)` without `scope`), update them to use `scope: .personal` explicitly — keep the default-parameter migration path even though tests typically pass it explicitly. diff --git a/docs/superpowers/specs/2026-05-11-kilo-organization-selection-design.md b/docs/superpowers/specs/2026-05-11-kilo-organization-selection-design.md new file mode 100644 index 0000000000..0483236c9e --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-kilo-organization-selection-design.md @@ -0,0 +1,156 @@ +--- +summary: "Design spec for Kilo organization-scoped usage fetching, settings, and menu rendering." +read_when: + - Designing or implementing Kilo organization support + - Changing Kilo scoped usage request headers + - Updating Kilo organization settings persistence +--- + +# Kilo Organization Selection & Usage — Design + +**Status:** approved +**Date:** 2026-05-11 +**Owner:** noefabris + +## Problem + +The Kilo provider in CodexBar always queries the personal account. Users who belong to one or more Kilo organizations cannot see organization-level credits, KiloPass usage, or plan info. Kilo's own clients (VS Code extension, CLI) let users pick "Personal" or any organization they belong to and route requests with an `X-KILOCODE-ORGANIZATIONID` header. + +Goal: let CodexBar users opt in to seeing one or more Kilo organizations alongside their personal account. + +## Non-goals + +- Menu-bar org switcher. Selection lives in Preferences only. +- Editing org membership from CodexBar (read-only consumer of Kilo's org list). +- Per-org auth (CodexBar reuses the same API key/CLI session; Kilo's gateway scopes via header). +- Replacing the existing single-account `Personal` flow when no orgs are configured. + +## Constraints / context + +- Kilo gateway accepts `X-KILOCODE-ORGANIZATIONID: ` to scope any authenticated request. Documented at `kilo.ai/docs/gateway/authentication`. +- Profile endpoint shape (from `Kilo-Org/kilocode` `packages/kilo-gateway/src/api/profile.ts`): + `GET /api/profile` → `{ user: { email, name }, organizations: [{ id, name, role }] }`. +- CodexBar's Kilo provider currently calls `https://app.kilo.ai/api/trpc` with procedures `user.getCreditBlocks`, `kiloPass.getState`, `user.getAutoTopUpPaymentMethod`. The `X-KILOCODE-ORGANIZATIONID` header is transport-level and must work for the same procedures. +- Existing CodexBar patterns to reuse: + - `ProviderIdentitySnapshot.accountOrganization` for rendering the org name in a card. + - Stacked multi-snapshot rendering used by Claude tokenAccounts (Preferences → Advanced → Display). + - `~/.codexbar/config.json` per-provider entry for persisted state. + +## User stories + +1. **As a user with no orgs**, the Kilo card looks exactly as it does today. No new UI noise. +2. **As a user with one or more orgs**, I can open Preferences → Providers → Kilo, hit "Refresh organizations", see my org list, and tick the orgs I want to monitor. Each enabled org appears as its own card stacked with Personal in the menu. +3. **As a user whose API key isn't set**, the Organizations section explains I need the key first and the Refresh button is disabled. +4. **As a user using CLI source mode**, org selection still works — the header is sent on every fetch regardless of where the bearer token came from. + +## Architecture + +### Data model + +- `KiloOrganization` (Sendable, Codable, Equatable) in `Sources/CodexBarCore/Providers/Kilo/`: + - `id: String` + - `name: String` + - `role: String?` (optional; treated as display-only) +- `KiloUsageScope` (Sendable, Hashable) in same module: + - `.personal` + - `.organization(id: String, name: String)` + - A `scopeIdentifier` computed property: `"personal"` or `"org:"` used as the snapshot map key. +- `KiloUsageSnapshot` gains `scope: KiloUsageScope` (default `.personal` keeps existing call sites compiling). + +### API layer (`KiloUsageFetcher`) + +- Existing `fetchUsage(apiKey:environment:)` becomes: + `fetchUsage(apiKey:scope:environment:)` with `scope: KiloUsageScope = .personal`. + - When `.organization(id, _)`: set request header `X-KILOCODE-ORGANIZATIONID: id` on the existing tRPC batch URLRequest. Everything else unchanged. +- New `fetchOrganizations(apiKey:environment:)` → `[KiloOrganization]`: + - Primary: tRPC batch call to `user.getOrganizations` against `https://app.kilo.ai/api/trpc`. Parse the same payload-context shape as other procedures (defensive against schema drift). + - Fallback: if tRPC returns 404 / endpoint not found, fall back to `GET https://api.kilo.ai/api/profile` and read `data.organizations`. Both endpoints are part of the documented Kilo Gateway. + - Returns `[]` (not error) when the user has no orgs. + - Maps `401/403 → KiloUsageError.unauthorized`, `404 → endpointNotFound`, etc., using the existing `statusError(for:)`. + +### Settings + +`SettingsStore` extension (new file `SettingsStore+Kilo.swift` or extend `KiloSettingsStore.swift`): + +- `kiloKnownOrganizations: [KiloOrganization]` — cache of organizations last fetched. Survives restart. +- `kiloEnabledOrganizationIDs: Set` — which orgs the user wants to fetch + render. +- Personal scope is implicit: always enabled, can't be toggled off. + +Persistence: +- Add `organizations: [KiloOrganization]?` and `enabledOrganizationIds: [String]?` to the Kilo provider's entry in `~/.codexbar/config.json`. +- Mutators write through `updateProviderConfig(provider: .kilo)`. + +### Refresh / UsageStore + +- `UsageStore`'s Kilo refresh path computes the active scope list: `[.personal] + enabled orgs from kiloKnownOrganizations`. +- Fan-out using a `TaskGroup`, one child per scope, each calling `KiloUsageFetcher.fetchUsage(apiKey:scope:)`. +- Per-scope failures isolated: a 403 on one org sets that scope's error state but does not affect personal or other orgs. +- Snapshots stored in a new dictionary `kiloScopedSnapshots: [String: KiloUsageSnapshot]` keyed by `scope.scopeIdentifier`, alongside the existing single-snapshot field. When `kiloScopedSnapshots` has more than one entry the menu uses stacked rendering; otherwise existing single-card rendering. + +### UI — Preferences → Providers → Kilo + +- New section header "Organizations" placed below the API key field. +- Row 1 (always): `Personal account` with a disabled-on checkbox. +- Rows 2..N: each known organization with a togglable checkbox `[✓] `. +- Empty state when `kiloKnownOrganizations` is empty: "No organizations loaded. Click Refresh after setting your API key." +- "Refresh organizations" button: + - Disabled when API key is empty. + - Calls `KiloUsageFetcher.fetchOrganizations(apiKey:)` on a background task. + - On success: writes to `kiloKnownOrganizations`, prunes `kiloEnabledOrganizationIDs` entries that no longer exist. + - On 401/403: surface inline error "API key unauthorized. Refresh or update it." + - On network error: surface inline error. + +### Menu rendering + +- The Kilo card renderer reads `kiloScopedSnapshots`. When count > 1, render each as a stacked card using the same vertical layout already used by Claude's stacked tokenAccount snapshots. +- Each scope's card sets `ProviderIdentitySnapshot.accountOrganization`: + - `.personal` → `"Personal"` + - `.organization(_, name)` → `name` +- Existing credit/pass/plan rows render unchanged inside each card. + +### Errors / edge cases + +| Case | Behavior | +| --- | --- | +| User has no orgs | Personal scope only. Org section shows empty state. No fan-out. | +| API key missing | Refresh button disabled. Existing missing-credentials error still surfaces on usage fetch. | +| Org refresh fails (401/403) | Inline error in Preferences; keep cached list. | +| Org usage fetch returns 403 | That org's card shows a small error label; personal + other orgs render normally. | +| Org removed on Kilo side | Next Refresh drops it from `kiloKnownOrganizations`; if it was in `kiloEnabledOrganizationIDs` it is silently pruned. | +| CLI source mode | Header sent same way. If the resolved CLI bearer can't scope to that org, it 403s — handled by the per-scope error case above. | +| Source = `auto` | Org scope follows the same auto fallback; failures inside the scope respect the fallback chain. | +| Multiple orgs enabled | Concurrent fetch; per-scope timeouts isolated. | + +## Testing + +- Unit tests (in `Tests/CodexBarTests`): + - `KiloUsageFetcherTests`: + - Header injection: `.organization(id, _)` → request contains `X-KILOCODE-ORGANIZATIONID: id`. `.personal` → no header. + - `fetchOrganizations` parses both tRPC shape and `/api/profile` REST shape. + - 401/403 → `.unauthorized`. + - `KiloSettingsStoreTests`: + - Round-trip of `kiloKnownOrganizations` and `kiloEnabledOrganizationIDs` through config.json. + - Pruning of stale IDs when known list shrinks. + - `UsageStore+KiloRefreshTests`: + - Fan-out runs N scopes, per-scope error isolation. + - Single-scope path unchanged when no orgs enabled. +- CLI snapshot test (`Tests/CodexBarTests/CLIRendererTests` or similar): `codexbar-cli kilo` shows one section per active scope when orgs are enabled. +- Run `make check` and `swift test` before handoff. + +## Migration / compatibility + +- `KiloUsageSnapshot.scope` defaults to `.personal` — all existing call sites compile unchanged. +- Config additions are optional — old configs continue to load as personal-only. +- No new dependencies. + +## Open items (verify during build) + +- Confirm `https://app.kilo.ai/api/trpc/user.getOrganizations` exists. If not, the REST fallback to `https://api.kilo.ai/api/profile` is the path of record. +- Confirm the tRPC procedures honor `X-KILOCODE-ORGANIZATIONID`. Documented for gateway; spot-check during integration. + +## Out of scope + +- Menu-bar org switcher UI. +- Per-org token storage / multiple API keys for the same provider. +- CLI command for org switching (`codexbar-cli` consumes the same settings). +- Widget rendering changes for multi-scope (widget continues to show personal scope). diff --git a/docs/ui.md b/docs/ui.md index 3c9c7c3bf2..e5d5e085cb 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -10,25 +10,36 @@ read_when: ## Menu bar - LSUIElement app: no Dock icon; status item uses custom NSImage. - Merge Icons toggle combines providers into one status item with a switcher. +- Provider status items use stable autosave names and are reused across provider toggles so macOS can preserve icon + positions. - When Overview has selected providers, the switcher includes an Overview tab that renders up to 3 provider rows. - Overview row order follows provider order; selecting a row jumps to that provider detail card. +- The global open-menu keyboard shortcut toggles the currently tracked menu closed before opening a new one. ## Icon rendering - 18×18 template image. -- Top bar = 5-hour window; bottom hairline = weekly window. +- Bar windows are provider/style-specific primary and secondary windows. - Fill represents percent remaining by default; “Show usage as used” flips to percent used. -- Dimmed when last refresh failed; status overlays render incident indicators. -- Advanced: menu bar can show provider branding icons with a percent label instead of critter bars. +- Renderer/critter icons dim when last refresh failed and can render incident indicators; brand display mode uses provider branding plus title text. +- Loading animation runs at a bounded frame rate and has a hard continuous-duration ceiling so provider hangs cannot keep + the menu bar redrawing forever. +- Display → Menu bar: menu bar can show provider branding icons with a percent label instead of critter bars. +- Providers → Codex → Menu bar metric can combine the session-window and weekly percentages in one compact label. ## Menu card -- Session + weekly rows with resets (countdown by default; optional absolute clock display). -- Codex-only: Credits + “Buy Credits…” in-card action. -- Web-only rows (when OpenAI cookies are enabled): code review remaining, usage breakdown submenu. +- Provider-specific rows with resets (countdown by default; optional absolute clock display). Primary, secondary, + tertiary, and extra windows render when the provider snapshot has data for them. +- Manual refresh updates the open card subtitle and persistent Refresh-row spinner in place. Repeated clicks share the + active request, and the existing row geometry remains fixed through success or failure. +- Codex credits can add a separate “Buy Credits…” menu action. +- Codex OpenAI web extras: code review remaining and usage breakdown render when dashboard data is attached. - Token accounts: optional account switcher bar or stacked account cards (up to 6) when multiple manual tokens exist. +- Provider storage usage is opt-in from Advanced settings. When enabled, overview rows and provider detail cards can show + local provider-owned storage totals, with a submenu for path breakdowns and copyable paths. ## Pace tracking -Pace compares your actual usage against an even-consumption budget that would spread your allowance evenly across the reset window. +Pace compares your actual usage against the expected consumption rate for the current window. Most providers use an even-consumption budget; Codex can use historical pace data when historical tracking is available. - **On pace** – usage matches the expected rate. - **X% in deficit** – you're consuming faster than the even rate; at this pace you'll run out before the window resets. @@ -36,18 +47,20 @@ Pace compares your actual usage against an even-consumption budget that would sp When usage is in deficit, the right-hand label shows an estimated "Runs out in …" countdown. When usage will last until the reset, it shows "Lasts until reset". -Pace is calculated for Codex and Claude weekly windows only and is hidden when less than 3% of the window has elapsed. +Pace is calculated for any provider window with enough reset timing data and is hidden when less than 3% of the +window has elapsed. ## Preferences notes - Advanced: “Disable Keychain access” turns off browser cookie import; paste Cookie headers manually in Providers. +- Advanced: “Show provider storage usage” enables background scans of known provider-owned local paths; CodexBar only + reports sizes and cleanup ideas, it does not delete files. - Display: “Overview tab providers” controls which providers appear in Merge Icons → Overview (up to 3). - If no providers are selected for Overview, the Overview tab is hidden. -- Providers → Claude: “Keychain prompt policy” controls Claude OAuth prompt behavior (Never / Only on user action / - Always allow prompts). -- When “Disable Keychain access” is enabled in Advanced, the Claude keychain prompt policy remains visible but is - inactive. +- Providers → Claude: “Avoid Keychain prompts” uses the prompt-free Security CLI reader when available. +- The lower-level “Keychain prompt policy” picker only appears when the Security.framework reader is active. ## Widgets (high level) -- Widget entries mirror the menu card; detailed pipeline in `docs/widgets.md`. +- Widgets render shared usage snapshots for the supported widget families and + provider picker; detailed pipeline in `docs/widgets.md`. See also: `docs/widgets.md`. diff --git a/docs/venice.md b/docs/venice.md new file mode 100644 index 0000000000..e0e8db4e48 --- /dev/null +++ b/docs/venice.md @@ -0,0 +1,48 @@ +--- +summary: "Venice provider setup, API-key balance query, and DIEM/USD balance display." +read_when: + - Adding or modifying the Venice provider + - Debugging Venice API-key balance fetching + - Explaining Venice setup or balance display +--- + +# Venice + +[Venice](https://venice.ai) is an AI inference platform that provides API access to various language models. + +## Setup + +1. Sign up or log in at https://venice.ai +2. Navigate to your API settings at https://venice.ai/settings/api +3. Create or retrieve your API key +4. In CodexBar, add your Venice API key via: + - Preferences > Providers > Venice, OR + - Set the environment variable `VENICE_API_KEY` or `VENICE_KEY` + +## Balance Query + +CodexBar fetches your current Venice API balance using the `/api/v1/billing/balance` endpoint. + +### Balance Types + +- **DIEM**: Venice's native credits (if epoch allocation is configured) +- **USD**: Dollar balance if available +- **Consumption Currency**: Indicates which currency is active for current billing + +### Display + +CodexBar shows: +- Current remaining balance (DIEM or USD) +- Epoch allocation progress (if applicable) +- "Balance unavailable" if consumption is temporarily disabled + +## Troubleshooting + +**No balance showing?** +- Verify your API key is correct +- Check network connectivity +- Ensure your Venice account has an active balance + +**API rate limiting?** +- CodexBar caches balance data and updates every 30 seconds +- If you hit rate limits, wait a moment before refreshing diff --git a/docs/widgets.md b/docs/widgets.md index 8fbff0f27d..adc749b1e8 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -11,11 +11,30 @@ read_when: ## Snapshot pipeline - `WidgetSnapshotStore` writes compact JSON snapshots to the app-group container. - Widgets read the snapshot and render usage/credits/history states. +- The app writes snapshots after the main refresh pipeline and token-usage refreshes; narrow single-provider refresh paths may wait for the next snapshot write. +- If no snapshot is available, widgets fall back to preview/empty data. ## Extension - `Sources/CodexBarWidget` contains timeline + views. +- `WidgetExtension/CodexBarWidgetExtension.xcodeproj` builds those sources as the packaged macOS WidgetKit app extension. - Keep data shape in sync with `WidgetSnapshot` in the main app. +## Widget types +- **CodexBar Switcher** (`CodexBarSwitcherWidget`): static provider switcher widget, small/medium/large. +- **CodexBar Usage** (`CodexBarUsageWidget`): configurable provider usage widget, small/medium/large. +- **CodexBar History** (`CodexBarHistoryWidget`): configurable usage-history chart, medium/large. +- **CodexBar Metric** (`CodexBarCompactWidget`): compact credits/today-cost/30-day-cost widget, small only. +- **CodexBar Burn Down** (`CodexBarBurnDownWidget`): configurable session or weekly burn-down chart, medium only. +- **CodexBar Burn Down (Combined)** (`CodexBarCombinedBurnDownWidget`): session and weekly burn-down charts, medium only. + +## Provider picker support +The configurable provider widgets currently expose: +Codex, Claude, Gemini, Alibaba, Antigravity, z.ai, Copilot, MiniMax, Kilo, OpenCode, and OpenCode Go. + +Providers without a `ProviderChoice` case can still be present in the app snapshot, but they are not selectable from the widget configuration UI yet. + +Burn-down widgets currently support Codex and Claude. Their dedicated configuration intents keep existing Usage and History widget configurations unchanged. + ## Visibility troubleshooting (macOS 14+) When widgets do not appear in the gallery at all, the issue is almost always registration, signing, or daemon caching (not SwiftUI code). @@ -24,6 +43,7 @@ registration, signing, or daemon caching (not SwiftUI code). ``` APP="/Applications/CodexBar.app" WAPPEX="$APP/Contents/PlugIns/CodexBarWidget.appex" +WIDGET_ID="com.steipete.codexbar.widget" # debug builds use com.steipete.codexbar.debug.widget ls -la "$WAPPEX" "$WAPPEX/Contents" "$WAPPEX/Contents/MacOS" ``` @@ -31,18 +51,18 @@ ls -la "$WAPPEX" "$WAPPEX/Contents" "$WAPPEX/Contents/MacOS" ### 2) PlugInKit registration (pkd) ``` pluginkit -m -p com.apple.widgetkit-extension -v | grep -i codexbar || true -pluginkit -m -p com.apple.widgetkit-extension -i com.steipete.codexbar.widget -vv +pluginkit -m -p com.apple.widgetkit-extension -i "$WIDGET_ID" -vv ``` Notes: - `+` = elected to use, `-` = ignored (PlugInKit elections). - If missing or ignored, force-add and re-elect: ``` pluginkit -a "$WAPPEX" -pluginkit -e use -p com.apple.widgetkit-extension -i com.steipete.codexbar.widget +pluginkit -e use -p com.apple.widgetkit-extension -i "$WIDGET_ID" ``` - Check for duplicates (old installs or version precedence): ``` -pluginkit -m -D -p com.apple.widgetkit-extension -i com.steipete.codexbar.widget -vv +pluginkit -m -D -p com.apple.widgetkit-extension -i "$WIDGET_ID" -vv ``` If multiple paths appear, delete older installs and bump `CFBundleVersion`. @@ -68,7 +88,7 @@ log stream --style compact --predicate '(process == "pkd" OR process == "chronod ``` ### 6) Packaging sanity checks -- Widget bundle id should be `com.steipete.codexbar.widget`. +- Widget bundle id should be `com.steipete.codexbar.widget` for release and `com.steipete.codexbar.debug.widget` for debug. - `NSExtensionPointIdentifier` must be `com.apple.widgetkit-extension`. - Bundle folder name should match: `CodexBarWidget.appex`. diff --git a/docs/windsurf.md b/docs/windsurf.md new file mode 100644 index 0000000000..dcbb19ed4a --- /dev/null +++ b/docs/windsurf.md @@ -0,0 +1,172 @@ +--- +summary: "Windsurf provider data sources: browser localStorage session import, local SQLite cache, and GetPlanStatus protobuf API." +read_when: + - Debugging Windsurf usage fetch + - Updating Windsurf web session import or API handling + - Adjusting Windsurf provider UI/menu behavior +--- + +# Windsurf provider + +Windsurf supports two data sources: a web API backed by the current website session, and a local SQLite cache. + +## Data sources + fallback order + +Usage source picker: +- Preferences → Providers → Windsurf → Usage source (Auto / Web API / Local). + +### Auto mode (default) +1) **Web API** (preferred) — real-time data from windsurf.com. +2) **Local SQLite cache** (fallback) — reads from Windsurf's `state.vscdb`. + +### Web API fetch order +1) **Manual session bundle** (when Cookie source = Manual). +2) **Browser localStorage import** — extracts complete `devin_*` session bundles from Chromium browsers, checking + `app.devin.ai` before the legacy `windsurf.com` origin. + +### Local SQLite cache +- File: `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb`. +- Key: `windsurf.settings.cachedPlanInfo` in `ItemTable`. +- Newer cache shapes may omit `quotaUsage` but include `usage` counters. In that case CodexBar derives + usage windows from `usedMessages/messages` and `usedFlowActions/flowActions`. +- Limitation: only updates when Windsurf is launched; can be significantly stale. + +## Cookie source settings + +Preferences → Providers → Windsurf → Cookie source: + +- **Automatic** (default): imports the active Windsurf session bundle from Chromium browser localStorage. +- **Manual**: paste a JSON bundle with `devin_session_token`, `devin_auth1_token`, `devin_account_id`, and `devin_primary_org_id`. +- **Off**: disables web API access entirely; only local SQLite cache is used. + +### How to get a manual session bundle + +1. Open [windsurf.com/profile](https://windsurf.com/profile) in Chrome or Edge and sign in. +2. Open Developer Tools (`F12` or `Cmd+Option+I`). +3. Go to the **Console** tab. +4. Paste the following JavaScript and press Enter: + +```javascript +(() => { + const keys = [ + "devin_session_token", + "devin_auth1_token", + "devin_account_id", + "devin_primary_org_id", + ]; + + const read = (key) => { + const value = localStorage.getItem(key); + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return value; + } + }; + + const payload = Object.fromEntries(keys.map((key) => [key, read(key)])); + const missing = keys.filter((key) => !payload[key]); + + if (missing.length > 0) { + console.log("Missing Windsurf session keys:", missing.join(", ")); + return; + } + + const json = JSON.stringify(payload, null, 2); + console.log(json); + if (typeof copy === "function") { + copy(json); + console.log("Copied Windsurf session bundle to clipboard."); + } +})(); +``` + +5. Copy the JSON output. +6. In CodexBar: Providers → Windsurf → Cookie source → Manual → paste the JSON bundle. + +## Authentication flow (Automatic mode) + +```text +Browser localStorage (leveldb on disk) + ↓ extract devin_session_token / devin_auth1_token / devin_account_id / devin_primary_org_id +POST https://windsurf.com/_backend/.../GetPlanStatus + ↓ headers: x-auth-token + x-devin-* + ↓ protobuf body: { auth_token, include_top_up_status: true } +UsageSnapshot (daily/weekly quota %) +``` + +## Browser session extraction + +- **Browsers scanned**: Chrome, Edge, Brave, Arc, Vivaldi, Chromium, and compatible Chromium forks. +- **Local storage path**: `~/Library/Application Support///Local Storage/leveldb/` +- **Origins**: `https://app.devin.ai`, then legacy `https://windsurf.com` +- Values from different structured origins are never combined. A partial app-origin bundle cannot contaminate a + complete legacy-origin fallback. +- **Required keys**: + - `devin_session_token` + - `devin_auth1_token` + - `devin_account_id` + - `devin_primary_org_id` + +## API endpoint + +### GetPlanStatus (ConnectRPC over protobuf) +- `POST https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus` +- Headers: + - `Content-Type: application/proto` + - `Connect-Protocol-Version: 1` + - `Origin: https://windsurf.com` + - `Referer: https://windsurf.com/profile` + - `x-auth-token: ` + - `x-devin-session-token: ` + - `x-devin-auth1-token: ` + - `x-devin-account-id: ` + - `x-devin-primary-org-id: ` +- Protobuf request fields: + - `1 auth_token: string` + - `2 include_top_up_status: bool` +- Parsed response fields used by CodexBar: + - `plan_status.plan_info.plan_name` + - `plan_status.plan_end` + - `plan_status.daily_quota_remaining_percent` + - `plan_status.weekly_quota_remaining_percent` + - `plan_status.daily_quota_reset_at_unix` + - `plan_status.weekly_quota_reset_at_unix` + +## Snapshot mapping +- Web primary: daily usage percent (`100 - daily_quota_remaining_percent`). +- Web secondary: weekly usage percent (`100 - weekly_quota_remaining_percent`). +- Local primary: daily quota percent when present; otherwise message usage (`usedMessages/messages`). +- Local secondary: weekly quota percent when present; otherwise flow-action usage (`usedFlowActions/flowActions`). +- Reset: daily/weekly reset timestamps (Unix seconds), when available. +- Plan: `plan_status.plan_info.plan_name`. +- Expiry: `plan_status.plan_end`. + +## Troubleshooting + +### "No Windsurf web session found in Chromium localStorage" +- Sign in to [app.devin.ai](https://app.devin.ai) or [windsurf.com](https://windsurf.com) in Chrome, Edge, or another + Chromium browser. +- Grant Full Disk Access to CodexBar (System Settings → Privacy & Security → Full Disk Access). +- Try Manual mode and paste the JSON session bundle directly. + +### "Invalid Windsurf session payload" +- The manual value must include all four keys: `devin_session_token`, `devin_auth1_token`, `devin_account_id`, and `devin_primary_org_id`. +- Re-run the console snippet on a logged-in `windsurf.com` page. + +### "Windsurf API call failed: HTTP 401" +- The imported browser session is stale or invalid. +- Refresh the Windsurf page in your browser and try again. +- If using Manual mode, paste a fresh JSON bundle. + +### Stale data with Local mode +- The local SQLite cache only updates when Windsurf is launched. Switch to Auto or Web API mode for real-time data. + +## Key files +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift` (local SQLite) +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift` (Chromium localStorage extraction) +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift` (protobuf request + response parsing) +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift` (fetch strategies) +- `Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift` (settings UI) +- `Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift` (settings persistence) diff --git a/docs/zai.md b/docs/zai.md index 8cf044e29e..7c4c2c2bbf 100644 --- a/docs/zai.md +++ b/docs/zai.md @@ -21,10 +21,19 @@ z.ai is API-token based. No browser cookies. - BigModel (China mainland) host: `https://open.bigmodel.cn` - Override host via Providers → z.ai → *API region* or `Z_AI_API_HOST=open.bigmodel.cn`. - Override the full quota URL (e.g. coding plan endpoint) via `Z_AI_QUOTA_URL=https://open.bigmodel.cn/api/coding/paas/v4`. +- Endpoint overrides must be explicit HTTPS URLs or bare hosts/paths that CodexBar normalizes to HTTPS. Explicit + `http://` overrides fail closed before the bearer token is attached to a request. If both z.ai overrides are set, + `Z_AI_QUOTA_URL` has priority for quota requests; a stale lower-priority `Z_AI_API_HOST` is ignored for that quota + path, but direct model-usage requests still validate `Z_AI_API_HOST` before sending bearer auth. - Headers: - `authorization: Bearer ` - `accept: application/json` +## Usage dashboard +- Global: `https://z.ai/manage-apikey/coding-plan/personal/my-plan` +- BigModel China: `https://bigmodel.cn/coding-plan/personal/usage` +- CodexBar's Usage Dashboard action follows the configured API region. + ## Parsing + mapping - Response fields: - `data.limits[]` → each limit entry. diff --git a/docs/zed.md b/docs/zed.md new file mode 100644 index 0000000000..b60994164c --- /dev/null +++ b/docs/zed.md @@ -0,0 +1,76 @@ +--- +summary: "Zed provider data source: editor Keychain session and Zed cloud API." +read_when: + - Debugging Zed usage fetch + - Updating Zed Keychain or cloud API handling + - Adjusting Zed provider UI/menu behavior +--- + +# Zed provider + +CodexBar monitors Zed plan status, billing cycle dates, edit-prediction quota, and overdue invoices via Zed's cloud API. + +## Data source + +**Local probe (Keychain + cloud API)** — reads the same credentials Zed stores after GitHub sign-in, then calls: + +```text +GET https://cloud.zed.dev/client/users/me +Authorization: {user_id} {access_token} +``` + +### Keychain credentials + +| Item | Value | +| --- | --- | +| Service URL | `https://zed.dev` by default, or the configured HTTPS `server_url` for a custom server | +| Keychain class | **Internet password** (`kSecClassInternetPassword`, server = service URL). Generic-password fallback is supported for older layouts. | +| Account | Zed user ID (string) | +| Secret | Access token (UTF-8 bytes) | + +CodexBar requests a non-interactive Keychain read. Existing Zed items can still carry an access-control list that makes +macOS show a SecurityAgent prompt the first time CodexBar reads them. Choose **Always Allow** to avoid repeat prompts. If +Zed has never been signed in, or access is denied, the provider reports **Not signed in to Zed**. + +### Settings override + +CodexBar reads Zed’s user settings from `~/.config/zed/settings.json`. The `credentials_url` setting (falls back to +`server_url`) selects which Keychain entry to read. For the trusted +`https://zed.dev` and `https://staging.zed.dev` servers, Zed may use a separate credential identifier. Custom servers +must use HTTPS and store credentials under the exact same `server_url`; CodexBar rejects cross-origin overrides so a +settings-file change cannot forward a Keychain token to another host. + +## Snapshot mapping + +| Zed field | CodexBar display | +| --- | --- | +| `plan.plan_v3` | Plan label (Free / Pro / Trial / Student / Business) | +| `plan.usage.edit_predictions` | Primary bar: used/limit or “Unlimited” on Pro+ | +| `plan.subscription_period.ended_at` | Billing cycle reset / secondary window | +| `plan.has_overdue_invoices` | Warning note + billing window marker | + +## Limitations + +### Not tracked as “Zed” + +Per [LLM Providers](https://zed.dev/docs/ai/llm-providers.html) and [External Agents](https://zed.dev/docs/ai/external-agents.html): + +- BYOK models → track via OpenAI, Claude, Gemini, etc. +- External agents (Claude Agent, Codex ACP) → bill through those providers + +## Troubleshooting + +### “Not signed in to Zed” +- Sign in from the **Zed editor app** (Command Palette → `client: sign in`). +- Confirm a Keychain internet-password entry exists for server `https://zed.dev` (or your custom `credentials_url`). + +### “Could not read Zed credentials from the Keychain” +- macOS may block Keychain access until you allow CodexBar (same class of issue as other IDE probes). +- Re-sign in to Zed after changing `credentials_url`. + +## Key files + +- `Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift` - Keychain read, cloud API, snapshot mapping +- `Sources/CodexBarCore/Providers/Zed/ZedProviderDescriptor.swift` - provider metadata and local fetch strategy +- `Sources/CodexBar/Providers/Zed/ZedProviderImplementation.swift` - app registration +- `Tests/CodexBarTests/ZedStatusProbeTests.swift` - cloud API and routing tests diff --git a/package.json b/package.json deleted file mode 100644 index f7a9875067..0000000000 --- a/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "codexbar", - "private": true, - "scripts": { - "start": "./Scripts/compile_and_run.sh", - "start:debug": "./Scripts/compile_and_run.sh", - "start:release": "sh -c './Scripts/package_app.sh release && (pkill -x CodexBar || pkill -f CodexBar.app || true) && cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app'", - "lint": "./Scripts/lint.sh lint", - "format": "./Scripts/lint.sh format", - "check": "./Scripts/lint.sh lint", - "docs:list": "node Scripts/docs-list.mjs", - "build": "swift build", - "test": "swift test", - "test:tty": "swift test --filter TTYIntegrationTests", - "test:live": "LIVE_TEST=1 swift test --filter LiveAccountTests", - "release": "./Scripts/package_app.sh release", - "restart": "pnpm start", - "stop": "pkill -x CodexBar || pkill -f CodexBar.app || true" - } -} diff --git a/version.env b/version.env index ec4258206f..33d5c45642 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.18.0-beta.3 -BUILD_NUMBER=51 +MARKETING_VERSION=0.37.3 +BUILD_NUMBER=93