diff --git a/packages/fxa-settings/src/components/App/index.tsx b/packages/fxa-settings/src/components/App/index.tsx index 6a7306a51a4..9bd1db219b9 100644 --- a/packages/fxa-settings/src/components/App/index.tsx +++ b/packages/fxa-settings/src/components/App/index.tsx @@ -212,7 +212,7 @@ const PairSupplicantSyncSuccess = lazy( () => import('../../pages/Pair2/Supplicant/SyncSuccess') ); const PairSupplicantTimeoutAndCancel = lazy( - () => import('../../pages/Pair2/Supplicant/TimeoutAndCancel') + () => import('../../pages/Pair2/Supplicant/TimeoutAndCancel/container') ); diff --git a/packages/fxa-settings/src/lib/channels/pairing-flow.test.ts b/packages/fxa-settings/src/lib/channels/pairing-flow.test.ts new file mode 100644 index 00000000000..1fb1d9a6253 --- /dev/null +++ b/packages/fxa-settings/src/lib/channels/pairing-flow.test.ts @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { pairingFlow } from './pairing-flow'; +import { PairingChannelClient } from './pairing-channel'; + +jest.mock('./pairing-channel'); + +// FXA-13869: on a cancel, timeout, or disconnect the flow must truly close the +// channel WebSocket, not just navigate away. +describe('PairingFlowController.reset channel teardown', () => { + beforeEach(async () => { + // Clear the module singleton so each test starts without a client. + await pairingFlow.reset(); + jest.clearAllMocks(); + }); + + it('closes the channel and clears connection state', async () => { + const close = jest.fn().mockResolvedValue(undefined); + (PairingChannelClient as jest.Mock).mockImplementation(() => ({ + open: jest.fn().mockResolvedValue(undefined), + close, + isConnected: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + + await pairingFlow.joinChannel('wss://channel.example', 'cid', 'ckey'); + expect(pairingFlow.isConnected).toBe(true); + + await pairingFlow.reset(); + + expect(close).toHaveBeenCalledTimes(1); + expect(pairingFlow.isConnected).toBe(false); + }); + + it('clears the held handshake state', async () => { + (PairingChannelClient as jest.Mock).mockImplementation(() => ({ + open: jest.fn().mockResolvedValue(undefined), + close: jest.fn().mockResolvedValue(undefined), + isConnected: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })); + + await pairingFlow.joinChannel('wss://channel.example', 'cid', 'ckey'); + pairingFlow.supplicantOAuth = { + state: 's', + scope: 'profile', + code_challenge: 'c', + }; + + await pairingFlow.reset(); + + expect(pairingFlow.channelId).toBeUndefined(); + expect(pairingFlow.channelKey).toBeUndefined(); + expect(pairingFlow.supplicantOAuth).toBeUndefined(); + expect(pairingFlow.completing).toBe(false); + }); +}); diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/ContinueOnMobile/container.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/ContinueOnMobile/container.tsx index 6655ed95558..18060fe3fde 100644 --- a/packages/fxa-settings/src/pages/Pair2/Authority/ContinueOnMobile/container.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Authority/ContinueOnMobile/container.tsx @@ -27,7 +27,7 @@ const ContinueOnMobileContainer = ({ if (integration instanceof PairingAuthorityIntegration) { integration.destroy(); } - navigateWithQuery('/pair/authority/timeout_and_cancel'); + navigateWithQuery('/pair/authority/timeout_and_cancel?reason=canceled'); }; return ; diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.test.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.test.tsx index de3b4dc797d..60a60240a27 100644 --- a/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.test.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.test.tsx @@ -168,14 +168,14 @@ describe('Pair2/Authority/ScanQR container', () => { expect(mockNavigate).toHaveBeenCalledWith('/pair/authority/approve_signin'); }); - it('navigates to the cancel screen when pairing fails', async () => { + it('navigates to the cancel screen with reason=timeout when pairing fails', async () => { renderContainer(); await waitFor(() => expect(integration.onStateChange).toBeTruthy()); emitState(integration, AuthorityState.Failed); expect(mockNavigate).toHaveBeenCalledWith( - '/pair/authority/timeout_and_cancel' + '/pair/authority/timeout_and_cancel?reason=timeout' ); }); diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.tsx index b7b9923f35d..f9300c76fd1 100644 --- a/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Authority/ScanQR/container.tsx @@ -13,6 +13,9 @@ import { PairingAuthorityIntegration, } from '../../../../models'; +// How long to show the QR before giving up if no device scans it. +const SCAN_QR_TIMEOUT_MS = 2 * 60 * 1000; + /** * Owns the pairing channel for the authority. Mints a channel on mount so the * QR always scans to one that exists on the channel server, and closes it on @@ -37,13 +40,18 @@ const ScanQRContainer = ({ integration }: { integration: Integration }) => { if (!authority) { return; } + let inactivityTimer: ReturnType | undefined; + authority.onStateChange = (state: AuthorityState) => { switch (state) { case AuthorityState.WaitingForAuthorizations: + // A device scanned, so the no-scan timeout no longer applies. + clearTimeout(inactivityTimer); navigate('/pair/authority/approve_signin'); break; case AuthorityState.Failed: - navigate('/pair/authority/timeout_and_cancel'); + clearTimeout(inactivityTimer); + navigate('/pair/authority/timeout_and_cancel?reason=timeout'); break; default: // Connecting and WaitingForMetadata both resolve on this page. @@ -57,6 +65,10 @@ const ScanQRContainer = ({ integration }: { integration: Integration }) => { const pairUrl = authority.getPairUrl('2'); plog('auth QR minted', pairUrl.split('#')[1] ?? ''); setQrCodeValue(pairUrl); + inactivityTimer = setTimeout( + () => navigate('/pair/authority/timeout_and_cancel?reason=timeout'), + SCAN_QR_TIMEOUT_MS + ); } catch (err) { setQrCodeValue(''); Sentry.captureException(err); @@ -64,6 +76,7 @@ const ScanQRContainer = ({ integration }: { integration: Integration }) => { })(); return () => { + clearTimeout(inactivityTimer); // Deliberately no `destroy()` here. The channel has to outlive this page // for the rest of the v2 flow, and tearing down on unmount also loses the // channel under StrictMode's double-invoked effect: cleanup's async diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.test.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.test.tsx new file mode 100644 index 00000000000..984b542f131 --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.test.tsx @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import { navigateWithQuery } from '../../../../lib/utilities'; +import { + MockAuthorityIntegration, + mockAuthorityIntegration, +} from '../ScanQR/mocks'; +import Container from './container'; + +jest.mock('../../../../lib/utilities', () => ({ + ...jest.requireActual('../../../../lib/utilities'), + navigateWithQuery: jest.fn(), +})); + +const mockNavigate = navigateWithQuery as jest.Mock; +let integration: MockAuthorityIntegration; + +const TIMEOUT_HEADING = 'Still want to connect a device?'; +const CANCELED_HEADING = 'Canceled'; + +const renderAt = (search: string) => + renderWithLocalizationProvider( + + + + ); + +describe('Pair2/Authority/TimeoutAndCancel container', () => { + beforeEach(() => { + integration = mockAuthorityIntegration(); + }); + afterEach(() => jest.clearAllMocks()); + + it('renders the canceled variant when reason=canceled', () => { + renderAt('?reason=canceled'); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + CANCELED_HEADING + ); + }); + + it('renders the timeout variant when reason is missing', () => { + renderAt(''); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + TIMEOUT_HEADING + ); + }); + + it('tears down the channel and returns to scan_qr on Try again', async () => { + const user = userEvent.setup(); + renderAt('?reason=timeout'); + + await user.click(screen.getByRole('button', { name: 'Try again' })); + + expect(integration.destroy).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith('/pair/authority/scan_qr'); + }); + + it('goes to settings on Cancel', async () => { + const user = userEvent.setup(); + renderAt('?reason=timeout'); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(mockNavigate).toHaveBeenCalledWith('/settings'); + }); +}); diff --git a/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.tsx b/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.tsx index f130742dde6..d49f0b8a931 100644 --- a/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Authority/TimeoutAndCancel/container.tsx @@ -3,23 +3,26 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import React from 'react'; -import { - Integration, - PairingAuthorityIntegration, -} from '../../../../models'; +import { useLocation } from 'react-router'; +import { Integration, PairingAuthorityIntegration } from '../../../../models'; import { navigateWithQuery } from '../../../../lib/utilities'; -import TimeoutAndCancel from '.'; +import TimeoutAndCancel, { TimeoutAndCancelReason } from '.'; /** * Authority timeout/cancel container (FXA-13869). The dead-end screen after a - * pairing attempt ends without connecting. "Try again" resets the flow and - * re-mints a channel from scan_qr. + * pairing attempt ends without connecting. The caller sets `?reason=timeout` on + * a disconnect and `?reason=canceled` on cancel; anything but an explicit cancel + * falls back to `timeout`. "Try again" resets the flow and re-mints from scan_qr. */ const TimeoutAndCancelContainer = ({ integration, }: { integration: Integration; }) => { + const raw = new URLSearchParams(useLocation().search).get('reason'); + const reason: TimeoutAndCancelReason = + raw === 'canceled' ? 'canceled' : 'timeout'; + const onTryAgain = () => { if (integration instanceof PairingAuthorityIntegration) { integration.destroy(); @@ -29,7 +32,9 @@ const TimeoutAndCancelContainer = ({ const onSyncSettings = () => navigateWithQuery('/settings'); const onCancel = () => navigateWithQuery('/settings'); - return ; + return ( + + ); }; export default TimeoutAndCancelContainer; diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/container.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/container.tsx index 8b8552574a2..29baef63e02 100644 --- a/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/container.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/ApproveSignIn/container.tsx @@ -104,7 +104,9 @@ const ApproveSignInContainer = () => { // An unexpected channel close (authority cancelled or connection // dropped) sends the supplicant to the timeout screen. pairingFlow.wireAbort(() => - navigateWithQuery('/pair/supplicant/timeout_and_cancel') + navigateWithQuery( + '/pair/supplicant/timeout_and_cancel?reason=timeout' + ) ); // Firefox mobile (app-services) already ran OAuth-start and passed the @@ -181,7 +183,7 @@ const ApproveSignInContainer = () => { const onCancel = () => { // Closing the channel signals the authority to abort too. pairingFlow.reset(); - navigateWithQuery('/pair/supplicant/timeout_and_cancel'); + navigateWithQuery('/pair/supplicant/timeout_and_cancel?reason=canceled'); }; return ( diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx index 006cf4f08ac..af8274b37ee 100644 --- a/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/ConnectThisDevice/container.tsx @@ -37,7 +37,9 @@ const ConnectThisDeviceContainer = () => { plog('supp state mismatch; abort'); off(); pairingFlow.reset(); - navigateWithQuery('/pair/supplicant/timeout_and_cancel'); + navigateWithQuery( + '/pair/supplicant/timeout_and_cancel?reason=canceled' + ); return; } @@ -74,7 +76,7 @@ const ConnectThisDeviceContainer = () => { const onCancel = () => { // Closing the channel signals the authority to abort too. pairingFlow.reset(); - navigateWithQuery('/pair/supplicant/timeout_and_cancel'); + navigateWithQuery('/pair/supplicant/timeout_and_cancel?reason=canceled'); }; return ( diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.test.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.test.tsx new file mode 100644 index 00000000000..5edb095b06a --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.test.tsx @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import Container from './container'; + +// The supplicant dead-end card has no actions, so the container's only job is to +// read `?reason` and render the matching variant (FXA-13869). +const TIMEOUT_HEADING = 'Looks like we timed out'; +const CANCELED_HEADING = 'Canceled'; + +const renderAt = (search: string) => + renderWithLocalizationProvider( + + + + ); + +describe('Pair2/Supplicant/TimeoutAndCancel container', () => { + it('renders the canceled variant when reason=canceled', () => { + renderAt('?reason=canceled'); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + CANCELED_HEADING + ); + }); + + it('renders the timeout variant when reason=timeout', () => { + renderAt('?reason=timeout'); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + TIMEOUT_HEADING + ); + }); + + it('falls back to timeout when reason is missing', () => { + renderAt(''); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + TIMEOUT_HEADING + ); + }); + + it('falls back to timeout when reason is unknown', () => { + renderAt('?reason=bogus'); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + TIMEOUT_HEADING + ); + }); +}); diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.tsx new file mode 100644 index 00000000000..9319a8cd282 --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/TimeoutAndCancel/container.tsx @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { useLocation } from 'react-router'; +import TimeoutAndCancel, { PairingInterruptionReason } from '.'; + +/** + * Supplicant timeout/cancel container (FXA-13869). The mobile dead-end screen + * has no actions, so the container only reads why the flow ended (the caller + * sets `?reason=timeout` on a disconnect and `?reason=canceled` on cancel) and + * passes it to the card. Anything but an explicit cancel falls back to `timeout`. + */ +const TimeoutAndCancelContainer = () => { + const raw = new URLSearchParams(useLocation().search).get('reason'); + const reason: PairingInterruptionReason = + raw === 'canceled' ? 'canceled' : 'timeout'; + + return ; +}; + +export default TimeoutAndCancelContainer;