Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,30 +1,17 @@
/* eslint-disable react-native/no-color-literals -- Phase 9.5: Host renders
* no visible chrome. The container fills the screen so React Navigation has
* a stack base for resets, but is fully transparent so the consumer's
* loading UI renders underneath. There is no theme token for "transparent",
* and the consumer paints the visible surface — the color literal is
* intentional and does not need a theme equivalent. */
import { StyleSheet } from 'react-native';
import { Theme } from '../../../../../util/theme/models';

const styleSheet = (params: { theme: Theme }) => {
const { theme } = params;
return StyleSheet.create({
const styleSheet = () =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: theme.colors.background.default,
},
body: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 24,
gap: 16,
},
spinner: {
marginBottom: 8,
},
text: {
textAlign: 'center',
},
cancelRow: {
paddingTop: 12,
backgroundColor: 'transparent',
},
});
};

export default styleSheet;
216 changes: 167 additions & 49 deletions app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import React from 'react';
import { act, fireEvent, screen, waitFor } from '@testing-library/react-native';
import { act, screen, waitFor } from '@testing-library/react-native';

import HeadlessHost, {
HEADLESS_HOST_BACK_BUTTON_TEST_ID,
HEADLESS_HOST_CANCEL_BUTTON_TEST_ID,
HEADLESS_HOST_LOADER_TEST_ID,
HEADLESS_HOST_NO_SESSION_TEST_ID,
HEADLESS_HOST_CONTAINER_TEST_ID,
createHeadlessHostNavDetails,
type HeadlessHostParams,
} from './HeadlessHost';
Expand All @@ -30,16 +27,23 @@ import Routes from '../../../../../constants/navigation/Routes';

const mockGoBack = jest.fn();
const mockNavigate = jest.fn();
const mockAddListener = jest.fn();
const mockContinueWithQuote = jest.fn();
const mockUseContinueWithQuoteOptions = jest.fn();

// Holds the most recent 'beforeRemove' listener registered against the
// mocked navigation object. Tests fire it directly to exercise the
// production beforeRemove path without spinning up a real navigator.
let registeredBeforeRemoveListener: (() => void) | null = null;

jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
return {
...actual,
useNavigation: () => ({
goBack: mockGoBack,
navigate: mockNavigate,
addListener: mockAddListener,
}),
};
});
Expand Down Expand Up @@ -158,6 +162,15 @@ describe('HeadlessHost', () => {
beforeEach(() => {
jest.clearAllMocks();
__resetSessionRegistryForTests();
registeredBeforeRemoveListener = null;
mockAddListener.mockImplementation(
(eventName: string, listener: () => void) => {
if (eventName === 'beforeRemove') {
registeredBeforeRemoveListener = listener;
}
return jest.fn();
},
);
mockUseRampAccountAddress.mockReturnValue('0xWALLET');
mockUseRampsUserRegion.mockReturnValue({
userRegion: { country: { currency: 'EUR' } },
Expand All @@ -180,39 +193,28 @@ describe('HeadlessHost', () => {
expect(params).toEqual({ headlessSessionId: 'headless-buy-abc' });
});

it('renders the no-session message when the session id is unknown', () => {
renderHost({ headlessSessionId: 'headless-buy-not-real' });
expect(
screen.getByTestId(HEADLESS_HOST_NO_SESSION_TEST_ID),
).toBeOnTheScreen();
expect(
screen.queryByTestId(HEADLESS_HOST_LOADER_TEST_ID),
).not.toBeOnTheScreen();
expect(mockContinueWithQuote).not.toHaveBeenCalled();
});

it('renders the loader while a matching session is being orchestrated', () => {
// Make continueWithQuote hang so the loader stays on screen.
mockContinueWithQuote.mockImplementation(
() => new Promise(() => undefined),
);
it('renders only a transparent container — no header, spinner, or buttons after Phase 9.5', () => {
// The Phase 9.5 contract: the consumer (TPC / MMPay) renders all
// user-visible loading UI. The Host is a stack base only.
const session = seedSession(buildAggregatorQuote());
renderHost({ headlessSessionId: session.id });
expect(
screen.getByTestId(HEADLESS_HOST_LOADER_TEST_ID),
screen.getByTestId(HEADLESS_HOST_CONTAINER_TEST_ID),
).toBeOnTheScreen();
// No legacy chrome should be present.
expect(screen.queryByText(/Cancel/i)).not.toBeOnTheScreen();
expect(screen.queryByText(/Preparing/i)).not.toBeOnTheScreen();
expect(screen.queryByText(/no longer active/i)).not.toBeOnTheScreen();
});

it('navigates back when the cancel button is pressed', () => {
renderHost();
fireEvent.press(screen.getByTestId(HEADLESS_HOST_CANCEL_BUTTON_TEST_ID));
expect(mockGoBack).toHaveBeenCalledTimes(1);
});

it('navigates back when the header back button is pressed', () => {
renderHost();
fireEvent.press(screen.getByTestId(HEADLESS_HOST_BACK_BUTTON_TEST_ID));
expect(mockGoBack).toHaveBeenCalledTimes(1);
it('renders the transparent container even with no session — no UI affordances surface to the user', () => {
// Pre-Phase 9.5 this rendered a "no session" message; the consumer
// now owns that surface.
renderHost({ headlessSessionId: 'headless-buy-not-real' });
expect(
screen.getByTestId(HEADLESS_HOST_CONTAINER_TEST_ID),
).toBeOnTheScreen();
expect(mockContinueWithQuote).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -300,17 +302,14 @@ describe('HeadlessHost', () => {
// effect must respect that and avoid a stale re-trigger.
closeSession(session.id, { reason: 'consumer_cancelled' });
renderHost({ headlessSessionId: session.id });
// No session left → the no-session branch renders, continueWithQuote
// is never called.
expect(
screen.getByTestId(HEADLESS_HOST_NO_SESSION_TEST_ID),
).toBeOnTheScreen();
// No session left → orchestration short-circuits. The consumer
// already received onClose from the closeSession call above.
expect(mockContinueWithQuote).not.toHaveBeenCalled();
});
});

describe('Error handling', () => {
it('forwards a malformed assetId as onError(UNKNOWN, ...) and closes the session', async () => {
it('forwards a malformed assetId as onError(UNKNOWN, ...) and closes the session', () => {
// Real hook: falsy chain id → null wallet. The invalid-assetId branch
// must run before the wallet deferral or the effect would return early
// forever (regression guard for guard ordering).
Expand All @@ -326,12 +325,9 @@ describe('HeadlessHost', () => {
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
expect(getSession(session.id)).toBeUndefined();
expect(mockContinueWithQuote).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByText(/not-a-caip-19/)).toBeOnTheScreen();
});
});

it('surfaces a continueWithQuote rejection as onError(UNKNOWN, ...) and renders the message', async () => {
it('surfaces a continueWithQuote rejection as onError(UNKNOWN, ...)', async () => {
mockContinueWithQuote.mockRejectedValueOnce(new Error('quote expired'));
const quote = buildAggregatorQuote();
const session = seedSession(quote);
Expand All @@ -345,7 +341,6 @@ describe('HeadlessHost', () => {
});
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
expect(getSession(session.id)).toBeUndefined();
expect(screen.getByText('quote expired')).toBeOnTheScreen();
});

it('surfaces limit failures as onError(LIMIT_EXCEEDED, ...)', async () => {
Expand All @@ -364,10 +359,9 @@ describe('HeadlessHost', () => {
});
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
expect(getSession(session.id)).toBeUndefined();
expect(screen.getByText('Daily limit exceeded')).toBeOnTheScreen();
});

it('does not run the continueWithQuote rejection path after unmount', async () => {
it('does not surface a continueWithQuote rejection that arrives after unmount', async () => {
let rejectDeferred: ((error: Error) => void) | undefined;
mockContinueWithQuote.mockImplementation(
() =>
Expand All @@ -383,15 +377,26 @@ describe('HeadlessHost', () => {
expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
);
unmount();
// Phase 8: unmount fires the dismissal close because the session
// had not reached a terminal status. After unmount the session is
// gone from the registry, so the .catch's live-session re-read
// short-circuits and does not produce a second onClose or an
// onError. (The .catch's `cancelled` flag is independent React
// unmount-state protection; this test does not exercise it
// directly.)
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({
reason: 'user_dismissed',
});
expect(getSession(session.id)).toBeUndefined();
await act(async () => {
rejectDeferred?.(new Error('late failure'));
});
expect(callbacks.onError).not.toHaveBeenCalled();
expect(callbacks.onClose).not.toHaveBeenCalled();
expect(getSession(session.id)?.status).toBe('continued');
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
});

it('forwards a nativeFlowError param as onError(AUTH_FAILED, ...), renders it, and closes the session', () => {
it('forwards a nativeFlowError param as onError(AUTH_FAILED, ...) and closes the session', () => {
const quote = buildNativeQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
Expand All @@ -408,7 +413,6 @@ describe('HeadlessHost', () => {
// The auth-error path also short-circuits the continue-on-focus effect
// — we never want to push EnterEmail again on top of the error message.
expect(mockContinueWithQuote).not.toHaveBeenCalled();
expect(screen.getByText('OTP rejected')).toBeOnTheScreen();
});

it('does not crash when the consumer onError callback throws', async () => {
Expand All @@ -434,4 +438,118 @@ describe('HeadlessHost', () => {
await waitFor(() => expect(getSession(session.id)).toBeUndefined());
});
});

describe('Dismissal (Phase 8 + 9.5)', () => {
it('registers a beforeRemove listener that synchronously closes the session with user_dismissed', () => {
// Phase 9.5 replaces the old visible Cancel/Back buttons with a
// navigation listener so the synchronous close still fires when the
// user backs out — even with no chrome to render.
const quote = buildAggregatorQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
renderHost({ headlessSessionId: session.id });
expect(mockAddListener).toHaveBeenCalledWith(
'beforeRemove',
expect.any(Function),
);
expect(typeof registeredBeforeRemoveListener).toBe('function');

// Fire the listener like React Navigation would on a back gesture.
registeredBeforeRemoveListener?.();

expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({
reason: 'user_dismissed',
});
expect(getSession(session.id)).toBeUndefined();
});

it('fires onClose({ reason: "user_dismissed" }) once when the host unmounts mid-flow without a terminal status', async () => {
mockContinueWithQuote.mockImplementation(
() => new Promise(() => undefined),
);
const quote = buildAggregatorQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
const { unmount } = renderHost({ headlessSessionId: session.id });
await waitFor(() =>
expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
);

unmount();

expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({
reason: 'user_dismissed',
});
expect(getSession(session.id)).toBeUndefined();
});

it('does not fire a second onClose when the session was already closed by Phase 6 success before unmount', async () => {
mockContinueWithQuote.mockImplementation(
() => new Promise(() => undefined),
);
const quote = buildAggregatorQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
const { unmount } = renderHost({ headlessSessionId: session.id });
await waitFor(() =>
expect(mockContinueWithQuote).toHaveBeenCalledTimes(1),
);

// Simulate Phase 6: useTransakRouting / Checkout fires
// closeSession({ reason: 'completed' }) after onOrderCreated.
closeSession(session.id, { reason: 'completed' });
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'completed' });

// beforeRemove fires too (React Navigation always fires it on screen
// removal), then unmount cleanup runs. Both find the session gone and
// no-op.
registeredBeforeRemoveListener?.();
unmount();

expect(callbacks.onClose).toHaveBeenCalledTimes(1);
});

it('does not fire a second onClose when a Phase 7 nativeFlowError already closed the session before unmount', () => {
const quote = buildNativeQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
const { unmount } = renderHost({
headlessSessionId: session.id,
nativeFlowError: 'OTP rejected',
});

// Phase 7: nativeFlowError handler funnels through failSession →
// closeSession({ reason: 'unknown' }, { terminalStatus: 'failed' }).
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });

registeredBeforeRemoveListener?.();
unmount();

// Both follow-up paths re-read, see nothing, no-op.
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
});

it('no-ops on unmount when the host mounted against an already-terminated session', () => {
const quote = buildAggregatorQuote();
const session = seedSession(quote);
const callbacks = session.callbacks;
// Cancel before the screen mounts; the Phase 8 dismissal cleanup must
// not produce a spurious second onClose.
closeSession(session.id, { reason: 'consumer_cancelled' });
expect(callbacks.onClose).toHaveBeenCalledTimes(1);
expect(callbacks.onClose).toHaveBeenCalledWith({
reason: 'consumer_cancelled',
});

const { unmount } = renderHost({ headlessSessionId: session.id });
registeredBeforeRemoveListener?.();
unmount();

expect(callbacks.onClose).toHaveBeenCalledTimes(1);
});
});
});
Loading
Loading