Files
orca/mobile/src/worktree/host-worktree-refresh.test.ts
T
NeilandOrca 4468d54f3c perf(mobile): gate host polling on foreground/background (#9857)
* perf(mobile): gate host polling on foreground

The mobile host screen ran two 3s polls (routed + embedded), each firing worktree.ps
AND repo.list, with no foreground/background gate — so a connected phone kept pinging
every 3s (worktree.ps is a full multi-repo process scan) plus a radio wakeup, including
brief background windows while the socket stays parked.

Consolidate both into one startHostWorktreeRefresh lifecycle and AppState-gate the
interval so BOTH polls stop while backgrounded and refresh immediately on foreground
return. worktree.ps keeps its 3s cadence while foregrounded (it carries live agent
status/preview/unread that no push event replaces). repo.list stays on the interval as
an AppState-gated, self-throttling (REPO_METADATA_REFRESH_MS=60s) convergence safety-net
— desktop Settings repo edits notify only the renderer, not the runtime clientEvents
stream, so it can't be made purely event-driven without going stale — and additionally
gets a reposChanged/worktreesChanged fast-path and reconnect-replay refetch.

Verified in a deps-installed mobile checkout: full mobile suite 2232 pass, typecheck,
oxlint (within the frozen max-lines budget), and oxfmt --check all clean.

Co-authored-by: Orca <help@stably.ai>

* chore(mobile): drop stale fetchRepoMetadata dep from the reconnect effect

Address CodeRabbit nitpick: the reconnect effect no longer calls fetchRepoMetadata
(that refetch moved into startHostWorktreeRefresh), so it shouldn't remain in the
effect's dependency array.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-22 14:12:55 -07:00

128 lines
4.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { startHostWorktreeRefresh } from './host-worktree-refresh'
const appState = vi.hoisted(() => ({
currentState: 'active',
listener: null as ((state: string) => void) | null,
remove: vi.fn()
}))
vi.mock('react-native', () => ({
AppState: {
get currentState() {
return appState.currentState
},
addEventListener: (_event: string, listener: (state: string) => void) => {
appState.listener = listener
return { remove: appState.remove }
}
}
}))
describe('startHostWorktreeRefresh', () => {
let eventListener: ((payload: unknown) => void) | null
let fetchWorktrees: ReturnType<typeof vi.fn>
let fetchRepoMetadata: ReturnType<typeof vi.fn>
let unsubscribe: ReturnType<typeof vi.fn>
let stop: (() => void) | null
beforeEach(() => {
vi.useFakeTimers()
appState.currentState = 'active'
appState.listener = null
appState.remove.mockClear()
eventListener = null
fetchWorktrees = vi.fn().mockResolvedValue(undefined)
fetchRepoMetadata = vi.fn().mockResolvedValue(undefined)
unsubscribe = vi.fn()
stop = null
})
function start(): void {
const client = {
subscribe: vi.fn(
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
eventListener = listener
return unsubscribe
}
)
} as unknown as RpcClient
stop = startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
}
afterEach(() => {
stop?.()
vi.useRealTimers()
})
it('keeps the worktree poll active but skips ticks while backgrounded', async () => {
start()
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
appState.currentState = 'background'
await vi.advanceTimersByTimeAsync(6_000)
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
appState.currentState = 'active'
await vi.advanceTimersByTimeAsync(3_000)
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
})
it('refreshes both snapshots immediately on foreground return', () => {
start()
fetchWorktrees.mockClear()
fetchRepoMetadata.mockClear()
appState.currentState = 'active'
appState.listener?.('active')
expect(fetchWorktrees).toHaveBeenCalledWith({ allowDuringModal: true })
expect(fetchRepoMetadata).toHaveBeenCalledWith({ queueIfInFlight: true })
})
it('polls repo metadata on the interval while foregrounded and skips it while backgrounded', async () => {
start()
// Mount force-fetch.
expect(fetchRepoMetadata).toHaveBeenCalledTimes(1)
// Foregrounded: repo.list rides the interval as a convergence safety-net for desktop
// Settings edits that never emit a runtime reposChanged (the callee self-throttles).
await vi.advanceTimersByTimeAsync(9_000)
expect(fetchWorktrees).toHaveBeenCalledTimes(4)
expect(fetchRepoMetadata).toHaveBeenCalledTimes(4)
// Backgrounded: neither snapshot is polled.
fetchWorktrees.mockClear()
fetchRepoMetadata.mockClear()
appState.currentState = 'background'
await vi.advanceTimersByTimeAsync(9_000)
expect(fetchWorktrees).not.toHaveBeenCalled()
expect(fetchRepoMetadata).not.toHaveBeenCalled()
})
it('force-refreshes repo metadata on reposChanged', () => {
start()
fetchRepoMetadata.mockClear()
eventListener?.({ type: 'reposChanged' })
expect(fetchRepoMetadata).toHaveBeenCalledOnce()
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
})
it('refreshes worktrees on worktreesChanged and both snapshots after stream replay', () => {
start()
fetchWorktrees.mockClear()
fetchRepoMetadata.mockClear()
eventListener?.({ type: 'worktreesChanged', repoId: 'repo-1' })
expect(fetchWorktrees).toHaveBeenCalledTimes(1)
eventListener?.({ type: 'ready', subscriptionId: 'events-1' })
eventListener?.({ type: 'ready', subscriptionId: 'events-2' })
expect(fetchWorktrees).toHaveBeenCalledTimes(2)
expect(fetchRepoMetadata).toHaveBeenCalledWith({ force: true, queueIfInFlight: true })
})
})