diff --git a/.changeset/mosaic-feature-test-flakes.md b/.changeset/mosaic-feature-test-flakes.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-feature-test-flakes.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/docs/testing.md b/packages/mosaic/docs/testing.md index 4f820c7398b..07d64e1e551 100644 --- a/packages/mosaic/docs/testing.md +++ b/packages/mosaic/docs/testing.md @@ -189,6 +189,13 @@ click. or Mosaic modules. - **Wait for things to disappear.** Popovers and dialogs run exit transitions in a real browser, so use `waitFor(() => expect(...).toBeNull())`. +- **Wait for an alert's text, not the alert.** Live regions such as + `Card.Banner` are always mounted, so `findByRole('alert')` can return one + before its text arrives. Use + `waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(...))`. +- **Control time instead of waiting it out.** Don't raise a `waitFor` or + `findBy*` timeout. If the test waits on a request or a poll, hold it with + `holdRequests()` and release it when the test is ready. - **One `describe` per user task**, so the file reads like the feature's spec. ### Clerk behaviors worth knowing diff --git a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx index 1caee889d0e..29c22fa3d29 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx @@ -75,7 +75,7 @@ describe('MembersTableTabView', () => { await user.click(screen.getByRole('menuitem', { name: 'Remove from organization' })); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove from organization' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove member.'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not remove member.')); }); it('chooses loading, retained rows, and filtered empty results from the supplied state', () => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx index 775cd2e4463..b3072c883b8 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-web3-wallets-section.feature.test.tsx @@ -163,7 +163,7 @@ describe('Web3 wallets', () => { await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); await waitFor(() => expect(creation.requests).toHaveLength(1)); creation.release(); - expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong. Please try again.')); const pendingId = fapi.client.sessions[0]?.user.web3_wallets[0]?.id; expect(pendingId).toBeDefined(); expect(fapi.client.sessions[0]?.user.web3_wallets).toHaveLength(1); @@ -261,7 +261,7 @@ describe('Web3 wallets', () => { ); await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect MetaMask' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Installez un portefeuille.'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Installez un portefeuille.')); rerender( @@ -296,7 +296,7 @@ describe('Web3 wallets', () => { await waitFor(() => expect(creation.requests).toHaveLength(1)); creation.fail('verification_invalid_strategy', 'Server copy'); - expect(await screen.findByRole('alert')).toHaveTextContent('Ce portefeuille est indisponible.'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Ce portefeuille est indisponible.')); expect(screen.queryByText('Server copy')).toBeNull(); }); @@ -421,7 +421,7 @@ describe('Web3 wallets', () => { expect(screen.getByRole('button', { name: 'Connect Solana' })).toBeDisabled(); hold.fail('wallet_creation_failed'); - expect(await screen.findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('wallet_creation_failed')); expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeEnabled(); const retryFapi = serveFapi(fapi); await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); @@ -451,7 +451,7 @@ describe('Web3 wallets', () => { await user.click(screen.getByRole('button', { name: 'Connect MetaMask' })); await waitFor(() => expect(creation.requests).toHaveLength(1)); creation.fail('wallet_creation_failed'); - expect(await screen.findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('wallet_creation_failed')); const primary = holdRequests('post', '/v1/me'); await user.click(screen.getByRole('button', { name: 'Manage Solana' })); @@ -503,7 +503,7 @@ describe('Web3 wallets', () => { expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toBeDisabled(); hold.fail('primary_update_failed'); - expect(await screen.findByRole('alert')).toHaveTextContent('primary_update_failed'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('primary_update_failed')); expect(screen.queryByText('Cannot verify your account')).not.toBeInTheDocument(); const retryFapi = serveFapi(fapi); await user.click(screen.getByRole('button', { name: 'Manage MetaMask' })); @@ -543,8 +543,8 @@ describe('Web3 wallets', () => { expect(confirm).toHaveAttribute('aria-busy', 'true'); hold.fail('wallet_removal_failed'); - expect(await within(screen.getByRole('alertdialog')).findByRole('alert')).toHaveTextContent( - 'wallet_removal_failed', + await waitFor(() => + expect(within(screen.getByRole('alertdialog')).getByRole('alert')).toHaveTextContent('wallet_removal_failed'), ); await waitFor(() => expect(confirm).not.toHaveAttribute('aria-busy', 'true')); const retryFapi = serveFapi(fapi); @@ -785,7 +785,9 @@ describe('Web3 wallets', () => { await user.keyboard('{Escape}'); expect(screen.getByRole('dialog')).toBeVisible(); creation.fail('wallet_creation_failed'); - expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent('wallet_creation_failed'); + await waitFor(() => + expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent('wallet_creation_failed'), + ); expect(screen.getAllByRole('alert', { hidden: true })).toHaveLength(1); const retryFapi = serveFapi(fapi); await user.click(screen.getByRole('button', { name: 'Second Solana' })); @@ -837,8 +839,10 @@ describe('Web3 wallets', () => { const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Connect Solana' })); await user.click(await screen.findByRole('button', { name: 'Test Solana' })); - expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent( - 'Something went wrong. Please try again.', + await waitFor(() => + expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent( + 'Something went wrong. Please try again.', + ), ); if (dismissal === 'Escape') { @@ -853,8 +857,10 @@ describe('Web3 wallets', () => { expect(within(screen.getByRole('dialog')).queryByText('Something went wrong. Please try again.')).toBeNull(); expect(screen.getByRole('button', { name: 'Test Solana' })).toBeEnabled(); await user.click(screen.getByRole('button', { name: 'Test Solana' })); - expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent( - 'Something went wrong. Please try again.', + await waitFor(() => + expect(within(screen.getByRole('dialog')).getByRole('alert')).toHaveTextContent( + 'Something went wrong. Please try again.', + ), ); expect(connect).toHaveBeenCalledTimes(2); } finally { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.feature.test.tsx index 167656ba2fc..94712c9f943 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.feature.test.tsx @@ -239,12 +239,11 @@ async function manageEmail(actor: Actor, label: string, action: string) { } describe('the user profile email addresses', () => { - it('adds an address, verifies the code it was sent, and returns focus to the trigger', async () => { + it('adds an address and verifies the code it was sent', async () => { const { actor } = await renderSection(signedInWithEmails([])); - const trigger = screen.getByRole('button', { name: 'Add email' }); expect(emailRow()).toHaveTextContent('No email addresses added'); - await actor.click(trigger); + await actor.click(screen.getByRole('button', { name: 'Add email' })); await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); await actor.click(screen.getByRole('button', { name: 'Continue' })); await enterCode(actor, VERIFICATION_CODE); @@ -252,13 +251,29 @@ describe('the user profile email addresses', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(emailsListed()).toEqual(['new@example.com']); expect(emailRow()).not.toHaveTextContent('Unverified'); - await waitFor(() => expect(trigger).toHaveFocus()); }); - it('verifies an address left unverified and returns focus to its menu', async () => { + it('holds the code field until the code has been sent', async () => { + const { actor } = await renderSection(signedInWithEmails([])); + const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification'); + + await actor.click(screen.getByRole('button', { name: 'Add email' })); + await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); + await actor.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + + expect(await screen.findByRole('textbox', { name: 'Verification code' })).toHaveAttribute('aria-disabled', 'true'); + + prepare.release(); + await enterCode(actor, VERIFICATION_CODE); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(emailsListed()).toEqual(['new@example.com']); + }); + + it('verifies an address left unverified', async () => { const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending])); - const trigger = within(emailRow()).getByRole('button', { name: 'Manage pending@example.com' }); expect(within(emailRow()).getByText('pending@example.com').parentElement).toHaveTextContent('Unverified'); await manageEmail(actor, 'pending@example.com', 'Verify'); @@ -268,21 +283,23 @@ describe('the user profile email addresses', () => { await waitFor(() => expect(within(emailRow()).getByText('pending@example.com').parentElement).not.toHaveTextContent('Unverified'), ); - await waitFor(() => expect(trigger).toHaveFocus()); }); it('waits for the emailed link when the instance verifies by link, then closes once it is opened', async () => { const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); const { actor, fapi } = await renderSection(signedInWithEmails([PRIMARY, pending], verifiesByLink)); + const poll = holdRequests('get', '/v1/me/email_addresses/:id'); await manageEmail(actor, 'pending@example.com', 'Verify'); const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); await waitFor(() => expect(dialog).toHaveTextContent('Open the link we sent to pending@example.com')); expect(screen.queryByRole('textbox', { name: 'Verification code' })).not.toBeInTheDocument(); + await waitFor(() => expect(poll.requests).toHaveLength(1)); verifyEmailOutOfBand(fapi, 'idn_pending'); + poll.release(); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), { timeout: 5000 }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); it('points the emailed link at the user profile on the host origin', async () => { @@ -422,8 +439,10 @@ describe('the user profile name, username and picture', () => { await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); - expect(await screen.findByRole('alert')).toHaveTextContent( - 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + ), ); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx index 668858ea656..d7559e90f3e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx @@ -225,7 +225,7 @@ function VerifyEmailStep(props: VerifyEmailStepProps) { > diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-cleanup.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-cleanup.feature.test.tsx index a958d22b074..867308eb745 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-cleanup.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-cleanup.feature.test.tsx @@ -55,6 +55,6 @@ it('retains the localized cancellation error when pending cleanup fails', async await userEvent.setup().click(screen.getByRole('button', { name: 'Add passkey' })); await waitFor(() => expect(cleanup.requests).toHaveLength(1)); cleanup.fail('session_reverification_required', 'Cleanup requires reverification'); - expect(await screen.findByRole('alert')).toHaveTextContent('Création annulée.'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Création annulée.')); expect(screen.getByRole('button', { name: 'Add passkey' })).toBeEnabled(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx index d84f0edbef9..2165e8ef78c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-lifecycle.feature.test.tsx @@ -105,7 +105,7 @@ describe('Changing the active passkey account', () => { await user.click(screen.getByRole('button', { name: 'Add passkey' })); await waitFor(() => expect(creation.requests).toHaveLength(1)); creation.fail('form_param_invalid', 'Alice credential failed'); - expect(await screen.findByRole('alert')).toHaveTextContent('Alice credential failed'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Alice credential failed')); await act(() => clerk.setActive({ session: 'sess_b' })); expect(await screen.findByText('Bob phone')).toBeVisible(); expect(screen.queryByRole('alert')).toBeNull(); @@ -253,7 +253,7 @@ describe('Submitting passkey actions', () => { ); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Add passkey' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Création annulée.'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Création annulée.')); }); }); @@ -357,7 +357,7 @@ describe('Passkey identity and policy at action time', () => { await user.click(screen.getByRole('button', { name: 'Add passkey' })); await waitFor(() => expect(creation.requests).toHaveLength(1)); creation.fail('form_param_invalid', 'Old session error'); - expect(await screen.findByRole('alert')).toHaveTextContent('Old session error'); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Old session error')); await act(() => clerk.setActive({ session: 'sess_a2' })); expect(clerk.session?.id).toBe('sess_a2'); expect(screen.queryByRole('alert')).toBeNull(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx index aac2d026483..cad3db5838e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx @@ -246,7 +246,7 @@ describe('Adding a passkey', () => { await user.click(screen.getByRole('button', { name: 'Add passkey' })); await waitFor(() => expect(creation.requests).toHaveLength(1)); creation.fail(code); - expect(await screen.findByRole('alert')).toHaveTextContent(code); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(code)); expect(screen.queryByRole('dialog')).toBeNull(); expect(creation.requests).toHaveLength(1); const retried = serveFapi(fapi);