diff --git a/shared/chat/conversation/info-panel/add-to-channel.test.tsx b/shared/chat/conversation/info-panel/add-to-channel.test.tsx
index d87fa9553599..8a9a93c37d22 100644
--- a/shared/chat/conversation/info-panel/add-to-channel.test.tsx
+++ b/shared/chat/conversation/info-panel/add-to-channel.test.tsx
@@ -2,38 +2,57 @@
///
import * as T from '@/constants/types'
import {addMembersToChannel} from './add-to-channel'
+import {installFakeEngine, type FakeEngine} from '@/test/fake-engine'
const conversationIDKey = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4]))
const convID = T.Chat.keyToConversationID(conversationIDKey)
+let engine: FakeEngine
+let onBulkAdd: () => void = () => {}
+let onRefresh: () => void = () => {}
+
+beforeEach(() => {
+ onBulkAdd = () => {}
+ onRefresh = () => {}
+ engine = installFakeEngine({
+ 'chat.1.local.bulkAddToConv': () => {
+ onBulkAdd()
+ },
+ 'chat.1.local.refreshParticipants': () => {
+ onRefresh()
+ },
+ })
+})
+
afterEach(() => {
+ engine.uninstall()
jest.restoreAllMocks()
})
test('adding members refreshes the conversation participants', async () => {
- jest.spyOn(T.RPCChat, 'localBulkAddToConvRpcPromise').mockResolvedValue(undefined)
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
-
await addMembersToChannel(conversationIDKey, ['testuser', 'testuser-mac'])
- expect(T.RPCChat.localBulkAddToConvRpcPromise).toHaveBeenCalledWith({
- convID,
- usernames: ['testuser', 'testuser-mac'],
- })
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).toHaveBeenCalledWith({convID})
+ expect(engine.calls('chat.1.local.bulkAddToConv')).toEqual([
+ {method: 'chat.1.local.bulkAddToConv', params: {convID, usernames: ['testuser', 'testuser-mac']}},
+ ])
+ expect(engine.calls('chat.1.local.refreshParticipants')).toEqual([
+ {method: 'chat.1.local.refreshParticipants', params: {convID}},
+ ])
})
test('a failed add never claims the participants are fresh', async () => {
- jest.spyOn(T.RPCChat, 'localBulkAddToConvRpcPromise').mockRejectedValue(new Error('nope'))
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
+ onBulkAdd = () => {
+ throw new Error('nope')
+ }
await expect(addMembersToChannel(conversationIDKey, ['testuser'])).rejects.toThrow('nope')
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).not.toHaveBeenCalled()
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(0)
})
test('a failed refresh does not fail the add', async () => {
- jest.spyOn(T.RPCChat, 'localBulkAddToConvRpcPromise').mockResolvedValue(undefined)
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockRejectedValue(new Error('offline'))
+ onRefresh = () => {
+ throw new Error('offline')
+ }
await expect(addMembersToChannel(conversationIDKey, ['testuser'])).resolves.toBeUndefined()
})
diff --git a/shared/chat/conversation/messages/reset-user.test.tsx b/shared/chat/conversation/messages/reset-user.test.tsx
index 735d10d6ac7a..91f8481ea46f 100644
--- a/shared/chat/conversation/messages/reset-user.test.tsx
+++ b/shared/chat/conversation/messages/reset-user.test.tsx
@@ -2,33 +2,45 @@
///
import * as T from '@/constants/types'
import {addTeamMemberAfterReset} from './reset-user'
+import {installFakeEngine, type FakeEngine} from '@/test/fake-engine'
const conversationIDKey = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4]))
const convID = T.Chat.keyToConversationID(conversationIDKey)
+let engine: FakeEngine
+let onAdd: () => void = () => {}
+
+beforeEach(() => {
+ onAdd = () => {}
+ engine = installFakeEngine({
+ 'chat.1.local.addTeamMemberAfterReset': () => {
+ onAdd()
+ },
+ 'chat.1.local.refreshParticipants': () => {},
+ })
+})
+
afterEach(() => {
+ engine.uninstall()
jest.restoreAllMocks()
})
test('letting a reset user back in refreshes the conversation participants', async () => {
- jest.spyOn(T.RPCChat, 'localAddTeamMemberAfterResetRpcPromise').mockResolvedValue(undefined)
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
-
await addTeamMemberAfterReset(conversationIDKey, 'testuser')
- expect(T.RPCChat.localAddTeamMemberAfterResetRpcPromise).toHaveBeenCalledWith({
- convID,
- username: 'testuser',
- })
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).toHaveBeenCalledWith({convID})
+ expect(engine.calls('chat.1.local.addTeamMemberAfterReset')).toEqual([
+ {method: 'chat.1.local.addTeamMemberAfterReset', params: {convID, username: 'testuser'}},
+ ])
+ expect(engine.calls('chat.1.local.refreshParticipants')).toEqual([
+ {method: 'chat.1.local.refreshParticipants', params: {convID}},
+ ])
})
test('a failed re-add never claims the participants are fresh', async () => {
- jest
- .spyOn(T.RPCChat, 'localAddTeamMemberAfterResetRpcPromise')
- .mockRejectedValue(new Error('still reset'))
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
+ onAdd = () => {
+ throw new Error('still reset')
+ }
await expect(addTeamMemberAfterReset(conversationIDKey, 'testuser')).rejects.toThrow('still reset')
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).not.toHaveBeenCalled()
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(0)
})
diff --git a/shared/chat/conversation/status-actions.test.tsx b/shared/chat/conversation/status-actions.test.tsx
index 0fd50dd39171..7bc1ce6507aa 100644
--- a/shared/chat/conversation/status-actions.test.tsx
+++ b/shared/chat/conversation/status-actions.test.tsx
@@ -2,6 +2,7 @@
///
import * as T from '@/constants/types'
import {joinConversation} from './status-actions'
+import {installFakeEngine, type FakeEngine} from '@/test/fake-engine'
const conversationIDKey = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4]))
const convID = T.Chat.keyToConversationID(conversationIDKey)
@@ -12,29 +13,44 @@ const flushPromises = async () => {
}
}
+let engine: FakeEngine
+let onJoin: () => void = () => {}
+
+beforeEach(() => {
+ onJoin = () => {}
+ engine = installFakeEngine({
+ 'chat.1.local.joinConversationByIDLocal': () => {
+ onJoin()
+ return {} as never
+ },
+ 'chat.1.local.refreshParticipants': () => {},
+ })
+})
+
afterEach(() => {
+ engine.uninstall()
jest.restoreAllMocks()
})
test('joining a conversation refreshes its participants', async () => {
- jest.spyOn(T.RPCChat, 'localJoinConversationByIDLocalRpcPromise').mockResolvedValue({} as never)
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
-
joinConversation(conversationIDKey)
await flushPromises()
- expect(T.RPCChat.localJoinConversationByIDLocalRpcPromise).toHaveBeenCalledWith({convID})
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).toHaveBeenCalledWith({convID})
+ expect(engine.calls('chat.1.local.joinConversationByIDLocal')).toEqual([
+ {method: 'chat.1.local.joinConversationByIDLocal', params: {convID}},
+ ])
+ expect(engine.calls('chat.1.local.refreshParticipants')).toEqual([
+ {method: 'chat.1.local.refreshParticipants', params: {convID}},
+ ])
})
test('a failed join never claims the participants are fresh', async () => {
- jest
- .spyOn(T.RPCChat, 'localJoinConversationByIDLocalRpcPromise')
- .mockRejectedValue(new Error('cannot join'))
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
+ onJoin = () => {
+ throw new Error('cannot join')
+ }
joinConversation(conversationIDKey)
await flushPromises()
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).not.toHaveBeenCalled()
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(0)
})
diff --git a/shared/chat/conversation/team-hooks.test.tsx b/shared/chat/conversation/team-hooks.test.tsx
index 4c0cc9d186d7..377cd718e823 100644
--- a/shared/chat/conversation/team-hooks.test.tsx
+++ b/shared/chat/conversation/team-hooks.test.tsx
@@ -5,6 +5,7 @@ import type * as React from 'react'
import * as T from '@/constants/types'
import {resetAllStores} from '@/util/zustand'
import {ChatTeamProvider, useChatTeamMemberRole, useChatTeamMembers} from './team-hooks'
+import {installFakeEngine, type FakeEngine} from '@/test/fake-engine'
// The provider reads the conversation's team off the thread meta.
let mockThreadMeta = {teamID: '' as T.Teams.TeamID, teamType: 'big' as T.Chat.TeamType, teamname: ''}
@@ -30,18 +31,28 @@ const flushPromises = async () => {
}
}
-const mockGetMembers = (members: ReadonlyArray) =>
- jest.spyOn(T.RPCGen, 'teamsTeamGetMembersByIDRpcPromise').mockResolvedValue(members)
+let engine: FakeEngine
+
+const installMembers = (
+ members: ReadonlyArray,
+ extra: Parameters[0] = {}
+) => {
+ engine = installFakeEngine({'keybase.1.teams.teamGetMembersByID': () => members, ...extra})
+ return engine
+}
+
+const getMembersCalls = () => engine.callCount('keybase.1.teams.teamGetMembersByID')
afterEach(() => {
cleanup()
+ engine.uninstall()
jest.restoreAllMocks()
resetAllStores()
})
test('useChatTeamMembers serves cached members on remount so roles render without a refetch', async () => {
const teamID = makeTeamID(1)
- const rpc = mockGetMembers([memberDetails('testuser', T.RPCGen.TeamRole.owner)])
+ installMembers([memberDetails('testuser', T.RPCGen.TeamRole.owner)])
const first = renderHook(() => useChatTeamMembers(teamID))
expect(first.result.current.loading).toBe(true)
@@ -49,7 +60,7 @@ test('useChatTeamMembers serves cached members on remount so roles render withou
await flushPromises()
})
expect(first.result.current.members.get('testuser')?.type).toBe('owner')
- expect(rpc).toHaveBeenCalledTimes(1)
+ expect(getMembersCalls()).toBe(1)
first.unmount()
// Reopening the same team must have the roles on the very first render.
@@ -59,19 +70,19 @@ test('useChatTeamMembers serves cached members on remount so roles render withou
await act(async () => {
await flushPromises()
})
- expect(rpc).toHaveBeenCalledTimes(1)
+ expect(getMembersCalls()).toBe(1)
})
test('useChatTeamMemberRole resolves from cache on the first render under a remounted provider', async () => {
const teamID = makeTeamID(2)
mockThreadMeta = {teamID, teamType: 'big', teamname: 'keybase'}
- const annotatedTeamRPC = jest
- .spyOn(T.RPCGen, 'teamsGetAnnotatedTeamRpcPromise')
- .mockResolvedValue({} as T.RPCGen.AnnotatedTeam)
- const rpc = mockGetMembers([
- memberDetails('testuser', T.RPCGen.TeamRole.admin),
- memberDetails('testuser-mac', T.RPCGen.TeamRole.reader),
- ])
+ installMembers(
+ [
+ memberDetails('testuser', T.RPCGen.TeamRole.admin),
+ memberDetails('testuser-mac', T.RPCGen.TeamRole.reader),
+ ],
+ {'keybase.1.teams.getAnnotatedTeam': () => ({}) as T.RPCGen.AnnotatedTeam}
+ )
const wrapper = ({children}: {children: React.ReactNode}) => (
{children}
@@ -88,14 +99,14 @@ test('useChatTeamMemberRole resolves from cache on the first render under a remo
await act(async () => {
await flushPromises()
})
- expect(rpc).toHaveBeenCalledTimes(1)
- expect(annotatedTeamRPC).not.toHaveBeenCalled()
+ expect(getMembersCalls()).toBe(1)
+ expect(engine.callCount('keybase.1.teams.getAnnotatedTeam')).toBe(0)
})
test('a disabled shadow useChatTeamMembers does not clobber the provider cache', async () => {
const teamID = makeTeamID(3)
mockThreadMeta = {teamID, teamType: 'big', teamname: 'keybase'}
- mockGetMembers([memberDetails('testuser', T.RPCGen.TeamRole.owner)])
+ installMembers([memberDetails('testuser', T.RPCGen.TeamRole.owner)])
const wrapper = ({children}: {children: React.ReactNode}) => (
{children}
diff --git a/shared/chat/inbox/refresh-participants.test.tsx b/shared/chat/inbox/refresh-participants.test.tsx
index d8d170279ff4..24ff177f8ad3 100644
--- a/shared/chat/inbox/refresh-participants.test.tsx
+++ b/shared/chat/inbox/refresh-participants.test.tsx
@@ -13,6 +13,7 @@ import {
refreshConversationParticipants,
useRefreshParticipantsOnTeamMembershipChange,
} from './refresh-participants'
+import {installFakeEngine, type FakeEngine} from '@/test/fake-engine'
const convA = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4]))
const convB = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8]))
@@ -42,39 +43,50 @@ const teamChangedByID = (
type: 'keybase.1.NotifyTeam.teamChangedByID',
}) as never
+let engine: FakeEngine
+// the failing-conversation case swaps this out for one test
+let onRefresh: (params: {convID: T.RPCChat.ConversationID}) => void = () => {}
+
+const refreshedConvIDs = () =>
+ engine
+ .calls('chat.1.local.refreshParticipants')
+ .map(c => (c.params as {convID: T.RPCChat.ConversationID}).convID)
+
+beforeEach(() => {
+ onRefresh = () => {}
+ engine = installFakeEngine({
+ 'chat.1.local.refreshParticipants': params => {
+ onRefresh(params)
+ },
+ })
+})
+
afterEach(() => {
cleanup()
+ engine.uninstall()
jest.restoreAllMocks()
resetAllStores()
})
describe('refreshConversationParticipants', () => {
test('asks the service to recompute participants for each conversation', async () => {
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
-
await refreshConversationParticipants([convA, convB])
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).toHaveBeenCalledTimes(2)
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).toHaveBeenCalledWith({
- convID: T.Chat.keyToConversationID(convA),
- })
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).toHaveBeenCalledWith({
- convID: T.Chat.keyToConversationID(convB),
- })
+ expect(refreshedConvIDs()).toEqual([
+ T.Chat.keyToConversationID(convA),
+ T.Chat.keyToConversationID(convB),
+ ])
})
test('a conversation named twice is refreshed once', async () => {
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
-
await refreshConversationParticipants([convA, convA, convB, convA])
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).toHaveBeenCalledTimes(2)
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(2)
})
// these are not conversations, so they must be dropped before the attempt rather than
// failing their way through it
test('placeholder conversation ids are never sent to the service', async () => {
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
jest.spyOn(logger, 'info').mockImplementation(() => {})
await refreshConversationParticipants([
@@ -83,92 +95,79 @@ describe('refreshConversationParticipants', () => {
T.Chat.pendingErrorConversationIDKey,
])
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).not.toHaveBeenCalled()
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(0)
expect(logger.info).not.toHaveBeenCalled()
})
test('nothing to refresh resolves without an rpc', async () => {
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
-
await expect(refreshConversationParticipants([])).resolves.toBeUndefined()
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).not.toHaveBeenCalled()
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(0)
})
test('one conversation failing neither rejects nor skips the others', async () => {
- jest
- .spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise')
- .mockImplementation(async ({convID}) => {
- await Promise.resolve()
- if (T.Chat.conversationIDToKey(convID) === convA) {
- throw new Error('offline')
- }
- })
+ onRefresh = ({convID}) => {
+ if (T.Chat.conversationIDToKey(convID) === convA) {
+ throw new Error('offline')
+ }
+ }
await expect(refreshConversationParticipants([convA, convB])).resolves.toBeUndefined()
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).toHaveBeenCalledTimes(2)
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(2)
})
})
describe('useRefreshParticipantsOnTeamMembershipChange', () => {
test('a membership change in this team refreshes the conversation', () => {
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
renderHook(() => useRefreshParticipantsOnTeamMembershipChange(teamID, convA))
notifyEngineActionListeners(teamChangedByID())
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).toHaveBeenCalledWith({
- convID: T.Chat.keyToConversationID(convA),
- })
+ expect(refreshedConvIDs()).toEqual([T.Chat.keyToConversationID(convA)])
})
// teamChangedByID also fires for every message sent in the team; refreshing on those
// would be one participant rpc per message for as long as the list is open
test('a team change that did not touch membership is ignored', () => {
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
renderHook(() => useRefreshParticipantsOnTeamMembershipChange(teamID, convA))
notifyEngineActionListeners(teamChangedByID({changes: {...noChanges, misc: true}}))
notifyEngineActionListeners(teamChangedByID({changes: {...noChanges, keyRotated: true}}))
notifyEngineActionListeners(teamChangedByID({changes: {...noChanges, renamed: true}}))
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).not.toHaveBeenCalled()
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(0)
})
test('a membership change in another team is ignored', () => {
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
renderHook(() => useRefreshParticipantsOnTeamMembershipChange(teamID, convA))
notifyEngineActionListeners(teamChangedByID({teamID: otherTeamID}))
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).not.toHaveBeenCalled()
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(0)
})
test('a disabled watcher does not refresh', () => {
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
renderHook(() => useRefreshParticipantsOnTeamMembershipChange(teamID, convA, false))
notifyEngineActionListeners(teamChangedByID())
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).not.toHaveBeenCalled()
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(0)
})
test('an adhoc conversation has no team to watch', () => {
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
renderHook(() => useRefreshParticipantsOnTeamMembershipChange(T.Teams.noTeamID, convA))
notifyEngineActionListeners(teamChangedByID({teamID: T.Teams.noTeamID}))
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).not.toHaveBeenCalled()
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(0)
})
test('an unmounted list stops refreshing', () => {
- jest.spyOn(T.RPCChat, 'localRefreshParticipantsRpcPromise').mockResolvedValue(undefined)
const {unmount} = renderHook(() => useRefreshParticipantsOnTeamMembershipChange(teamID, convA))
unmount()
notifyEngineActionListeners(teamChangedByID())
- expect(T.RPCChat.localRefreshParticipantsRpcPromise).not.toHaveBeenCalled()
+ expect(engine.callCount('chat.1.local.refreshParticipants')).toBe(0)
})
})
diff --git a/shared/engine/require.tsx b/shared/engine/require.tsx
index bf1fca36d405..8e65b20b31ad 100644
--- a/shared/engine/require.tsx
+++ b/shared/engine/require.tsx
@@ -1,14 +1,26 @@
// Helper to get engine and break require loops
import type {Engine} from '.'
-let _engine: Engine | undefined
-export function initEngine(e: Engine) {
+// The engine as seen through this seam: only the members the generated RPC
+// helpers, the listener and sessions actually reach for. Narrow on purpose, so a
+// stand-in (test/fake-engine) can answer here without impersonating the whole
+// transport.
+export type EngineSeam = Pick<
+ Engine,
+ '_rpcOutgoing' | 'cancelSession' | 'createSession' | 'dispatchWaitingAction'
+>
+
+let _engine: EngineSeam | undefined
+export function initEngine(e: EngineSeam) {
_engine = e
}
+export function resetEngine() {
+ _engine = undefined
+}
export function hasEngine(): boolean {
return !!_engine
}
-export function getEngine(): Engine {
+export function getEngine(): EngineSeam {
if (!_engine) {
throw new Error('No engine?')
}
diff --git a/shared/teams/team/use-loaded-team.test.tsx b/shared/teams/team/use-loaded-team.test.tsx
index 6964cec60d39..2b48a56b72f2 100644
--- a/shared/teams/team/use-loaded-team.test.tsx
+++ b/shared/teams/team/use-loaded-team.test.tsx
@@ -1,6 +1,6 @@
/** @jest-environment jsdom */
///
-import {afterEach, beforeEach, expect, jest, test} from '@jest/globals'
+import {afterEach, beforeEach, expect, test} from '@jest/globals'
import {act, cleanup, render} from '@testing-library/react'
import * as T from '@/constants/types'
import {useCurrentUserState} from '@/stores/current-user'
@@ -10,6 +10,7 @@ import {LoadedTeamsListProvider} from '../use-teams-list'
import {LoadedTeamChannelsProvider, useLoadedTeamChannels} from '../common/use-loaded-team-channels'
import {LoadedTeamProvider, useLoadedTeam} from './use-loaded-team'
import {flush} from '@/test/flush'
+import {installFakeEngine, type FakeEngine} from '@/test/fake-engine'
const teamID = 'tid1' as T.Teams.TeamID
@@ -24,51 +25,40 @@ const annotated = {
transitiveSubteamsUnverified: {entries: []},
} as unknown as T.RPCGen.AnnotatedTeam
-let annotatedCalls = 0
-jest.spyOn(T.RPCGen, 'teamsGetAnnotatedTeamRpcPromise').mockImplementation(async () => {
- annotatedCalls++
- await Promise.resolve()
- return annotated
-})
-jest.spyOn(T.RPCChat, 'localGetTLFConversationsLocalRpcPromise').mockImplementation(async () => {
- await Promise.resolve()
- return {convs: [], offline: false} as never
-})
-let listCalls = 0
-jest.spyOn(T.RPCGen, 'teamsTeamListUnverifiedRpcPromise').mockImplementation(async () => {
- listCalls++
- await Promise.resolve()
- return {
- teams: [
- {
- fqName: 'testteam',
- isOpenTeam: false,
- memberCount: 1,
- role: T.RPCGen.TeamRole.owner,
- teamID,
- username: 'testuser',
- },
- ],
- } as never
-})
-jest.spyOn(T.RPCGen, 'teamsGetTeamRoleMapRpcPromise').mockImplementation(async () => {
- await Promise.resolve()
- return {teams: {}, version: 1} as never
-})
-
+let engine: FakeEngine
let bodyRenders = 0
-// these counters and the module-scope resource caches both outlive a single
-// test, so without a reset each test sees whatever the previous one left behind
+const annotatedCalls = () => engine.callCount('keybase.1.teams.getAnnotatedTeam')
+const listCalls = () => engine.callCount('keybase.1.teams.teamListUnverified')
+
+// the module-scope resource caches outlive a single test, so without a fresh
+// engine and a store reset each test sees whatever the previous one left behind
beforeEach(() => {
- annotatedCalls = 0
- listCalls = 0
bodyRenders = 0
+ engine = installFakeEngine({
+ 'chat.1.local.getTLFConversationsLocal': () => ({convs: [], offline: false}),
+ 'keybase.1.teams.getAnnotatedTeam': () => annotated,
+ 'keybase.1.teams.getTeamRoleMap': () => ({teams: {}, version: 1}),
+ 'keybase.1.teams.teamListUnverified': () =>
+ ({
+ teams: [
+ {
+ fqName: 'testteam',
+ isOpenTeam: false,
+ memberCount: 1,
+ role: T.RPCGen.TeamRole.owner,
+ teamID,
+ username: 'testuser',
+ },
+ ],
+ }) as unknown as T.RPCGen.AnnotatedTeamList,
+ })
resetAllStores()
})
afterEach(() => {
cleanup()
+ engine.uninstall()
})
const Body = () => {
@@ -107,9 +97,9 @@ test('team screen loads getAnnotatedTeam once', async () => {
)
await flush()
- expect(listCalls).toBe(1)
+ expect(listCalls()).toBe(1)
expect(bodyRenders).toBeLessThan(10)
- expect(annotatedCalls).toBe(1)
+ expect(annotatedCalls()).toBe(1)
})
// These caches live at module scope, so they outlive the signed-in session. If
@@ -127,7 +117,7 @@ test('signing out drops the shared team caches', async () => {
)
await flush()
- const callsWhileSignedIn = annotatedCalls
+ const callsWhileSignedIn = annotatedCalls()
expect(callsWhileSignedIn).toBeGreaterThan(0)
first.unmount()
@@ -147,5 +137,5 @@ test('signing out drops the shared team caches', async () => {
)
await flush()
- expect(annotatedCalls).toBe(callsWhileSignedIn + 1)
+ expect(annotatedCalls()).toBe(callsWhileSignedIn + 1)
})
diff --git a/shared/test/fake-engine.test.tsx b/shared/test/fake-engine.test.tsx
new file mode 100644
index 000000000000..a8a5ccc1b8a4
--- /dev/null
+++ b/shared/test/fake-engine.test.tsx
@@ -0,0 +1,177 @@
+///
+import * as T from '@/constants/types'
+import {StatusCode} from '@/constants/rpc/rpc-gen'
+import {installFakeEngine, type FakeEngine} from './fake-engine'
+import {resetAllStores} from '@/util/zustand'
+import {useWaitingState} from '@/stores/waiting'
+import {RPCError} from '@/util/errors'
+
+// RPCError deliberately does not extend Error, so a rejection with one has to be
+// built in a single place the lint rule can be told about.
+const rejectWithRPCError = (desc: string): never => {
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
+ throw new RPCError(desc, StatusCode.scgeneric)
+}
+
+let engine: FakeEngine | undefined
+
+afterEach(() => {
+ engine?.uninstall()
+ engine = undefined
+ resetAllStores()
+})
+
+test('answers a generated RpcPromise by wire method and counts the call', async () => {
+ engine = installFakeEngine({
+ 'keybase.1.teams.getTeamRoleMap': () => ({teams: {}, version: 7}),
+ })
+
+ const res = await T.RPCGen.teamsGetTeamRoleMapRpcPromise()
+
+ expect(res.version).toBe(7)
+ expect(engine.callCount('keybase.1.teams.getTeamRoleMap')).toBe(1)
+ expect(engine.unhandledMethods()).toEqual([])
+})
+
+test('records the params the caller sent', async () => {
+ engine = installFakeEngine({
+ 'keybase.1.teams.getAnnotatedTeam': () => ({name: 'testteam'}) as never,
+ })
+
+ await T.RPCGen.teamsGetAnnotatedTeamRpcPromise({teamID: 'tid1' as T.Teams.TeamID})
+
+ expect(engine.calls('keybase.1.teams.getAnnotatedTeam')).toEqual([
+ {method: 'keybase.1.teams.getAnnotatedTeam', params: {teamID: 'tid1'}},
+ ])
+})
+
+test('a rejecting handler rejects the promise', async () => {
+ engine = installFakeEngine({
+ 'keybase.1.teams.getTeamRoleMap': () => rejectWithRPCError('nope'),
+ })
+
+ await expect(T.RPCGen.teamsGetTeamRoleMapRpcPromise()).rejects.toMatchObject({desc: 'nope'})
+})
+
+test('an unstubbed method fails loudly instead of hanging', async () => {
+ // this test asserts on the failure itself, so it opts out of fail-on-console
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {})
+ engine = installFakeEngine({})
+
+ await expect(T.RPCGen.teamsGetTeamRoleMapRpcPromise()).rejects.toMatchObject({
+ code: StatusCode.scgeneric,
+ })
+ expect(engine.unhandledMethods()).toEqual(['keybase.1.teams.getTeamRoleMap'])
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining('keybase.1.teams.getTeamRoleMap'))
+ spy.mockRestore()
+})
+
+test('a waitingKey is held for the life of the call', async () => {
+ let release = (_: unknown) => {}
+ engine = installFakeEngine({
+ 'keybase.1.teams.getTeamRoleMap': () => new Promise(resolve => (release = resolve)) as never,
+ })
+
+ const p = T.RPCGen.teamsGetTeamRoleMapRpcPromise(undefined, 'rolemap')
+ expect(useWaitingState.getState().counts.get('rolemap')).toBe(1)
+
+ // handlers run a microtask after the call, so let this one install its resolver
+ await Promise.resolve()
+ release({teams: {}, version: 1})
+ await p
+ expect(useWaitingState.getState().counts.get('rolemap')).toBeUndefined()
+})
+
+test('a failing call leaves the error on the waiting key', async () => {
+ engine = installFakeEngine({
+ 'keybase.1.teams.getTeamRoleMap': () => rejectWithRPCError('boom'),
+ })
+
+ await expect(T.RPCGen.teamsGetTeamRoleMapRpcPromise(undefined, 'rolemap')).rejects.toBeTruthy()
+ expect(useWaitingState.getState().counts.get('rolemap')).toBeUndefined()
+ expect(useWaitingState.getState().errors.get('rolemap')?.desc).toBe('boom')
+})
+
+test('streaming handlers reach the callers incomingCallMap before the call settles', async () => {
+ const hits: Array = []
+ engine = installFakeEngine({
+ 'chat.1.local.searchInbox': async (_params, ctx) => {
+ ctx.incoming('chat.1.chatUi.chatSearchInboxHit', {searchHit: {query: 'a'}})
+ ctx.incoming('chat.1.chatUi.chatSearchInboxHit', {searchHit: {query: 'b'}})
+ // the listener defers each incoming call by a macrotask, so let them land
+ await new Promise(resolve => setTimeout(resolve, 0))
+ return {} as never
+ },
+ })
+
+ await T.RPCChat.localSearchInboxRpcListener({
+ incomingCallMap: {
+ 'chat.1.chatUi.chatSearchInboxHit': (p: {searchHit?: {query?: string}}) => {
+ hits.push(p.searchHit?.query ?? '')
+ },
+ } as never,
+ params: {} as never,
+ })
+
+ expect(hits).toEqual(['a', 'b'])
+})
+
+test('cancelling a session rejects the caller rather than stranding it', async () => {
+ let cancel = () => {}
+ engine = installFakeEngine({
+ 'chat.1.local.searchInbox': () => new Promise(() => {}) as never,
+ })
+
+ const p = T.RPCChat.localSearchInboxRpcListener({
+ incomingCallMap: {} as never,
+ onSessionCreated: c => {
+ cancel = c
+ },
+ params: {} as never,
+ })
+ cancel()
+
+ await expect(p).rejects.toMatchObject({code: StatusCode.sccanceled})
+})
+
+test('setHandlers swaps the answer for a later call', async () => {
+ engine = installFakeEngine({
+ 'keybase.1.teams.getTeamRoleMap': () => ({teams: {}, version: 1}),
+ })
+ expect((await T.RPCGen.teamsGetTeamRoleMapRpcPromise()).version).toBe(1)
+
+ engine.setHandlers({'keybase.1.teams.getTeamRoleMap': () => ({teams: {}, version: 2})})
+ expect((await T.RPCGen.teamsGetTeamRoleMapRpcPromise()).version).toBe(2)
+ expect(engine.callCount('keybase.1.teams.getTeamRoleMap')).toBe(2)
+})
+
+// Session.start puts the sessionID in the outgoing param, so a stub standing in
+// for the service sees it - while calls() keeps the raw params for assertions.
+test('the stub sees the sessionID the real session would have injected', async () => {
+ let seen: unknown
+ engine = installFakeEngine({
+ 'keybase.1.teams.getTeamRoleMap': params => {
+ seen = params
+ return {teams: {}, version: 1}
+ },
+ })
+
+ await T.RPCGen.teamsGetTeamRoleMapRpcPromise()
+
+ expect(seen).toEqual({sessionID: expect.any(Number)})
+ expect(engine.calls('keybase.1.teams.getTeamRoleMap')[0]?.params).toBeUndefined()
+})
+
+// A file that installs twice, or one that had a real engine, must not be left
+// with an empty seam - the next getEngine() would throw.
+test('uninstall restores the adapter that was installed before it', async () => {
+ const first = installFakeEngine({'keybase.1.teams.getTeamRoleMap': () => ({teams: {}, version: 1})})
+ const second = installFakeEngine({'keybase.1.teams.getTeamRoleMap': () => ({teams: {}, version: 2})})
+ expect((await T.RPCGen.teamsGetTeamRoleMapRpcPromise()).version).toBe(2)
+
+ second.uninstall()
+ expect((await T.RPCGen.teamsGetTeamRoleMapRpcPromise()).version).toBe(1)
+ expect(first.callCount('keybase.1.teams.getTeamRoleMap')).toBe(1)
+
+ engine = first
+})
diff --git a/shared/test/fake-engine.tsx b/shared/test/fake-engine.tsx
new file mode 100644
index 000000000000..9f60790f7c3b
--- /dev/null
+++ b/shared/test/fake-engine.tsx
@@ -0,0 +1,262 @@
+// A second adapter behind the engine seam, for tests.
+//
+// Every generated *RpcPromise and every engine listener funnels through
+// getEngine()._rpcOutgoing, so answering there lets a test say what the SERVICE
+// returns for a wire method instead of spying on whichever generated symbol the
+// implementation currently happens to call.
+import {RPCError} from '@/util/errors'
+import {StatusCode} from '@/constants/rpc/rpc-gen'
+import {
+ getEngineListener,
+ hasEngine,
+ initEngine,
+ initEngineListener,
+ getEngine,
+ resetEngine,
+ type EngineSeam,
+} from '@/engine/require'
+import engineListener from '@/engine/listener'
+import {useWaitingState} from '@/stores/waiting'
+import type * as RPCChatGen from '@/constants/rpc/rpc-chat-gen'
+import type * as RPCGen from '@/constants/rpc/rpc-gen'
+import type * as RPCGregorGen from '@/constants/rpc/rpc-gregor-gen'
+import type * as RPCStellarGen from '@/constants/rpc/rpc-stellar-gen'
+import type {WaitingKey} from '@/engine/types'
+
+type AllMessageTypes = RPCGen.MessageTypes &
+ RPCChatGen.MessageTypes &
+ RPCGregorGen.MessageTypes &
+ RPCStellarGen.MessageTypes
+
+export type WireMethod = keyof AllMessageTypes
+
+export type FakeRpcResponse = {
+ error?: (err: unknown) => void
+ result?: (...args: Array) => void
+}
+
+export type FakeRpcContext = {
+ method: string
+ sessionID: number
+ /**
+ * Fire one of the call's incoming (streaming/incremental) callbacks, the way
+ * the service does mid-flight. Only meaningful for methods the caller reached
+ * through an incomingCallMap.
+ *
+ * The real listener defers each incoming handler by a macrotask, so a stub
+ * that emits and then returns settles the outer promise first. Await a
+ * macrotask before returning if the test needs them in service order.
+ */
+ incoming: (method: string, params: unknown, response?: FakeRpcResponse) => void
+ /** True once cancelSession ran; a streaming handler should stop emitting. */
+ isCancelled: () => boolean
+}
+
+export type FakeEngineHandlers = {
+ [M in WireMethod]?: (
+ params: AllMessageTypes[M]['inParam'],
+ ctx: FakeRpcContext
+ ) => AllMessageTypes[M]['outParam'] | Promise
+}
+
+type UntypedHandler = (params: unknown, ctx: FakeRpcContext) => unknown
+type IncomingHandler = (params: unknown, response: FakeRpcResponse) => void
+
+export type FakeEngine = {
+ /** How many times this wire method was called since install (or the last reset). */
+ callCount: (method: WireMethod) => number
+ /** Every recorded call, oldest first; pass a method to filter. */
+ calls: (method?: WireMethod) => ReadonlyArray<{method: string; params: unknown}>
+ /** Methods a test asked for but never stubbed. Each also failed loudly when it happened. */
+ unhandledMethods: () => ReadonlyArray
+ /** Add to or replace handlers mid-test, e.g. to make a reload return new data. */
+ setHandlers: (handlers: FakeEngineHandlers) => void
+ resetCalls: () => void
+ uninstall: () => void
+}
+
+const noopResponse: FakeRpcResponse = {error: () => {}, result: () => {}}
+
+export const installFakeEngine = (initialHandlers: FakeEngineHandlers = {}): FakeEngine => {
+ const previousEngine = hasEngine() ? getEngine() : undefined
+ const previousListener: unknown = getEngineListener()
+ let handlers = {...initialHandlers} as {[key: string]: UntypedHandler | undefined}
+ const recorded: Array<{method: string; params: unknown}> = []
+ const unhandled: Array = []
+ type FakeSession = {
+ cancelled: boolean
+ done: boolean
+ finish: (error?: RPCError, skipWaitingRelease?: boolean) => void
+ // seqids the caller still owes the service a response for, mirroring
+ // Session._seqIDsAwaitingResponse
+ owed: number
+ }
+ const sessions = new Map()
+ let nextSessionID = 1
+
+ // The real engine throttles waiting changes over 500ms. Applying them straight
+ // through keeps a test's assertion about a waiting key readable without having
+ // to drive timers.
+ const dispatchWaitingAction = (key: WaitingKey, waiting: boolean, error?: RPCError) => {
+ useWaitingState.getState().dispatch.batch([{error, increment: waiting, key}])
+ }
+
+ const seam: EngineSeam = {
+ _rpcOutgoing: p => {
+ const {method, params, callback, incomingCallMap, customResponseIncomingCallMap, waitingKey} = p
+ const sessionID = nextSessionID++
+ recorded.push({method, params})
+ const session: FakeSession = {cancelled: false, done: false, finish: () => {}, owed: 0}
+ sessions.set(sessionID, session)
+
+ const setWaiting = (waiting: boolean, error?: RPCError) => {
+ if (waitingKey) {
+ dispatchWaitingAction(waitingKey, waiting, error)
+ }
+ }
+
+ const finish = (error: RPCError | undefined, result?: unknown, skipWaitingRelease = false) => {
+ if (session.done) {
+ return
+ }
+ session.done = true
+ sessions.delete(sessionID)
+ if (!skipWaitingRelease) {
+ setWaiting(false, error)
+ }
+ callback(error, result)
+ }
+ session.finish = (error, skipWaitingRelease) => {
+ finish(error, undefined, skipWaitingRelease)
+ }
+
+ const plainMap = incomingCallMap as {[key: string]: IncomingHandler | undefined} | undefined
+ const customMap = customResponseIncomingCallMap as
+ | {[key: string]: IncomingHandler | undefined}
+ | undefined
+
+ const ctx: FakeRpcContext = {
+ incoming: (incomingMethod, incomingParams, response) => {
+ if (session.done) {
+ return
+ }
+ const handler = plainMap?.[incomingMethod] ?? customMap?.[incomingMethod]
+ if (!handler) {
+ console.error(
+ `fake engine: "${method}" emitted incoming call "${incomingMethod}" but the caller registered no handler for it`
+ )
+ return
+ }
+ // Mirrors Session.incomingCall: the service talking to us means we are
+ // no longer waiting on it, and we are again once we have replied.
+ setWaiting(false)
+ session.owed++
+ const responded = () => {
+ session.owed--
+ setWaiting(true)
+ }
+ const wrapped: FakeRpcResponse = {
+ error: (...args: Array) => {
+ ;(response ?? noopResponse).error?.(args[0])
+ responded()
+ },
+ result: (...args: Array) => {
+ ;(response ?? noopResponse).result?.(...args)
+ responded()
+ },
+ }
+ handler(incomingParams, wrapped)
+ },
+ isCancelled: () => session.cancelled,
+ method,
+ sessionID,
+ }
+
+ setWaiting(true)
+
+ const handler = handlers[method]
+ if (!handler) {
+ const message = `fake engine: no handler installed for RPC "${method}" - add it to installFakeEngine({...})`
+ unhandled.push(method)
+ // console.error fails the test through test/fail-on-console even when the
+ // caller swallows the rejection, so an unstubbed RPC can never look like a
+ // load that simply never finished.
+ console.error(message)
+ Promise.resolve()
+ .then(() => finish(new RPCError(message, StatusCode.scgeneric)))
+ .catch(() => {})
+ return sessionID
+ }
+
+ Promise.resolve()
+ // Session.start puts the sessionID in the outgoing param, so the stub -
+ // which stands in for the service - sees it too. `calls()` keeps the raw
+ // params the caller passed, which is what assertions want.
+ .then(() => handler({...(params ?? {}), sessionID}, ctx))
+ .then(
+ result => {
+ if (!session.cancelled) {
+ finish(undefined, result)
+ }
+ },
+ (error: unknown) => {
+ if (!session.cancelled) {
+ finish(error as RPCError)
+ }
+ }
+ )
+ .catch(() => {})
+
+ return sessionID
+ },
+ cancelSession: sessionID => {
+ const session = sessions.get(sessionID)
+ if (!session || session.done) {
+ return
+ }
+ session.cancelled = true
+ // Same rejection the real Session.cancel hands back, so a caller that
+ // branches on sccanceled - or just awaits - is not left hanging. The
+ // waiting release is skipped while the caller still owes the service a
+ // response: that path already released it, and the waiting store does not
+ // clamp, so releasing twice drives the count negative.
+ session.finish(
+ new RPCError('Received RPC cancel for session', StatusCode.sccanceled),
+ session.owed !== 0
+ )
+ },
+ createSession: () => {
+ throw new Error('fake engine: createSession is not supported')
+ },
+ dispatchWaitingAction,
+ }
+
+ initEngine(seam)
+ // The real listener, so *RpcListener calls take the production path down to
+ // _rpcOutgoing instead of needing their own stand-in.
+ initEngineListener(engineListener)
+
+ return {
+ callCount: method => recorded.reduce((n, c) => (c.method === method ? n + 1 : n), 0),
+ calls: method => (method ? recorded.filter(c => c.method === method) : [...recorded]),
+ resetCalls: () => {
+ recorded.length = 0
+ unhandled.length = 0
+ },
+ setHandlers: next => {
+ handlers = {...handlers, ...(next as {[key: string]: UntypedHandler | undefined})}
+ },
+ uninstall: () => {
+ // restore, not just clear: a file may install twice, or may have had a
+ // real engine before, and leaving the seam empty makes the next
+ // getEngine() throw instead of reaching whatever was there
+ if (previousEngine) {
+ initEngine(previousEngine)
+ } else {
+ resetEngine()
+ }
+ initEngineListener(previousListener)
+ },
+ unhandledMethods: () => [...unhandled],
+ }
+}