diff --git a/mobile/src/components/HostProtocolGate.test.ts b/mobile/src/components/HostProtocolGate.test.ts index 44a2265ccb3..2b5cf9a18ec 100644 --- a/mobile/src/components/HostProtocolGate.test.ts +++ b/mobile/src/components/HostProtocolGate.test.ts @@ -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): 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() diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 0bbdb462d4c..c1c3c99fe6d 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -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', diff --git a/mobile/src/components/codex-reset-credit-capability.test.ts b/mobile/src/components/codex-reset-credit-capability.test.ts index 7afaa7d7b80..46c6bf33219 100644 --- a/mobile/src/components/codex-reset-credit-capability.test.ts +++ b/mobile/src/components/codex-reset-credit-capability.test.ts @@ -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() { diff --git a/mobile/src/home/mobile-home-connection-state.test.ts b/mobile/src/home/mobile-home-connection-state.test.ts index 7e625034a0a..b577963f857 100644 --- a/mobile/src/home/mobile-home-connection-state.test.ts +++ b/mobile/src/home/mobile-home-connection-state.test.ts @@ -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', () => { diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts index d85b1363005..d1ef0df8a2b 100644 --- a/mobile/src/notifications/mobile-notifications.test.ts +++ b/mobile/src/notifications/mobile-notifications.test.ts @@ -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 }, diff --git a/mobile/src/tasks/worktree-create-capability.test.ts b/mobile/src/tasks/worktree-create-capability.test.ts index cad108e29d7..40925dabbdc 100644 --- a/mobile/src/tasks/worktree-create-capability.test.ts +++ b/mobile/src/tasks/worktree-create-capability.test.ts @@ -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', () => { diff --git a/mobile/src/tasks/worktree-create-retry.test.ts b/mobile/src/tasks/worktree-create-retry.test.ts index b4463207ebe..c16bbfb609b 100644 --- a/mobile/src/tasks/worktree-create-retry.test.ts +++ b/mobile/src/tasks/worktree-create-retry.test.ts @@ -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', () => { diff --git a/mobile/src/test/rpc-client-test-double.ts b/mobile/src/test/rpc-client-test-double.ts new file mode 100644 index 00000000000..4507457e366 --- /dev/null +++ b/mobile/src/test/rpc-client-test-double.ts @@ -0,0 +1,19 @@ +import type { RpcClient } from '../transport/rpc-client' + +export function createRpcClientTestDouble(): RpcClient +export function createRpcClientTestDouble>(overrides: T): RpcClient & T +export function createRpcClientTestDouble(overrides: Partial = {}): 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) +}