test(mobile): share complete RPC client double

This commit is contained in:
Brennan Benson
2026-08-27 19:21:13 -07:00
parent d61fdee9b5
commit dca8c00e7b
8 changed files with 84 additions and 55 deletions
+12 -9
View File
@@ -1,6 +1,7 @@
import { createElement, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRpcClientTestDouble } from '../test/rpc-client-test-double'
import type { RpcClient } from '../transport/rpc-client'
import { HostProtocolGate, useHostProtocolGates } from './HostProtocolGate'
@@ -36,7 +37,9 @@ vi.mock('../transport/client-context', () => ({
}))
function clientWithStatus(result: Record<string, unknown>): RpcClient {
return { sendRequest: vi.fn().mockResolvedValue({ ok: true, result }) } as unknown as RpcClient
return createRpcClientTestDouble({
sendRequest: vi.fn().mockResolvedValue({ ok: true, result })
})
}
function GateConsumer() {
@@ -161,9 +164,9 @@ describe('HostProtocolGate', () => {
})
it('does not mount host routes before a connected host passes the compatibility probe', async () => {
const client = {
const client = createRpcClientTestDouble({
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
} as unknown as RpcClient
})
hostClient.current = { client, state: 'connected' }
renderer = await renderGate()
const output = renderedText(renderer)
@@ -179,9 +182,9 @@ describe('HostProtocolGate', () => {
expect(renderedText(renderer)).toContain('HostContent')
expect(probeMounts.count).toBe(1)
const client = {
const client = createRpcClientTestDouble({
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
} as unknown as RpcClient
})
await act(async () => {
hostClient.current = { client, state: 'connected' }
renderer?.update(gateElement())
@@ -220,7 +223,7 @@ describe('HostProtocolGate', () => {
})
it('keeps an already-validated host route mounted while reconnect status is pending', async () => {
const client = {
const client = createRpcClientTestDouble({
sendRequest: vi
.fn()
.mockResolvedValueOnce({
@@ -228,7 +231,7 @@ describe('HostProtocolGate', () => {
result: { protocolVersion: 5, minCompatibleMobileVersion: 0 }
})
.mockReturnValueOnce(new Promise(() => {}))
} as unknown as RpcClient
})
hostClient.current = { client, state: 'connected' }
renderer = await renderGate()
@@ -251,9 +254,9 @@ describe('HostProtocolGate', () => {
it('fails open when a connected host cannot answer the status probe', async () => {
hostClient.current = {
client: {
client: createRpcClientTestDouble({
sendRequest: vi.fn().mockResolvedValue({ ok: false, error: { message: 'unavailable' } })
} as unknown as RpcClient,
}),
state: 'connected'
}
renderer = await renderGate()
+25 -21
View File
@@ -1,7 +1,8 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createRpcClientTestDouble } from '../test/rpc-client-test-double'
import type { RpcResponse } from '../transport/types'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn().mockResolvedValue(null),
@@ -49,6 +50,10 @@ import { NewWorktreeModal } from './NewWorktreeModal'
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
function rpcSuccess(result: unknown): RpcResponse {
return { id: '1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
const repos = [
{
id: 'repo-1',
@@ -100,11 +105,11 @@ describe('NewWorktreeModal project targets', () => {
return Promise.reject(new Error('connection closed'))
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
return Promise.resolve(rpcSuccess({ hostPlatform: 'darwin' }))
}
return new Promise(() => {})
})
const client = { sendRequest } as unknown as RpcClient
const client = createRpcClientTestDouble({ sendRequest })
await act(async () => {
renderer = create(
@@ -118,9 +123,7 @@ describe('NewWorktreeModal project targets', () => {
)
await Promise.resolve()
})
await act(async () => {
await Promise.resolve()
})
await flushUpdates()
expect(sendRequest).toHaveBeenCalledWith('repo.list')
expect(pickerItems(renderer, 'Project')).toEqual([
@@ -143,17 +146,17 @@ describe('NewWorktreeModal project targets', () => {
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
const client = {
const client = createRpcClientTestDouble({
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: listedRepos } })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
return Promise.resolve(rpcSuccess({ hostPlatform: 'darwin' }))
}
return new Promise(() => {})
})
} as unknown as RpcClient
})
await act(async () => {
renderer = create(
@@ -167,6 +170,7 @@ describe('NewWorktreeModal project targets', () => {
)
await Promise.resolve()
})
await flushUpdates()
expect(pickerItems(renderer, 'Project')).toEqual([
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
@@ -223,14 +227,14 @@ describe('NewWorktreeModal project targets', () => {
return Promise.resolve({ ok: true, result: {} })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'linux' } })
return Promise.resolve(rpcSuccess({ hostPlatform: 'linux' }))
}
if (method === 'repo.hooks') {
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
}
return Promise.resolve({ ok: true, result: {} })
})
const client = { sendRequest } as unknown as RpcClient
const client = createRpcClientTestDouble({ sendRequest })
await act(async () => {
renderer = create(
@@ -279,14 +283,14 @@ describe('NewWorktreeModal project targets', () => {
return Promise.resolve({ ok: true, result: {} })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
return Promise.resolve(rpcSuccess({ hostPlatform: 'darwin' }))
}
if (method === 'repo.hooks') {
return Promise.resolve({ ok: true, result: { hooks: null, source: null } })
}
return Promise.resolve({ ok: true, result: {} })
})
const client = { sendRequest } as unknown as RpcClient
const client = createRpcClientTestDouble({ sendRequest })
await act(async () => {
renderer = create(
@@ -312,25 +316,25 @@ describe('NewWorktreeModal project targets', () => {
resolveOldList = resolve
})
const freshRepo = { ...repos[0]!, id: 'repo-fresh', displayName: 'fresh' }
const oldClient = {
const oldClient = createRpcClientTestDouble({
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return oldList
}
return new Promise(() => {})
})
} as unknown as RpcClient
const freshClient = {
})
const freshClient = createRpcClientTestDouble({
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: [freshRepo] } })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
return Promise.resolve(rpcSuccess({ hostPlatform: 'darwin' }))
}
return new Promise(() => {})
})
} as unknown as RpcClient
})
const modalProps = {
visible: true,
hostId: 'host-1',
@@ -357,7 +361,7 @@ describe('NewWorktreeModal project targets', () => {
it('remounts before the reopened session renders, so no stale instance sees visible', async () => {
const sendRequest = vi.fn().mockImplementation(() => new Promise(() => {}))
const client = { sendRequest } as unknown as RpcClient
const client = createRpcClientTestDouble({ sendRequest })
const modalProps = {
client,
hostId: 'host-1',
@@ -378,9 +382,9 @@ describe('NewWorktreeModal project targets', () => {
})
it('starts with fresh form state after closing and reopening', async () => {
const client = {
const client = createRpcClientTestDouble({
sendRequest: vi.fn().mockImplementation(() => new Promise(() => {}))
} as unknown as RpcClient
})
const modalProps = {
client,
hostId: 'host-1',
@@ -1,6 +1,7 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createRpcClientTestDouble } from '../test/rpc-client-test-double'
import type { RpcClient } from '../transport/rpc-client'
const probe = vi.hoisted(() => ({
@@ -58,7 +59,7 @@ describe('useCodexResetCreditCapability', () => {
return cancel
}
)
const client = { sendRequest: vi.fn() } as unknown as RpcClient
const client = createRpcClientTestDouble({ sendRequest: vi.fn() })
let renderer: ReactTestRenderer | null = null
function Harness() {
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createRpcClientTestDouble } from '../test/rpc-client-test-double'
import type { HostCatalogEntry } from '../transport/types'
import {
reconcileMobileHomeHostStates,
@@ -22,7 +22,7 @@ function catalogHost(
}
function liveClient(hostId: string, state: MobileHomeClientEntry['state']): MobileHomeClientEntry {
return { hostId, state, client: {} as RpcClient }
return { hostId, state, client: createRpcClientTestDouble() }
}
describe('mobile home connection state', () => {
@@ -7,8 +7,8 @@ import {
subscribeToDesktopNotifications
} from './mobile-notifications'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { createRpcClientTestDouble } from '../test/rpc-client-test-double'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
vi.mock('expo-notifications', () => ({
@@ -93,11 +93,11 @@ describe('subscribeToDesktopNotifications', () => {
it('drops the local stream when disposed before the desktop returns ready', () => {
const unsubscribeStream = vi.fn()
const client = {
const client = createRpcClientTestDouble({
subscribe: vi.fn(() => unsubscribeStream),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
})
const unsubscribe = subscribeToDesktopNotifications(client, 'host-1')
unsubscribe()
@@ -117,14 +117,14 @@ describe('subscribeToDesktopNotifications', () => {
.mockResolvedValueOnce('scheduled-2')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
const client = createRpcClientTestDouble({
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
})
subscribeToDesktopNotifications(client, 'host-1')
onEvent?.({
@@ -173,14 +173,14 @@ describe('subscribeToDesktopNotifications', () => {
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
let onEvent: ((data: unknown) => void) | null = null
const client = {
const client = createRpcClientTestDouble({
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
})
subscribeToDesktopNotifications(client, 'host-concurrent')
onEvent?.({
@@ -217,14 +217,14 @@ describe('subscribeToDesktopNotifications', () => {
)
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
const client = createRpcClientTestDouble({
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
})
subscribeToDesktopNotifications(client, 'host-dismiss-race')
onEvent?.({
@@ -257,14 +257,14 @@ describe('subscribeToDesktopNotifications', () => {
.mockResolvedValueOnce('scheduled-2')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
const client = createRpcClientTestDouble({
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
})
subscribeToDesktopNotifications(client, 'host-dismiss-failed-replacement')
onEvent?.({
@@ -304,14 +304,14 @@ describe('subscribeToDesktopNotifications', () => {
it('treats unknown dismiss events as no-ops', async () => {
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
const client = createRpcClientTestDouble({
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
})
subscribeToDesktopNotifications(client, 'host-unknown')
onEvent?.({ type: 'dismiss', notificationId: 'agent:missing' })
@@ -335,14 +335,14 @@ describe('subscribeToDesktopNotifications', () => {
.mockResolvedValueOnce('scheduled-new')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
const client = createRpcClientTestDouble({
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
})
subscribeToDesktopNotifications(client, 'host-1')
onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:old' })
@@ -386,7 +386,7 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
function makeClient() {
let onData: ((data: unknown) => void) | null = null
const sentRequests: { method: string; params: unknown }[] = []
const client = {
const client = createRpcClientTestDouble({
subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
@@ -399,12 +399,12 @@ describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined
}) as never
)
}
})
// Why: onData is captured live via a getter (not destructured) because the
// subscribe mock assigns it asynchronously as a side effect of
// subscribeToDesktopNotifications calling client.subscribe.
return {
client: client as unknown as RpcClient,
client,
get onData() {
return onData
},
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { createRpcClientTestDouble } from '../test/rpc-client-test-double'
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
@@ -18,7 +19,7 @@ type StatusOutcome =
function statusClient(outcomes: StatusOutcome[]): RpcClient {
let call = 0
return {
return createRpcClientTestDouble({
sendRequest: async () => {
const outcome = outcomes[Math.min(call, outcomes.length - 1)]!
call += 1
@@ -38,7 +39,7 @@ function statusClient(outcomes: StatusOutcome[]): RpcClient {
_meta: { runtimeId: 'r' }
}
}
} as unknown as RpcClient
})
}
describe('readNewWorktreeRuntimeCapabilities', () => {
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { createRpcClientTestDouble } from '../test/rpc-client-test-double'
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
@@ -70,7 +71,7 @@ function scriptedClient(
lastInboundAt?: number | (() => number)
): RpcClient {
let call = 0
return {
return createRpcClientTestDouble({
getState: () => connection?.getState() ?? 'connected',
getLastInboundAt: () =>
(typeof lastInboundAt === 'function' ? lastInboundAt() : lastInboundAt) ?? null,
@@ -107,7 +108,7 @@ function scriptedClient(
_meta: { runtimeId: 'r' }
}
}
} as unknown as RpcClient
})
}
describe('createWorktreeWithNameRetry', () => {
+19
View File
@@ -0,0 +1,19 @@
import type { RpcClient } from '../transport/rpc-client'
export function createRpcClientTestDouble(): RpcClient
export function createRpcClientTestDouble<T extends Partial<RpcClient>>(overrides: T): RpcClient & T
export function createRpcClientTestDouble(overrides: Partial<RpcClient> = {}): RpcClient {
const client: RpcClient = {
sendRequest: () => Promise.reject(new Error('Unconfigured RPC test request')),
subscribe: () => () => {},
updateTerminalSubscriptionViewport: () => {},
getState: () => 'connected',
getReconnectAttempt: () => 0,
getLastConnectedAt: () => null,
getLastInboundAt: () => null,
onStateChange: () => () => {},
notifyForeground: () => {},
close: () => {}
}
return Object.assign(client, overrides)
}