diff --git a/CHANGELOG.md b/CHANGELOG.md index 869aa08981b..535649b8fa2 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ## [Windows] Unreleased +- Raycast: automatic cookie import walks every supported browser in the shared order and uses the first accepted session, instead of reading Chrome alone (upstream #4443, 0aef66c73). - Price Codex **codex-auto-review** requests made from a ChatGPT account at $0 from 2026-10-06 07:00 UTC onward: tokens stay in totals, the model is no longer listed as unpriced, and an explicit custom price for the model still applies (upstream #4452, #4453). - Show a spend-only Claude account's monthly cap (for example an Enterprise seat) as one primary meter with calendar-month pace and a midnight-UTC-on-the-1st reset inferred when Claude reports none, instead of a separate Extra usage bar; quota accounts keep their session and weekly lanes and Extra usage section unchanged. Fixes #4381 (upstream #4480) - Read the Charm Hyper balance from your signed-in browser session on the team dashboard, with plan and refresh rows when shown. The credits endpoint only accepts API keys, so session balances no longer depend on it. An expired session is reported as expired before any API-key fallback; API-key balances are unchanged (upstream #4435). diff --git a/README.md b/README.md index c390bbbf075..5283c490d3e 100755 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | OpenAI | Admin API / API Key | Usage, Requests, Project-scoped cost, Credit Balance | | Grok | Cookies / auth.json | Billing | | Helmcode (also NaN Builders) | Browser cookies / manual Cookie header | Per-model token quotas, reset windows, Helmcode prepaid balance | -| Raycast | Cookies (Chrome auto or manual) | Monthly AI credits, plan, renewal | +| Raycast | Cookies (browser auto or manual) | Monthly AI credits, plan, renewal | | Replicate | Cookies / token accounts | Monthly spend, credit balance | | Aixy | API Key / token accounts | Applicable budget balances, 7-day key usage | | ClawRouter | API Key | Monthly budget, requests, tokens, routed-provider spend | diff --git a/rust/src/browser/detection.rs b/rust/src/browser/detection.rs index bc932196211..ce4d5259400 100755 --- a/rust/src/browser/detection.rs +++ b/rust/src/browser/detection.rs @@ -365,6 +365,49 @@ mod tests { } } + /// Firefox Developer Edition keeps its profiles under the shared Roaming + /// root with a `.dev-edition-default` name. It must be detected as + /// the default profile, like `.default-release`, and not be dropped. + #[test] + fn firefox_developer_edition_profiles_are_detected_as_default() { + let temp = tempfile::tempdir().unwrap(); + let roaming = temp.path().join("Roaming"); + let profiles = roaming.join("Mozilla/Firefox/Profiles"); + let dev_edition = profiles.join("k7x2m9ab.dev-edition-default"); + std::fs::create_dir_all(&dev_edition).unwrap(); + std::fs::create_dir_all(profiles.join("q3w8e1rt.default-release")).unwrap(); + std::fs::create_dir_all(profiles.join("z9y8x7wv.other")).unwrap(); + + let detected = BrowserDetector::detect_in_roots( + BrowserType::Firefox, + temp.path(), + Some(roaming.as_path()), + ) + .unwrap(); + + assert_eq!(detected.user_data_dir, profiles); + assert_eq!(detected.profiles.len(), 3); + let dev = detected + .profiles + .iter() + .find(|profile| profile.name == "k7x2m9ab.dev-edition-default") + .expect("Developer Edition profile should be detected"); + assert_eq!(dev.path, dev_edition); + assert!(dev.is_default); + assert!( + detected + .profiles + .iter() + .any(|profile| profile.name == "q3w8e1rt.default-release" && profile.is_default) + ); + assert!( + detected + .profiles + .iter() + .any(|profile| profile.name == "z9y8x7wv.other" && !profile.is_default) + ); + } + #[test] fn test_browser_detection() { let browsers = BrowserDetector::detect_all(); diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 1208b0b9b9e..c1e8886d455 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1135,7 +1135,7 @@ UsageSpendModelsPeriodCaption = Local model history for: { "{}" }. UsageSpendProjectsPeriodCaption = Ranked Codex local project spend for: { "{}" } BurndownChartTitle = Usage remaining BurndownChartAriaLabel = Recorded remaining quota burndown -ProviderRaycastAutoImportHelp = Automatically imports the signed-in www.raycast.com session from Chrome. +ProviderRaycastAutoImportHelp = Automatically imports the signed-in www.raycast.com session from your installed browsers. ProviderRaycastManualCookieHelp = Paste a Cookie header from www.raycast.com/settings. ProviderRaycastCookiesDisabled = Raycast cookies are disabled. CookieHelpCodexManual = Paste a Cookie header from a chatgpt.com request. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 38ee356ccc0..bae82eec619 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1132,7 +1132,7 @@ UsageSpendModelsPeriodCaption = Historial local de modelos para: { "{}" }. UsageSpendProjectsPeriodCaption = Gasto local de proyectos de Codex, ordenado, para: { "{}" } BurndownChartTitle = Uso restante BurndownChartAriaLabel = Consumo registrado de la cuota restante -ProviderRaycastAutoImportHelp = Importa automáticamente desde Chrome la sesión iniciada en www.raycast.com. +ProviderRaycastAutoImportHelp = Importa automáticamente la sesión iniciada en www.raycast.com desde los navegadores instalados. ProviderRaycastManualCookieHelp = Pega un encabezado Cookie de www.raycast.com/settings. ProviderRaycastCookiesDisabled = Las cookies de Raycast están desactivadas. CookieHelpCodexManual = Pega un encabezado Cookie de una solicitud a chatgpt.com. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index c72697624d7..d22791f1f7c 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1133,7 +1133,7 @@ UsageSpendModelsPeriodCaption = ローカルのモデル履歴の期間: { "{}" UsageSpendProjectsPeriodCaption = Codex ローカルプロジェクトのコストランキングの期間: { "{}" } BurndownChartTitle = 残り使用量 BurndownChartAriaLabel = 記録された残りクォータの推移 -ProviderRaycastAutoImportHelp = Chrome からサインイン済みの www.raycast.com セッションを自動でインポートします。 +ProviderRaycastAutoImportHelp = インストール済みのブラウザーからサインイン済みの www.raycast.com セッションを自動でインポートします。 ProviderRaycastManualCookieHelp = www.raycast.com/settings の Cookie ヘッダーを貼り付けます。 ProviderRaycastCookiesDisabled = Raycast の Cookie は無効です。 CookieHelpCodexManual = chatgpt.com へのリクエストの Cookie ヘッダーを貼り付けます。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 763e5488cea..77ffaa9cc67 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1132,7 +1132,7 @@ UsageSpendModelsPeriodCaption = 로컬 모델 기록 기간: { "{}" }. UsageSpendProjectsPeriodCaption = Codex 로컬 프로젝트 비용 순위 기간: { "{}" } BurndownChartTitle = 남은 사용량 BurndownChartAriaLabel = 기록된 남은 할당량 소진 추이 -ProviderRaycastAutoImportHelp = Chrome에서 로그인된 www.raycast.com 세션을 자동으로 가져옵니다. +ProviderRaycastAutoImportHelp = 설치된 브라우저에서 로그인된 www.raycast.com 세션을 자동으로 가져옵니다. ProviderRaycastManualCookieHelp = www.raycast.com/settings의 Cookie 헤더를 붙여넣습니다. ProviderRaycastCookiesDisabled = Raycast 쿠키가 비활성화되었습니다. CookieHelpCodexManual = chatgpt.com 요청의 Cookie 헤더를 붙여넣습니다. diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 1487ce7f165..fe008149568 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1137,7 +1137,7 @@ ProviderLiteLLMModelActivity = Mostrar atividade por modelo ProviderLiteLLMModelActivityHelper = Adiciona tokens e solicitações por modelo do seu usuário nos últimos 30 dias UTC (top 20). Faz até 3 solicitações extras ao seu proxy LiteLLM por atualização; se ele estiver indisponível, a seção fica oculta e os orçamentos não são afetados. Desativado por padrão. ProviderClaudeWorkspaceSpend = Mostrar gasto por workspace ProviderClaudeWorkspaceSpendHelper = Adiciona o gasto da Admin API por workspace nos últimos 30 dias (top 20, exibido só quando mais de um workspace tem gasto). Os totais da organização não mudam e nenhuma solicitação extra é feita. Desativado por padrão. -ProviderRaycastAutoImportHelp = Importa automaticamente a sessão conectada de www.raycast.com do Chrome. +ProviderRaycastAutoImportHelp = Importa automaticamente a sessão conectada de www.raycast.com dos navegadores instalados. ProviderRaycastManualCookieHelp = Cole um cabeçalho Cookie de www.raycast.com/settings. ProviderRaycastCookiesDisabled = Os cookies do Raycast estão desativados. CookieHelpCodexManual = Cole um cabeçalho Cookie de uma solicitação para chatgpt.com. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 47c86f3e430..9ea0ed3af74 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1133,7 +1133,7 @@ UsageSpendModelsPeriodCaption = Локальная история моделей UsageSpendProjectsPeriodCaption = Расходы локальных проектов Codex по убыванию за период: { "{}" } BurndownChartTitle = Остаток лимита BurndownChartAriaLabel = График расхода оставшейся квоты -ProviderRaycastAutoImportHelp = Автоматически импортирует сеанс www.raycast.com, открытый в Chrome. +ProviderRaycastAutoImportHelp = Автоматически импортирует сеанс www.raycast.com, открытый в установленных браузерах. ProviderRaycastManualCookieHelp = Вставьте заголовок Cookie со страницы www.raycast.com/settings. ProviderRaycastCookiesDisabled = Файлы cookie Raycast отключены. CookieHelpCodexManual = Вставьте заголовок Cookie из запроса к chatgpt.com. diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 3192c5bd346..2a34a0a062e 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1133,7 +1133,7 @@ DeepSeekPricingEffective = Geçerli olduğu yerel saat: DeepSeekPricingAdvice = Resmî tarife: yoğun saatler UTC 01:00-04:00 ve 06:00-10:00; yoğun olmayan saatlerde yarı fiyat. MistralMonthlySpend = Aylık API harcaması MistralMonthlySpendHelper = Kullandıkça öde Mistral hesapları için geçerli ayın API kullanımı. -ProviderRaycastAutoImportHelp = Chrome'da açık olan www.raycast.com oturumunu otomatik olarak içe aktarır. +ProviderRaycastAutoImportHelp = Yüklü tarayıcılardaki açık www.raycast.com oturumunu otomatik olarak içe aktarır. ProviderRaycastManualCookieHelp = www.raycast.com/settings adresinden bir Cookie başlığı yapıştırın. ProviderRaycastCookiesDisabled = Raycast çerezleri devre dışı. CookieHelpCodexManual = chatgpt.com isteğinden bir Cookie başlığı yapıştırın. diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 66f3adabe6f..73b364d4448 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1131,7 +1131,7 @@ UsageSpendModelsPeriodCaption = Локальна історія моделей UsageSpendProjectsPeriodCaption = Витрати локальних проєктів Codex за спаданням за період: { "{}" } BurndownChartTitle = Залишок ліміту BurndownChartAriaLabel = Графік витрачання залишку квоти -ProviderRaycastAutoImportHelp = Автоматично імпортує сеанс www.raycast.com, у якому виконано вхід у Chrome. +ProviderRaycastAutoImportHelp = Автоматично імпортує сеанс www.raycast.com, у якому виконано вхід, з установлених браузерів. ProviderRaycastManualCookieHelp = Вставте заголовок Cookie зі сторінки www.raycast.com/settings. ProviderRaycastCookiesDisabled = Файли cookie Raycast вимкнено. CookieHelpCodexManual = Вставте заголовок Cookie із запиту до chatgpt.com. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 15b403581b3..5ec0c1e96b0 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1133,7 +1133,7 @@ UsageSpendModelsPeriodCaption = 本地模型历史:{ "{}" }。 UsageSpendProjectsPeriodCaption = Codex 本地项目费用排名:{ "{}" } BurndownChartTitle = 剩余用量 BurndownChartAriaLabel = 已记录的剩余配额消耗曲线 -ProviderRaycastAutoImportHelp = 自动从 Chrome 导入已登录的 www.raycast.com 会话。 +ProviderRaycastAutoImportHelp = 自动从已安装的浏览器导入已登录的 www.raycast.com 会话。 ProviderRaycastManualCookieHelp = 粘贴来自 www.raycast.com/settings 的 Cookie 标头。 ProviderRaycastCookiesDisabled = Raycast Cookie 已禁用。 CookieHelpCodexManual = 粘贴来自 chatgpt.com 请求的 Cookie 标头。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 4d2c95283ca..e911f0ff1d4 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1133,7 +1133,7 @@ UsageSpendModelsPeriodCaption = 本機模型歷史:{ "{}" }。 UsageSpendProjectsPeriodCaption = Codex 本機專案費用排名:{ "{}" } BurndownChartTitle = 剩餘用量 BurndownChartAriaLabel = 已記錄的剩餘配額消耗趨勢 -ProviderRaycastAutoImportHelp = 自動從 Chrome 匯入已登入的 www.raycast.com 工作階段。 +ProviderRaycastAutoImportHelp = 自動從已安裝的瀏覽器匯入已登入的 www.raycast.com 工作階段。 ProviderRaycastManualCookieHelp = 貼上來自 www.raycast.com/settings 的 Cookie 標頭。 ProviderRaycastCookiesDisabled = Raycast Cookie 已停用。 CookieHelpCodexManual = 貼上來自 chatgpt.com 請求的 Cookie 標頭。 diff --git a/rust/src/providers/raycast/cookies.rs b/rust/src/providers/raycast/cookies.rs index ecc733efa69..838cbb7c68e 100644 --- a/rust/src/providers/raycast/cookies.rs +++ b/rust/src/providers/raycast/cookies.rs @@ -5,6 +5,7 @@ //! profile is dropped before a request is built. use crate::browser::cookies::Cookie; +use crate::browser::detection::BrowserType; pub(super) const SESSION_COOKIE: &str = "__raycast_session"; const CSRF_COOKIE: &str = "csrf_token"; @@ -66,6 +67,17 @@ pub(super) fn browser_candidates(cookies: &[Cookie]) -> Vec { headers } +/// Candidate headers for each scanned browser, in the scan's browser order and +/// labelled with the browser's display name. Browsers without a usable session +/// keep an empty list so callers can skip them. +pub(super) fn browser_groups( + scan: &[(BrowserType, Vec)], +) -> Vec<(&'static str, Vec)> { + scan.iter() + .map(|(browser, cookies)| (browser.display_name(), browser_candidates(cookies))) + .collect() +} + /// Reduce a pasted header to the two forwarded cookies. Returns `None` without /// a non-empty `__raycast_session`. The first occurrence of a name wins, which /// matches the most specific cookie a browser sends. diff --git a/rust/src/providers/raycast/mod.rs b/rust/src/providers/raycast/mod.rs index 8cd578462a9..bb3200d83b9 100644 --- a/rust/src/providers/raycast/mod.rs +++ b/rust/src/providers/raycast/mod.rs @@ -2,10 +2,11 @@ //! //! Raycast has no public credits API for a website session, so this provider //! calls the unofficial `frontend_api/current_user/ai_credits` route with the -//! `__raycast_session` and `csrf_token` cookies only. Automatic import reads -//! Chrome alone to avoid unrelated browser prompts; a Manual Cookie header is -//! pinned and never falls back to a browser; Off makes no request. Cookie -//! material stays in memory for the current fetch and is never logged. +//! `__raycast_session` and `csrf_token` cookies only. Automatic import walks +//! every detected browser in the shared browser order and stops at the first +//! accepted session; a Manual Cookie header is pinned and never falls back to a +//! browser; Off makes no request. Cookie material stays in memory for the +//! current fetch and is never logged. mod cookies; mod parse; @@ -16,7 +17,6 @@ use async_trait::async_trait; use reqwest::{Client, StatusCode}; use std::time::Duration; -use crate::browser::detection::BrowserType; use crate::core::{ FetchContext, ManualEmptyCookiePolicy, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, ProviderStateKind, SourceMode, @@ -30,7 +30,6 @@ const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleW const MAX_RESPONSE_BYTES: usize = 1024 * 1024; const MIN_TIMEOUT_SECONDS: u64 = 1; const MAX_TIMEOUT_SECONDS: u64 = 30; -const BROWSER: BrowserType = BrowserType::Chrome; const MISSING_SESSION: &str = "No Raycast session cookies found. Sign in at www.raycast.com/settings or paste a Cookie header."; const SESSION_EXPIRED: &str = "Raycast website session expired. Sign in at www.raycast.com/settings or paste a fresh Cookie header."; @@ -141,18 +140,49 @@ impl RaycastProvider { if ctx.manual_cookie_missing { return Err(ProviderError::Other(MISSING_SESSION.to_string())); } - let extracted = tokio::task::spawn_blocking(|| { - crate::providers::browser_cookies_from_browser(BROWSER, cookies::EXTRACT_DOMAIN) + let scanned = tokio::task::spawn_blocking(|| { + crate::providers::browser_cookie_candidates_for_domain(cookies::EXTRACT_DOMAIN) }) .await .map_err(|_| ProviderError::Other("Raycast cookie import was interrupted.".into()))?; - let headers = match extracted { - Ok(found) => cookies::browser_candidates(&found), + let groups = match scanned { + Ok(scan) => cookies::browser_groups(&scan), Err(ProviderError::NoCookies) => Vec::new(), Err(error) => return Err(error), }; - self.fetch_candidates(&headers, BROWSER.display_name(), timeout) - .await + self.fetch_browser_groups(&groups, timeout).await + } + + /// Try each browser's candidate headers in the shared browser order. A + /// browser whose sessions are all rejected yields to the next browser; a + /// browser with no session is skipped without a request; any other outcome + /// (success or a non-authentication failure) ends the search. + async fn fetch_browser_groups( + &self, + groups: &[(&str, Vec)], + timeout: Duration, + ) -> Result { + let mut rejected = false; + for (source_label, headers) in groups { + if headers.is_empty() { + continue; + } + match self.fetch_candidates(headers, source_label, timeout).await { + Ok(result) => return Ok(result), + Err(ProviderError::Other(message)) if message == SESSION_EXPIRED => { + rejected = true; + } + Err(error) => return Err(error), + } + } + Err(ProviderError::Other( + if rejected { + SESSION_EXPIRED + } else { + MISSING_SESSION + } + .to_string(), + )) } } diff --git a/rust/src/providers/raycast/tests.rs b/rust/src/providers/raycast/tests.rs index 618988d3fef..4acf4dcc7e6 100644 --- a/rust/src/providers/raycast/tests.rs +++ b/rust/src/providers/raycast/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::browser::cookies::Cookie; +use crate::browser::detection::BrowserType; use chrono::TimeZone; use parse::parse_credits; @@ -706,3 +707,189 @@ async fn malformed_success_body_is_a_parse_failure_that_stops_retries() { mock.assert_async().await; assert!(matches!(error, ProviderError::Parse(_))); } + +// ---- browser order ------------------------------------------------------- + +#[test] +fn browser_groups_keep_the_shared_order_and_skip_browsers_without_a_session() { + let scan = vec![ + ( + BrowserType::Chrome, + vec![cookie("csrf_token", "chrome-csrf", "www.raycast.com")], + ), + ( + BrowserType::Edge, + vec![ + cookie("__raycast_session", "edge-session", "www.raycast.com"), + cookie("csrf_token", "edge-csrf", "www.raycast.com"), + ], + ), + ]; + + let groups = cookies::browser_groups(&scan); + + assert_eq!( + groups, + vec![ + ("Google Chrome", Vec::new()), + ( + "Microsoft Edge", + vec!["__raycast_session=edge-session; csrf_token=edge-csrf".to_string()], + ), + ] + ); +} + +#[tokio::test] +async fn browser_groups_move_past_a_rejected_browser_to_the_next_session() { + let mut server = mockito::Server::new_async().await; + let rejected = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .match_header("cookie", "__raycast_session=chrome-rejected") + .with_status(401) + .expect(1) + .create_async() + .await; + let accepted = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .match_header("cookie", FIXTURE_COOKIE) + .with_status(200) + .with_body(CREDITS) + .expect(1) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let groups = vec![ + ( + "Google Chrome", + headers(&["__raycast_session=chrome-rejected"]), + ), + ("Microsoft Edge", headers(&[FIXTURE_COOKIE])), + ]; + let result = provider + .fetch_browser_groups(&groups, Duration::from_secs(5)) + .await + .expect("the Edge session should be accepted"); + + rejected.assert_async().await; + accepted.assert_async().await; + assert_eq!(result.source_label, "Microsoft Edge"); + assert_eq!(result.usage.primary.used_percent, 75.0); +} + +#[tokio::test] +async fn browser_groups_stop_at_the_first_accepted_session() { + let mut server = mockito::Server::new_async().await; + let chrome = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .match_header("cookie", FIXTURE_COOKIE) + .with_status(200) + .with_body(CREDITS) + .expect(1) + .create_async() + .await; + let edge = server + .mock("GET", mockito::Matcher::Any) + .expect(0) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let groups = vec![ + ("Google Chrome", headers(&[FIXTURE_COOKIE])), + ( + "Microsoft Edge", + headers(&["__raycast_session=edge-fixture-session"]), + ), + ]; + let result = provider + .fetch_browser_groups(&groups, Duration::from_secs(5)) + .await + .expect("Chrome session should be accepted"); + + chrome.assert_async().await; + edge.assert_async().await; + assert_eq!(result.source_label, "Google Chrome"); +} + +#[tokio::test] +async fn browser_groups_without_any_session_make_no_request() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", mockito::Matcher::Any) + .expect(0) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let groups = vec![ + ("Google Chrome", Vec::new()), + ("Microsoft Edge", Vec::new()), + ]; + let error = provider + .fetch_browser_groups(&groups, Duration::from_secs(5)) + .await + .expect_err("no browser holds a session"); + + mock.assert_async().await; + assert_eq!(error_message(error), MISSING_SESSION); +} + +#[tokio::test] +async fn browser_groups_report_expiry_only_when_every_browser_rejects() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .with_status(401) + .expect(2) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let groups = vec![ + ("Google Chrome", headers(&["__raycast_session=chrome-a"])), + ("Microsoft Edge", headers(&["__raycast_session=edge-b"])), + ]; + let error = provider + .fetch_browser_groups(&groups, Duration::from_secs(5)) + .await + .expect_err("every session rejected"); + + mock.assert_async().await; + assert_eq!(error_message(error), SESSION_EXPIRED); +} + +#[tokio::test] +async fn browser_groups_stop_on_a_service_failure_instead_of_trying_the_next_browser() { + let mut server = mockito::Server::new_async().await; + let unavailable = server + .mock("GET", "/frontend_api/current_user/ai_credits") + .match_header("cookie", "__raycast_session=chrome-session") + .with_status(503) + .expect(1) + .create_async() + .await; + let edge = server + .mock("GET", mockito::Matcher::Any) + .expect(0) + .create_async() + .await; + + let provider = RaycastProvider::with_origin(&server.url()); + let groups = vec![ + ( + "Google Chrome", + headers(&["__raycast_session=chrome-session"]), + ), + ("Microsoft Edge", headers(&[FIXTURE_COOKIE])), + ]; + let error = provider + .fetch_browser_groups(&groups, Duration::from_secs(5)) + .await + .expect_err("service outage"); + + unavailable.assert_async().await; + edge.assert_async().await; + assert!(error_message(error).contains("unavailable")); +}