diff --git a/shared/chat/inbox/reselect-conversation.test.ts b/shared/chat/inbox/reselect-conversation.test.ts index aba95f2a25ed..c0daf574ea06 100644 --- a/shared/chat/inbox/reselect-conversation.test.ts +++ b/shared/chat/inbox/reselect-conversation.test.ts @@ -1,22 +1,16 @@ /// import * as T from '@/constants/types' +import * as Tabs from '@/constants/tabs' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '@/stores/config' -jest.mock('@/constants/router', () => ({ - getModalStack: jest.fn(() => []), - getVisibleScreen: jest.fn(() => undefined), - navigateToInbox: jest.fn(), - navigateToThread: jest.fn(), -})) - jest.mock('@/constants/chat/common', () => ({ ...jest.requireActual('@/constants/chat/common'), getSelectedConversation: jest.fn(), })) import * as Common from '@/constants/chat/common' -import {navigateToInbox, navigateToThread} from '@/constants/router' +import {installFakeNavigator, makeRootState, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' import {maybeChangeSelectedConversation} from './metadata' const newConvID = 'ff00ff00' @@ -25,12 +19,21 @@ const mockedSelected = Common.getSelectedConversation as jest.Mock const layout = (over: Partial): T.RPCChat.UIInboxLayout => ({reselectInfo: {oldConvID: '', ...over}}) as T.RPCChat.UIInboxLayout +let nav: FakeNavigator + +// navigateToInbox defers a tick, so every assertion below has to let that tick run. +const runDeferredNavigation = () => jest.advanceTimersByTime(1) + beforeEach(() => { + jest.useFakeTimers() + nav = installFakeNavigator() useConfigState.setState({loggedIn: true}) global.isMobile = true }) afterEach(() => { + restoreNavigator() + jest.useRealTimers() jest.clearAllMocks() resetAllStores() global.isMobile = false @@ -45,8 +48,8 @@ test('a reselect while a conversation creation is pending does not pop to the in maybeChangeSelectedConversation(layout({newConvID})) - expect(navigateToInbox).not.toHaveBeenCalled() - expect(navigateToThread).not.toHaveBeenCalled() + runDeferredNavigation() + expect(nav.actions).toEqual([]) }) test('a reselect while the create error screen is up does not pop to the inbox', () => { @@ -54,7 +57,8 @@ test('a reselect while the create error screen is up does not pop to the inbox', maybeChangeSelectedConversation(layout({newConvID})) - expect(navigateToInbox).not.toHaveBeenCalled() + runDeferredNavigation() + expect(nav.actions).toEqual([]) }) // the real "we are on a dead conversation" case still has to bounce @@ -63,5 +67,20 @@ test('a reselect with nothing selected still goes to the inbox on mobile', () => maybeChangeSelectedConversation(layout({newConvID})) - expect(navigateToInbox).toHaveBeenCalledWith(false) + runDeferredNavigation() + // navigateToInbox(false): stay on the chat tab and pop its stack back to the inbox + expect(nav.types()).toContain('POP_TO') + expect(nav.lastAction()?.payload).toMatchObject({name: 'chatRoot'}) +}) + +// The bounce is navigateToInbox(false): it must not pull the user off whatever tab they +// are on. Only the chat tab's own stack gets popped. +test('a reselect while another tab is up leaves that tab alone', () => { + nav = installFakeNavigator({rootState: makeRootState({tab: Tabs.teamsTab})}) + mockedSelected.mockReturnValue(T.Chat.noConversationIDKey) + + maybeChangeSelectedConversation(layout({newConvID})) + + runDeferredNavigation() + expect(nav.actions).toEqual([]) }) diff --git a/shared/common-adapters/name-with-icon.test.tsx b/shared/common-adapters/name-with-icon.test.tsx index cbeb0206c674..e25b46afdc0f 100644 --- a/shared/common-adapters/name-with-icon.test.tsx +++ b/shared/common-adapters/name-with-icon.test.tsx @@ -17,7 +17,6 @@ jest.mock('@/stores/followers', () => ({ }, })) jest.mock('@/teams/use-teams-list', () => ({useTeamsListNameToIDMap: () => new Map()})) -jest.mock('@/constants/router', () => ({navToProfile: jest.fn()})) jest.mock('./avatar', () => ({ __esModule: true, default: ({ diff --git a/shared/constants/navigate-to-thread.test.ts b/shared/constants/navigate-to-thread.test.ts index ce66ad1b5589..0a9274262e3a 100644 --- a/shared/constants/navigate-to-thread.test.ts +++ b/shared/constants/navigate-to-thread.test.ts @@ -3,12 +3,16 @@ jest.mock('@/constants/chat/layout', () => ({isSplit: false, threadRouteName: 'chatConversation'})) import * as T from '@/constants/types' -import {navigateToPendingThread, navigateToThread, navigationRef, setModalRouteNames} from '@/constants/router' +import type {NavState} from '@/constants/nav-tree' +import {navigateToPendingThread, navigateToThread} from '@/constants/router' +import {installFakeNavigator, restoreNavigator, type FakeNavigator, type RecordedAction} from '@/test/fake-navigator' import {useInboxMetadataState} from '@/chat/inbox/metadata-store' import {useCurrentUserState} from '@/stores/current-user' import {useInputIntentState} from '@/chat/conversation/input-intent-store' -const dispatch = jest.fn() +let nav: FakeNavigator +// Set per test by the ordering tests; called at the moment of dispatch. +let onDispatch: ((action: RecordedAction) => void) | undefined const loggedIn = { key: 'loggedIn-1', @@ -27,15 +31,17 @@ const loggedIn = { }, } +// Installs a root state and marks the navigator ready, i.e. the container has mounted. const setRootRoutes = (routes: Array) => { - const state = {index: routes.length - 1, key: 'root-1', routeNames: [], routes, stale: false, type: 'stack'} - // the jest mock's container ref is a plain object, so stub its methods directly - const nr = navigationRef as unknown as Record - nr['current'] = {} - nr['dispatch'] = dispatch - nr['getRootState'] = () => state - nr['isReady'] = () => true - nr['addListener'] = () => () => {} + nav.setReady(true) + nav.setRootState({ + index: routes.length - 1, + key: 'root-1', + routeNames: [], + routes, + stale: false, + type: 'stack', + } as NavState) } const pendingRoute = { @@ -45,16 +51,6 @@ const pendingRoute = { } const realConvID = 'ff00ff00' as T.Chat.ConversationIDKey -// Distinct per deep-link test: navigateAppend's `_pendingAppend` "uncommitted dupe" cache is -// module-level state that the mocked `addListener` never clears (the real navigator would fire -// its 'state' listener and clear it; this stub's listener never fires), so a later test in this -// file reusing `realConvID` with an equal-shaped params object would be silently caught by that -// leftover cache instead of by the code under test. A conv id used nowhere else sidesteps that. -// -// Not laziness: there is no reset to put in beforeEach. `_pendingAppend` is module-private and -// unexported, and jest.resetModules() would hand each test a fresh copy of constants/router with -// its own `navigationRef`, so the stub installed by setRootRoutes would no longer be the one the -// code under test reads. Distinct ids are the only lever from outside the module. const deepLinkConvID = 'aa11aa11' as T.Chat.ConversationIDKey const deepLinkConvID2 = 'bb22bb22' as T.Chat.ConversationIDKey const optionsConvID = 'cc33cc33' as T.Chat.ConversationIDKey @@ -63,11 +59,19 @@ const optionsConvID3 = 'ee55ee55' as T.Chat.ConversationIDKey const optionsConvID4 = 'ff66ff66' as T.Chat.ConversationIDKey beforeEach(() => { - dispatch.mockReset() - setModalRouteNames(['chatNewChat']) + onDispatch = undefined + nav = installFakeNavigator({ + modalRouteNames: ['chatNewChat'], + onDispatch: action => onDispatch?.(action), + ready: false, + }) useInputIntentState.getState().dispatch.resetState() }) +afterEach(() => { + restoreNavigator() +}) + // Creating a conversation parks the thread screen on PENDING-WAITING while the RPC runs, so the // resolved conv is the same chat arriving on the same screen. react-native-screens always animates // a replace on iOS, so a StackActions.replace here (like a push) makes one new chat read as two @@ -77,11 +81,11 @@ test('pending -> resolved conversation retargets the live screen instead of anim navigateToThread(realConvID, 'justCreated') - expect(dispatch).toHaveBeenCalledTimes(1) - const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: unknown; source?: string} - expect(action.type).toBe('SET_PARAMS') - expect(action.source).toBe(pendingRoute.key) - expect(action.payload).toMatchObject({conversationIDKey: realConvID}) + expect(nav.actions).toHaveLength(1) + const action = nav.actions[0] + expect(action?.type).toBe('SET_PARAMS') + expect(action?.source).toBe(pendingRoute.key) + expect(action?.payload?.['params']).toMatchObject({conversationIDKey: realConvID}) }) test('no thread on screen still pushes the conversation', () => { @@ -89,10 +93,10 @@ test('no thread on screen still pushes the conversation', () => { navigateToThread(realConvID, 'justCreated') - expect(dispatch).toHaveBeenCalledTimes(1) - const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: {name: string}} - expect(action.type).toBe('PUSH') - expect(action.payload.name).toBe('chatConversation') + expect(nav.actions).toHaveLength(1) + const action = nav.actions[0] + expect(action?.type).toBe('PUSH') + expect(action?.payload?.['name']).toBe('chatConversation') }) // The old `sameVisibleThread && highlightMessageID` early return is gone, so every call issued @@ -115,11 +119,11 @@ test('reissuing navigateToThread on the same visible thread retargets instead of navigateToThread(realConvID, 'createdMessagePrivately') - expect(dispatch).toHaveBeenCalledTimes(1) - const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: unknown; source?: string} - expect(action.type).toBe('SET_PARAMS') - expect(action.source).toBe(visibleThreadRoute.key) - expect(action.payload).toMatchObject({conversationIDKey: realConvID}) + expect(nav.actions).toHaveLength(1) + const action = nav.actions[0] + expect(action?.type).toBe('SET_PARAMS') + expect(action?.source).toBe(visibleThreadRoute.key) + expect(action?.payload?.['params']).toMatchObject({conversationIDKey: realConvID}) }) // A conversation opened via a `keybase://convid/` deep link lands on chatConversation with @@ -137,11 +141,11 @@ test('reissuing navigateToThread on a deep-linked thread (single-key params) doe navigateToThread(deepLinkConvID, 'createdMessagePrivately') - expect(dispatch).toHaveBeenCalledTimes(1) - const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: unknown; source?: string} - expect(action.type).toBe('SET_PARAMS') - expect(action.source).toBe(deepLinkedThreadRoute.key) - expect(action.payload).toMatchObject({conversationIDKey: deepLinkConvID}) + expect(nav.actions).toHaveLength(1) + const action = nav.actions[0] + expect(action?.type).toBe('SET_PARAMS') + expect(action?.source).toBe(deepLinkedThreadRoute.key) + expect(action?.payload?.['params']).toMatchObject({conversationIDKey: deepLinkConvID}) }) // Same shape as the deep-link case above, but reached by a reason that never carried an intent - @@ -157,10 +161,10 @@ test('a plain re-navigate to a deep-linked thread does not push a duplicate', () navigateToThread(deepLinkConvID2, 'focused') - expect(dispatch).toHaveBeenCalledTimes(1) - const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: unknown; source?: string} - expect(action.type).toBe('SET_PARAMS') - expect(action.source).toBe(deepLinkedThreadRoute.key) + expect(nav.actions).toHaveLength(1) + const action = nav.actions[0] + expect(action?.type).toBe('SET_PARAMS') + expect(action?.source).toBe(deepLinkedThreadRoute.key) }) // The options object replaced a positional tail (highlightMessageID, threadSearchQuery, @@ -170,9 +174,9 @@ test('the options object writes the intent before navigating and forwards thread setRootRoutes([loggedIn]) const messageID = T.Chat.numberToMessageID(99) const order: Array = [] - dispatch.mockImplementation(() => { + onDispatch = () => { order.push(`intent:${String(useInputIntentState.getState().intents.has(optionsConvID))}`) - }) + } navigateToThread(optionsConvID, 'justCreated', { intent: {messageID, type: 'highlight'}, @@ -184,9 +188,9 @@ test('the options object writes the intent before navigating and forwards thread messageID, type: 'highlight', }) - const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: {params: object}} - expect(action.type).toBe('PUSH') - expect(action.payload.params).toMatchObject({ + const action = nav.actions[0] + expect(action?.type).toBe('PUSH') + expect(action?.payload?.['params']).toMatchObject({ conversationIDKey: optionsConvID, threadSearch: {query: 'needle'}, }) @@ -201,7 +205,7 @@ test('an aborted navigation writes no intent', () => { intent: {messageID: T.Chat.numberToMessageID(99), type: 'highlight'}, }) - expect(dispatch).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) expect(useInputIntentState.getState().intents.size).toBe(0) }) @@ -212,9 +216,9 @@ test('an aborted navigation writes no intent', () => { test('an injectText intent is written before navigating, and an undefined one writes nothing', () => { setRootRoutes([loggedIn]) const order: Array = [] - dispatch.mockImplementation(() => { + onDispatch = () => { order.push(`intent:${String(useInputIntentState.getState().intents.has(optionsConvID3))}`) - }) + } navigateToThread(optionsConvID3, 'justCreated', {intent: {text: 'prefill me', type: 'injectText'}}) @@ -247,8 +251,7 @@ test('the pending thread is seeded with the participants so its header title is const seeded = useInboxMetadataState.getState().participants.get(T.Chat.pendingWaitingConversationIDKey) expect(seeded?.name).toEqual(['testuser', 'testuser-mac']) - const action = dispatch.mock.calls[0]?.[0] as {type: string; payload: {params: object}} - expect(action.payload.params).toMatchObject({ + expect(nav.actions[0]?.payload?.['params']).toMatchObject({ conversationIDKey: T.Chat.pendingWaitingConversationIDKey, }) }) @@ -259,18 +262,12 @@ test('the pending thread is seeded with the participants so its header title is // handler before the nav container is ready. A durable intent left behind by a navigation that // never occurred would fire on some later, unrelated mount of that conversation. test('a navigation that cannot dispatch leaves no intent behind', () => { - const nr = navigationRef as unknown as Record - nr['current'] = undefined - nr['dispatch'] = dispatch - nr['getRootState'] = () => undefined - nr['isReady'] = () => false - const convID = T.Chat.stringToConversationIDKey('conv-no-navigator') navigateToThread(convID, 'push', { intent: {messageID: T.Chat.numberToMessageID(7), type: 'highlight'}, }) - expect(dispatch).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) expect(useInputIntentState.getState().intents.get(convID)).toBeUndefined() }) @@ -281,7 +278,7 @@ test('a navigation that does dispatch keeps the intent for the mount to consume' intent: {messageID: T.Chat.numberToMessageID(7), type: 'highlight'}, }) - expect(dispatch).toHaveBeenCalled() + expect(nav.actions.length).toBeGreaterThan(0) expect(useInputIntentState.getState().intents.get(convID)).toEqual({ messageID: T.Chat.numberToMessageID(7), type: 'highlight', diff --git a/shared/constants/navigator.tsx b/shared/constants/navigator.tsx new file mode 100644 index 000000000000..375734ffb203 --- /dev/null +++ b/shared/constants/navigator.tsx @@ -0,0 +1,319 @@ +// The one seam between the app and React Navigation. +// +// Everything that changes navigation goes through a Navigator: a thing that can +// dispatch an action, read the root state, say whether it is ready, and be listened +// to. There are exactly two implementations - the real binding to the app's +// NavigationContainerRef (below), and the in-memory fake in test/fake-navigator. +// +// The operations here are the ones that need the tree shape to decide what to +// dispatch; they read it through NavTree and never re-derive it. Dispatches that +// target a *specific* navigator rather than the root (e.g. a tab bar acting on the +// navigation object handed to it by its own navigator) stay where they are - they +// are not this seam. +import * as NavTree from './nav-tree' +import * as Tabs from './tabs' +import { + CommonActions, + StackActions, + TabActions, + createNavigationContainerRef, + type NavigationContainerRef, +} from '@react-navigation/core' +import {registerDebugClear} from '@/util/debug-registry' +import {shallowEqual} from './utils' +import type {NavigateAppendType, RouteKeys, RootParamList} from '@/router-v2/route-params' + +type ContainerRef = NavigationContainerRef +export type NavAction = Parameters[0] + +// What an adapter has to provide. Deliberately the smallest surface that the +// operations below need, so a fake is a handful of lines rather than a mock of +// React Navigation. +export type NavigatorRef = { + isReady: () => boolean + getRootState: () => NavTree.NavState | undefined + dispatch: (action: NavAction) => void + addListener: (type: 'state', cb: () => void) => () => void +} + +export type Navigator = NavigatorRef & { + navigateUp: () => void + popStack: () => void + clearModals: () => void + // Returns whether the target is now the visible route - either because we dispatched, + // or because we were already there. False means nothing happened and nothing will. + navigateAppend: (path: NavigateAppendType, replace?: boolean) => boolean + navUpToScreen: (nameOrPath: RouteKeys | NavigateAppendType, replaceIfMissing?: boolean) => void + switchTab: (name: Tabs.AppTab) => void + // Returns whether chatRoot now carries these params - by dispatch, or because it + // already did. False means the nav tree was not in a state where anything could happen. + setChatRootParams: (params: Partial>) => boolean +} + +const DEBUG_NAV = __DEV__ && (false as boolean) + +export const makeNavigator = (ref: NavigatorRef): Navigator => { + // A push dispatched this tick isn't in getRootState() until React Navigation commits, so the + // visible-route dupe check below misses repeat taps that land before the commit (e.g. a janky JS + // thread queueing both). Track the in-flight push until the next state event; the time bound is a + // backstop in case the container tears down before the listener fires. + let pendingAppend: {name: string; params?: object; time: number} | undefined + + const navigateUp = () => { + if (DEBUG_NAV) { + console.log('[Nav] navigateUp') + } + if (!ref.isReady()) return + ref.dispatch(CommonActions.goBack()) + } + + const popStack = () => { + if (DEBUG_NAV) { + console.log('[Nav] popStack') + } + if (!ref.isReady()) return + ref.dispatch(StackActions.popToTop()) + } + + const clearModals = () => { + if (DEBUG_NAV) { + console.log('[Nav] clearModals') + } + if (!ref.isReady()) return + const ns = ref.getRootState() + if (!NavTree.isLoggedIn(ns)) { + return + } + const rootRoutes = ns?.routes ?? [] + const keepRoutes = rootRoutes.filter((route, index) => index === 0 || !NavTree.isModalRouteName(route.name)) + if (keepRoutes.length !== rootRoutes.length) { + ref.dispatch({ + ...CommonActions.reset({ + ...ns, + index: keepRoutes.length - 1, + routes: keepRoutes, + } as Parameters[0]), + target: ns?.key, + }) + } + } + + const navigateAppend = (path: NavigateAppendType, replace?: boolean): boolean => { + if (DEBUG_NAV) { + console.log('[Nav] navigateAppend', {path}) + } + if (!ref.isReady()) { + return false + } + const ns = ref.getRootState() + if (!ns) { + return false + } + const nextPath = path as {name: string | number | symbol; params: object} + const routeName = typeof nextPath.name === 'string' ? nextPath.name : String(nextPath.name) + const params = nextPath.params + if (!routeName) { + if (DEBUG_NAV) { + console.log('[Nav] navigateAppend no routeName bail', routeName) + } + return false + } + const visible = NavTree.visibleScreen(ns) + if (visible) { + if (routeName === visible.name && shallowEqual(visible.params, params)) { + console.log('Skipping append dupe') + // Already the visible route with these params - the caller's goal is met. + return true + } + } + + if (replace) { + if (visible?.name === routeName) { + ref.dispatch(CommonActions.setParams(params)) + return true + } else { + ref.dispatch(StackActions.replace(routeName, params)) + return true + } + } + + if ( + pendingAppend?.name === routeName && + shallowEqual(pendingAppend.params, params) && + Date.now() - pendingAppend.time < 1000 + ) { + console.log('Skipping append dupe (uncommitted)') + // An identical push is already in flight and uncommitted. + return true + } + pendingAppend = {name: routeName, params, time: Date.now()} + const unsub = ref.addListener('state', () => { + pendingAppend = undefined + unsub() + }) + ref.dispatch(StackActions.push(routeName, params)) + return true + } + + const navUpToScreen = (nameOrPath: RouteKeys | NavigateAppendType, replaceIfMissing = false) => { + if (DEBUG_NAV) { + console.log('[Nav] navUpToScreen', {nameOrPath, replaceIfMissing}) + } + if (!ref.isReady()) return + const activeStackState = NavTree.activeStack(ref.getRootState()) + const activeStackKey = activeStackState?.key + if (typeof nameOrPath === 'string') { + const action = StackActions.popTo(nameOrPath) + ref.dispatch(activeStackKey ? {...action, target: activeStackKey} : action) + return + } + + const routeName = nameOrPath.name + const params = nameOrPath.params as object + + const activeStackRoutes = activeStackState?.routes as Array | undefined + let routeIndex = -1 + if (activeStackRoutes) { + for (let i = activeStackRoutes.length - 1; i >= 0; i--) { + if (activeStackRoutes[i]?.name === routeName) { + routeIndex = i + break + } + } + } + if (routeIndex >= 0 && activeStackState) { + const nextRoutes = activeStackRoutes! + .slice(0, routeIndex + 1) + .map((route, index) => (index === routeIndex ? {...route, params} : route)) + ref.dispatch({ + ...CommonActions.reset({ + ...activeStackState, + index: routeIndex, + routes: nextRoutes, + } as Parameters[0]), + target: activeStackKey, + }) + return + } + + if (replaceIfMissing) { + const action = StackActions.replace(routeName, params) + ref.dispatch(activeStackKey ? {...action, target: activeStackKey} : action) + return + } + + const action = StackActions.popTo(routeName) + ref.dispatch(activeStackKey ? {...action, target: activeStackKey} : action) + } + + const switchTab = (name: Tabs.AppTab) => { + if (DEBUG_NAV) { + console.log('[Nav] switchTab', {name}) + } + if (!ref.isReady()) return + const tabNavState = NavTree.tabNavigatorState(ref.getRootState()) + if (!tabNavState?.key) return + ref.dispatch({ + ...TabActions.jumpTo(name), + target: tabNavState.key, + }) + } + + const setChatRootParams = (params: Partial>): boolean => { + if (!ref.isReady()) return false + const tabNavState = NavTree.tabNavigatorState(ref.getRootState()) + if (!tabNavState?.key) return false + const tabRoutes = tabNavState.routes as Array + const chatTabIndex = tabRoutes.findIndex(r => r.name === Tabs.chatTab) + if (chatTabIndex < 0) return false + const chatTabRoute = tabRoutes[chatTabIndex] + const chatStackState = chatTabRoute?.state + const chatStackRoutes = chatStackState?.routes as Array | undefined + const chatStackIndex = chatStackState?.index ?? 0 + const currentChatRoute = chatStackRoutes?.[chatStackIndex] + const currentChatRoot = chatStackRoutes?.[0] + const updatedRoutes = tabRoutes.map((route, i) => { + if (i !== chatTabIndex) return route + const currentParams = currentChatRoot?.name === 'chatRoot' ? currentChatRoot.params : undefined + return { + ...route, + state: { + ...(route.state ?? {}), + index: 0, + routes: [{name: 'chatRoot', params: {...currentParams, ...params}}], + }, + } + }) + const nextChatRoot = updatedRoutes[chatTabIndex]?.state?.routes[0] + if ( + tabNavState.index === chatTabIndex && + currentChatRoute?.name === 'chatRoot' && + chatStackState?.key && + nextChatRoot?.params + ) { + // When split chat is already showing chatRoot, update that route in place instead of + // resetting the whole tab navigator. This avoids an extra same-screen navigation when + // the tab becomes visible and chat selects a thread immediately afterward. + if (!shallowEqual(currentChatRoute.params, nextChatRoot.params)) { + ref.dispatch({ + ...CommonActions.navigate('chatRoot', nextChatRoot.params, {merge: true}), + target: chatStackState.key, + }) + } + // Either we just merged the params in, or they were already what we wanted. + return true + } + ref.dispatch({ + ...CommonActions.reset({...tabNavState, index: chatTabIndex, routes: updatedRoutes} as Parameters< + typeof CommonActions.reset + >[0]), + target: tabNavState.key, + }) + return true + } + + return { + addListener: ref.addListener, + clearModals, + dispatch: ref.dispatch, + getRootState: ref.getRootState, + isReady: ref.isReady, + navUpToScreen, + navigateAppend, + navigateUp, + popStack, + setChatRootParams, + switchTab, + } +} + +// ---- The real adapter ---- + +export const navigationRef = createNavigationContainerRef() + +registerDebugClear(() => { + navigationRef.current = null +}) + +const containerRefAdapter: NavigatorRef = { + addListener: (type, cb) => (navigationRef.isReady() ? navigationRef.addListener(type, cb) : () => {}), + dispatch: action => { + if (navigationRef.isReady()) { + navigationRef.dispatch(action) + } + }, + getRootState: () => (navigationRef.isReady() ? navigationRef.getRootState() : undefined), + isReady: () => navigationRef.isReady(), +} + +const realNavigator = makeNavigator(containerRefAdapter) + +let currentNavigator: Navigator = realNavigator + +export const getNavigator = () => currentNavigator + +// Swaps in another adapter - the in-memory fake in tests. Passing nothing restores +// the real one. +export const setNavigator = (navigator?: Navigator) => { + currentNavigator = navigator ?? realNavigator +} diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 4a296d4972fb..4c1cd2b072c3 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -5,21 +5,14 @@ import {clearInputIntent, setInputIntent, type InputIntent} from '@/chat/convers import {refreshInboxLayout} from '@/chat/inbox/inbox-refresh' import {useCurrentUserState} from '@/stores/current-user' import * as Tabs from './tabs' -import { - StackActions, - TabActions, - CommonActions, - type NavigationContainerRef, - NavigationContext, - createNavigationContainerRef, -} from '@react-navigation/core' +import {CommonActions, type NavigationContainerRef, NavigationContext} from '@react-navigation/core' import type {StaticScreenProps} from '@react-navigation/core' import type {NavigateAppendType, RouteKeys, RootParamList as KBRootParamList} from '@/router-v2/route-params' import * as NavTree from './nav-tree' +import {getNavigator} from './navigator' import type {GetOptionsRet, RouteDef} from './types/router' import {isSplit, threadRouteName} from './chat/layout' -import {ignorePromise, shallowEqual} from './utils' -import {registerDebugClear} from '@/util/debug-registry' +import {ignorePromise} from './utils' import {makeUUID} from '@/util/uuid' import * as Meta from './chat/meta' import * as Strings from './strings' @@ -53,18 +46,12 @@ type ScreenComponent> = ( p: StaticScreenProps> ) => React.ReactElement -export const navigationRef = createNavigationContainerRef() - -registerDebugClear(() => { - navigationRef.current = null -}) - export type {Route, NavState} from './nav-tree' -type Route = NavTree.Route type NavState = NavTree.NavState -export type Navigator = NavigationContainerRef +export type NavigationRef = NavigationContainerRef export {setModalRouteNames} from './nav-tree' +export {navigationRef} from './navigator' const DEBUG_NAV = __DEV__ && (false as boolean) @@ -85,21 +72,11 @@ const uiParticipantsToParticipantInfo = ( return participantInfo } -export const getRootState = (): NavState | undefined => { - if (!navigationRef.isReady()) return - return navigationRef.getRootState() -} +export const getRootState = (): NavState | undefined => getNavigator().getRootState() export const getTab = (navState?: T.Immutable): undefined | Tabs.Tab => NavTree.currentTab(navState || getRootState()) -export const _getNavigator = () => { - return navigationRef.isReady() ? navigationRef : undefined -} - -const getActiveStackState = (navState?: T.Immutable) => - NavTree.activeStack(navState || getRootState()) - // Public API // gives you loggedin/tab/stackitems + modals export const getVisiblePath = (navState?: T.Immutable, includeModals?: boolean) => @@ -185,179 +162,32 @@ export function makeScreen>( } } +// Free-function facade over the default Navigator. Every call site in the app goes +// through these; the adapter underneath is what tests swap. export const clearModals = () => { - if (DEBUG_NAV) { - console.log('[Nav] clearModals') - } - const n = _getNavigator() - if (!n) return - const ns = getRootState() - if (!NavTree.isLoggedIn(ns)) { - return - } - const rootRoutes = ns?.routes ?? [] - const keepRoutes = rootRoutes.filter((route, index) => index === 0 || !NavTree.isModalRouteName(route.name)) - if (keepRoutes.length !== rootRoutes.length) { - n.dispatch({ - ...CommonActions.reset({ - ...ns, - index: keepRoutes.length - 1, - routes: keepRoutes, - } as Parameters[0]), - target: ns?.key, - }) - } + getNavigator().clearModals() } export const navigateUp = () => { - if (DEBUG_NAV) { - console.log('[Nav] navigateUp') - } - const n = _getNavigator() - return n?.dispatch(CommonActions.goBack()) + getNavigator().navigateUp() } export const popStack = () => { - if (DEBUG_NAV) { - console.log('[Nav] popStack') - } - const n = _getNavigator() - n?.dispatch(StackActions.popToTop()) + getNavigator().popStack() } export function navUpToScreen(name: RouteKeys): void export function navUpToScreen(path: NavigateAppendType, replaceIfMissing?: boolean): void export function navUpToScreen(nameOrPath: RouteKeys | NavigateAppendType, replaceIfMissing = false) { - if (DEBUG_NAV) { - console.log('[Nav] navUpToScreen', {nameOrPath, replaceIfMissing}) - } - const n = _getNavigator() - if (!n) return - const activeStackState = getActiveStackState() - const activeStackKey = activeStackState?.key - if (typeof nameOrPath === 'string') { - const action = StackActions.popTo(nameOrPath) - n.dispatch(activeStackKey ? {...action, target: activeStackKey} : action) - return - } - - const routeName = nameOrPath.name - const params = nameOrPath.params as object - - const activeStackRoutes = activeStackState?.routes as Array | undefined - let routeIndex = -1 - if (activeStackRoutes) { - for (let i = activeStackRoutes.length - 1; i >= 0; i--) { - if (activeStackRoutes[i]?.name === routeName) { - routeIndex = i - break - } - } - } - if (routeIndex >= 0 && activeStackState) { - const nextRoutes = activeStackRoutes!.slice(0, routeIndex + 1).map((route, index) => - index === routeIndex ? {...route, params} : route - ) - n.dispatch({ - ...CommonActions.reset({ - ...activeStackState, - index: routeIndex, - routes: nextRoutes, - } as Parameters[0]), - target: activeStackKey, - }) - return - } - - if (replaceIfMissing) { - const action = StackActions.replace(routeName, params) - n.dispatch(activeStackKey ? {...action, target: activeStackKey} : action) - return - } - - const action = StackActions.popTo(routeName) - n.dispatch(activeStackKey ? {...action, target: activeStackKey} : action) + getNavigator().navUpToScreen(nameOrPath, replaceIfMissing) } -// A push dispatched this tick isn't in getRootState() until React Navigation commits, so the -// visible-route dupe check below misses repeat taps that land before the commit (e.g. a janky JS -// thread queueing both). Track the in-flight push until the next state event; the time bound is a -// backstop in case the container tears down before the listener fires. -let _pendingAppend: {name: string; params?: object; time: number} | undefined - -// Returns whether the target is now the visible route - either because we dispatched, or -// because we were already there. False means nothing happened and nothing will. export function navigateAppend(path: NavigateAppendType, replace?: boolean): boolean { - if (DEBUG_NAV) { - console.log('[Nav] navigateAppend', {path}) - } - const n = _getNavigator() - if (!n) { - return false - } - const ns = getRootState() - if (!ns) { - return false - } - const nextPath = path as {name: string | number | symbol; params: object} - const routeName = typeof nextPath.name === 'string' ? nextPath.name : String(nextPath.name) - const params = nextPath.params - if (!routeName) { - if (DEBUG_NAV) { - console.log('[Nav] navigateAppend no routeName bail', routeName) - } - return false - } - const vp = getVisiblePath(ns) - const visible = vp.at(-1) - if (visible) { - if (routeName === visible.name && shallowEqual(visible.params, params)) { - console.log('Skipping append dupe') - // Already the visible route with these params - the caller's goal is met. - return true - } - } - - if (replace) { - if (visible?.name === routeName) { - n.dispatch(CommonActions.setParams(params)) - return true - } else { - n.dispatch(StackActions.replace(routeName, params)) - return true - } - } - - if ( - _pendingAppend?.name === routeName && - shallowEqual(_pendingAppend.params, params) && - Date.now() - _pendingAppend.time < 1000 - ) { - console.log('Skipping append dupe (uncommitted)') - // An identical push is already in flight and uncommitted. - return true - } - _pendingAppend = {name: routeName, params, time: Date.now()} - const unsub = n.addListener('state', () => { - _pendingAppend = undefined - unsub() - }) - n.dispatch(StackActions.push(routeName, params)) - return true + return getNavigator().navigateAppend(path, replace) } export const switchTab = (name: Tabs.AppTab) => { - if (DEBUG_NAV) { - console.log('[Nav] switchTab', {name}) - } - const n = _getNavigator() - if (!n) return - const tabNavState = NavTree.tabNavigatorState(getRootState()) - if (!tabNavState?.key) return - n.dispatch({ - ...TabActions.jumpTo(name), - target: tabNavState.key, - }) + getNavigator().switchTab(name) } export const navToProfile = (username: string) => { @@ -621,63 +451,8 @@ export const previewConversation = (p: PreviewConversationParams) => { ignorePromise(previewConversationTeam()) } -// Returns whether chatRoot now carries these params - by dispatch, or because it already did. -// False means the nav tree was not in a state where anything could happen. -export const setChatRootParams = ( - params: Partial> -): boolean => { - const n = _getNavigator() - if (!n) return false - const tabNavState = NavTree.tabNavigatorState(getRootState()) - if (!tabNavState?.key) return false - const tabRoutes = tabNavState.routes as Array - const chatTabIndex = tabRoutes.findIndex(r => r.name === Tabs.chatTab) - if (chatTabIndex < 0) return false - const chatTabRoute = tabRoutes[chatTabIndex] - const chatStackState = chatTabRoute?.state - const chatStackRoutes = chatStackState?.routes as Array | undefined - const chatStackIndex = chatStackState?.index ?? 0 - const currentChatRoute = chatStackRoutes?.[chatStackIndex] - const currentChatRoot = chatStackRoutes?.[0] - const updatedRoutes = tabRoutes.map((route, i) => { - if (i !== chatTabIndex) return route - const currentParams = currentChatRoot?.name === 'chatRoot' ? currentChatRoot.params : undefined - return { - ...route, - state: { - ...(route.state ?? {}), - index: 0, - routes: [{name: 'chatRoot', params: {...currentParams, ...params}}], - }, - } - }) - const nextChatRoot = updatedRoutes[chatTabIndex]?.state?.routes[0] - if ( - tabNavState.index === chatTabIndex && - currentChatRoute?.name === 'chatRoot' && - chatStackState?.key && - nextChatRoot?.params - ) { - // When split chat is already showing chatRoot, update that route in place instead of - // resetting the whole tab navigator. This avoids an extra same-screen navigation when - // the tab becomes visible and chat selects a thread immediately afterward. - if (!shallowEqual(currentChatRoute.params, nextChatRoot.params)) { - n.dispatch({ - ...CommonActions.navigate('chatRoot', nextChatRoot.params, {merge: true}), - target: chatStackState.key, - }) - } - // Either we just merged the params in, or they were already what we wanted. - return true - } - n.dispatch({ - ...CommonActions.reset({...tabNavState, index: chatTabIndex, routes: updatedRoutes} as Parameters< - typeof CommonActions.reset - >[0]), - target: tabNavState.key, - }) - return true -} +export const setChatRootParams = (params: Partial>): boolean => + getNavigator().setChatRootParams(params) export const setThreadInputCommandStatus = ( conversationIDKey: T.Chat.ConversationIDKey, @@ -741,9 +516,9 @@ const navToThread = ( if (DEBUG_NAV) { console.log('[Nav] navToThread', conversationIDKey) } - const n = _getNavigator() - if (!n) return false - const rs = getRootState() + const nav = getNavigator() + if (!nav.isReady()) return false + const rs = nav.getRootState() if (!rs?.key) return false const params = { conversationIDKey, @@ -760,7 +535,7 @@ const navToThread = ( } else { // Phone: switch to the chat tab, then push the conversation above the tabs. const nextState = NavTree.pushedAboveTabs(Tabs.chatTab, {name: 'chatConversation', params}) - n.dispatch({ + nav.dispatch({ ...CommonActions.reset(nextState as Parameters[0]), target: rs.key, }) @@ -845,9 +620,9 @@ export const navigateToThread = ( // re-measures a title subview it first measured empty, so a blank pending title would // leave the bar blank for the real conv too. Same-conversation retargets ride this path // too: the screen is already showing real content, so setParams is a plain in-place merge. - const n = _getNavigator() - n?.dispatch({...CommonActions.setParams(params), source: visible?.key}) - navigated = !!n + const nav = getNavigator() + nav.dispatch({...CommonActions.setParams(params), source: visible?.key}) + navigated = nav.isReady() } else { navigated = navigateAppend({name: threadRouteName, params}) } diff --git a/shared/constants/tests/navigator.test.ts b/shared/constants/tests/navigator.test.ts new file mode 100644 index 000000000000..4fd169ed91b3 --- /dev/null +++ b/shared/constants/tests/navigator.test.ts @@ -0,0 +1,352 @@ +/// +import * as Tabs from '@/constants/tabs' +import {CommonActions} from '@react-navigation/core' +import { + clearModals, + navUpToScreen, + navigateAppend, + navigateUp, + popStack, + setChatRootParams, + switchTab, +} from '@/constants/router' +import { + installFakeNavigator, + makeRootState, + restoreNavigator, + type FakeNavigator, +} from '@/test/fake-navigator' + +let nav: FakeNavigator + +afterEach(() => { + restoreNavigator() + jest.useRealTimers() +}) + +// The two adapters have to agree about readiness or the fake is not a stand-in: the real one +// drops dispatches and reports no root state until the container has mounted, and callers rely +// on that instead of guarding themselves (constants/router's same-conversation retarget +// dispatches straight through the adapter and only then asks whether it was ready). +describe('readiness', () => { + test('a not-ready navigator drops raw dispatches and reports no root state', () => { + nav = installFakeNavigator({ready: false}) + + nav.dispatch(CommonActions.goBack()) + + expect(nav.actions).toEqual([]) + expect(nav.getRootState()).toBeUndefined() + }) + + test('and serves both once the container has mounted', () => { + nav = installFakeNavigator({ready: false}) + nav.setReady(true) + + nav.dispatch(CommonActions.goBack()) + + expect(nav.actions).toEqual([{type: 'GO_BACK'}]) + expect(nav.getRootState()?.key).toBe('root') + }) +}) + +// ---- navigateUp / popStack ---- + +describe('navigateUp and popStack', () => { + test('go back and pop-to-top are dispatched at the root, untargeted', () => { + nav = installFakeNavigator() + + navigateUp() + popStack() + + expect(nav.actions).toEqual([{type: 'GO_BACK'}, {type: 'POP_TO_TOP'}]) + }) + + test('a not-ready navigator dispatches neither', () => { + nav = installFakeNavigator({ready: false}) + + navigateUp() + popStack() + + expect(nav.actions).toEqual([]) + }) +}) + +// ---- navigateAppend ---- + +describe('navigateAppend', () => { + beforeEach(() => { + nav = installFakeNavigator({ + rootState: makeRootState({tabStack: [{name: 'chatRoot'}, {name: 'profile', params: {username: 'testuser'}}]}), + }) + }) + + test('pushes a screen that is not already visible', () => { + expect(navigateAppend({name: 'profile', params: {username: 'testuser-mac'}})).toBe(true) + + expect(nav.pushes()).toEqual([{name: 'profile', params: {username: 'testuser-mac'}}]) + }) + + // The caller's goal - "that screen with those params is what the user is looking at" - + // is already met, so this reports success without a second identical screen. + test('is a no-op when the target is already the visible route with the same params', () => { + expect(navigateAppend({name: 'profile', params: {username: 'testuser'}})).toBe(true) + + expect(nav.actions).toEqual([]) + }) + + test('a not-ready navigator dispatches nothing and reports failure', () => { + nav.setReady(false) + + expect(navigateAppend({name: 'profile', params: {username: 'testuser-mac'}})).toBe(false) + expect(nav.actions).toEqual([]) + }) + + test('replace retargets the visible screen in place when the name matches', () => { + expect(navigateAppend({name: 'profile', params: {username: 'testuser-mac'}}, true)).toBe(true) + + expect(nav.lastAction()?.type).toBe('SET_PARAMS') + expect(nav.lastAction()?.payload).toEqual({params: {username: 'testuser-mac'}}) + }) + + test('replace swaps the screen when the visible one is a different route', () => { + expect(navigateAppend({name: 'chatNewChat', params: {namespace: 'chat', title: 'New chat'}}, true)).toBe(true) + + expect(nav.lastAction()?.type).toBe('REPLACE') + expect(nav.lastAction()?.payload).toMatchObject({name: 'chatNewChat'}) + }) +}) + +// A push dispatched this tick is not in getRootState() until React Navigation commits, so +// the visible-route check above cannot see it. Repeat taps that land inside that window - +// a janky JS thread queueing both - would otherwise push the same screen twice. +describe('navigateAppend in-flight dedupe', () => { + beforeEach(() => { + jest.useFakeTimers() + nav = installFakeNavigator() + }) + + test('a second identical push before the state commits is dropped', () => { + navigateAppend({name: 'profile', params: {username: 'testuser'}}) + navigateAppend({name: 'profile', params: {username: 'testuser'}}) + + expect(nav.pushes()).toEqual([{name: 'profile', params: {username: 'testuser'}}]) + }) + + test('a different push inside the window still goes through', () => { + navigateAppend({name: 'profile', params: {username: 'testuser'}}) + navigateAppend({name: 'profile', params: {username: 'testuser-mac'}}) + + expect(nav.pushes()).toHaveLength(2) + }) + + // The window is a backstop for the container tearing down before the state listener + // fires; it must not swallow a genuine repeat navigation forever. + test('the same push is allowed again once the 1000ms window has passed', () => { + navigateAppend({name: 'profile', params: {username: 'testuser'}}) + jest.advanceTimersByTime(1001) + navigateAppend({name: 'profile', params: {username: 'testuser'}}) + + expect(nav.pushes()).toHaveLength(2) + }) + + test('the window is still closed one tick before it expires', () => { + navigateAppend({name: 'profile', params: {username: 'testuser'}}) + jest.advanceTimersByTime(999) + navigateAppend({name: 'profile', params: {username: 'testuser'}}) + + expect(nav.pushes()).toHaveLength(1) + }) + + // The commit is the real end of the window: once the navigator reports new state the + // visible-route check can see the pushed screen, so the backstop stands down. + test('a committed state event ends the window early', () => { + navigateAppend({name: 'profile', params: {username: 'testuser'}}) + nav.setRootState(makeRootState({tabStack: [{name: 'chatRoot'}, {name: 'peopleRoot'}]})) + navigateAppend({name: 'profile', params: {username: 'testuser'}}) + + expect(nav.pushes()).toHaveLength(2) + }) +}) + +// ---- navUpToScreen ---- + +describe('navUpToScreen', () => { + const stack = [{name: 'teamsRoot'}, {name: 'team', params: {teamID: 'T1'}}, {name: 'teamChannel'}] + + beforeEach(() => { + nav = installFakeNavigator({rootState: makeRootState({tab: Tabs.teamsTab, tabStack: stack})}) + }) + + test('a bare route name pops the active stack back to it', () => { + navUpToScreen('teamsRoot') + + expect(nav.lastAction()).toMatchObject({ + payload: {name: 'teamsRoot'}, + target: `${Tabs.teamsTab}-stack`, + type: 'POP_TO', + }) + }) + + // A path carries params, and popTo cannot set them, so the stack is rebuilt in place: + // truncated at the target with the new params written onto it. + test('a path already in the stack resets the stack onto it with the new params', () => { + navUpToScreen({name: 'team', params: {teamID: 'T2'}}) + + const action = nav.lastAction() + expect(action?.type).toBe('RESET') + expect(action?.target).toBe(`${Tabs.teamsTab}-stack`) + const payload = action?.payload as {index: number; routes: Array<{name: string; params?: object}>} + expect(payload.index).toBe(1) + expect(payload.routes.map(r => r.name)).toEqual(['teamsRoot', 'team']) + expect(payload.routes[1]?.params).toEqual({teamID: 'T2'}) + }) + + test('a path that is not in the stack is put in place of the current screen when asked', () => { + navUpToScreen({name: 'teamMember', params: {teamID: 'T1', username: 'testuser'}}, true) + + expect(nav.lastAction()).toMatchObject({ + payload: {name: 'teamMember', params: {teamID: 'T1', username: 'testuser'}}, + target: `${Tabs.teamsTab}-stack`, + type: 'REPLACE', + }) + }) + + test('a path that is not in the stack pops towards it otherwise', () => { + navUpToScreen({name: 'teamMember', params: {teamID: 'T1', username: 'testuser'}}) + + expect(nav.lastAction()).toMatchObject({payload: {name: 'teamMember'}, type: 'POP_TO'}) + }) + + test('a not-ready navigator dispatches nothing', () => { + nav.setReady(false) + navUpToScreen('teamsRoot') + + expect(nav.actions).toEqual([]) + }) +}) + +// ---- clearModals ---- + +describe('clearModals', () => { + test('drops the modals and keeps the tab navigator and non-modal pushed screens', () => { + nav = installFakeNavigator({ + modalRouteNames: ['chatInfoPanel', 'chatNewChat'], + rootState: makeRootState({ + above: [{name: 'chatConversation', params: {conversationIDKey: 'CONV'}}, {name: 'chatInfoPanel'}], + }), + }) + + clearModals() + + const action = nav.lastAction() + expect(action?.type).toBe('RESET') + expect(action?.target).toBe('root') + const payload = action?.payload as {index: number; routes: Array<{name: string}>} + expect(payload.routes.map(r => r.name)).toEqual(['loggedIn', 'chatConversation']) + expect(payload.index).toBe(1) + }) + + test('dispatches nothing when there is no modal to clear', () => { + nav = installFakeNavigator({modalRouteNames: ['chatInfoPanel']}) + + clearModals() + + expect(nav.actions).toEqual([]) + }) + + // A modal can sit above the logged-out stack too, so "nothing to clear" is not what makes + // this a no-op - clearModals only ever acts on the logged-in root. + test('dispatches nothing when logged out, even with a modal on top', () => { + nav = installFakeNavigator({ + modalRouteNames: ['chatInfoPanel'], + rootState: makeRootState({above: [{name: 'chatInfoPanel'}], loggedIn: false}), + }) + + clearModals() + + expect(nav.actions).toEqual([]) + }) +}) + +// ---- setChatRootParams ---- + +describe('setChatRootParams', () => { + test('merges into the live chatRoot in place when it is already showing', () => { + nav = installFakeNavigator({ + rootState: makeRootState({tabStack: [{name: 'chatRoot', params: {conversationIDKey: 'OLD'}}]}), + }) + + expect(setChatRootParams({conversationIDKey: 'NEW' as never})).toBe(true) + + // in place, targeting the chat tab's own stack - not a reset of the tab navigator + expect(nav.lastAction()).toMatchObject({ + payload: {name: 'chatRoot', params: {conversationIDKey: 'NEW'}}, + target: `${Tabs.chatTab}-stack`, + type: 'NAVIGATE', + }) + }) + + test('dispatches nothing when chatRoot already carries those params', () => { + nav = installFakeNavigator({ + rootState: makeRootState({tabStack: [{name: 'chatRoot', params: {conversationIDKey: 'SAME'}}]}), + }) + + expect(setChatRootParams({conversationIDKey: 'SAME' as never})).toBe(true) + expect(nav.actions).toEqual([]) + }) + + // Something else is on top of the chat stack, so the tab navigator is reset back onto a + // chatRoot carrying the merged params. + test('resets the tab navigator when chatRoot is not the current screen', () => { + nav = installFakeNavigator({ + rootState: makeRootState({ + tabStack: [{name: 'chatRoot', params: {conversationIDKey: 'OLD'}}, {name: 'chatInfoPanel'}], + }), + }) + + expect(setChatRootParams({conversationIDKey: 'NEW' as never})).toBe(true) + + const action = nav.lastAction() + expect(action?.type).toBe('RESET') + expect(action?.target).toBe('tabs') + const payload = action?.payload as {routes: Array<{state?: {routes: Array<{params?: object}>}}>} + expect(payload.routes[0]?.state?.routes).toEqual([ + {name: 'chatRoot', params: {conversationIDKey: 'NEW'}}, + ]) + }) + + test('reports failure when there is no chat tab to target', () => { + nav = installFakeNavigator({rootState: makeRootState({tab: Tabs.teamsTab})}) + + expect(setChatRootParams({conversationIDKey: 'NEW' as never})).toBe(false) + expect(nav.actions).toEqual([]) + }) + + test('reports failure when logged out', () => { + nav = installFakeNavigator({rootState: makeRootState({loggedIn: false})}) + + expect(setChatRootParams({conversationIDKey: 'NEW' as never})).toBe(false) + expect(nav.actions).toEqual([]) + }) +}) + +// ---- switchTab ---- + +describe('switchTab', () => { + test('jumps within the tab navigator, not the root stack', () => { + nav = installFakeNavigator() + + switchTab(Tabs.teamsTab) + + expect(nav.lastAction()).toMatchObject({payload: {name: Tabs.teamsTab}, target: 'tabs', type: 'JUMP_TO'}) + }) + + // The logged-out root has a stack of its own, with a key a tab jump could be aimed at. + test('dispatches nothing when logged out', () => { + nav = installFakeNavigator({rootState: makeRootState({loggedIn: false})}) + + switchTab(Tabs.teamsTab) + + expect(nav.actions).toEqual([]) + }) +}) diff --git a/shared/git/delete-repo.test.tsx b/shared/git/delete-repo.test.tsx index 630c3226e33b..c52d5f0919c3 100644 --- a/shared/git/delete-repo.test.tsx +++ b/shared/git/delete-repo.test.tsx @@ -8,7 +8,6 @@ jest.mock('@/constants', () => ({ ...(jest.requireActual('@/constants') as object), useRPC: jest.fn(), })) -jest.mock('@/constants/router', () => ({navigateUp: jest.fn()})) // the confirmation gate is the logic under test; the chrome around it is // native/electron-only jest.mock('@/common-adapters', () => { diff --git a/shared/login/recover-password/flow-prompts.test.tsx b/shared/login/recover-password/flow-prompts.test.tsx index 876f548fee3a..e01d3efcb8a6 100644 --- a/shared/login/recover-password/flow-prompts.test.tsx +++ b/shared/login/recover-password/flow-prompts.test.tsx @@ -4,16 +4,6 @@ import {resetAllStores} from '@/util/zustand' import {useConfigState} from '@/stores/config' import {RPCError} from '@/util/errors' -jest.mock('@/constants/router', () => { - const actual = jest.requireActual('@/constants/router') - return { - ...actual, - clearModals: jest.fn(), - navigateAppend: jest.fn(), - navigateUp: jest.fn(), - } -}) - import { cancelRecoverPassword, startRecoverPassword, @@ -22,22 +12,25 @@ import { submitRecoverPasswordPaperKey, submitRecoverPasswordPassword, } from './flow' +import {installFakeNavigator, makeRootState, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' -const { - clearModals: mockClearModals, - navigateAppend: mockNavigateAppend, - navigateUp: mockNavigateUp, -} = require('@/constants/router') as { - clearModals: jest.Mock - navigateAppend: jest.Mock - navigateUp: jest.Mock -} +let nav: FakeNavigator + +// Recovery runs from a modal, so the fake starts with one open: clearModals only has +// something to dispatch when a modal is actually on screen. Nothing below replaces onto +// this name - a replace onto the visible route collapses into a setParams instead. +const openModal = 'recoverPasswordPromptResetPassword' + +beforeEach(() => { + nav = installFakeNavigator({ + modalRouteNames: [openModal], + rootState: makeRootState({above: [{name: openModal}]}), + }) +}) afterEach(() => { + restoreNavigator() jest.restoreAllMocks() - mockClearModals.mockReset() - mockNavigateAppend.mockReset() - mockNavigateUp.mockReset() resetAllStores() }) @@ -85,7 +78,7 @@ describe('device selection', () => { code: T.RPCGen.StatusCode.scinputcanceled, desc: 'Input canceled', }) - expect(mockNavigateUp).toHaveBeenCalled() + expect(nav.types()).toContain('GO_BACK') }) test('selecting no device answers with an empty device id', async () => { @@ -131,10 +124,11 @@ describe('device selection', () => { {error: jest.fn(), result: jest.fn()} as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'recoverPasswordDeviceSelector', params: {devices: []}}, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordDeviceSelector', + params: {devices: []}, + replace: true, + }) }) }) @@ -148,10 +142,11 @@ describe('paper key prompt', () => { response as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'recoverPasswordPaperKey', params: {error: 'nope'}}, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordPaperKey', + params: {error: 'nope'}, + replace: true, + }) submitRecoverPasswordPaperKey('one two three') @@ -166,10 +161,11 @@ describe('paper key prompt', () => { {error: jest.fn(), result: jest.fn()} as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'recoverPasswordPaperKey', params: {error: undefined}}, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordPaperKey', + params: {error: undefined}, + replace: true, + }) }) test('backing out of the paper key prompt restarts recovery from the top', async () => { @@ -205,9 +201,10 @@ describe('new password prompt', () => { response as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith({ + expect(nav.navigations()).toContainEqual({ name: 'recoverPasswordSetPassword', params: {error: undefined}, + replace: false, }) submitRecoverPasswordPassword('hunter2hunter2') @@ -223,10 +220,11 @@ describe('new password prompt', () => { {error: jest.fn(), result: jest.fn()} as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'recoverPasswordSetPassword', params: {error: 'too short'}}, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordSetPassword', + params: {error: 'too short'}, + replace: true, + }) }) test('cancelling the new password prompt rejects the rpc without restarting', async () => { @@ -253,13 +251,11 @@ test('a device-recovery explanation replaces the current screen', async () => { {kind: T.RPCGen.DeviceType.mobile, name: 'testuser-mac'} as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - { - name: 'recoverPasswordExplainDevice', - params: {deviceName: 'testuser-mac', deviceType: T.RPCGen.DeviceType.mobile, username: 'testuser'}, - }, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordExplainDevice', + params: {deviceName: 'testuser-mac', deviceType: T.RPCGen.DeviceType.mobile, username: 'testuser'}, + replace: true, + }) }) test('a reset prompt that is not a password reset hands off to the account reset flow', async () => { @@ -271,10 +267,11 @@ test('a reset prompt that is not a password reset hands off to the account reset response as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'recoverPasswordPromptResetAccount', params: {skipPassword: true, username: 'testuser'}}, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordPromptResetAccount', + params: {skipPassword: true, username: 'testuser'}, + replace: true, + }) expect(response.result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.nothing) }) @@ -285,7 +282,7 @@ describe('completion', () => { first.resolve() await flush() - expect(mockClearModals).toHaveBeenCalled() + expect(nav.modalsCleared()).toBe(true) }) test('a cancelled recovery shows no error screen and leaves modals alone', async () => { @@ -294,10 +291,9 @@ describe('completion', () => { first.reject(new RPCError('Input canceled', T.RPCGen.StatusCode.scinputcanceled)) await flush() - expect(mockClearModals).not.toHaveBeenCalled() - expect(mockNavigateAppend).not.toHaveBeenCalledWith( - expect.objectContaining({name: 'recoverPasswordError'}), - true + expect(nav.modalsCleared()).toBe(false) + expect(nav.navigations()).not.toContainEqual( + expect.objectContaining({name: 'recoverPasswordError', replace: true}) ) }) @@ -308,11 +304,12 @@ describe('completion', () => { first.reject(error) await flush() - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'recoverPasswordError', params: {error: error.message}}, - true - ) - expect(mockClearModals).not.toHaveBeenCalled() + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordError', + params: {error: error.message}, + replace: true, + }) + expect(nav.modalsCleared()).toBe(false) }) test('a failure while logged in shows the error as a modal', async () => { @@ -323,10 +320,11 @@ describe('completion', () => { first.reject(error) await flush() - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'recoverPasswordErrorModal', params: {error: error.message}}, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordErrorModal', + params: {error: error.message}, + replace: true, + }) }) test('handlers stop responding once the run is over', async () => { diff --git a/shared/login/recover-password/flow.test.tsx b/shared/login/recover-password/flow.test.tsx index 4d42a02652ca..6629e6c3483f 100644 --- a/shared/login/recover-password/flow.test.tsx +++ b/shared/login/recover-password/flow.test.tsx @@ -2,37 +2,22 @@ import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' -jest.mock('@/constants/router', () => { - const actual = jest.requireActual('@/constants/router') - return { - ...actual, - clearModals: jest.fn(), - navigateAppend: jest.fn(), - navigateUp: jest.fn(), - } -}) - +import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' import { startRecoverPassword, submitRecoverPasswordDeviceSelect, submitRecoverPasswordReset, } from './flow' -const { - clearModals: mockClearModals, - navigateAppend: mockNavigateAppend, - navigateUp: mockNavigateUp, -} = require('@/constants/router') as { - clearModals: jest.Mock - navigateAppend: jest.Mock - navigateUp: jest.Mock -} +let nav: FakeNavigator + +beforeEach(() => { + nav = installFakeNavigator() +}) afterEach(() => { + restoreNavigator() jest.restoreAllMocks() - mockClearModals.mockReset() - mockNavigateAppend.mockReset() - mockNavigateUp.mockReset() resetAllStores() }) @@ -70,21 +55,19 @@ test('startRecoverPassword exposes device selection handlers', async () => { startRecoverPassword({username: 'alice'}) await flush() - expect(mockNavigateAppend).toHaveBeenCalledWith( - { - name: 'recoverPasswordDeviceSelector', - params: { - devices: [ - expect.objectContaining({ - id: T.Devices.stringToDeviceID('device-1'), - name: 'phone', - type: 'mobile', - }), - ], - }, + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordDeviceSelector', + params: { + devices: [ + expect.objectContaining({ + id: T.Devices.stringToDeviceID('device-1'), + name: 'phone', + type: 'mobile', + }), + ], }, - false - ) + replace: false, + }) submitRecoverPasswordDeviceSelect(T.Devices.stringToDeviceID('device-1')) submitRecoverPasswordDeviceSelect(T.Devices.stringToDeviceID('device-1')) @@ -156,9 +139,10 @@ test('reset-password prompt resolves callback and local banner handler', async ( startRecoverPassword({onResetEmailSent, username: 'alice'}) await flush() - expect(mockNavigateAppend).toHaveBeenCalledWith({ + expect(nav.navigations()).toContainEqual({ name: 'recoverPasswordPromptResetPassword', params: {username: 'alice'}, + replace: false, }) submitRecoverPasswordReset(T.RPCGen.ResetPromptResponse.confirmReset) @@ -167,7 +151,7 @@ test('reset-password prompt resolves callback and local banner handler', async ( expect(promptResponse?.result).toHaveBeenCalledTimes(1) expect(promptResponse?.result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.confirmReset) expect(onResetEmailSent).toHaveBeenCalledTimes(1) - expect(mockNavigateUp).toHaveBeenCalledTimes(1) + expect(nav.types().filter(t => t === 'GO_BACK')).toHaveLength(1) } finally { finishListener() await flush() diff --git a/shared/login/reset/account-reset.test.ts b/shared/login/reset/account-reset.test.ts index 306c657c90ae..616b1280c8fa 100644 --- a/shared/login/reset/account-reset.test.ts +++ b/shared/login/reset/account-reset.test.ts @@ -5,30 +5,26 @@ import {RPCError} from '@/util/errors' const mockStartProvision = jest.fn() -jest.mock('@/constants/router', () => { - const actual = jest.requireActual('@/constants/router') - return { - ...actual, - navUpToScreen: jest.fn(), - navigateAppend: jest.fn(), - } -}) - jest.mock('@/provision/flow', () => ({ startProvision: (...args: Array) => mockStartProvision(...args), })) +import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' import {enterResetPipeline, startAccountReset, submitResetPrompt} from './account-reset' -const {navigateAppend: mockNavigateAppend, navUpToScreen: mockNavUpToScreen} = require('@/constants/router') as { - navigateAppend: jest.Mock - navUpToScreen: jest.Mock -} +let nav: FakeNavigator + +// The confirm screen is handed a one-shot key, and the only way to learn it is to read +// the params the flow navigated with. +const lastResetKey = () => (nav.navigations().at(-1)?.params as {resetKey?: string} | undefined)?.resetKey ?? '' + +beforeEach(() => { + nav = installFakeNavigator() +}) afterEach(() => { + restoreNavigator() jest.restoreAllMocks() - mockNavigateAppend.mockReset() - mockNavUpToScreen.mockReset() mockStartProvision.mockReset() resetAllStores() }) @@ -38,13 +34,11 @@ const flush = async () => new Promise(resolve => setImmediate(resolve)) test('startAccountReset navigates into the reset flow', () => { startAccountReset(true, 'testuser') - expect(mockNavigateAppend).toHaveBeenCalledWith( - { - name: 'recoverPasswordPromptResetAccount', - params: {skipPassword: true, username: 'testuser'}, - }, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordPromptResetAccount', + params: {skipPassword: true, username: 'testuser'}, + replace: true, + }) }) test('enterResetPipeline exposes a submit handler for the confirm screen and starts provision on confirm', async () => { @@ -71,12 +65,12 @@ test('enterResetPipeline exposes a submit handler for the confirm screen and sta enterResetPipeline({username: 'testuser'}) await flush() - const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params - ?.resetKey as string - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'resetConfirm', params: {hasWallet: true, resetKey}}, - true - ) + const resetKey = lastResetKey() + expect(nav.navigations()).toContainEqual({ + name: 'resetConfirm', + params: {hasWallet: true, resetKey}, + replace: true, + }) submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.confirmReset) @@ -107,13 +101,11 @@ test('enterResetPipeline responds and starts the reset flow for non-complete pro await Promise.resolve() expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.nothing) - expect(mockNavigateAppend).toHaveBeenCalledWith( - { - name: 'recoverPasswordPromptResetAccount', - params: {skipPassword: true, username: 'testuser'}, - }, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'recoverPasswordPromptResetAccount', + params: {skipPassword: true, username: 'testuser'}, + replace: true, + }) }) test('submitResetPrompt sends cancel responses back to the login flow', async () => { @@ -139,12 +131,11 @@ test('submitResetPrompt sends cancel responses back to the login flow', async () try { enterResetPipeline({username: 'testuser'}) await flush() - const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params - ?.resetKey as string + const resetKey = lastResetKey() submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.cancelReset) expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.cancelReset) - expect(mockNavUpToScreen).toHaveBeenCalledWith('login') + expect(nav.actions).toContainEqual(expect.objectContaining({payload: {name: 'login'}, type: 'POP_TO'})) } finally { finishListener() await flush() @@ -174,12 +165,11 @@ test('submitResetPrompt sends nothing responses back to the login flow', async ( try { enterResetPipeline({username: 'testuser'}) await flush() - const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params - ?.resetKey as string + const resetKey = lastResetKey() submitResetPrompt(resetKey, T.RPCGen.ResetPromptResponse.nothing) expect(result).toHaveBeenCalledWith(T.RPCGen.ResetPromptResponse.nothing) - expect(mockNavUpToScreen).toHaveBeenCalledWith('login') + expect(nav.actions).toContainEqual(expect.objectContaining({payload: {name: 'login'}, type: 'POP_TO'})) } finally { finishListener() await flush() @@ -209,8 +199,7 @@ test('enterResetPipeline disposes an unconsumed reset prompt when the listener e enterResetPipeline({username: 'testuser'}) await flush() - const resetKey = mockNavigateAppend.mock.calls[mockNavigateAppend.mock.calls.length - 1]?.[0]?.params - ?.resetKey as string + const resetKey = lastResetKey() finishListener() await flush() @@ -231,10 +220,11 @@ test('reset progress before verification shows the check-your-email screen', asy enterResetPipeline({username: 'testuser'}) await flush() - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'resetWaiting', params: {endTime: undefined, pipelineStarted: false, username: 'testuser'}}, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'resetWaiting', + params: {endTime: undefined, pipelineStarted: false, username: 'testuser'}, + replace: true, + }) }) test('reset progress after verification passes the countdown end time in milliseconds', async () => { @@ -248,10 +238,11 @@ test('reset progress after verification passes the countdown end time in millise enterResetPipeline({username: 'testuser'}) await flush() - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'resetWaiting', params: {endTime: 1700000000000, pipelineStarted: true, username: 'testuser'}}, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'resetWaiting', + params: {endTime: 1700000000000, pipelineStarted: true, username: 'testuser'}, + replace: true, + }) }) test('an rpc failure clears then reports the error to the caller', async () => { diff --git a/shared/provision/flow-prompts.test.tsx b/shared/provision/flow-prompts.test.tsx index 8e29255d30b0..cc00cfd73980 100644 --- a/shared/provision/flow-prompts.test.tsx +++ b/shared/provision/flow-prompts.test.tsx @@ -7,16 +7,6 @@ import {useWaitingState} from '@/stores/waiting' import {waitingKeyProvision} from '@/constants/strings' import {RPCError} from '@/util/errors' -jest.mock('@/constants/router', () => { - const actual = jest.requireActual('@/constants/router') - return { - ...actual, - clearModals: jest.fn(), - navigateAppend: jest.fn(), - navigateUp: jest.fn(), - } -}) - import { cancelProvision, startProvision, @@ -25,16 +15,26 @@ import { submitProvisionUsername, } from './flow' -const {clearModals: mockClearModals, navigateAppend: mockNavigateAppend} = require('@/constants/router') as { - clearModals: jest.Mock - navigateAppend: jest.Mock -} +import {installFakeNavigator, makeRootState, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' + +let nav: FakeNavigator + +// Provisioning runs from a modal, so the fake starts with one open: clearModals only has +// something to dispatch when a modal is actually on screen. This one is never a +// navigation target below, so a replace onto another screen stays a replace. +const openModal = 'deviceAdd' + +beforeEach(() => { + nav = installFakeNavigator({ + modalRouteNames: [openModal], + rootState: makeRootState({above: [{name: openModal}]}), + }) +}) afterEach(() => { + restoreNavigator() cancelProvision() jest.restoreAllMocks() - mockClearModals.mockReset() - mockNavigateAppend.mockReset() resetAllStores() }) @@ -81,14 +81,12 @@ describe('final error handling', () => { attempt.reject(new RPCError('no such user', T.RPCGen.StatusCode.scnotfound)) await flush() - expect(mockNavigateAppend).toHaveBeenCalledWith( - { - name: 'username', - params: {inlineErrorCode: T.RPCGen.StatusCode.scnotfound, username: 'testuser'}, - }, - true - ) - expect(mockClearModals).not.toHaveBeenCalled() + expect(nav.navigations()).toContainEqual({ + name: 'username', + params: {inlineErrorCode: T.RPCGen.StatusCode.scnotfound, username: 'testuser'}, + replace: true, + }) + expect(nav.modalsCleared()).toBe(false) }) test('a malformed username also stays on the username screen', async () => { @@ -97,13 +95,11 @@ describe('final error handling', () => { attempt.reject(new RPCError('bad username', T.RPCGen.StatusCode.scbadusername)) await flush() - expect(mockNavigateAppend).toHaveBeenCalledWith( - { - name: 'username', - params: {inlineErrorCode: T.RPCGen.StatusCode.scbadusername, username: 'testuser'}, - }, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'username', + params: {inlineErrorCode: T.RPCGen.StatusCode.scbadusername, username: 'testuser'}, + replace: true, + }) }) test('any other error clears modals and shows the error screen with the rpc details', async () => { @@ -115,23 +111,21 @@ describe('final error handling', () => { attempt.reject(error) await flush() - expect(mockClearModals).toHaveBeenCalled() - expect(mockNavigateAppend).toHaveBeenCalledWith( - { - name: 'error', - params: { - error: { - code: T.RPCGen.StatusCode.scdeviceprovisionoffline, - desc: error.desc, - details: error.details, - fields: [{key: 'has_active_device', value: '1'}], - message: error.message, - }, - username: 'testuser', + expect(nav.modalsCleared()).toBe(true) + expect(nav.navigations()).toContainEqual({ + name: 'error', + params: { + error: { + code: T.RPCGen.StatusCode.scdeviceprovisionoffline, + desc: error.desc, + details: error.details, + fields: [{key: 'has_active_device', value: '1'}], + message: error.message, }, + username: 'testuser', }, - true - ) + replace: true, + }) }) test('an error caused by our own cancel shows nothing', async () => { @@ -140,8 +134,8 @@ describe('final error handling', () => { attempt.reject(new RPCError('Input canceled', T.RPCGen.StatusCode.scgeneric)) await flush() - expect(mockClearModals).not.toHaveBeenCalled() - expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'error'}), true) + expect(nav.modalsCleared()).toBe(false) + expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'error', replace: true})) }) test('a kex cancel from the daemon shows nothing', async () => { @@ -150,7 +144,7 @@ describe('final error handling', () => { attempt.reject(new RPCError('kex canceled by caller', T.RPCGen.StatusCode.scgeneric)) await flush() - expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'error'}), true) + expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'error', replace: true})) }) test('a non-rpc failure does not navigate anywhere', async () => { @@ -159,8 +153,8 @@ describe('final error handling', () => { attempt.reject(new Error('boom')) await flush() - expect(mockClearModals).not.toHaveBeenCalled() - expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'error'}), true) + expect(nav.modalsCleared()).toBe(false) + expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'error', replace: true})) }) }) @@ -174,10 +168,11 @@ describe('passphrase prompts', () => { response as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'password', params: {error: undefined, username: 'testuser'}}, - false - ) + expect(nav.navigations()).toContainEqual({ + name: 'password', + params: {error: undefined, username: 'testuser'}, + replace: false, + }) }) test('the service rejecting the password is rewritten to a readable error and replaces the screen', async () => { @@ -189,10 +184,11 @@ describe('passphrase prompts', () => { response as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'password', params: {error: 'Incorrect password.', username: 'testuser'}}, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'password', + params: {error: 'Incorrect password.', username: 'testuser'}, + replace: true, + }) }) test('any other retry label is passed through verbatim', async () => { @@ -204,10 +200,11 @@ describe('passphrase prompts', () => { response as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'password', params: {error: 'Try again', username: 'testuser'}}, - true - ) + expect(nav.navigations()).toContainEqual({ + name: 'password', + params: {error: 'Try again', username: 'testuser'}, + replace: true, + }) }) test('a paper key prompt names the device the user picked', async () => { @@ -226,10 +223,11 @@ describe('passphrase prompts', () => { response as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'paperkey', params: {deviceName: 'paper key one', error: undefined}}, - false - ) + expect(nav.navigations()).toContainEqual({ + name: 'paperkey', + params: {deviceName: 'paper key one', error: undefined}, + replace: false, + }) }) }) @@ -243,18 +241,16 @@ describe('text code prompt', () => { response as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - { - name: 'codePage', - params: { - deviceName: '', - error: undefined, - otherDevice: expect.objectContaining({name: ''}), - textCode: 'one two three', - }, + expect(nav.navigations()).toContainEqual({ + name: 'codePage', + params: { + deviceName: '', + error: undefined, + otherDevice: expect.objectContaining({name: ''}), + textCode: 'one two three', }, - false - ) + replace: false, + }) submitProvisionTextCode(' one,two\n\nthree ') @@ -270,12 +266,12 @@ describe('text code prompt', () => { response as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( + expect(nav.navigations()).toContainEqual( expect.objectContaining({ name: 'codePage', params: expect.objectContaining({error: 'nope', textCode: 'four five six'}), - }), - true + replace: true, + }) ) }) }) @@ -314,9 +310,10 @@ test('starting provisioning while logged in logs out first', async () => { await flush() expect(logout).toHaveBeenCalledWith({force: false, keepSecrets: true}, 'config:loginAsOther') - expect(mockNavigateAppend).toHaveBeenCalledWith({ + expect(nav.navigations()).toContainEqual({ name: 'username', params: {fromReset: false, username: 'testuser'}, + replace: false, }) }) diff --git a/shared/provision/flow.test.tsx b/shared/provision/flow.test.tsx index 2f68a7990fef..885b269aba70 100644 --- a/shared/provision/flow.test.tsx +++ b/shared/provision/flow.test.tsx @@ -3,16 +3,6 @@ import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {RPCError} from '@/util/errors' -jest.mock('@/constants/router', () => { - const actual = jest.requireActual('@/constants/router') - return { - ...actual, - clearModals: jest.fn(), - navigateAppend: jest.fn(), - navigateUp: jest.fn(), - } -}) - import { cancelProvision, pauseProvision, @@ -23,16 +13,26 @@ import { startProvision, } from './flow' -const {clearModals: mockClearModals, navigateAppend: mockNavigateAppend} = require('@/constants/router') as { - clearModals: jest.Mock - navigateAppend: jest.Mock -} +import {installFakeNavigator, makeRootState, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' + +let nav: FakeNavigator + +// Provisioning runs from a modal, so the fake starts with one open: clearModals only has +// something to dispatch when a modal is actually on screen. This one is never a +// navigation target below, so a replace onto another screen stays a replace. +const openModal = 'deviceAdd' + +beforeEach(() => { + nav = installFakeNavigator({ + modalRouteNames: [openModal], + rootState: makeRootState({above: [{name: openModal}]}), + }) +}) afterEach(() => { + restoreNavigator() cancelProvision() jest.restoreAllMocks() - mockClearModals.mockReset() - mockNavigateAppend.mockReset() resetAllStores() }) @@ -73,9 +73,10 @@ const mockLoginAttempts = () => { test('startProvision navigates to the username screen', () => { startProvision('alice', true) - expect(mockNavigateAppend).toHaveBeenCalledWith({ + expect(nav.navigations()).toContainEqual({ name: 'username', params: {fromReset: true, username: 'alice'}, + replace: false, }) }) @@ -92,7 +93,7 @@ test('chooseDevice prompt navigates with devices and the selection resolves once response as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith({ + expect(nav.navigations()).toContainEqual({ name: 'selectOtherDevice', params: { devices: [ @@ -104,6 +105,7 @@ test('chooseDevice prompt navigates with devices and the selection resolves once ], username: 'alice', }, + replace: false, }) submitProvisionDeviceSelect('phone') @@ -130,10 +132,11 @@ test('changing an earlier answer restarts the RPC and replays recorded answers', {errorMessage: ''} as any, nameResponse1 as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'setPublicName', params: {devices: [], error: undefined}}, - false - ) + expect(nav.navigations()).toContainEqual({ + name: 'setPublicName', + params: {devices: [], error: undefined}, + replace: false, + }) submitProvisionDeviceName('dev1') expect(nameResponse1.result).toHaveBeenCalledWith('dev1') @@ -148,10 +151,11 @@ test('changing an earlier answer restarts the RPC and replays recorded answers', {pinentry: {retryLabel: '', type: T.RPCGen.PassphraseType.passPhrase}} as any, passphraseResponse as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - {name: 'password', params: {error: undefined, username: 'alice'}}, - false - ) + expect(nav.navigations()).toContainEqual({ + name: 'password', + params: {error: undefined, username: 'alice'}, + replace: false, + }) // the user goes back and submits a different device name: the pending password // prompt is cancelled and the RPC restarts @@ -162,14 +166,14 @@ test('changing an earlier answer restarts the RPC and replays recorded answers', const attempt2 = attempts[1]! // the device name prompt in the new attempt is auto-submitted with the new answer - mockNavigateAppend.mockClear() + nav.clearActions() const nameResponse2 = {error: jest.fn(), result: jest.fn()} attempt2.listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.PromptNewDeviceName']?.( {errorMessage: ''} as any, nameResponse2 as any ) expect(nameResponse2.result).toHaveBeenCalledWith('dev2') - expect(mockNavigateAppend).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) attempt2.resolve() await flush() @@ -204,35 +208,29 @@ test('a cancelled add-device run does not clear modals out from under a retry', {phrase: 'one two three', previousErr: ''} as any, response1 as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - expect.objectContaining({name: 'codePage'}), - false - ) + expect(nav.navigations()).toContainEqual(expect.objectContaining({name: 'codePage', replace: false})) // the user cancels, then tries again: the dead run must not clear modals or eat the new run's UI startAddNewDevice('mobile') expect(response1.error).toHaveBeenCalled() await flush() - expect(mockClearModals).not.toHaveBeenCalled() + expect(nav.modalsCleared()).toBe(false) expect(attempts.length).toBe(2) const attempt2 = attempts[1]! - mockNavigateAppend.mockClear() + nav.clearActions() const response2 = {error: jest.fn(), result: jest.fn()} attempt2.listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.DisplayAndPromptSecret']?.( {phrase: 'four five six', previousErr: ''} as any, response2 as any ) - expect(mockNavigateAppend).toHaveBeenCalledWith( - expect.objectContaining({name: 'codePage'}), - false - ) + expect(nav.navigations()).toContainEqual(expect.objectContaining({name: 'codePage', replace: false})) expect(response2.error).not.toHaveBeenCalled() attempt2.resolve() await flush() // the successful run still clears modals when it finishes - expect(mockClearModals).toHaveBeenCalled() + expect(nav.modalsCleared()).toBe(true) }) test('cancel before any prompt kills the RPC at its first prompt', async () => { @@ -259,11 +257,11 @@ test('cancel before any prompt kills the RPC at its first prompt', async () => { response as any ) expect(response.error).toHaveBeenCalled() - expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'codePage'}), false) + expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'codePage', replace: false})) finishListener(new RPCError('Input canceled', T.RPCGen.StatusCode.scinputcanceled)) await flush() - expect(mockClearModals).not.toHaveBeenCalled() + expect(nav.modalsCleared()).toBe(false) }) test('pause during server work cancels the attempt and parks the run', async () => { @@ -279,7 +277,7 @@ test('pause during server work cancels the attempt and parks the run', async () // parked: no restart, no error navigation expect(attempts.length).toBe(1) - expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'error'}), true) + expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'error', replace: true})) }) test('resubmit while parked restarts and replays recorded answers', async () => { @@ -308,14 +306,14 @@ test('resubmit while parked restarts and replays recorded answers', async () => expect(attempts.length).toBe(2) // the new attempt auto-submits the replayed answer without navigating - mockNavigateAppend.mockClear() + nav.clearActions() const nameResponse2 = {error: jest.fn(), result: jest.fn()} attempts[1]!.listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.PromptNewDeviceName']?.( {errorMessage: ''} as any, nameResponse2 as any ) expect(nameResponse2.result).toHaveBeenCalledWith('dev2') - expect(mockNavigateAppend).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) attempts[1]!.resolve() await flush() @@ -336,7 +334,7 @@ test('cancel while parked tears the run down', async () => { submitProvisionDeviceName('dev1') await flush() expect(attempts.length).toBe(1) - expect(mockNavigateAppend).not.toHaveBeenCalledWith(expect.objectContaining({name: 'error'}), true) + expect(nav.navigations()).not.toContainEqual(expect.objectContaining({name: 'error', replace: true})) }) test('a prompt arriving after pause is rejected and does not navigate', async () => { @@ -349,14 +347,14 @@ test('a prompt arriving after pause is rejected and does not navigate', async () pauseProvision() await flush() - mockNavigateAppend.mockClear() + nav.clearActions() const response = {error: jest.fn(), result: jest.fn()} attempt1.listener.customResponseIncomingCallMap?.['keybase.1.secretUi.getPassphrase']?.( {pinentry: {retryLabel: '', type: T.RPCGen.PassphraseType.passPhrase}} as any, response as any ) expect(response.error).toHaveBeenCalled() - expect(mockNavigateAppend).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) }) test('pause with a pending prompt still resumes when the same step is resubmitted', async () => { @@ -392,7 +390,7 @@ test('pause with a pending prompt still resumes when the same step is resubmitte const attempt2 = attempts[1]! // the new attempt auto-submits the replayed answer without navigating - mockNavigateAppend.mockClear() + nav.clearActions() const nameResponse2 = {error: jest.fn(), result: jest.fn()} attempt2.listener.customResponseIncomingCallMap?.['keybase.1.provisionUi.PromptNewDeviceName']?.( {errorMessage: ''} as any, diff --git a/shared/provision/waiting-overlay.test.tsx b/shared/provision/waiting-overlay.test.tsx index ad41ed8ac09a..961f8055b8a4 100644 --- a/shared/provision/waiting-overlay.test.tsx +++ b/shared/provision/waiting-overlay.test.tsx @@ -8,7 +8,6 @@ import {useWaitingState} from '@/stores/waiting' import {waitingKeyProvision} from '@/constants/strings' const mockPauseProvision = jest.fn() -const mockNavigateUp = jest.fn() const mockAddListener = jest.fn() jest.mock('@/common-adapters', () => { @@ -32,22 +31,21 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({addListener: mockAddListener}), })) -jest.mock('@/constants/router', () => ({ - navigateUp: (...args: Array) => mockNavigateUp(...args), -})) - jest.mock('./flow', () => ({ pauseProvision: (...args: Array) => mockPauseProvision(...args), })) +import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' import ProvisionWaitingOverlay from './waiting-overlay' type BeforeRemoveEvent = {data: {action: {type: string}}} describe('ProvisionWaitingOverlay', () => { let beforeRemove: undefined | ((e: BeforeRemoveEvent) => void) + let nav: FakeNavigator beforeEach(() => { + nav = installFakeNavigator() jest.useFakeTimers() beforeRemove = undefined mockAddListener.mockImplementation((event: string, callback: (e: BeforeRemoveEvent) => void) => { @@ -59,11 +57,11 @@ describe('ProvisionWaitingOverlay', () => { }) afterEach(() => { + restoreNavigator() cleanup() jest.useRealTimers() mockAddListener.mockReset() mockPauseProvision.mockReset() - mockNavigateUp.mockReset() resetAllStores() }) @@ -121,7 +119,7 @@ describe('ProvisionWaitingOverlay', () => { act(() => screen.getByText('Cancel').click()) expect(mockPauseProvision).toHaveBeenCalled() - expect(mockNavigateUp).toHaveBeenCalled() + expect(nav.types()).toContain('GO_BACK') }) test('popping the screen while waiting pauses the flow', () => { diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx index 4a3592c7adcf..a3d0b601de4d 100644 --- a/shared/router-v2/tab-bar.desktop.tsx +++ b/shared/router-v2/tab-bar.desktop.tsx @@ -26,7 +26,7 @@ import {dumpLogs} from '@/util/storeless-actions' const {hideWindow, ctlQuit} = KB2.functions export type Props = { - navigation: C.Router2.Navigator + navigation: C.Router2.NavigationRef state: C.Router2.NavState } diff --git a/shared/settings/password.test.tsx b/shared/settings/password.test.tsx index bec8ce687d5b..7c03744e9ad2 100644 --- a/shared/settings/password.test.tsx +++ b/shared/settings/password.test.tsx @@ -8,12 +8,6 @@ jest.mock('@/constants', () => ({ ...(jest.requireActual('@/constants') as object), useRPC: jest.fn(), })) -jest.mock('@/constants/router', () => ({ - clearModals: jest.fn(), - navigateAppend: jest.fn(), - navigateUp: jest.fn(), - switchTab: jest.fn(), -})) // the real components pull in native/electron-only rendering; we only care // about the password validation logic here jest.mock('@/common-adapters', () => { @@ -65,8 +59,8 @@ import {act, cleanup, fireEvent, render, renderHook, screen} from '@testing-libr import * as C from '@/constants' import RPCError from '@/util/rpcerror' import * as T from '@/constants/types' -import {navigateUp} from '@/constants/router' import {resetAllStores} from '@/util/zustand' +import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' import {UpdatePassword, useSubmitNewPassword} from './password' const typePasswords = (password: string, confirm: string) => { @@ -76,7 +70,14 @@ const typePasswords = (password: string, confirm: string) => { const saveButton = () => screen.getByText('Save') as HTMLButtonElement +let nav: FakeNavigator + +beforeEach(() => { + nav = installFakeNavigator() +}) + afterEach(() => { + restoreNavigator() cleanup() jest.clearAllMocks() jest.restoreAllMocks() @@ -164,7 +165,7 @@ test('useSubmitNewPassword force-changes the password and navigates back', () => act(() => { rpc.resolveNext() }) - expect(navigateUp).toHaveBeenCalled() + expect(nav.types()).toContain('GO_BACK') expect(result.current.error).toBe('') }) @@ -206,7 +207,7 @@ test('useSubmitNewPassword logs the user out after a successful change when aske // requestLogout() starts by asking the service whether logging out is safe expect(rpcs.submitFor(T.RPCGen.userCanLogoutRpcPromise)).toHaveBeenCalled() - expect(navigateUp).toHaveBeenCalled() + expect(nav.types()).toContain('GO_BACK') }) test('useSubmitNewPassword leaves the session alone when it is not asked to log out', () => { @@ -221,7 +222,7 @@ test('useSubmitNewPassword leaves the session alone when it is not asked to log }) expect(rpcs.submitFor(T.RPCGen.userCanLogoutRpcPromise)).not.toHaveBeenCalled() - expect(navigateUp).toHaveBeenCalled() + expect(nav.types()).toContain('GO_BACK') }) test('useSubmitNewPassword does not log out when the change fails', () => { @@ -239,7 +240,7 @@ test('useSubmitNewPassword does not log out when the change fails', () => { }) expect(rpcs.submitFor(T.RPCGen.userCanLogoutRpcPromise)).not.toHaveBeenCalled() - expect(navigateUp).not.toHaveBeenCalled() + expect(nav.types()).not.toContain('GO_BACK') expect(result.current.error).toBe('too weak') }) @@ -254,7 +255,7 @@ test('useSubmitNewPassword shows the service description on failure and clears i rpc.rejectNext(new RPCError('too weak', T.RPCGen.StatusCode.scgeneric)) }) expect(result.current.error).toBe('too weak') - expect(navigateUp).not.toHaveBeenCalled() + expect(nav.types()).not.toContain('GO_BACK') act(() => { result.current.onSave('longenough2') diff --git a/shared/settings/use-delete-account.test.tsx b/shared/settings/use-delete-account.test.tsx index eacdf3d5b395..c78d81911660 100644 --- a/shared/settings/use-delete-account.test.tsx +++ b/shared/settings/use-delete-account.test.tsx @@ -11,20 +11,15 @@ jest.mock('@/constants', () => ({ }, useRPC: jest.fn(), })) -jest.mock('@/constants/router', () => ({ - clearModals: jest.fn(), - navigateAppend: jest.fn(), -})) - import {act, cleanup, renderHook} from '@testing-library/react' import * as C from '@/constants' import * as T from '@/constants/types' import RPCError from '@/util/rpcerror' import logger from '@/logger' -import {clearModals, navigateAppend} from '@/constants/router' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' +import {installFakeNavigator, makeRootState, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' import {useDeleteAccount} from './use-delete-account' type DeleteSubmit = ( @@ -33,6 +28,18 @@ type DeleteSubmit = ( reject: (error: RPCError) => void ) => void +// The delete flow runs from a confirmation modal, so the fake starts with one open: +// clearModals only has something to dispatch when a modal is actually on screen. +const deleteModal = 'settingsDeleteConfirm' +let nav: FakeNavigator + +beforeEach(() => { + nav = installFakeNavigator({ + modalRouteNames: [deleteModal], + rootState: makeRootState({above: [{name: deleteModal}]}), + }) +}) + const mockDeleteRPC = () => { const pending = new Array<{reject: (error: RPCError) => void; resolve: () => void}>() const submit = jest.fn( @@ -53,6 +60,7 @@ const mockDeleteRPC = () => { } afterEach(() => { + restoreNavigator() cleanup() mockAndroidIsTestDevice.value = false jest.clearAllMocks() @@ -91,8 +99,12 @@ test('deletes forever, records the deleted self and sends the user to login', () }) expect(setJustDeletedSelf).toHaveBeenCalledWith('testuser') - expect(clearModals).toHaveBeenCalled() - expect(navigateAppend).toHaveBeenCalledWith({name: C.Tabs.loginTab, params: {}}) + // clearModals: the confirmation modal is reset away, leaving only the tab navigator + const cleared = nav.actions.find(a => a.type === 'RESET') + expect((cleared?.payload as {routes: Array<{name: string}>} | undefined)?.routes.map(r => r.name)).toEqual([ + 'loggedIn', + ]) + expect(nav.pushes()).toContainEqual({name: C.Tabs.loginTab, params: {}}) }) test('passes an undefined passphrase through for accounts without one', () => { @@ -126,8 +138,7 @@ test('pre-launch test devices never reach the delete rpc', () => { }) expect(rpc.submit).not.toHaveBeenCalled() - expect(clearModals).not.toHaveBeenCalled() - expect(navigateAppend).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) }) test('logs and stays put when the delete rpc fails', () => { @@ -151,5 +162,5 @@ test('logs and stays put when the delete rpc fails', () => { expect.objectContaining({code: T.RPCGen.StatusCode.scgeneric}) ) expect(setJustDeletedSelf).not.toHaveBeenCalled() - expect(clearModals).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) }) diff --git a/shared/settings/use-request-logout.test.tsx b/shared/settings/use-request-logout.test.tsx index f38b72098479..86ce8650740d 100644 --- a/shared/settings/use-request-logout.test.tsx +++ b/shared/settings/use-request-logout.test.tsx @@ -6,19 +6,14 @@ jest.mock('@/constants', () => ({ ...(jest.requireActual('@/constants') as object), useRPC: jest.fn(), })) -jest.mock('@/constants/router', () => ({ - navigateAppend: jest.fn(), - switchTab: jest.fn(), -})) - import {act, cleanup, renderHook, waitFor} from '@testing-library/react' import * as C from '@/constants' import * as T from '@/constants/types' import * as Tabs from '@/constants/tabs' -import {navigateAppend, switchTab} from '@/constants/router' import {settingsPasswordTab} from '@/constants/settings' import {resetAllStores} from '@/util/zustand' import {usePushState} from '@/stores/push' +import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' import {useRequestLogout} from './use-request-logout' type MutableGlobals = {isMobile: boolean} @@ -44,7 +39,14 @@ const mockCanLogoutRPC = () => { } } +let nav: FakeNavigator + +beforeEach(() => { + nav = installFakeNavigator() +}) + afterEach(() => { + restoreNavigator() cleanup() jest.clearAllMocks() jest.restoreAllMocks() @@ -78,7 +80,7 @@ test('logs out after unregistering the push token when the service allows it', a await waitFor(() => expect(logoutRPC).toHaveBeenCalledWith({force: false, keepSecrets: false})) // the API call needs the still-logged-in session, so the token has to go first expect(order).toEqual(['deleteToken', 'logout']) - expect(navigateAppend).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) }) test('a failing logout rpc is swallowed', async () => { @@ -102,7 +104,7 @@ test('a failing logout rpc is swallowed', async () => { await waitFor(() => expect(logoutRPC).toHaveBeenCalled()) // failures are swallowed: nothing navigates and the caller never sees a rejection - expect(navigateAppend).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) }) test('desktop routes to the password tab when the user cannot log out yet', () => { @@ -119,8 +121,10 @@ test('desktop routes to the password tab when the user cannot log out yet', () = }) expect(logoutRPC).not.toHaveBeenCalled() - expect(switchTab).toHaveBeenCalledWith(Tabs.settingsTab) - expect(navigateAppend).toHaveBeenCalledWith({name: settingsPasswordTab, params: {}}) + expect(nav.actions).toContainEqual( + expect.objectContaining({payload: {name: Tabs.settingsTab}, type: 'JUMP_TO'}) + ) + expect(nav.pushes()).toContainEqual({name: settingsPasswordTab, params: {}}) }) test('mobile pushes the password tab without switching tabs', () => { @@ -136,6 +140,6 @@ test('mobile pushes the password tab without switching tabs', () => { rpc.answer(false) }) - expect(switchTab).not.toHaveBeenCalled() - expect(navigateAppend).toHaveBeenCalledWith({name: settingsPasswordTab, params: {}}) + expect(nav.types()).not.toContain('JUMP_TO') + expect(nav.pushes()).toContainEqual({name: settingsPasswordTab, params: {}}) }) diff --git a/shared/signup/use-request-auto-invite.test.tsx b/shared/signup/use-request-auto-invite.test.tsx index c6fe7725b19c..eccc01bde234 100644 --- a/shared/signup/use-request-auto-invite.test.tsx +++ b/shared/signup/use-request-auto-invite.test.tsx @@ -7,19 +7,19 @@ import {useConfigState} from '@/stores/config' import {useWaitingState} from '@/stores/waiting' import {waitingKeySignup} from '@/constants/strings' -jest.mock('@/constants/router', () => { - const actual = jest.requireActual('@/constants/router') - return {...actual, navigateAppend: jest.fn()} -}) - +import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' import useRequestAutoInvite from './use-request-auto-invite' -const {navigateAppend: mockNavigateAppend} = require('@/constants/router') as {navigateAppend: jest.Mock} +let nav: FakeNavigator + +beforeEach(() => { + nav = installFakeNavigator() +}) afterEach(() => { + restoreNavigator() cleanup() jest.restoreAllMocks() - mockNavigateAppend.mockReset() resetAllStores() }) @@ -33,7 +33,7 @@ test('fetches an invite code and moves on to the username screen', async () => { result.current('testuser') await waitFor(() => - expect(mockNavigateAppend).toHaveBeenCalledWith({ + expect(nav.pushes()).toContainEqual({ name: 'signupEnterUsername', params: {inviteCode: 'invite-code', username: 'testuser'}, }) @@ -51,7 +51,7 @@ test('logs out first when an account is already signed in', async () => { result.current('testuser') await waitFor(() => - expect(mockNavigateAppend).toHaveBeenCalledWith({ + expect(nav.pushes()).toContainEqual({ name: 'signupEnterUsername', params: {inviteCode: 'invite-code', username: 'testuser'}, }) @@ -66,7 +66,7 @@ test('a failed invite code fetch still continues with an empty code', async () = result.current('testuser') await waitFor(() => - expect(mockNavigateAppend).toHaveBeenCalledWith({ + expect(nav.pushes()).toContainEqual({ name: 'signupEnterUsername', params: {inviteCode: '', username: 'testuser'}, }) @@ -85,5 +85,5 @@ test('a request already in flight is ignored', async () => { // nothing to wait for; give any queued work a full macrotask to run anyway await new Promise(resolve => setTimeout(resolve, 0)) expect(getCode).not.toHaveBeenCalled() - expect(mockNavigateAppend).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) }) diff --git a/shared/teams/actions.test.ts b/shared/teams/actions.test.ts index 8d645a586c5b..8d411a692327 100644 --- a/shared/teams/actions.test.ts +++ b/shared/teams/actions.test.ts @@ -1,66 +1,61 @@ /// import * as T from '@/constants/types' -const mockNavigateAppend = jest.fn() -jest.mock('@/constants/router', () => ({ - clearModals: jest.fn(), - navUpToScreen: jest.fn(), - navigateAppend: (...args: Array) => mockNavigateAppend(...args), - navigateUp: jest.fn(), -})) - import {RPCError} from '@/util/errors' +import {installFakeNavigator, restoreNavigator, type FakeNavigator} from '@/test/fake-navigator' import {handleContactSettingsBlock, handleNotAdded} from './actions' +let nav: FakeNavigator + +beforeEach(() => { + nav = installFakeNavigator() +}) + +afterEach(() => { + restoreNavigator() +}) + const contactSettingsError = (fields: unknown) => new RPCError('blocked', T.RPCGen.StatusCode.scteamcontactsettingsblock, fields) describe('handleContactSettingsBlock', () => { - beforeEach(() => { - mockNavigateAppend.mockClear() - }) - test('ignores other error codes', () => { expect(handleContactSettingsBlock(new RPCError('nope', T.RPCGen.StatusCode.scgeneric))).toBe(false) - expect(mockNavigateAppend).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) }) test('navigates with the blocked usernames', () => { expect( handleContactSettingsBlock(contactSettingsError([{key: 'usernames', value: 'testuser,testuser-mac'}])) ).toBe(true) - expect(mockNavigateAppend).toHaveBeenCalledWith({ - name: 'contactRestricted', - params: {source: 'teamAddAllFailed', usernames: ['testuser', 'testuser-mac']}, - }) + expect(nav.pushes()).toEqual([ + { + name: 'contactRestricted', + params: {source: 'teamAddAllFailed', usernames: ['testuser', 'testuser-mac']}, + }, + ]) }) // '' splits into [''], which used to put a blank row on the contactRestricted screen test('has no usernames when the field is empty or missing', () => { for (const fields of [[{key: 'usernames', value: ''}], [{key: 'other', value: 'testuser'}], undefined]) { - mockNavigateAppend.mockClear() + nav.clearActions() expect(handleContactSettingsBlock(contactSettingsError(fields))).toBe(true) - expect(mockNavigateAppend).toHaveBeenCalledWith({ - name: 'contactRestricted', - params: {source: 'teamAddAllFailed', usernames: []}, - }) + expect(nav.pushes()).toEqual([ + {name: 'contactRestricted', params: {source: 'teamAddAllFailed', usernames: []}}, + ]) } }) }) describe('handleNotAdded', () => { - beforeEach(() => { - mockNavigateAppend.mockClear() - }) - test('navigates only when somebody was skipped', () => { handleNotAdded([]) handleNotAdded(undefined) - expect(mockNavigateAppend).not.toHaveBeenCalled() + expect(nav.actions).toEqual([]) handleNotAdded([{username: 'testuser'}]) - expect(mockNavigateAppend).toHaveBeenCalledWith({ - name: 'contactRestricted', - params: {source: 'teamAddSomeFailed', usernames: ['testuser']}, - }) + expect(nav.pushes()).toEqual([ + {name: 'contactRestricted', params: {source: 'teamAddSomeFailed', usernames: ['testuser']}}, + ]) }) }) diff --git a/shared/test/fake-navigator.ts b/shared/test/fake-navigator.ts new file mode 100644 index 000000000000..93332a730ded --- /dev/null +++ b/shared/test/fake-navigator.ts @@ -0,0 +1,188 @@ +// The in-memory Navigator adapter: the second implementation of the seam, so that +// substituting navigation in a test is installing an adapter rather than mocking a +// module. It records what was dispatched and serves whatever root state the test set; +// it deliberately does not reduce actions into state - a test that needs the tree to +// change says so with setRootState. +import * as NavTree from '@/constants/nav-tree' +import * as Tabs from '@/constants/tabs' +import {makeNavigator, setNavigator, type Navigator, type NavigatorRef} from '@/constants/navigator' + +// The action shapes React Navigation's creators produce, narrowed to what assertions need. +export type RecordedAction = { + type: string + payload?: Record + target?: string + source?: string +} + +export type FakeNavigator = Navigator & { + actions: Array + lastAction: () => RecordedAction | undefined + // Every route name pushed, in order, with the params it was pushed with. + pushes: () => Array<{name?: unknown; params?: unknown}> + // Every screen navigateAppend asked for, in order: pushes plus the replaces it + // dispatches when called with replace=true. + navigations: () => Array<{name?: unknown; params?: unknown; replace: boolean}> + // Whether a reset of the root stack left no modal behind, which is what clearModals does. + modalsCleared: () => boolean + // The dispatched action types, in order (PUSH, RESET, GO_BACK, JUMP_TO, ...). + types: () => Array + clearActions: () => void + // Replaces the root state and fires the 'state' listeners, as a real commit would. + setRootState: (state?: NavTree.NavState) => void + setReady: (ready: boolean) => void +} + +type RouteSpec = {name: string; params?: object} + +// A keyed, logged-in root state. Keys are stable and readable so that `target`/`source` +// assertions can name them: 'root', 'tabs', '-stack', '-'. +export const makeRootState = (p?: { + tab?: Tabs.AppTab + // screens inside the selected tab's stack; defaults to that tab's root screen + tabStack?: ReadonlyArray + // screens in the root stack above the tab navigator: modals and phone-pushed screens + above?: ReadonlyArray + // false builds the logged-out root instead; `above` still applies, `tab`/`tabStack` do not + loggedIn?: boolean +}): NavTree.NavState => { + const tab = p?.tab ?? Tabs.chatTab + const above = p?.above ?? [] + if (p?.loggedIn === false) { + // The logged-out root is a real stack with its own key and screens, so a reader that + // forgets to check which root it is looking at finds something to act on. + return { + index: above.length, + key: 'root', + routes: [ + { + key: 'loggedOut', + name: 'loggedOut', + state: { + index: 0, + key: 'loggedOut-stack', + routes: [{key: 'login-0', name: 'login'}], + type: 'stack', + }, + }, + ...above.map((r, i) => ({key: `${r.name}-above-${i}`, name: r.name, params: r.params})), + ], + type: 'stack', + } + } + const tabStack = p?.tabStack ?? [{name: NavTree.tabRoots[tab]}] + return { + index: above.length, + key: 'root', + routes: [ + { + key: 'loggedIn', + name: 'loggedIn', + state: { + index: 0, + key: 'tabs', + routes: [ + { + key: tab, + name: tab, + state: { + index: tabStack.length - 1, + key: `${tab}-stack`, + routes: tabStack.map((r, i) => ({key: `${r.name}-${i}`, name: r.name, params: r.params})), + type: 'stack', + }, + }, + ], + type: 'tab', + }, + }, + ...above.map((r, i) => ({key: `${r.name}-above-${i}`, name: r.name, params: r.params})), + ], + type: 'stack', + } +} + +export const makeFakeNavigator = (p?: { + rootState?: NavTree.NavState + ready?: boolean + // Called at the moment of dispatch, for tests that assert ordering against it. + onDispatch?: (action: RecordedAction) => void +}): FakeNavigator => { + const actions: Array = [] + let rootState = p?.rootState ?? makeRootState() + let ready = p?.ready ?? true + const listeners = new Set<() => void>() + + const ref: NavigatorRef = { + addListener: (_type, cb) => { + listeners.add(cb) + return () => listeners.delete(cb) + }, + dispatch: action => { + if (!ready) return + const recorded = action as unknown as RecordedAction + actions.push(recorded) + p?.onDispatch?.(recorded) + }, + getRootState: () => (ready ? rootState : undefined), + isReady: () => ready, + } + + const navigator = makeNavigator(ref) + + return { + ...navigator, + actions, + clearActions: () => { + actions.length = 0 + }, + lastAction: () => actions.at(-1), + modalsCleared: () => + actions.some( + a => + a.type === 'RESET' && + a.target === 'root' && + ((a.payload?.['routes'] ?? []) as ReadonlyArray<{name: string}>).every( + r => !NavTree.isModalRouteName(r.name) + ) + ), + navigations: () => + actions + .filter(a => a.type === 'PUSH' || a.type === 'REPLACE') + .map(a => ({name: a.payload?.['name'], params: a.payload?.['params'], replace: a.type === 'REPLACE'})), + pushes: () => + actions + .filter(a => a.type === 'PUSH') + .map(a => ({name: a.payload?.['name'], params: a.payload?.['params']})), + setReady: next => { + ready = next + }, + setRootState: next => { + rootState = next + for (const cb of [...listeners]) { + cb() + } + }, + types: () => actions.map(a => a.type), + } +} + +// Installs the fake as the app-wide Navigator, so the free-function facade +// (C.Router2.navigateAppend and friends) drives it. Also registers the modal route +// names, which the tree readers require. +export const installFakeNavigator = (p?: { + rootState?: NavTree.NavState + ready?: boolean + modalRouteNames?: Iterable + onDispatch?: (action: RecordedAction) => void +}): FakeNavigator => { + NavTree.setModalRouteNames(p?.modalRouteNames ?? []) + const fake = makeFakeNavigator(p) + setNavigator(fake) + return fake +} + +export const restoreNavigator = () => { + setNavigator() + NavTree.setModalRouteNames([]) +} diff --git a/shared/test/mocks/react-navigation-core.js b/shared/test/mocks/react-navigation-core.js index 79739b7e6795..988ece9828d1 100644 --- a/shared/test/mocks/react-navigation-core.js +++ b/shared/test/mocks/react-navigation-core.js @@ -52,15 +52,23 @@ exports.useFocusEffect = fn => { } exports.createNavigationContainerRef = () => makeNavigationContainerRef() +// Action creators, matching @react-navigation/routers' real payload shapes so that a +// test asserting on a dispatched action is asserting what production dispatches. exports.CommonActions = { goBack: () => ({type: 'GO_BACK'}), - navigate: payload => ({payload, type: 'NAVIGATE'}), + navigate: (name, params, options) => ({ + payload: {merge: options && options.merge, name, params, pop: options && options.pop}, + type: 'NAVIGATE', + }), reset: payload => ({payload, type: 'RESET'}), - setParams: payload => ({payload, type: 'SET_PARAMS'}), + setParams: params => ({payload: {params}, type: 'SET_PARAMS'}), } exports.StackActions = { - popTo: name => ({payload: {name}, type: 'POP_TO'}), + popTo: (name, params, options) => ({ + payload: {merge: options && options.merge, name, params}, + type: 'POP_TO', + }), popToTop: () => ({type: 'POP_TO_TOP'}), push: (name, params) => ({payload: {name, params}, type: 'PUSH'}), replace: (name, params) => ({payload: {name, params}, type: 'REPLACE'}),