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], + } +}