fix(usage): guard web client against undefined usage scan state (#10073)
The claude/codex/openCode usage store slices read `scanState.enabled` directly off `window.api.<provider>Usage.getScanState()`. In the web client that usage IPC is not bridged, so the preload fallback proxy resolves those calls to `undefined`, and enabling usage tracking from Settings -> Stats & Usage throws `TypeError: Cannot read properties of undefined (reading 'enabled')` (reproduced live against `orca serve` v1.4.150; present on main too). Guard the getScanState()/setEnabled() seams in all three slices so an absent scan state degrades to a graceful no-op instead of crashing. Desktop behavior is unchanged (a real ScanState is always truthy). Adds a regression test that stubs the web-client fallback (every call -> undefined) and asserts fetch*/enable* no-op without throwing for all three providers. Co-authored-by: ECO2G Migration <cmeia.ai02@cmeia.co.kr>
This commit is contained in:
parent
dc18ba9cda
commit
6997bc40ab
|
|
@ -45,7 +45,14 @@ export const createClaudeUsageSlice: StateCreator<AppState, [], [], ClaudeUsageS
|
|||
try {
|
||||
const nextScanState = (await window.api.claudeUsage.setEnabled({
|
||||
enabled
|
||||
})) as ClaudeUsageScanState
|
||||
})) as ClaudeUsageScanState | undefined
|
||||
// Why: the web client (paired runtime) does not bridge the desktop-only
|
||||
// usage IPC; its preload fallback resolves this call to `undefined`. Bail
|
||||
// so the toggle no-ops instead of seeding an empty scan state and then
|
||||
// crashing on the follow-up fetch.
|
||||
if (!nextScanState) {
|
||||
return
|
||||
}
|
||||
set({
|
||||
// Why: every enable should look like a fresh scan cycle in the UI.
|
||||
// Reusing the last completed timestamp makes repeated toggles skip the
|
||||
|
|
@ -84,7 +91,16 @@ export const createClaudeUsageSlice: StateCreator<AppState, [], [], ClaudeUsageS
|
|||
|
||||
fetchClaudeUsage: async (opts) => {
|
||||
try {
|
||||
const scanState = (await window.api.claudeUsage.getScanState()) as ClaudeUsageScanState
|
||||
const scanState = (await window.api.claudeUsage.getScanState()) as
|
||||
| ClaudeUsageScanState
|
||||
| undefined
|
||||
// Why: in the web client the usage IPC is unavailable and the preload
|
||||
// fallback resolves to `undefined`; reading `scanState.enabled` below would
|
||||
// throw `Cannot read properties of undefined (reading 'enabled')`. Treat an
|
||||
// absent scan state as "usage unavailable" and stop.
|
||||
if (!scanState) {
|
||||
return
|
||||
}
|
||||
const currentScanState = get().claudeUsageScanState
|
||||
const shouldPreserveLoadingState =
|
||||
opts?.forceRefresh === true &&
|
||||
|
|
|
|||
|
|
@ -45,7 +45,14 @@ export const createCodexUsageSlice: StateCreator<AppState, [], [], CodexUsageSli
|
|||
try {
|
||||
const nextScanState = (await window.api.codexUsage.setEnabled({
|
||||
enabled
|
||||
})) as CodexUsageScanState
|
||||
})) as CodexUsageScanState | undefined
|
||||
// Why: the web client (paired runtime) does not bridge the desktop-only
|
||||
// usage IPC; its preload fallback resolves this call to `undefined`. Bail
|
||||
// so the toggle no-ops instead of seeding an empty scan state and then
|
||||
// crashing on the follow-up fetch.
|
||||
if (!nextScanState) {
|
||||
return
|
||||
}
|
||||
set({
|
||||
codexUsageScanState: enabled
|
||||
? {
|
||||
|
|
@ -81,7 +88,16 @@ export const createCodexUsageSlice: StateCreator<AppState, [], [], CodexUsageSli
|
|||
|
||||
fetchCodexUsage: async (opts) => {
|
||||
try {
|
||||
const scanState = (await window.api.codexUsage.getScanState()) as CodexUsageScanState
|
||||
const scanState = (await window.api.codexUsage.getScanState()) as
|
||||
| CodexUsageScanState
|
||||
| undefined
|
||||
// Why: in the web client the usage IPC is unavailable and the preload
|
||||
// fallback resolves to `undefined`; reading `scanState.enabled` below would
|
||||
// throw `Cannot read properties of undefined (reading 'enabled')`. Treat an
|
||||
// absent scan state as "usage unavailable" and stop.
|
||||
if (!scanState) {
|
||||
return
|
||||
}
|
||||
const currentScanState = get().codexUsageScanState
|
||||
const shouldPreserveLoadingState =
|
||||
opts?.forceRefresh === true &&
|
||||
|
|
|
|||
|
|
@ -45,7 +45,14 @@ export const createOpenCodeUsageSlice: StateCreator<AppState, [], [], OpenCodeUs
|
|||
try {
|
||||
const nextScanState = (await window.api.openCodeUsage.setEnabled({
|
||||
enabled
|
||||
})) as OpenCodeUsageScanState
|
||||
})) as OpenCodeUsageScanState | undefined
|
||||
// Why: the web client (paired runtime) does not bridge the desktop-only
|
||||
// usage IPC; its preload fallback resolves this call to `undefined`. Bail
|
||||
// so the toggle no-ops instead of seeding an empty scan state and then
|
||||
// crashing on the follow-up fetch.
|
||||
if (!nextScanState) {
|
||||
return
|
||||
}
|
||||
set({
|
||||
openCodeUsageScanState: enabled
|
||||
? {
|
||||
|
|
@ -81,7 +88,16 @@ export const createOpenCodeUsageSlice: StateCreator<AppState, [], [], OpenCodeUs
|
|||
|
||||
fetchOpenCodeUsage: async (opts) => {
|
||||
try {
|
||||
const scanState = (await window.api.openCodeUsage.getScanState()) as OpenCodeUsageScanState
|
||||
const scanState = (await window.api.openCodeUsage.getScanState()) as
|
||||
| OpenCodeUsageScanState
|
||||
| undefined
|
||||
// Why: in the web client the usage IPC is unavailable and the preload
|
||||
// fallback resolves to `undefined`; reading `scanState.enabled` below would
|
||||
// throw `Cannot read properties of undefined (reading 'enabled')`. Treat an
|
||||
// absent scan state as "usage unavailable" and stop.
|
||||
if (!scanState) {
|
||||
return
|
||||
}
|
||||
const currentScanState = get().openCodeUsageScanState
|
||||
const shouldPreserveLoadingState =
|
||||
opts?.forceRefresh === true &&
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { create } from 'zustand'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import { createClaudeUsageSlice } from './claude-usage'
|
||||
import { createCodexUsageSlice } from './codex-usage'
|
||||
import { createOpenCodeUsageSlice } from './opencode-usage'
|
||||
|
||||
// Regression: in the web client (paired `orca serve` runtime) the desktop-only
|
||||
// usage IPC is not bridged, so the preload fallback proxy resolves every
|
||||
// `window.api.<provider>Usage.*` call to `undefined`. Before the guards, the
|
||||
// slices read `scanState.enabled` off that `undefined` and threw
|
||||
// `TypeError: Cannot read properties of undefined (reading 'enabled')` when a
|
||||
// user opened Settings -> Stats & Usage and pressed "enable" for an agent.
|
||||
//
|
||||
// These tests stub the web-client fallback (every call -> undefined) and assert
|
||||
// the slices degrade to a no-op instead of throwing.
|
||||
|
||||
function stubWebClientFallback(): void {
|
||||
// Mirrors web-preload-api's createFallbackProxy: any method resolves to undefined.
|
||||
const undefinedAsync = vi.fn(() => Promise.resolve(undefined))
|
||||
const provider = {
|
||||
getScanState: undefinedAsync,
|
||||
setEnabled: undefinedAsync,
|
||||
getSnapshot: undefinedAsync,
|
||||
refresh: undefinedAsync,
|
||||
getSummary: undefinedAsync,
|
||||
getDaily: undefinedAsync,
|
||||
getBreakdown: undefinedAsync,
|
||||
getRecentSessions: undefinedAsync
|
||||
}
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
claudeUsage: provider,
|
||||
codexUsage: provider,
|
||||
openCodeUsage: provider
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('usage slices in the web client (preload fallback -> undefined)', () => {
|
||||
it('claude: fetch and enable no-op without throwing', async () => {
|
||||
stubWebClientFallback()
|
||||
const store = create<AppState>()((...args) => createClaudeUsageSlice(...args) as AppState)
|
||||
await expect(store.getState().fetchClaudeUsage()).resolves.toBeUndefined()
|
||||
await expect(store.getState().enableClaudeUsage()).resolves.toBeUndefined()
|
||||
expect(store.getState().claudeUsageScanState).toBeNull()
|
||||
expect(store.getState().claudeUsageSummary).toBeNull()
|
||||
})
|
||||
|
||||
it('codex: fetch and enable no-op without throwing', async () => {
|
||||
stubWebClientFallback()
|
||||
const store = create<AppState>()((...args) => createCodexUsageSlice(...args) as AppState)
|
||||
await expect(store.getState().fetchCodexUsage()).resolves.toBeUndefined()
|
||||
await expect(store.getState().enableCodexUsage()).resolves.toBeUndefined()
|
||||
expect(store.getState().codexUsageScanState).toBeNull()
|
||||
expect(store.getState().codexUsageSummary).toBeNull()
|
||||
})
|
||||
|
||||
it('opencode: fetch and enable no-op without throwing', async () => {
|
||||
stubWebClientFallback()
|
||||
const store = create<AppState>()((...args) => createOpenCodeUsageSlice(...args) as AppState)
|
||||
await expect(store.getState().fetchOpenCodeUsage()).resolves.toBeUndefined()
|
||||
await expect(store.getState().enableOpenCodeUsage()).resolves.toBeUndefined()
|
||||
expect(store.getState().openCodeUsageScanState).toBeNull()
|
||||
expect(store.getState().openCodeUsageSummary).toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue