mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 08:02:54 +00:00
* feat(rate-limits): add Gemini and OpenCode Go usage tracking - Extend provider union and RateLimitState with 'gemini' and 'opencode-go' - Add opencodeSessionCookie to GlobalSettings (password input in GeneralPane) - Add GeminiIcon and OpenCodeGoIcon to status bar icons - Implement gemini-usage-fetcher: OAuth creds from ~/.gemini/oauth_creds.json, auto-refresh expired tokens (including server-side 401 retry), loadCodeAssist project resolution, retrieveUserQuota with pro/flash bucket mapping - Implement opencode-go-usage-fetcher: cookie-based POST to opencode.ai/_server, two-step workspace+subscription fetch, regex parse of text/javascript response - Wire both fetchers into RateLimitService via Promise.allSettled for isolation - Add formatWindowLabel() replacing hardcoded '5h'/'wk' strings in StatusBar - Render Gemini and OpenCode Go segments in StatusBar and tooltip - 28 new tests across fetchers, service, and label formatter Closes #1022 * fix(persistence): additive merge for statusBarItems on load New providers (gemini, opencode-go) were invisible to users with existing settings because the persisted statusBarItems array (without the new entries) overwrote the defaults. Union saved items with current defaults so new providers appear automatically after upgrade without user intervention. * feat(rate-limits): add Gemini multi-bucket usage Restore provider auth wiring, preserve Gemini model buckets in detailed views, and keep compact status rendering summary-only. Avoid embedding OAuth client secrets by failing closed for auth.json refreshes. * fix(gemini-oauth): resolve symlinks and use known-path + bundle-dir extraction Replace the recursive directory walker with two targeted strategies: 1. Known paths: checks explicit Homebrew/Nix/npm layouts without walking. 2. Bundle dir: walks up from the binary to find package.json, then scans the bundle dir for hash-named oauth2 chunks. Also threads the refresh token as a plain string through tryRefreshTokenFromBundle so both the oauth_creds.json and auth.json paths share one refresh flow without coupling to either struct. Windows: uses 'where gemini' and splits on newlines for multi-result output. * fix(status-bar): fix Gemini bucket display — names, window size, model context Three issues fixed: 1. windowMinutes was computed as time-remaining-until-reset instead of the fixed window size. Gemini buckets are always 1-hour windows; use the constant 60 so labels read "93% Pro 1h" instead of "93% 47m". 2. Unknown bucket names now humanize gracefully. Unknown model IDs get the "gemini-" prefix stripped and title-cased ("gemini-3.0-ultra" → "3.0 Ultra") instead of showing "Unknown (gemini-3.0-ultra)". Added more known model mappings (2.0 Flash, 2.0 Flash Lite, 1.5 Pro, 1.5 Flash, Flash Lite). 3. Status bar segment now shows the most-constrained bucket name next to the percentage so users know which model is the binding constraint. Pure unit tests for getBucketName/deriveSessionSummary extracted to gemini-bucket-helpers.test.ts to keep gemini-usage-fetcher.test.ts under the 300-line lint limit. * feat(status-bar): show all Gemini buckets individually in status bar Instead of showing only the most-constrained bucket summary, render each bucket by name with its remaining percentage (e.g. "Flash 93% · 3.1 Pro Preview 7%"). Falls back to the session/weekly window display for providers without buckets. * fix(status-bar): show only Flash and 3.1 Pro Preview buckets in Gemini segment Filter to the two most relevant buckets (Flash + 3.1 Pro Preview) to avoid cluttering the bar. Falls back to session summary if neither bucket is present. * fix(icons): replace GeminiIcon with official 2025 multicolor gradient The previous icon used a simple linear gradient (blue→purple→red). The official Google Gemini 2025 icon uses 11 blurred ellipses (feGaussianBlur) stacked under an alpha mask to produce the characteristic multicolor glow effect. Each instance gets unique filter/mask IDs via a module-level counter to prevent ID collisions when the icon is rendered multiple times on the same page. * feat(opencode): add monthly limits, workspace override, and improved cookie handling * fix(opencode): show correct status when session cookie is missing * fix(rate-limits): discard stale data on unavailable and show errors in tooltip * fix(rate-limits): invalidate stale data when opencode config changes * fix(security): resolve vulnerabilities and harden rate-limit fetchers * fix: address critical security, performance and concurrency issues identified during code review * fix(build): resolve claude rate-limit export and broken type definitions * fix(build): remove unused code in claude-fetcher after rebase * Fix claude fetcher * fix: resolve synchronization, performance, and robustness issues in AI providers - Convert synchronous file I/O to asynchronous to prevent main process blocking - Implement concurrency limiting in recursive directory copying to avoid EMFILE errors - Persist refreshed Gemini OAuth tokens to disk and improve project ID resolution - Fix OpenCode Go fetcher to correctly handle workspace overrides and robustly resolve IDs - Refine scraping regexes to handle nested objects and improve resilience - Update status bar bucket names and fix Gemini model mapping typos - Ensure proper handling of async operations in background services * feat(gemini): deduplicate quota buckets and update model mappings * fix(rate-limits): preserve stale data for Gemini and OpenCode Go on fetch errors * fix: remove out-of-scope Linear changes and restore files to upstream/main * fix(opencode-go): handle React Flight wire format and duplicate keys The opencode.ai page uses React Server Components. Usage keys like monthlyUsage appear twice: once as `key:$R[N]={...}` with real data and once as `key:null` inside a billing component. Render order varies, so on refresh the null could appear first, causing monthly to vanish or show 100% from a sibling sub-object. Replace flat regex extraction with extractUsageBlock, which: - Iterates all occurrences of each key - Skips null assignments (no { in 30-char window after colon) - Handles the $R[N]= token between colon and opening brace - Validates usagePercent + resetInSec as direct fields before accepting Add regression tests using the real React Flight HTML format. * fix(rate-limits): address PR review feedback from nwparker Scope reverts (out-of-scope changes removed): - Revert codex-accounts async refactor (fs-utils.ts, service.ts) - Revert filesystem-auth.ts path-auth reordering - Revert filesystem-mutations.ts bundled changes - Revert repos.ts handler move; keep only -- separator fix - Revert relay/fs-handler-git-fallback.ts RegExp try/catch - Revert fs-handler.ts stat→lstat, useFileDeletion.ts !isRemote guard - Revert persistence.ts statusBarItems additive merge Feature fixes: - Validate opencodeWorkspaceId override with ^(wrk|wk)_[A-Za-z0-9]+$ before URL interpolation - Remove internal 5-min cache from fetchGeminiRateLimits (service polls already) - Make saveGeminiCredentials atomic using tmp-file + rename pattern - Add fetchGeneration counter to OpenCode to discard stale mid-flight results - Add console.warn on safeStorage decrypt failure in persistence.ts - Add geminiCliOAuthEnabled opt-in setting (default: false) with UI toggle and risk disclosure - Use per-candidate AbortControllers in opencode-go fetcher - Add fragility comments to brace-depth parsers in opencode-go fetcher Refactor (keep under 300-line lint limit): - Extract gemini-bucket-formatting.ts from gemini-usage-fetcher.ts - Extract opencode-go-page-scraper.ts from opencode-go-usage-fetcher.ts Co-authored-by: Orca <help@stably.ai> * fix(rate-limits): restore OpenCode Go workspace ID regex + cleanup - opencode-go-usage-fetcher: regex accidentally shipped as `\\s*` (matches literal backslash-s) instead of `\s*` during the review-feedback commit, breaking workspace ID extraction when no override is configured — parseWorkspaceIds always returned []. 10 tests were failing as a result. - gemini-usage-fetcher: drop unused `_force` parameter left over from the removed 5-min internal cache; update service.ts call site. - codex-accounts test fixtures: add missing `geminiCliOAuthEnabled` to createSettings() to fix pre-existing typecheck errors introduced when the new setting was added. Co-authored-by: Orca <help@stably.ai> * test(rate-limits): stabilize Gemini fetcher tests, silence max-lines - Rewire Gemini fetcher tests to mock the CLI-credential extractor at the module boundary instead of stubbing every fs/child_process call. The extractor is a self-contained dependency with a simple async contract, and was previously being reached through sync-fs mocks that stopped matching after the extractor was refactored to node:fs/promises — three tests were silently failing as a result. With this change, all 17 Gemini fetcher tests pass. - Replace the "proceeds with empty projectId" test (which asserted that an empty projectId still hits the quota API — the current fetcher correctly short-circuits to an actionable error instead) with a test that documents the new behavior, plus an explicit test for the geminiCliOAuthEnabled=false unavailable path. - Add a max-lines disable pragma to service.test.ts matching the one already on service.ts, so the rate-limit fetch-ordering contract and its tests remain reviewable as a single unit. Co-authored-by: Orca <help@stably.ai> * fix(codex-accounts): revert runtime-home-service async refactor The review explicitly asked to drop the codex async refactor from this PR (fires floating promises from the constructor via void, and the prepare* helpers no longer block on sync completion — that is a behavior change around account prep racing with launch that deserves its own design discussion, not a drive-by). Earlier reverts removed fs-utils.ts and service.ts but left runtime-home-service.ts untouched, which was causing the matching runtime-home-service.test.ts suite (11 tests) to fail in CI. This restores the file to main so the suite is green and the PR stays scoped to the Gemini / OpenCode Go feature. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const netFetchMock = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('electron', () => ({
|
|
net: { fetch: netFetchMock }
|
|
}))
|
|
|
|
import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-usage-fetcher'
|
|
|
|
const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f'
|
|
|
|
function makeResponse(body: string, status = 200): Response {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
text: async () => body
|
|
} as Response
|
|
}
|
|
|
|
// Real React Flight wire format from opencode.ai — keys like `monthlyUsage`
|
|
// appear multiple times: once with actual data (as `$R[N]={...}`) and once as
|
|
// `null` inside a billing-context object. The parser must pick the data one.
|
|
const USAGE_PAGE_WITH_MONTHLY = `
|
|
<html><body><script>
|
|
$RC=function(a,b){/*...*/};
|
|
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:7200,usagePercent:30},weeklyUsage:$R[22]={status:"ok",resetInSec:259200,usagePercent:51},monthlyUsage:$R[23]={status:"ok",resetInSec:1296000,usagePercent:89}};
|
|
$R[14]={customerID:"cus_ABC",reloadTrigger:5,monthlyLimit:null,monthlyUsage:null,timeMonthlyUsageUpdated:null};
|
|
</script></body></html>
|
|
`
|
|
|
|
const USAGE_PAGE_NO_MONTHLY = `
|
|
<html><body><script>
|
|
$R[20]={rollingUsage:$R[21]={status:"ok",resetInSec:3600,usagePercent:10},weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:20}};
|
|
</script></body></html>
|
|
`
|
|
|
|
const WORKSPACES_RESPONSE = 'id: "wrk_TESTWORKSPACEID123"'
|
|
|
|
describe('fetchOpenCodeGoRateLimits', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2026-04-24T12:00:00.000Z'))
|
|
netFetchMock.mockReset()
|
|
})
|
|
|
|
it('returns unavailable when cookie is empty', async () => {
|
|
const result = await fetchOpenCodeGoRateLimits('')
|
|
|
|
expect(result.status).toBe('unavailable')
|
|
expect(result.provider).toBe('opencode-go')
|
|
expect(result.session).toBeNull()
|
|
expect(result.weekly).toBeNull()
|
|
expect(result.monthly).toBeNull()
|
|
expect(result.error).toBe('Session cookie not configured')
|
|
expect(netFetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns unavailable when cookie is only whitespace', async () => {
|
|
const result = await fetchOpenCodeGoRateLimits(' ')
|
|
|
|
expect(result.status).toBe('unavailable')
|
|
expect(netFetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns error when cookie has no auth or __Host-auth name', async () => {
|
|
const result = await fetchOpenCodeGoRateLimits('session=abc123; other=xyz')
|
|
|
|
expect(result.status).toBe('error')
|
|
expect(result.error).toMatch(/No auth cookie found/)
|
|
expect(netFetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
describe('normalizeCookieInput', () => {
|
|
it('returns empty string unchanged', () => {
|
|
expect(normalizeCookieInput('')).toBe('')
|
|
expect(normalizeCookieInput(' ')).toBe('')
|
|
})
|
|
|
|
it('wraps a bare token as auth=<token>', () => {
|
|
expect(normalizeCookieInput('Fe26.2**abc123')).toBe('auth=Fe26.2**abc123')
|
|
})
|
|
|
|
it('leaves auth=... unchanged', () => {
|
|
expect(normalizeCookieInput('auth=Fe26.2**abc123')).toBe('auth=Fe26.2**abc123')
|
|
})
|
|
|
|
it('leaves __Host-auth=... unchanged', () => {
|
|
expect(normalizeCookieInput('__Host-auth=token')).toBe('__Host-auth=token')
|
|
})
|
|
|
|
it('leaves multi-pair cookie headers unchanged', () => {
|
|
expect(normalizeCookieInput('auth=tok; other=val')).toBe('auth=tok; other=val')
|
|
})
|
|
|
|
it('trims surrounding whitespace before wrapping', () => {
|
|
expect(normalizeCookieInput(' Fe26.2**abc ')).toBe('auth=Fe26.2**abc')
|
|
})
|
|
|
|
it('does not wrap unknown or malformed tokens', () => {
|
|
expect(normalizeCookieInput('invalid token format')).toBe('invalid token format')
|
|
expect(normalizeCookieInput('{}')).toBe('{}')
|
|
expect(normalizeCookieInput('{"token":"abc"}')).toBe('{"token":"abc"}')
|
|
})
|
|
})
|
|
|
|
it('accepts a bare token (auto-wraps to auth=<token>)', async () => {
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('Fe26.2**baretoken')
|
|
|
|
expect(result.status).toBe('ok')
|
|
// Cookie sent to the server must be auth=<token>, not the bare value.
|
|
expect(netFetchMock.mock.calls[0][1].headers.Cookie).toBe('auth=Fe26.2**baretoken')
|
|
})
|
|
|
|
it('uses GET /_server?id=<hash> with correct headers for workspaces', async () => {
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
|
|
|
await fetchOpenCodeGoRateLimits('auth=mytoken')
|
|
|
|
expect(netFetchMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
`https://opencode.ai/_server?id=${WORKSPACES_SERVER_ID}`,
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
headers: expect.objectContaining({
|
|
Cookie: 'auth=mytoken',
|
|
'X-Server-Id': WORKSPACES_SERVER_ID
|
|
})
|
|
})
|
|
)
|
|
})
|
|
|
|
it('fetches usage from /workspace/<id>/go after resolving workspace ID', async () => {
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
|
|
|
await fetchOpenCodeGoRateLimits('auth=mytoken')
|
|
|
|
expect(netFetchMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://opencode.ai/workspace/wrk_TESTWORKSPACEID123/go',
|
|
expect.objectContaining({ method: 'GET' })
|
|
)
|
|
})
|
|
|
|
it('returns ok with session, weekly, and monthly windows', async () => {
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
|
|
|
const now = Date.now()
|
|
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
|
|
|
expect(result.status).toBe('ok')
|
|
expect(result.error).toBeNull()
|
|
|
|
expect(result.session).toEqual({
|
|
usedPercent: 30,
|
|
windowMinutes: 300,
|
|
resetsAt: now + 7200 * 1000,
|
|
resetDescription: null
|
|
})
|
|
expect(result.weekly).toEqual({
|
|
usedPercent: 51,
|
|
windowMinutes: 10080,
|
|
resetsAt: now + 259200 * 1000,
|
|
resetDescription: null
|
|
})
|
|
expect(result.monthly).toEqual({
|
|
usedPercent: 89,
|
|
windowMinutes: 43200,
|
|
resetsAt: now + 1296000 * 1000,
|
|
resetDescription: null
|
|
})
|
|
})
|
|
|
|
it('returns ok with null monthly when monthlyUsage is absent', async () => {
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_NO_MONTHLY))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
|
|
|
expect(result.status).toBe('ok')
|
|
expect(result.session?.usedPercent).toBe(10)
|
|
expect(result.weekly?.usedPercent).toBe(20)
|
|
expect(result.monthly).toBeNull()
|
|
})
|
|
|
|
it('caps usedPercent at 100 and floors at 0', async () => {
|
|
const page = `
|
|
rollingUsage: { usagePercent: 150, resetInSec: 3600 }
|
|
weeklyUsage: { usagePercent: -5, resetInSec: 86400 }
|
|
`
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse(page))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=token')
|
|
|
|
expect(result.status).toBe('ok')
|
|
expect(result.session?.usedPercent).toBe(100)
|
|
expect(result.weekly?.usedPercent).toBe(0)
|
|
})
|
|
|
|
it('parses React Flight wire format with $R[N]= assignment tokens', async () => {
|
|
// Real format from opencode.ai — keys have $R[N]= between the colon and brace.
|
|
const page = `
|
|
rollingUsage:$R[21]={status:"ok",resetInSec:1337,usagePercent:42},
|
|
weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:68}
|
|
`
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse(page))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=token')
|
|
|
|
expect(result.status).toBe('ok')
|
|
expect(result.session?.usedPercent).toBe(42)
|
|
expect(result.weekly?.usedPercent).toBe(68)
|
|
})
|
|
|
|
it('skips null occurrences and finds the real data block for monthlyUsage', async () => {
|
|
// Regression: on refresh, monthlyUsage:null appeared BEFORE the real
|
|
// monthlyUsage:$R[N]={usagePercent:89,...} in a different component's props.
|
|
// Parser must skip the null and find the data block.
|
|
const page = `
|
|
rollingUsage:$R[21]={status:"ok",resetInSec:18000,usagePercent:0},
|
|
weeklyUsage:$R[22]={status:"ok",resetInSec:57781,usagePercent:51},
|
|
monthlyUsage:null,timeMonthlyUsageUpdated:null,
|
|
monthlyUsage:$R[28]={status:"ok",resetInSec:1214779,usagePercent:89}
|
|
`
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse(page))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=token')
|
|
|
|
expect(result.status).toBe('ok')
|
|
expect(result.monthly?.usedPercent).toBe(89)
|
|
expect(result.monthly?.resetsAt).toBe(Date.now() + 1214779 * 1000)
|
|
})
|
|
|
|
it('returns null monthly when all monthlyUsage occurrences are null', async () => {
|
|
const page = `
|
|
rollingUsage:$R[21]={status:"ok",resetInSec:3600,usagePercent:10},
|
|
weeklyUsage:$R[22]={status:"ok",resetInSec:86400,usagePercent:20},
|
|
monthlyUsage:null,timeMonthlyUsageUpdated:null
|
|
`
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse(page))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=token')
|
|
|
|
expect(result.status).toBe('ok')
|
|
expect(result.monthly).toBeNull()
|
|
})
|
|
|
|
it('skips workspace lookup when workspaceIdOverride is provided', async () => {
|
|
netFetchMock.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123')
|
|
|
|
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
|
expect(netFetchMock).toHaveBeenCalledWith(
|
|
'https://opencode.ai/workspace/wrk_OVERRIDE123/go',
|
|
expect.anything()
|
|
)
|
|
expect(result.status).toBe('ok')
|
|
})
|
|
|
|
it('filters cookie to auth name only', async () => {
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse(USAGE_PAGE_WITH_MONTHLY))
|
|
|
|
await fetchOpenCodeGoRateLimits('session=secret; auth=realtoken; tracking=xyz')
|
|
|
|
const firstCall = netFetchMock.mock.calls[0]
|
|
expect(firstCall[1].headers.Cookie).toBe('auth=realtoken')
|
|
})
|
|
|
|
it('returns error on 404 from workspaces fetch', async () => {
|
|
netFetchMock.mockResolvedValueOnce(makeResponse('Not Found', 404))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
|
|
|
expect(result.status).toBe('error')
|
|
expect(result.error).toBe('Workspaces fetch failed (404)')
|
|
expect(result.session).toBeNull()
|
|
})
|
|
|
|
it('returns error on 401 from workspaces fetch', async () => {
|
|
netFetchMock.mockResolvedValueOnce(makeResponse('Unauthorized', 401))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
|
|
|
expect(result.status).toBe('error')
|
|
expect(result.error).toBe('Workspaces fetch failed (401)')
|
|
})
|
|
|
|
it('returns error when no workspace ID found in response', async () => {
|
|
netFetchMock.mockResolvedValueOnce(makeResponse('no workspace id here'))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
|
|
|
expect(result.status).toBe('error')
|
|
expect(result.error).toMatch(/No workspace ID found/)
|
|
})
|
|
|
|
it('returns error on non-ok usage page response', async () => {
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse('Not Found', 404))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
|
|
|
expect(result.status).toBe('error')
|
|
expect(result.error).toBe('Usage page fetch failed (404)')
|
|
})
|
|
|
|
it('returns error when usage data cannot be parsed from page', async () => {
|
|
netFetchMock
|
|
.mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE))
|
|
.mockResolvedValueOnce(makeResponse('<html>no usage data here</html>'))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=mytoken')
|
|
|
|
expect(result.status).toBe('error')
|
|
expect(result.error).toBe('Could not parse usage data from page')
|
|
})
|
|
|
|
it('never logs the cookie in error messages', async () => {
|
|
netFetchMock.mockRejectedValueOnce(new Error('network timeout'))
|
|
|
|
const result = await fetchOpenCodeGoRateLimits('auth=secret123')
|
|
|
|
expect(result.status).toBe('error')
|
|
expect(result.error).toBe('network timeout')
|
|
expect(result.error).not.toContain('secret123')
|
|
})
|
|
})
|