Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
7 changes: 6 additions & 1 deletion app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,12 @@ describe('Checkout', () => {
});

await waitFor(() => {
expect(onOrderCreated).toHaveBeenCalledWith('headless-order-1');
// Fix #3.1: onOrderCreated now takes (orderId, order) — the second
// arg is the same `rampsOrder` we resolved from `getOrderFromCallback`.
expect(onOrderCreated).toHaveBeenCalledWith(
'headless-order-1',
mockOrder,
);
});
expect(mockCloseSession).toHaveBeenCalledWith('hs-1', {
reason: 'completed',
Expand Down
16 changes: 11 additions & 5 deletions app/components/UI/Ramp/Views/Checkout/Checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,14 +313,20 @@ const Checkout = () => {
addOrder(rampsOrder);
dispatch(protectWalletModalVisible());

// Headless mode: hand the orderId to the consumer, close the
// session, and unwind out of the ramp stack so the caller regains
// foreground. Skip the toast + RAMPS_ORDER_DETAILS reset — both
// are user-facing UI the headless consumer didn't ask for.
// Headless mode: hand the orderId AND the creation-snapshot order
// to the consumer, close the session, and unwind out of the ramp
// stack so the caller regains foreground. Skip the toast +
// RAMPS_ORDER_DETAILS reset — both are user-facing UI the headless
// consumer didn't ask for. The order arg (Phase 9 / Fix #3.1) lets
// consumers call `awaitOrderTerminalState(orderId, { walletAddress:
// order.walletAddress })` without an extra `getOrder` round-trip.
const session = getSession(headlessSessionId);
if (headlessSessionId && session) {
try {
session.callbacks.onOrderCreated(rampsOrder.providerOrderId);
session.callbacks.onOrderCreated(
rampsOrder.providerOrderId,
rampsOrder,
);
} catch (callbackError) {
Logger.error(
callbackError as Error,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,28 @@ const styleSheet = (params: { theme: Theme }) => {
paddingVertical: 4,
paddingHorizontal: 0,
},
orderTrackingSection: {
marginTop: 12,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 8,
backgroundColor: theme.colors.background.alternative,
},
orderTrackingTitle: {
marginBottom: 8,
},
orderTrackingRow: {
paddingVertical: 4,
},
orderTrackingActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 8,
},
orderTrackingBadge: {
marginTop: 8,
},
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import HeadlessPlayground, {
HEADLESS_PLAYGROUND_RESET_ASSET_TEST_ID,
HEADLESS_PLAYGROUND_RESET_PAYMENT_METHOD_TEST_ID,
HEADLESS_PLAYGROUND_RESET_PROVIDER_TEST_ID,
HEADLESS_PLAYGROUND_ORDER_TRACKING_AWAIT_TEST_ID,
HEADLESS_PLAYGROUND_ORDER_TRACKING_REFRESH_TEST_ID,
HEADLESS_PLAYGROUND_ORDER_TRACKING_SECTION_TEST_ID,
HEADLESS_PLAYGROUND_ORDER_TRACKING_STATUS_BADGE_TEST_ID,
HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID,
HEADLESS_PLAYGROUND_SUMMARY_DIVIDER_TEST_ID,
HEADLESS_PLAYGROUND_SUMMARY_TEST_ID,
Expand Down Expand Up @@ -155,6 +159,10 @@ const mockUseRampsControllerInitialValues: ReturnType<

let mockUseRampsControllerValues = mockUseRampsControllerInitialValues;

const mockGetOrder = jest.fn();
const mockRefreshOrder = jest.fn();
const mockAwaitOrderTerminalState = jest.fn();

const mockUseHeadlessBuyInitialValues: ReturnType<typeof useHeadlessBuy> = {
userRegion: mockUserRegion,
providers: mockProviders,
Expand All @@ -163,6 +171,9 @@ const mockUseHeadlessBuyInitialValues: ReturnType<typeof useHeadlessBuy> = {
tokens: { topTokens: mockTokens, allTokens: mockTokens },
orders: [],
getOrderById: mockGetOrderById,
getOrder: mockGetOrder,
refreshOrder: mockRefreshOrder,
awaitOrderTerminalState: mockAwaitOrderTerminalState,
getQuotes: mockGetQuotes,
startHeadlessBuy: mockStartHeadlessBuy,
isLoading: false,
Expand Down Expand Up @@ -1182,5 +1193,186 @@ describe('HeadlessPlayground', () => {
screen.queryByTestId(HEADLESS_PLAYGROUND_CANCEL_BUTTON_TEST_ID),
).not.toBeOnTheScreen();
});

describe('Phase 9 order tracking panel', () => {
it('does not render the order tracking panel before onOrderCreated fires', async () => {
await renderWithQuotes();
expect(
screen.queryByTestId(
HEADLESS_PLAYGROUND_ORDER_TRACKING_SECTION_TEST_ID,
),
).not.toBeOnTheScreen();
});

it('renders order id, status, and refresh/await actions after onOrderCreated', async () => {
mockGetOrder.mockReturnValue({
providerOrderId: 'order-xyz',
status: 'PENDING',
provider: { id: '/providers/moonpay' },
walletAddress: '0xWALLET',
});
await renderWithQuotes();
fireEvent.press(
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
);
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
onOrderCreated: (orderId: string) => void;
};
act(() => {
callbacks.onOrderCreated('order-xyz');
});
expect(
screen.getByTestId(
HEADLESS_PLAYGROUND_ORDER_TRACKING_SECTION_TEST_ID,
),
).toBeOnTheScreen();
expect(screen.getByText('order-xyz')).toBeOnTheScreen();
expect(screen.getByText('PENDING')).toBeOnTheScreen();
});

it('renders the (not yet in state) status placeholder when getOrder returns undefined', async () => {
mockGetOrder.mockReturnValue(undefined);
await renderWithQuotes();
fireEvent.press(
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
);
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
onOrderCreated: (orderId: string) => void;
};
act(() => {
callbacks.onOrderCreated('order-xyz');
});
expect(screen.getByText(/not yet in state/i)).toBeOnTheScreen();
});

it('disables the refresh button when the order is not yet in state', async () => {
mockGetOrder.mockReturnValue(undefined);
await renderWithQuotes();
fireEvent.press(
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
);
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
onOrderCreated: (orderId: string) => void;
};
act(() => {
callbacks.onOrderCreated('order-xyz');
});
const refreshButton = screen.getByTestId(
HEADLESS_PLAYGROUND_ORDER_TRACKING_REFRESH_TEST_ID,
);
expect(refreshButton.props.accessibilityState?.disabled).toBe(true);
});

it('calls refreshOrder when the user taps the refresh button', async () => {
mockGetOrder.mockReturnValue({
providerOrderId: 'order-xyz',
status: 'PENDING',
provider: { id: '/providers/moonpay' },
walletAddress: '0xWALLET',
});
mockRefreshOrder.mockResolvedValue({
providerOrderId: 'order-xyz',
status: 'COMPLETED',
});
await renderWithQuotes();
fireEvent.press(
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
);
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
onOrderCreated: (orderId: string) => void;
};
act(() => {
callbacks.onOrderCreated('order-xyz');
});
await act(async () => {
fireEvent.press(
screen.getByTestId(
HEADLESS_PLAYGROUND_ORDER_TRACKING_REFRESH_TEST_ID,
),
);
});
expect(mockRefreshOrder).toHaveBeenCalledWith('order-xyz');
});

it('renders an "Awaiting…" badge while awaitOrderTerminalState is in flight', async () => {
mockGetOrder.mockReturnValue({
providerOrderId: 'order-xyz',
status: 'PENDING',
provider: { id: '/providers/moonpay' },
walletAddress: '0xWALLET',
});
let resolveAwait: ((order: unknown) => void) | undefined;
mockAwaitOrderTerminalState.mockImplementation(
() =>
new Promise((resolve) => {
resolveAwait = resolve;
}),
);
await renderWithQuotes();
fireEvent.press(
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
);
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
onOrderCreated: (orderId: string) => void;
};
act(() => {
callbacks.onOrderCreated('order-xyz');
});
await act(async () => {
fireEvent.press(
screen.getByTestId(
HEADLESS_PLAYGROUND_ORDER_TRACKING_AWAIT_TEST_ID,
),
);
});
expect(
screen.getByTestId(
HEADLESS_PLAYGROUND_ORDER_TRACKING_STATUS_BADGE_TEST_ID,
),
).toBeOnTheScreen();
expect(screen.getByText(/Awaiting/i)).toBeOnTheScreen();

// Resolve the promise to keep the test isolation clean.
await act(async () => {
resolveAwait?.({
providerOrderId: 'order-xyz',
status: 'COMPLETED',
});
});
expect(screen.getByText(/Terminal state reached/i)).toBeOnTheScreen();
});

it('renders a timed-out badge when awaitOrderTerminalState rejects with OrderTerminalStateTimeoutError', async () => {
const { OrderTerminalStateTimeoutError } =
jest.requireActual('../../headless');
mockGetOrder.mockReturnValue({
providerOrderId: 'order-xyz',
status: 'PENDING',
provider: { id: '/providers/moonpay' },
walletAddress: '0xWALLET',
});
mockAwaitOrderTerminalState.mockRejectedValue(
new OrderTerminalStateTimeoutError('boom'),
);
await renderWithQuotes();
fireEvent.press(
screen.getByTestId(`${HEADLESS_PLAYGROUND_START_BUTTON_TEST_ID}-0`),
);
const callbacks = mockStartHeadlessBuy.mock.calls[0][1] as {
onOrderCreated: (orderId: string) => void;
};
act(() => {
callbacks.onOrderCreated('order-xyz');
});
await act(async () => {
fireEvent.press(
screen.getByTestId(
HEADLESS_PLAYGROUND_ORDER_TRACKING_AWAIT_TEST_ID,
),
);
});
expect(screen.getByText(/Timed out/i)).toBeOnTheScreen();
});
});
});
});
Loading
Loading