11import React from 'react' ;
2- import { act , fireEvent , screen , waitFor } from '@testing-library/react-native' ;
2+ import { act , screen , waitFor } from '@testing-library/react-native' ;
33
44import HeadlessHost , {
5- HEADLESS_HOST_BACK_BUTTON_TEST_ID ,
6- HEADLESS_HOST_CANCEL_BUTTON_TEST_ID ,
7- HEADLESS_HOST_LOADER_TEST_ID ,
8- HEADLESS_HOST_NO_SESSION_TEST_ID ,
5+ HEADLESS_HOST_CONTAINER_TEST_ID ,
96 createHeadlessHostNavDetails ,
107 type HeadlessHostParams ,
118} from './HeadlessHost' ;
@@ -30,16 +27,23 @@ import Routes from '../../../../../constants/navigation/Routes';
3027
3128const mockGoBack = jest . fn ( ) ;
3229const mockNavigate = jest . fn ( ) ;
30+ const mockAddListener = jest . fn ( ) ;
3331const mockContinueWithQuote = jest . fn ( ) ;
3432const mockUseContinueWithQuoteOptions = jest . fn ( ) ;
3533
34+ // Holds the most recent 'beforeRemove' listener registered against the
35+ // mocked navigation object. Tests fire it directly to exercise the
36+ // production beforeRemove path without spinning up a real navigator.
37+ let registeredBeforeRemoveListener : ( ( ) => void ) | null = null ;
38+
3639jest . mock ( '@react-navigation/native' , ( ) => {
3740 const actual = jest . requireActual ( '@react-navigation/native' ) ;
3841 return {
3942 ...actual ,
4043 useNavigation : ( ) => ( {
4144 goBack : mockGoBack ,
4245 navigate : mockNavigate ,
46+ addListener : mockAddListener ,
4347 } ) ,
4448 } ;
4549} ) ;
@@ -158,6 +162,15 @@ describe('HeadlessHost', () => {
158162 beforeEach ( ( ) => {
159163 jest . clearAllMocks ( ) ;
160164 __resetSessionRegistryForTests ( ) ;
165+ registeredBeforeRemoveListener = null ;
166+ mockAddListener . mockImplementation (
167+ ( eventName : string , listener : ( ) => void ) => {
168+ if ( eventName === 'beforeRemove' ) {
169+ registeredBeforeRemoveListener = listener ;
170+ }
171+ return jest . fn ( ) ;
172+ } ,
173+ ) ;
161174 mockUseRampAccountAddress . mockReturnValue ( '0xWALLET' ) ;
162175 mockUseRampsUserRegion . mockReturnValue ( {
163176 userRegion : { country : { currency : 'EUR' } } ,
@@ -180,39 +193,28 @@ describe('HeadlessHost', () => {
180193 expect ( params ) . toEqual ( { headlessSessionId : 'headless-buy-abc' } ) ;
181194 } ) ;
182195
183- it ( 'renders the no-session message when the session id is unknown' , ( ) => {
184- renderHost ( { headlessSessionId : 'headless-buy-not-real' } ) ;
185- expect (
186- screen . getByTestId ( HEADLESS_HOST_NO_SESSION_TEST_ID ) ,
187- ) . toBeOnTheScreen ( ) ;
188- expect (
189- screen . queryByTestId ( HEADLESS_HOST_LOADER_TEST_ID ) ,
190- ) . not . toBeOnTheScreen ( ) ;
191- expect ( mockContinueWithQuote ) . not . toHaveBeenCalled ( ) ;
192- } ) ;
193-
194- it ( 'renders the loader while a matching session is being orchestrated' , ( ) => {
195- // Make continueWithQuote hang so the loader stays on screen.
196- mockContinueWithQuote . mockImplementation (
197- ( ) => new Promise ( ( ) => undefined ) ,
198- ) ;
196+ it ( 'renders only a transparent container — no header, spinner, or buttons after Phase 9.5' , ( ) => {
197+ // The Phase 9.5 contract: the consumer (TPC / MMPay) renders all
198+ // user-visible loading UI. The Host is a stack base only.
199199 const session = seedSession ( buildAggregatorQuote ( ) ) ;
200200 renderHost ( { headlessSessionId : session . id } ) ;
201201 expect (
202- screen . getByTestId ( HEADLESS_HOST_LOADER_TEST_ID ) ,
202+ screen . getByTestId ( HEADLESS_HOST_CONTAINER_TEST_ID ) ,
203203 ) . toBeOnTheScreen ( ) ;
204+ // No legacy chrome should be present.
205+ expect ( screen . queryByText ( / C a n c e l / i) ) . not . toBeOnTheScreen ( ) ;
206+ expect ( screen . queryByText ( / P r e p a r i n g / i) ) . not . toBeOnTheScreen ( ) ;
207+ expect ( screen . queryByText ( / n o l o n g e r a c t i v e / i) ) . not . toBeOnTheScreen ( ) ;
204208 } ) ;
205209
206- it ( 'navigates back when the cancel button is pressed' , ( ) => {
207- renderHost ( ) ;
208- fireEvent . press ( screen . getByTestId ( HEADLESS_HOST_CANCEL_BUTTON_TEST_ID ) ) ;
209- expect ( mockGoBack ) . toHaveBeenCalledTimes ( 1 ) ;
210- } ) ;
211-
212- it ( 'navigates back when the header back button is pressed' , ( ) => {
213- renderHost ( ) ;
214- fireEvent . press ( screen . getByTestId ( HEADLESS_HOST_BACK_BUTTON_TEST_ID ) ) ;
215- expect ( mockGoBack ) . toHaveBeenCalledTimes ( 1 ) ;
210+ it ( 'renders the transparent container even with no session — no UI affordances surface to the user' , ( ) => {
211+ // Pre-Phase 9.5 this rendered a "no session" message; the consumer
212+ // now owns that surface.
213+ renderHost ( { headlessSessionId : 'headless-buy-not-real' } ) ;
214+ expect (
215+ screen . getByTestId ( HEADLESS_HOST_CONTAINER_TEST_ID ) ,
216+ ) . toBeOnTheScreen ( ) ;
217+ expect ( mockContinueWithQuote ) . not . toHaveBeenCalled ( ) ;
216218 } ) ;
217219 } ) ;
218220
@@ -300,17 +302,14 @@ describe('HeadlessHost', () => {
300302 // effect must respect that and avoid a stale re-trigger.
301303 closeSession ( session . id , { reason : 'consumer_cancelled' } ) ;
302304 renderHost ( { headlessSessionId : session . id } ) ;
303- // No session left → the no-session branch renders, continueWithQuote
304- // is never called.
305- expect (
306- screen . getByTestId ( HEADLESS_HOST_NO_SESSION_TEST_ID ) ,
307- ) . toBeOnTheScreen ( ) ;
305+ // No session left → orchestration short-circuits. The consumer
306+ // already received onClose from the closeSession call above.
308307 expect ( mockContinueWithQuote ) . not . toHaveBeenCalled ( ) ;
309308 } ) ;
310309 } ) ;
311310
312311 describe ( 'Error handling' , ( ) => {
313- it ( 'forwards a malformed assetId as onError(UNKNOWN, ...) and closes the session' , async ( ) => {
312+ it ( 'forwards a malformed assetId as onError(UNKNOWN, ...) and closes the session' , ( ) => {
314313 // Real hook: falsy chain id → null wallet. The invalid-assetId branch
315314 // must run before the wallet deferral or the effect would return early
316315 // forever (regression guard for guard ordering).
@@ -326,12 +325,9 @@ describe('HeadlessHost', () => {
326325 expect ( callbacks . onClose ) . toHaveBeenCalledWith ( { reason : 'unknown' } ) ;
327326 expect ( getSession ( session . id ) ) . toBeUndefined ( ) ;
328327 expect ( mockContinueWithQuote ) . not . toHaveBeenCalled ( ) ;
329- await waitFor ( ( ) => {
330- expect ( screen . getByText ( / n o t - a - c a i p - 1 9 / ) ) . toBeOnTheScreen ( ) ;
331- } ) ;
332328 } ) ;
333329
334- it ( 'surfaces a continueWithQuote rejection as onError(UNKNOWN, ...) and renders the message ' , async ( ) => {
330+ it ( 'surfaces a continueWithQuote rejection as onError(UNKNOWN, ...)' , async ( ) => {
335331 mockContinueWithQuote . mockRejectedValueOnce ( new Error ( 'quote expired' ) ) ;
336332 const quote = buildAggregatorQuote ( ) ;
337333 const session = seedSession ( quote ) ;
@@ -345,7 +341,6 @@ describe('HeadlessHost', () => {
345341 } ) ;
346342 expect ( callbacks . onClose ) . toHaveBeenCalledWith ( { reason : 'unknown' } ) ;
347343 expect ( getSession ( session . id ) ) . toBeUndefined ( ) ;
348- expect ( screen . getByText ( 'quote expired' ) ) . toBeOnTheScreen ( ) ;
349344 } ) ;
350345
351346 it ( 'surfaces limit failures as onError(LIMIT_EXCEEDED, ...)' , async ( ) => {
@@ -364,7 +359,6 @@ describe('HeadlessHost', () => {
364359 } ) ;
365360 expect ( callbacks . onClose ) . toHaveBeenCalledWith ( { reason : 'unknown' } ) ;
366361 expect ( getSession ( session . id ) ) . toBeUndefined ( ) ;
367- expect ( screen . getByText ( 'Daily limit exceeded' ) ) . toBeOnTheScreen ( ) ;
368362 } ) ;
369363
370364 it ( 'does not surface a continueWithQuote rejection that arrives after unmount' , async ( ) => {
@@ -402,7 +396,7 @@ describe('HeadlessHost', () => {
402396 expect ( callbacks . onClose ) . toHaveBeenCalledTimes ( 1 ) ;
403397 } ) ;
404398
405- it ( 'forwards a nativeFlowError param as onError(AUTH_FAILED, ...), renders it, and closes the session' , ( ) => {
399+ it ( 'forwards a nativeFlowError param as onError(AUTH_FAILED, ...) and closes the session' , ( ) => {
406400 const quote = buildNativeQuote ( ) ;
407401 const session = seedSession ( quote ) ;
408402 const callbacks = session . callbacks ;
@@ -419,7 +413,6 @@ describe('HeadlessHost', () => {
419413 // The auth-error path also short-circuits the continue-on-focus effect
420414 // — we never want to push EnterEmail again on top of the error message.
421415 expect ( mockContinueWithQuote ) . not . toHaveBeenCalled ( ) ;
422- expect ( screen . getByText ( 'OTP rejected' ) ) . toBeOnTheScreen ( ) ;
423416 } ) ;
424417
425418 it ( 'does not crash when the consumer onError callback throws' , async ( ) => {
@@ -446,50 +439,28 @@ describe('HeadlessHost', () => {
446439 } ) ;
447440 } ) ;
448441
449- describe ( 'Dismissal (Phase 8)' , ( ) => {
450- it ( 'fires onClose({ reason: "user_dismissed" }) and navigates back when the cancel button is pressed mid-flow' , async ( ) => {
451- // Make continueWithQuote hang so the session stays non-terminal while
452- // the user taps Cancel — this is the typical dismissal path.
453- mockContinueWithQuote . mockImplementation (
454- ( ) => new Promise ( ( ) => undefined ) ,
455- ) ;
456- const quote = buildAggregatorQuote ( ) ;
457- const session = seedSession ( quote ) ;
458- const callbacks = session . callbacks ;
459- renderHost ( { headlessSessionId : session . id } ) ;
460- await waitFor ( ( ) =>
461- expect ( mockContinueWithQuote ) . toHaveBeenCalledTimes ( 1 ) ,
462- ) ;
463-
464- fireEvent . press ( screen . getByTestId ( HEADLESS_HOST_CANCEL_BUTTON_TEST_ID ) ) ;
465-
466- expect ( callbacks . onClose ) . toHaveBeenCalledTimes ( 1 ) ;
467- expect ( callbacks . onClose ) . toHaveBeenCalledWith ( {
468- reason : 'user_dismissed' ,
469- } ) ;
470- expect ( mockGoBack ) . toHaveBeenCalledTimes ( 1 ) ;
471- expect ( getSession ( session . id ) ) . toBeUndefined ( ) ;
472- } ) ;
473-
474- it ( 'fires onClose({ reason: "user_dismissed" }) and navigates back when the header back button is pressed mid-flow' , async ( ) => {
475- mockContinueWithQuote . mockImplementation (
476- ( ) => new Promise ( ( ) => undefined ) ,
477- ) ;
442+ describe ( 'Dismissal (Phase 8 + 9.5)' , ( ) => {
443+ it ( 'registers a beforeRemove listener that synchronously closes the session with user_dismissed' , ( ) => {
444+ // Phase 9.5 replaces the old visible Cancel/Back buttons with a
445+ // navigation listener so the synchronous close still fires when the
446+ // user backs out — even with no chrome to render.
478447 const quote = buildAggregatorQuote ( ) ;
479448 const session = seedSession ( quote ) ;
480449 const callbacks = session . callbacks ;
481450 renderHost ( { headlessSessionId : session . id } ) ;
482- await waitFor ( ( ) =>
483- expect ( mockContinueWithQuote ) . toHaveBeenCalledTimes ( 1 ) ,
451+ expect ( mockAddListener ) . toHaveBeenCalledWith (
452+ 'beforeRemove' ,
453+ expect . any ( Function ) ,
484454 ) ;
455+ expect ( typeof registeredBeforeRemoveListener ) . toBe ( 'function' ) ;
485456
486- fireEvent . press ( screen . getByTestId ( HEADLESS_HOST_BACK_BUTTON_TEST_ID ) ) ;
457+ // Fire the listener like React Navigation would on a back gesture.
458+ registeredBeforeRemoveListener ?.( ) ;
487459
488460 expect ( callbacks . onClose ) . toHaveBeenCalledTimes ( 1 ) ;
489461 expect ( callbacks . onClose ) . toHaveBeenCalledWith ( {
490462 reason : 'user_dismissed' ,
491463 } ) ;
492- expect ( mockGoBack ) . toHaveBeenCalledTimes ( 1 ) ;
493464 expect ( getSession ( session . id ) ) . toBeUndefined ( ) ;
494465 } ) ;
495466
@@ -532,10 +503,12 @@ describe('HeadlessHost', () => {
532503 expect ( callbacks . onClose ) . toHaveBeenCalledTimes ( 1 ) ;
533504 expect ( callbacks . onClose ) . toHaveBeenCalledWith ( { reason : 'completed' } ) ;
534505
506+ // beforeRemove fires too (React Navigation always fires it on screen
507+ // removal), then unmount cleanup runs. Both find the session gone and
508+ // no-op.
509+ registeredBeforeRemoveListener ?.( ) ;
535510 unmount ( ) ;
536511
537- // Dismissal hook re-reads from the registry on cleanup, sees the
538- // session is gone, and no-ops. No second onClose.
539512 expect ( callbacks . onClose ) . toHaveBeenCalledTimes ( 1 ) ;
540513 } ) ;
541514
@@ -553,26 +526,27 @@ describe('HeadlessHost', () => {
553526 expect ( callbacks . onClose ) . toHaveBeenCalledTimes ( 1 ) ;
554527 expect ( callbacks . onClose ) . toHaveBeenCalledWith ( { reason : 'unknown' } ) ;
555528
529+ registeredBeforeRemoveListener ?.( ) ;
556530 unmount ( ) ;
557531
558- // Dismissal hook re-reads, sees nothing, no-ops .
532+ // Both follow-up paths re-read, see nothing, no-op .
559533 expect ( callbacks . onClose ) . toHaveBeenCalledTimes ( 1 ) ;
560534 } ) ;
561535
562536 it ( 'no-ops on unmount when the host mounted against an already-terminated session' , ( ) => {
563537 const quote = buildAggregatorQuote ( ) ;
564538 const session = seedSession ( quote ) ;
565539 const callbacks = session . callbacks ;
566- // Cancel before the screen mounts; matches the existing
567- // "skips orchestration" assertion but additionally verifies the
568- // dismissal cleanup does not produce a spurious second onClose.
540+ // Cancel before the screen mounts; the Phase 8 dismissal cleanup must
541+ // not produce a spurious second onClose.
569542 closeSession ( session . id , { reason : 'consumer_cancelled' } ) ;
570543 expect ( callbacks . onClose ) . toHaveBeenCalledTimes ( 1 ) ;
571544 expect ( callbacks . onClose ) . toHaveBeenCalledWith ( {
572545 reason : 'consumer_cancelled' ,
573546 } ) ;
574547
575548 const { unmount } = renderHost ( { headlessSessionId : session . id } ) ;
549+ registeredBeforeRemoveListener ?.( ) ;
576550 unmount ( ) ;
577551
578552 expect ( callbacks . onClose ) . toHaveBeenCalledTimes ( 1 ) ;
0 commit comments