Skip to content

Commit 6f6f998

Browse files
committed
test(mfa): add model-based UI contract test
Render the real MFA modal navigator, walk the machine graph (getSimplePaths plus a fixed lifecycle lap), drive each event through real gestures, and assert the modal/outcome DOM markers at every step. Group the UI-only helpers under tests/utils/mfa/ui/ (harness, mocks, eventExecutors, jestMocks); keep the graph path model in createMfaTestModel and export toStateValue from machineStates for it. The createTestModel-style harness mirrors path.test({events, states}) on top of xstate/graph because the real createTestModel rejects the machine's `after` (closeFallback) transition.
1 parent 98e299d commit 6f6f998

7 files changed

Lines changed: 413 additions & 0 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return -- jest.mock factories delegate to require()'d helpers, which resolve as `any`. */
2+
import {resetMfaNavigation} from '@components/MultifactorAuthentication/mfaNavigation';
3+
import CONST from '@src/CONST';
4+
import createMfaTestModel from '../../utils/mfa/createMfaTestModel';
5+
import mfaEventExecutors from '../../utils/mfa/ui/eventExecutors';
6+
import {flushMfaUi, isModalOverlayMounted, isOutcomeScreenVisible, renderMfaUi} from '../../utils/mfa/ui/harness';
7+
import {resetMfaUiMocks} from '../../utils/mfa/ui/mocks';
8+
9+
// Wide layout so the navigator renders the backdrop overlay the assertions use as the mounted marker.
10+
jest.mock('@hooks/useResponsiveLayout');
11+
// Dev-only Stately inspector wiring -> the plain @xstate/react adapter the provider needs.
12+
jest.mock('@hooks/useInspectedMachine', () => require('../../utils/mfa/ui/jestMocks').inspectedMachineMock());
13+
// Native / WebAuthn biometrics are out of scope for the modal-lifecycle contract.
14+
jest.mock('@components/MultifactorAuthentication/biometrics/useBiometrics', () => require('../../utils/mfa/ui/jestMocks').biometricsHookMock());
15+
// Browser/Android back-history wiring is a separate concern from the machine <-> UI contract.
16+
jest.mock('@components/MultifactorAuthentication/useSyncMfaModalNavigatorWithHistory', () => require('../../utils/mfa/ui/jestMocks').syncHistoryMock());
17+
// Navigation automock leaves methods undefined; supply the three the flow needs and no-op the rest.
18+
jest.mock('@libs/Navigation/Navigation', () => require('../../utils/mfa/ui/jestMocks').navigationMock());
19+
20+
const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE;
21+
22+
const testModel = createMfaTestModel();
23+
24+
// The createTestModel-style config: events map -> UI gesture, states map -> per-state assertion.
25+
// State keys are dot-path values matched with `matchesState`, so they can target any depth - here the
26+
// settled success leaf `open.outcome.success` rather than just the `open` parent.
27+
const testConfig = {
28+
events: mfaEventExecutors,
29+
states: {
30+
[MFA_STATE.CLOSED]: () => {
31+
expect(isModalOverlayMounted()).toBe(false);
32+
expect(isOutcomeScreenVisible()).toBe(false);
33+
},
34+
[`${MFA_STATE.OPEN}.${MFA_STATE.OUTCOME}.${MFA_STATE.SUCCESS}`]: () => {
35+
expect(isModalOverlayMounted()).toBe(true);
36+
expect(isOutcomeScreenVisible()).toBe(true);
37+
},
38+
[MFA_STATE.CLOSING]: () => {
39+
expect(isModalOverlayMounted()).toBe(true);
40+
expect(isOutcomeScreenVisible()).toBe(false);
41+
},
42+
},
43+
};
44+
45+
describe('mfaMachine driven through the real MFA UI', () => {
46+
beforeEach(() => {
47+
resetMfaUiMocks();
48+
resetMfaNavigation();
49+
});
50+
51+
afterEach(() => {
52+
jest.clearAllMocks();
53+
});
54+
55+
// getSimplePaths reaches every state; the lifecycle lap adds the MODAL_CLOSED teardown it skips.
56+
const paths = [...testModel.getSimplePaths(), ...testModel.getLifecyclePaths()];
57+
58+
for (const path of paths) {
59+
it(`reaches ${JSON.stringify(path.state.value)} via [${path.description}]`, async () => {
60+
renderMfaUi();
61+
await flushMfaUi();
62+
await path.test(testConfig);
63+
});
64+
}
65+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {matchesState} from 'xstate';
2+
import type {SnapshotFrom} from 'xstate';
3+
import {getPathsFromEvents, getSimplePaths} from 'xstate/graph';
4+
import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine';
5+
import type {MfaEvent} from '@components/MultifactorAuthentication/machine/types';
6+
import {toStateValue} from './machineStates';
7+
import createInitEvent from './mfaTestFixtures';
8+
9+
/**
10+
* A small `createTestModel`-style harness over `xstate/graph`'s plain path generators: each path
11+
* exposes `path.test({events, states})`, which replays the path through the real UI (drive the
12+
* matching event executor at each step, then run every matching state assertion).
13+
*
14+
* Why not the real `createTestModel`? Its constructor calls `validateMachine`, which throws "After
15+
* events on test machines are not supported", and `mfaMachine.closing` has an `after` (closeFallback)
16+
* transition. The plain generators have no such restriction.
17+
*
18+
* Paths are generated WITHOUT a pinned event list, so the graph synthesizes an event for every
19+
* transition the machine declares and auto-discovers new (sub)states as the chart grows. Each step's
20+
* `state` is the deepest settled configuration (e.g. `open.outcome.success`), so the `states` map
21+
* routes by `matchesState` and can assert at any depth - transient routers (`always`/`initial` such
22+
* as `preparing`/`outcome`) are passed through and never become their own node.
23+
*/
24+
25+
type MfaSnapshot = SnapshotFrom<typeof mfaMachine>;
26+
27+
/** UI action that produces a machine event - an `events` map entry passed to `path.test`. */
28+
type MfaEventExecutor = () => void | Promise<void>;
29+
30+
/** Assertion run when the walk reaches a matching state - a `states` map entry passed to `path.test`. */
31+
type MfaStateAssertion = (state: MfaSnapshot) => void | Promise<void>;
32+
33+
type MfaPathTestConfig = {
34+
events: Partial<Record<MfaEvent['type'], MfaEventExecutor>>;
35+
/** Keyed by dot-path state value (e.g. `open.outcome.success`), matched against each step with `matchesState`. */
36+
states: Record<string, MfaStateAssertion>;
37+
};
38+
39+
type RawPath = {
40+
state: MfaSnapshot;
41+
steps: ReadonlyArray<{state: MfaSnapshot; event: {type: string}}>;
42+
};
43+
44+
type MfaTestPath = {
45+
/** Final-state snapshot, e.g. for `JSON.stringify(path.state.value)` test names. */
46+
state: MfaSnapshot;
47+
/** The driven event sequence, e.g. `INIT -> CLOSE_MODAL`. */
48+
description: string;
49+
/** Walks the path: for each step, runs the matching event executor then asserts every matching state. */
50+
test: (config: MfaPathTestConfig) => Promise<void>;
51+
};
52+
53+
// INIT carries the real scenario payload; CLOSE_MODAL/MODAL_CLOSED are bare. Used only for the explicit
54+
// teardown lap (getLifecyclePaths); simple paths auto-discover events from the chart.
55+
const DRIVING_EVENTS: MfaEvent[] = [createInitEvent(), {type: 'CLOSE_MODAL'}, {type: 'MODAL_CLOSED'}];
56+
const INIT_STEP_EVENT_TYPE = 'xstate.init';
57+
const DELAYED_EVENT_PREFIX = 'xstate.after';
58+
59+
// Delayed (`after`) transitions are timers, not gestures - drop any path that would need to fire one.
60+
// The closing -> closed teardown they cover is driven explicitly via MODAL_CLOSED in getLifecyclePaths.
61+
function isGestureDrivablePath(path: RawPath): boolean {
62+
return path.steps.every((step) => !step.event.type.startsWith(DELAYED_EVENT_PREFIX));
63+
}
64+
65+
async function assertMatchingStates(snapshot: MfaSnapshot, states: MfaPathTestConfig['states']): Promise<void> {
66+
for (const [stateValue, assertState] of Object.entries(states)) {
67+
if (matchesState(toStateValue(stateValue.split('.')), snapshot.value)) {
68+
await assertState(snapshot);
69+
}
70+
}
71+
}
72+
73+
function wrapPath(graphPath: RawPath): MfaTestPath {
74+
const drivenEventTypes = graphPath.steps.map((step) => step.event.type).filter((type) => type !== INIT_STEP_EVENT_TYPE);
75+
76+
return {
77+
state: graphPath.state,
78+
description: drivenEventTypes.length > 0 ? drivenEventTypes.join(' -> ') : '(initial state)',
79+
test: async ({events, states}) => {
80+
const executorByEventType: Partial<Record<string, MfaEventExecutor>> = events;
81+
for (const step of graphPath.steps) {
82+
if (step.event.type !== INIT_STEP_EVENT_TYPE) {
83+
const executeEvent = executorByEventType[step.event.type];
84+
if (!executeEvent) {
85+
throw new Error(`No event executor provided for "${step.event.type}"`);
86+
}
87+
await executeEvent();
88+
}
89+
await assertMatchingStates(step.state, states);
90+
}
91+
},
92+
};
93+
}
94+
95+
function toTestPaths(rawPaths: RawPath[]): MfaTestPath[] {
96+
return rawPaths.filter(isGestureDrivablePath).map(wrapPath);
97+
}
98+
99+
function createMfaTestModel() {
100+
return {
101+
getSimplePaths: (): MfaTestPath[] => toTestPaths(getSimplePaths(mfaMachine)),
102+
// The full teardown lap (... -> MODAL_CLOSED -> closed) that simple paths skip because it revisits `closed`.
103+
getLifecyclePaths: (): MfaTestPath[] => toTestPaths(getPathsFromEvents(mfaMachine, DRIVING_EVENTS)),
104+
};
105+
}
106+
107+
export default createMfaTestModel;
108+
export type {MfaPathTestConfig, MfaTestPath};

tests/utils/mfa/machineStates.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ function getSettleableLeafStates(machine: AnyStateMachine): SettleableLeafState[
2727
}
2828

2929
export default getSettleableLeafStates;
30+
export {toStateValue};
3031
export type {SettleableLeafState};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {act, fireEvent, screen} from '@testing-library/react-native';
2+
import type {MfaEvent} from '@components/MultifactorAuthentication/machine/types';
3+
import {handleInitialScreenLayout} from '@components/MultifactorAuthentication/mfaNavigation';
4+
import {MFA_TEST_SCENARIO_NAME} from '../mfaTestFixtures';
5+
import {flushMfaUi, getMfaControls} from './harness';
6+
import {pendingModalClose} from './mocks';
7+
8+
type MfaEventType = MfaEvent['type'];
9+
type MfaEventExecutor = () => Promise<void>;
10+
11+
/** Translated `common.buttonConfirm` shown on the outcome screen's confirm button (and back button). */
12+
const CONFIRM_BUTTON_TEXT = 'Got it';
13+
14+
/**
15+
* The dictionary the model walk drives the real UI with: one entry per machine event, each performing
16+
* the gesture (or the system step) that produces that event in production.
17+
*
18+
* Not every event is a DOM gesture in this slice: `INIT` is fired by an external consumer through the
19+
* public API (no button inside the MFA modal starts a flow), and `MODAL_CLOSED` is the navigator's
20+
* teardown signal rather than a user action - here we run the exact callback the navigator handed us.
21+
* `satisfies Record<MfaEventType, ...>` makes a new machine event a compile error until it gets an executor.
22+
*/
23+
/* eslint-disable @typescript-eslint/naming-convention -- keys mirror the machine's event type union. */
24+
const mfaEventExecutors = {
25+
INIT: async () => {
26+
await act(async () => {
27+
await getMfaControls().executeScenario(MFA_TEST_SCENARIO_NAME);
28+
});
29+
await flushMfaUi();
30+
// The transparent initial screen's onLayout does not fire in jsdom; call the exact handler it
31+
// wires so the buffered push to the outcome screen runs, just like a real layout pass.
32+
act(() => handleInitialScreenLayout());
33+
await flushMfaUi();
34+
},
35+
CLOSE_MODAL: async () => {
36+
fireEvent.press(screen.getByText(CONFIRM_BUTTON_TEXT));
37+
await flushMfaUi();
38+
},
39+
MODAL_CLOSED: async () => {
40+
act(() => pendingModalClose.run());
41+
await flushMfaUi();
42+
},
43+
} satisfies Record<MfaEventType, MfaEventExecutor>;
44+
/* eslint-enable @typescript-eslint/naming-convention */
45+
46+
export default mfaEventExecutors;

tests/utils/mfa/ui/harness.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {render, screen} from '@testing-library/react-native';
2+
import React, {useEffect} from 'react';
3+
import {SafeAreaProvider} from 'react-native-safe-area-context';
4+
import ComposeProviders from '@components/ComposeProviders';
5+
import {LocaleContextProvider} from '@components/LocaleContextProvider';
6+
import {MultifactorAuthenticationContextProviders, useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context';
7+
import OnyxListItemProvider from '@components/OnyxListItemProvider';
8+
import MultifactorAuthenticationModalNavigator from '@navigation/AppNavigator/Navigators/MultifactorAuthenticationModalNavigator';
9+
import waitForBatchedUpdatesWithAct from '../../waitForBatchedUpdatesWithAct';
10+
11+
/** The two queryable markers the state tests assert against. `OutcomeScreenBase` is the success/failure
12+
* screen's testID; the backdrop's `common.close` label only ever renders inside the mounted navigator. */
13+
const OUTCOME_SCREEN_TEST_ID = 'OutcomeScreenBase';
14+
const MODAL_OVERLAY_LABEL = 'Close';
15+
16+
type MfaUiControls = {
17+
executeScenario: ReturnType<typeof useMultifactorAuthentication>['executeScenario'];
18+
};
19+
20+
const controlsHolder: {current: MfaUiControls | undefined} = {current: undefined};
21+
22+
/**
23+
* Renders nothing; it sits inside the providers only to capture the live context API so the event
24+
* executors can start a flow through the public API.
25+
*/
26+
function MfaControlsCapture() {
27+
const {executeScenario} = useMultifactorAuthentication();
28+
useEffect(() => {
29+
controlsHolder.current = {executeScenario};
30+
});
31+
return null;
32+
}
33+
MfaControlsCapture.displayName = 'MfaControlsCapture';
34+
35+
const INITIAL_SAFE_AREA_METRICS = {
36+
frame: {x: 0, y: 0, width: 390, height: 844},
37+
insets: {top: 0, left: 0, right: 0, bottom: 0},
38+
};
39+
40+
/** Mounts the real provider stack and the real MFA modal navigator the app renders in production. */
41+
function renderMfaUi() {
42+
controlsHolder.current = undefined;
43+
return render(
44+
<SafeAreaProvider initialMetrics={INITIAL_SAFE_AREA_METRICS}>
45+
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider]}>
46+
<MultifactorAuthenticationContextProviders>
47+
<MfaControlsCapture />
48+
<MultifactorAuthenticationModalNavigator />
49+
</MultifactorAuthenticationContextProviders>
50+
</ComposeProviders>
51+
</SafeAreaProvider>,
52+
);
53+
}
54+
55+
function getMfaControls(): MfaUiControls {
56+
if (!controlsHolder.current) {
57+
throw new Error('MFA UI controls were not captured. Call renderMfaUi() and flushMfaUi() first.');
58+
}
59+
return controlsHolder.current;
60+
}
61+
62+
async function flushMfaUi(): Promise<void> {
63+
await waitForBatchedUpdatesWithAct();
64+
}
65+
66+
/** True while the outcome screen (success/failure) is the visible route. */
67+
function isOutcomeScreenVisible(): boolean {
68+
return screen.queryAllByTestId(OUTCOME_SCREEN_TEST_ID).length > 0;
69+
}
70+
71+
/** True while the MFA overlay (navigator root + backdrop) is mounted. */
72+
function isModalOverlayMounted(): boolean {
73+
return screen.queryAllByLabelText(MODAL_OVERLAY_LABEL).length > 0;
74+
}
75+
76+
export {renderMfaUi, getMfaControls, flushMfaUi, isOutcomeScreenVisible, isModalOverlayMounted};

tests/utils/mfa/ui/jestMocks.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import {useMachine} from '@xstate/react';
2+
import type {AnyStateMachine} from 'xstate';
3+
import {biometricsMock, pendingModalClose} from './mocks';
4+
5+
/**
6+
* jest.mock factory bodies for the model-based MFA UI test, kept out of the test file so it reads as
7+
* mock registrations plus test logic. Each is called from a `jest.mock(..., () => require(...).xMock())`
8+
* factory, so it returns a ready-to-use mock module (`__esModule` + `default`).
9+
*/
10+
11+
/** Drops the dev-only Stately inspector wiring; the provider just needs the plain @xstate/react adapter. */
12+
function inspectedMachineMock() {
13+
// Named `use*` so rules-of-hooks treats it as a custom hook (it calls useMachine).
14+
const useInspectedMachineMock = (machine: AnyStateMachine) => useMachine(machine);
15+
return {
16+
__esModule: true,
17+
default: useInspectedMachineMock,
18+
};
19+
}
20+
21+
/** Native / WebAuthn biometrics are out of scope for the modal-lifecycle contract; serve the shared seam. */
22+
function biometricsHookMock() {
23+
return {
24+
__esModule: true,
25+
default: () => biometricsMock,
26+
};
27+
}
28+
29+
/** Browser/Android back-history wiring is a separate concern from the machine <-> UI contract. */
30+
function syncHistoryMock() {
31+
return {
32+
__esModule: true,
33+
default: () => {},
34+
};
35+
}
36+
37+
/**
38+
* Automock leaves the default export's methods undefined, so provide the three the flow needs and
39+
* resolve any other `Navigation.*` the render path touches to a no-op jest.fn().
40+
*
41+
* `runAfterTransition` runs its callback immediately (no active navigation transition in jsdom).
42+
* `runAfterUpcomingTransition` captures the navigator's teardown callback so MODAL_CLOSED is driven
43+
* from the event map, not a timer.
44+
*/
45+
function navigationMock() {
46+
const handlers: Record<string, unknown> = {
47+
runAfterTransition: (callback: () => void) => {
48+
callback();
49+
return {cancel: () => {}};
50+
},
51+
runAfterUpcomingTransition: (callback: () => void) => {
52+
pendingModalClose.capture(callback);
53+
return {cancel: () => pendingModalClose.clear()};
54+
},
55+
isNavigationReady: () => Promise.resolve(),
56+
};
57+
return {
58+
__esModule: true,
59+
default: new Proxy(handlers, {
60+
get: (target, property) => {
61+
if (typeof property === 'string' && property in target) {
62+
return target[property];
63+
}
64+
return jest.fn();
65+
},
66+
}),
67+
};
68+
}
69+
70+
export {inspectedMachineMock, biometricsHookMock, syncHistoryMock, navigationMock};

0 commit comments

Comments
 (0)