From 6e75633b2a3a2e375cbe2805494ed580522ed5a8 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Tue, 27 Oct 2026 23:53:44 +0000 Subject: [PATCH] test(frontend): wait for async handlers instead of sleeping in unit tests Replace the 123 fixed setTimeout sleeps (10/20/50/100ms, plus one 2100ms) that frontend unit tests used to let async handlers finish: - flushPromises() (new src/__tests__/flush-promises.ts) resolves on a zero-delay timer, which fires only after the microtask queue is empty, so a handler chain over mocked promises settles however many awaits it has. Used for the 122 sleeps in auth, auth-mfa-enroll, plans*, history-*, dashboard, savings-history and riexchange tests. - The riexchange "keeps the exchange ID on screen" test slept 2.1s to outlast the old 2s auto-close. It now runs every timer the exchange schedules under jest fake timers, so it also catches an auto-close at any longer delay, in about 50ms. - no-wallclock-sleeps.test.ts fails if a test under src/__tests__ awaits a non-zero setTimeout again (123 hits on main, 0 here). Test-only; no production frontend code changes. The Go sleeps from the issue are on another branch. Refs #36 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/auth-mfa-enroll.test.ts | 28 +++---- frontend/src/__tests__/auth.test.ts | 79 ++++++++++--------- frontend/src/__tests__/dashboard.test.ts | 9 ++- frontend/src/__tests__/flush-promises.ts | 15 ++++ .../__tests__/history-approval-queue.test.ts | 7 +- .../__tests__/history-approve-button.test.ts | 9 ++- .../__tests__/history-cancel-button.test.ts | 7 +- .../__tests__/history-retry-button.test.ts | 13 +-- .../src/__tests__/no-wallclock-sleeps.test.ts | 58 ++++++++++++++ .../src/__tests__/plans-ramp-label.test.ts | 7 +- .../__tests__/plans-range-validation.test.ts | 5 +- .../src/__tests__/plans-service-ids.test.ts | 5 +- frontend/src/__tests__/plans.test.ts | 71 +++++++++-------- frontend/src/__tests__/riexchange.test.ts | 24 ++++-- .../src/__tests__/savings-history.test.ts | 11 +-- 15 files changed, 222 insertions(+), 126 deletions(-) create mode 100644 frontend/src/__tests__/flush-promises.ts create mode 100644 frontend/src/__tests__/no-wallclock-sleeps.test.ts diff --git a/frontend/src/__tests__/auth-mfa-enroll.test.ts b/frontend/src/__tests__/auth-mfa-enroll.test.ts index 6b1e6c0d3..60874fa9d 100644 --- a/frontend/src/__tests__/auth-mfa-enroll.test.ts +++ b/frontend/src/__tests__/auth-mfa-enroll.test.ts @@ -50,6 +50,7 @@ import * as state from '../state'; // updateUserUI mounts it. import { updateUserUI } from '../auth'; import { closeModal } from '../modal'; +import { flushPromises } from './flush-promises'; beforeEach(() => { document.body.innerHTML = ` @@ -104,7 +105,7 @@ describe('MFA enrollment flow', () => { expect(document.getElementById('mfa-enroll-password')).not.toBeNull(); (document.getElementById('mfa-enroll-password') as HTMLInputElement).value = 'pw'; document.getElementById('mfa-enroll-continue')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(api.setupMFA).toHaveBeenCalledWith('pw'); expect(document.getElementById('mfa-qr')).not.toBeNull(); expect(document.getElementById('mfa-secret-display')?.textContent).toMatch(/JBSW/); @@ -122,10 +123,10 @@ describe('MFA enrollment flow', () => { document.getElementById('mfa-enable-btn')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); (document.getElementById('mfa-enroll-password') as HTMLInputElement).value = 'pw'; document.getElementById('mfa-enroll-continue')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); (document.getElementById('mfa-enroll-code') as HTMLInputElement).value = '123456'; document.getElementById('mfa-qr-verify')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(api.enableMFA).toHaveBeenCalledWith('123456'); expect(document.body.textContent).toContain('AAAA-BBBB'); expect(document.body.textContent).toContain('CCCC-DDDD'); @@ -140,10 +141,10 @@ describe('MFA enrollment flow', () => { document.getElementById('mfa-enable-btn')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); (document.getElementById('mfa-enroll-password') as HTMLInputElement).value = 'pw'; document.getElementById('mfa-enroll-continue')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); (document.getElementById('mfa-enroll-code') as HTMLInputElement).value = '000000'; document.getElementById('mfa-qr-verify')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); const err = document.getElementById('mfa-flow-error'); expect(err?.classList.contains('hidden')).toBe(false); // Still on the QR step (#mfa-qr-verify still present). @@ -166,7 +167,7 @@ describe('MFA disable flow', () => { (document.getElementById('mfa-disable-password') as HTMLInputElement).value = 'pw'; (document.getElementById('mfa-disable-code') as HTMLInputElement).value = '123456'; document.getElementById('mfa-disable-submit')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(api.disableMFA).toHaveBeenCalledWith('pw', '123456'); }); @@ -175,7 +176,7 @@ describe('MFA disable flow', () => { document.getElementById('mfa-disable-btn')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); (document.getElementById('mfa-disable-code') as HTMLInputElement).value = '123456'; document.getElementById('mfa-disable-submit')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(api.disableMFA).not.toHaveBeenCalled(); expect(document.getElementById('mfa-flow-error')?.textContent).toMatch(/password/i); }); @@ -197,7 +198,7 @@ describe('MFA regenerate-recovery-codes flow', () => { document.getElementById('mfa-regenerate-btn')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); (document.getElementById('mfa-regen-code') as HTMLInputElement).value = '123456'; document.getElementById('mfa-regen-submit')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(api.regenerateMFARecoveryCodes).toHaveBeenCalledWith('123456'); expect(document.body.textContent).toContain('NEW1-XXXX'); }); @@ -208,7 +209,7 @@ describe('MFA regenerate-recovery-codes flow', () => { document.getElementById('mfa-regenerate-btn')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); (document.getElementById('mfa-regen-code') as HTMLInputElement).value = '123456'; document.getElementById('mfa-regen-submit')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); const modal = document.getElementById('profile-modal')!; modal.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); @@ -226,13 +227,13 @@ describe('MFA regenerate-recovery-codes flow', () => { document.getElementById('mfa-regenerate-btn')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); (document.getElementById('mfa-regen-code') as HTMLInputElement).value = '123456'; document.getElementById('mfa-regen-submit')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); const modal = document.getElementById('profile-modal')!; const save = modal.querySelector('#profile-form button[type="submit"]')!; expect(save.disabled).toBe(true); document.getElementById('profile-form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(modal.classList.contains('hidden')).toBe(false); closeModal(modal); expect(modal.classList.contains('hidden')).toBe(false); @@ -250,7 +251,6 @@ describe('MFA panels are styled by classes, not inline styles (issue #745)', () const click = (id: string): void => { document.getElementById(id)!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }; - const tick = (): Promise => new Promise((r) => setTimeout(r, 10)); const inlineStyled = (): number => document.querySelectorAll('#profile-modal [style]').length; test('enrollment steps use .mfa-panel, .mfa-secret, .code-input and .recovery-codes', async () => { @@ -268,7 +268,7 @@ describe('MFA panels are styled by classes, not inline styles (issue #745)', () (document.getElementById('mfa-enroll-password') as HTMLInputElement).value = 'pw'; click('mfa-enroll-continue'); - await tick(); + await flushPromises(); expect(document.getElementById('mfa-qr')?.classList.contains('mfa-qr')).toBe(true); expect(document.querySelector('.mfa-secret #mfa-secret-display')).not.toBeNull(); expect(document.getElementById('mfa-enroll-code')?.classList.contains('code-input')).toBe(true); @@ -276,7 +276,7 @@ describe('MFA panels are styled by classes, not inline styles (issue #745)', () (document.getElementById('mfa-enroll-code') as HTMLInputElement).value = '123456'; click('mfa-qr-verify'); - await tick(); + await flushPromises(); expect(document.querySelector('#mfa-flow-host .mfa-panel.mfa-panel-emphasis')).not.toBeNull(); expect(document.querySelectorAll('ul.recovery-codes li')).toHaveLength(2); expect(inlineStyled()).toBe(0); diff --git a/frontend/src/__tests__/auth.test.ts b/frontend/src/__tests__/auth.test.ts index c98381230..1fb5a600c 100644 --- a/frontend/src/__tests__/auth.test.ts +++ b/frontend/src/__tests__/auth.test.ts @@ -49,6 +49,7 @@ jest.mock('../state', () => ({ import * as api from '../api'; import * as state from '../state'; +import { flushPromises } from './flush-promises'; // Mock toast so alert() -> showToast() migrations (finding 11-L3) don't // require a real DOM toast implementation in jsdom. @@ -124,7 +125,7 @@ describe('Auth Module', () => { loginForm?.dispatchEvent(new Event('submit', { cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.login).toHaveBeenCalledWith('test@example.com', 'password123'); }); @@ -142,7 +143,7 @@ describe('Auth Module', () => { loginForm?.dispatchEvent(new Event('submit', { cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const errorDiv = document.getElementById('login-error'); expect(errorDiv?.classList.contains('hidden')).toBe(false); @@ -160,7 +161,7 @@ describe('Auth Module', () => { emailInput.value = email; passwordInput.value = password; loginForm?.dispatchEvent(new Event('submit', { cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const errorDiv = document.getElementById('login-error'); return errorDiv?.textContent; } @@ -257,7 +258,7 @@ describe('Auth Module', () => { loginForm?.dispatchEvent(new Event('submit', { cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(reloadPage).toHaveBeenCalled(); }); @@ -279,7 +280,7 @@ describe('Auth Module', () => { const forgotLink = document.getElementById('forgot-password-link'); forgotLink?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const resetEmail = document.getElementById('reset-email'); const sendBtn = document.getElementById('send-reset-btn'); @@ -292,11 +293,11 @@ describe('Auth Module', () => { await showLoginModal(); document.getElementById('forgot-password-link') ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); (document.getElementById('reset-email') as HTMLInputElement).value = 'test@example.com'; document.getElementById('login-form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.requestPasswordReset).toHaveBeenCalledWith('test@example.com'); expect(api.login).not.toHaveBeenCalled(); @@ -307,7 +308,7 @@ describe('Auth Module', () => { await showLoginModal(); document.getElementById('forgot-password-link') ?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); // Both handlers write synchronously and the reset handler writes last, // so a stale login handler is only visible in the mutation records. @@ -317,7 +318,7 @@ describe('Auth Module', () => { }); observer.observe(document.getElementById('login-modal')!, { childList: true, characterData: true, subtree: true }); document.getElementById('login-form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 20)); + await flushPromises(); observer.disconnect(); expect(writes.some(text => /Enter email|Enter password/.test(text))).toBe(false); @@ -331,7 +332,7 @@ describe('Auth Module', () => { const forgotLink = document.getElementById('forgot-password-link'); forgotLink?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const resetEmail = document.getElementById('reset-email') as HTMLInputElement; const sendBtn = document.getElementById('send-reset-btn'); @@ -339,7 +340,7 @@ describe('Auth Module', () => { resetEmail.value = 'test@example.com'; sendBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.requestPasswordReset).toHaveBeenCalledWith('test@example.com'); // No alert(): the lingering-modal bug came from stacking an @@ -361,12 +362,12 @@ describe('Auth Module', () => { const forgotLink = document.getElementById('forgot-password-link'); forgotLink?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const resetEmail = document.getElementById('reset-email') as HTMLInputElement; resetEmail.value = 'test@example.com'; document.getElementById('send-reset-btn')?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); document.getElementById('reset-confirmation-close')?.click(); @@ -379,12 +380,12 @@ describe('Auth Module', () => { const forgotLink = document.getElementById('forgot-password-link'); forgotLink?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const sendBtn = document.getElementById('send-reset-btn'); sendBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); // Issue #457: error is now inline in the modal, not via alert(). const errorDiv = document.getElementById('login-error'); @@ -402,7 +403,7 @@ describe('Auth Module', () => { const forgotLink = document.getElementById('forgot-password-link'); forgotLink?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const resetEmail = document.getElementById('reset-email') as HTMLInputElement; const sendBtn = document.getElementById('send-reset-btn'); @@ -410,7 +411,7 @@ describe('Auth Module', () => { resetEmail.value = 'test@example.com'; sendBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const errorDiv = document.getElementById('login-error'); expect(errorDiv?.textContent).toBe('Failed to send reset email. Please try again.'); @@ -424,12 +425,12 @@ describe('Auth Module', () => { const forgotLink = document.getElementById('forgot-password-link'); forgotLink?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const backLink = document.getElementById('back-to-login-link'); backLink?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); expect(reloadPage).toHaveBeenCalled(); }); @@ -652,7 +653,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const modal = document.getElementById('profile-modal'); expect(modal).toBeTruthy(); @@ -665,7 +666,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const emailInput = document.getElementById('profile-email') as HTMLInputElement; expect(emailInput.value).toBe('test@example.com'); @@ -677,7 +678,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const cancelBtn = document.getElementById('profile-cancel'); cancelBtn?.click(); @@ -692,12 +693,12 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const form = document.getElementById('profile-form'); form?.dispatchEvent(new Event('submit', { cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); // alert() replaced by showToast() (finding 11-L3). expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Please enter your current password to save changes', kind: 'error' })); @@ -710,7 +711,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const currentPasswordInput = document.getElementById('profile-current-password') as HTMLInputElement; const newPasswordInput = document.getElementById('profile-new-password') as HTMLInputElement; @@ -723,7 +724,7 @@ describe('Auth Module', () => { const form = document.getElementById('profile-form'); form?.dispatchEvent(new Event('submit', { cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); // Password mismatch is surfaced inline (parity with reset / setup // flows). `alert` must not fire for this path. @@ -740,7 +741,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const currentPasswordInput = document.getElementById('profile-current-password') as HTMLInputElement; const newPasswordInput = document.getElementById('profile-new-password') as HTMLInputElement; @@ -754,7 +755,7 @@ describe('Auth Module', () => { const form = document.getElementById('profile-form'); form?.dispatchEvent(new Event('submit', { cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const errorDiv = document.getElementById('profile-password-error') as HTMLElement; expect(errorDiv.textContent).toBe('Password must be at least 12 characters long'); @@ -769,7 +770,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); // All five indicators must be present and prefixed `profile-req-` // so they cannot collide with the reset / setup flows. @@ -786,7 +787,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const newPasswordInput = document.getElementById('profile-new-password') as HTMLInputElement; @@ -817,7 +818,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); // Dirty the indicators and the inline error. const newPasswordInput = document.getElementById('profile-new-password') as HTMLInputElement; @@ -831,7 +832,7 @@ describe('Auth Module', () => { const cancelBtn = document.getElementById('profile-cancel'); cancelBtn?.click(); userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); // Re-query after reopen so we assert on the live DOM node, not a // potentially stale pre-close reference. @@ -856,7 +857,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const emailInput = document.getElementById('profile-email') as HTMLInputElement; const currentPasswordInput = document.getElementById('profile-current-password') as HTMLInputElement; @@ -867,7 +868,7 @@ describe('Auth Module', () => { const form = document.getElementById('profile-form'); form?.dispatchEvent(new Event('submit', { cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 100)); + await flushPromises(); expect(api.apiRequest).toHaveBeenCalledWith('/auth/profile', expect.objectContaining({ method: 'PUT', @@ -888,7 +889,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const emailInput = document.getElementById('profile-email') as HTMLInputElement; const currentPasswordInput = document.getElementById('profile-current-password') as HTMLInputElement; @@ -899,7 +900,7 @@ describe('Auth Module', () => { const form = document.getElementById('profile-form'); form?.dispatchEvent(new Event('submit', { cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 100)); + await flushPromises(); // alert() replaced by showToast() (finding 11-L3). expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to update profile: Update failed', kind: 'error' })); @@ -912,7 +913,7 @@ describe('Auth Module', () => { const userEmail = document.getElementById('user-email-display') as HTMLElement; userEmail.click(); - await new Promise(resolve => setTimeout(resolve, 10)); + await flushPromises(); const emailInput = document.getElementById('profile-email') as HTMLInputElement; const currentPasswordInput = document.getElementById('profile-current-password') as HTMLInputElement; @@ -927,7 +928,7 @@ describe('Auth Module', () => { const form = document.getElementById('profile-form'); form?.dispatchEvent(new Event('submit', { cancelable: true })); - await new Promise(resolve => setTimeout(resolve, 100)); + await flushPromises(); const callArgs = (api.apiRequest as jest.Mock).mock.calls[0]; const bodyData = JSON.parse(callArgs[1].body); @@ -1026,7 +1027,7 @@ describe('Auth Module', () => { await showResetPasswordModal('expired-token'); document.getElementById('reset-expired-request-new')?.click(); - await new Promise(resolve => setTimeout(resolve, 20)); + await flushPromises(); // The reset modal is gone; the login modal is up. expect(document.getElementById('reset-password-modal')).toBeNull(); diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index f8faf0022..b1dc9d4a2 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -121,6 +121,7 @@ jest.mock('../utils', () => ({ import * as api from '../api'; import * as state from '../state'; +import { flushPromises } from './flush-promises'; describe('Dashboard Module', () => { beforeEach(() => { @@ -631,7 +632,7 @@ describe('Dashboard Module', () => { const cancelBtn = document.querySelector('[data-action="cancel-purchase"]') as HTMLButtonElement; cancelBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.deletePlannedPurchase).toHaveBeenCalledWith('exec-123'); // Cancel must NOT delete the entire plan — that was the wrong fix @@ -669,7 +670,7 @@ describe('Dashboard Module', () => { const cancelBtn = document.querySelector('[data-action="cancel-purchase"]') as HTMLButtonElement; cancelBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.deletePlannedPurchase).not.toHaveBeenCalled(); }); @@ -702,7 +703,7 @@ describe('Dashboard Module', () => { const cancelBtn = document.querySelector('[data-action="cancel-purchase"]') as HTMLButtonElement; cancelBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to cancel purchase', @@ -764,7 +765,7 @@ describe('Dashboard Module', () => { (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); finishDelete(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(document.getElementById('purchase-details-modal')).toBeNull(); expect(api.deletePlannedPurchase).toHaveBeenCalledWith('exec-731'); }); diff --git a/frontend/src/__tests__/flush-promises.ts b/frontend/src/__tests__/flush-promises.ts new file mode 100644 index 000000000..d2e200635 --- /dev/null +++ b/frontend/src/__tests__/flush-promises.ts @@ -0,0 +1,15 @@ +/** + * Resolves once every promise callback queued so far, and every callback + * those queue in turn, has run. + * + * Use it after dispatching an event whose async handler awaits mocked + * (already settled) promises. A zero-delay timer fires only after the + * microtask queue is empty, so the whole handler chain settles however many + * awaits it has, with no guessed wall-clock window. It does not wait for + * timers the code under test schedules itself; use jest fake timers for + * those. Do not call it while fake timers are installed: the zero-delay + * timer would never fire. + */ +export function flushPromises(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/frontend/src/__tests__/history-approval-queue.test.ts b/frontend/src/__tests__/history-approval-queue.test.ts index 8168084ae..c557cbb32 100644 --- a/frontend/src/__tests__/history-approval-queue.test.ts +++ b/frontend/src/__tests__/history-approval-queue.test.ts @@ -91,6 +91,7 @@ import * as state from '../state'; import { getCurrentUser } from '../state'; import { getAccountName } from '../recommendations'; import { ADMINISTRATORS_GROUP_ID } from '../permissions'; +import { flushPromises } from './flush-promises'; const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID] }; @@ -212,7 +213,7 @@ describe('Approval queue card (issue #340 sub-task)', () => { const btn = queue.querySelector('.history-approve-btn[data-approve-id="p-pending"]'); expect(btn).toBeTruthy(); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(confirmDialog).toHaveBeenCalled(); expect(api.approvePurchase).toHaveBeenCalledWith('p-pending'); @@ -237,7 +238,7 @@ describe('Approval queue card (issue #340 sub-task)', () => { const btn = queue.querySelector('.history-cancel-btn[data-cancel-id="p-pending"]'); expect(btn).toBeTruthy(); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(confirmDialog).toHaveBeenCalled(); expect(api.cancelPurchase).toHaveBeenCalledWith('p-pending'); @@ -263,7 +264,7 @@ describe('Approval queue card (issue #340 sub-task)', () => { const queue = document.getElementById('purchases-approval-queue')!; const btn = queue.querySelector('.history-approve-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' })); // No reload after failure. diff --git a/frontend/src/__tests__/history-approve-button.test.ts b/frontend/src/__tests__/history-approve-button.test.ts index 8448ea525..b464ee82c 100644 --- a/frontend/src/__tests__/history-approve-button.test.ts +++ b/frontend/src/__tests__/history-approve-button.test.ts @@ -82,6 +82,7 @@ import { confirmDialog } from '../confirmDialog'; import { showToast } from '../toast'; import { getCurrentUser, subscribeAmortizeUpfront } from '../state'; import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions'; +import { flushPromises } from './flush-promises'; // Admin user includes Purchaser membership (mirrors the auto-migration for // existing admins on first deploy of issue #923). approve-any:purchases is @@ -457,7 +458,7 @@ describe('History inline Approve button (issue #286)', () => { const btn = document.querySelector('.history-approve-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(api.approvePurchase).toHaveBeenCalledWith('exec-1'); // Reload was triggered → second getHistory call. @@ -478,7 +479,7 @@ describe('History inline Approve button (issue #286)', () => { await loadHistory(); const btn = document.querySelector('.history-approve-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' })); expect(btn?.disabled).toBe(false); @@ -525,7 +526,7 @@ describe('History inline Approve button (issue #286)', () => { // Resolve the in-flight promise + let the success path complete. resolveApprove?.(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); }); test('approve API failure re-enables BOTH Approve and Cancel for the row (CR pass)', async () => { @@ -542,7 +543,7 @@ describe('History inline Approve button (issue #286)', () => { const approveBtn = document.querySelector('.history-approve-btn'); const cancelBtn = document.querySelector('.history-cancel-btn'); approveBtn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); // After the API rejects, BOTH buttons must be re-enabled. expect(approveBtn?.disabled).toBe(false); diff --git a/frontend/src/__tests__/history-cancel-button.test.ts b/frontend/src/__tests__/history-cancel-button.test.ts index 1fd715671..01d0ad157 100644 --- a/frontend/src/__tests__/history-cancel-button.test.ts +++ b/frontend/src/__tests__/history-cancel-button.test.ts @@ -77,6 +77,7 @@ import { confirmDialog } from '../confirmDialog'; import { showToast } from '../toast'; import { getCurrentUser } from '../state'; import { ADMINISTRATORS_GROUP_ID } from '../permissions'; +import { flushPromises } from './flush-promises'; const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMINISTRATORS_GROUP_ID] }; // REG_USER carries the default-user effective permission set (cancel-own @@ -290,7 +291,7 @@ describe('Approval queue Cancel button (issue #46)', () => { const btn = document.querySelector('.history-cancel-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(api.cancelPurchase).toHaveBeenCalledWith('exec-1'); // Reload was triggered → second getHistory call. @@ -323,7 +324,7 @@ describe('Approval queue Cancel button (issue #46)', () => { await loadHistory(); const btn = document.querySelector('.history-cancel-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); // Success toast lands BEFORE the reload error. const successCalls = (showToast as jest.Mock).mock.calls.filter((c) => c[0].kind === 'success'); @@ -345,7 +346,7 @@ describe('Approval queue Cancel button (issue #46)', () => { await loadHistory(); const btn = document.querySelector('.history-cancel-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' })); expect(btn?.disabled).toBe(false); diff --git a/frontend/src/__tests__/history-retry-button.test.ts b/frontend/src/__tests__/history-retry-button.test.ts index 4321d59b9..b37d2cd8a 100644 --- a/frontend/src/__tests__/history-retry-button.test.ts +++ b/frontend/src/__tests__/history-retry-button.test.ts @@ -85,6 +85,7 @@ import { confirmDialog } from '../confirmDialog'; import { showToast } from '../toast'; import { getCurrentUser } from '../state'; import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions'; +import { flushPromises } from './flush-promises'; // Admin user includes Purchaser membership (mirrors the auto-migration for // existing admins on first deploy of issue #923). retry-any:purchases is @@ -339,7 +340,7 @@ describe('History inline Retry button (issue #47)', () => { expect(btn?.textContent).toContain('Retried 5×'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(api.retryPurchase).toHaveBeenCalledWith('r-1', { force: true }); }); @@ -433,7 +434,7 @@ describe('History inline Retry button (issue #47)', () => { expect(api.getHistory).toHaveBeenCalledTimes(1); const btn = document.querySelector('.history-retry-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(api.retryPurchase).toHaveBeenCalledWith('r-1', undefined); expect(api.getHistory).toHaveBeenCalledTimes(2); @@ -460,7 +461,7 @@ describe('History inline Retry button (issue #47)', () => { await loadHistory(); const btn = document.querySelector('.history-retry-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(showToast).toHaveBeenCalledWith( expect.objectContaining({ kind: 'success', message: 'Purchase request sent for approval' }), @@ -486,7 +487,7 @@ describe('History inline Retry button (issue #47)', () => { await loadHistory(); const btn = document.querySelector('.history-retry-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(showToast).toHaveBeenCalledWith( expect.objectContaining({ @@ -517,7 +518,7 @@ describe('History inline Retry button (issue #47)', () => { await loadHistory(); const btn = document.querySelector('.history-retry-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(showToast).toHaveBeenCalledWith( expect.objectContaining({ @@ -543,7 +544,7 @@ describe('History inline Retry button (issue #47)', () => { await loadHistory(); const btn = document.querySelector('.history-retry-btn'); btn?.click(); - await new Promise((r) => setTimeout(r, 10)); + await flushPromises(); expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' })); expect(btn?.disabled).toBe(false); diff --git a/frontend/src/__tests__/no-wallclock-sleeps.test.ts b/frontend/src/__tests__/no-wallclock-sleeps.test.ts new file mode 100644 index 000000000..253ca67fb --- /dev/null +++ b/frontend/src/__tests__/no-wallclock-sleeps.test.ts @@ -0,0 +1,58 @@ +/** + * Guard against wall-clock sleeps in the unit tests (issue #36). + * + * `await new Promise(r => setTimeout(r, 50))` guesses how long an async + * handler needs. It wastes that time on every run and still flakes when a + * loaded CI runner needs longer. Wait for the work itself instead: + * flushPromises() from ./flush-promises when the handler awaits mocked + * promises, jest fake timers when the code under test schedules a timer, or + * waitFor() from @testing-library/dom for a condition. A zero-delay timer + * (the flush itself) is allowed. + */ +import fs from 'fs'; +import path from 'path'; + +const testsDir = __dirname; + +// `new Promise(r => setTimeout(r, N))` and `new Promise(r => setTimeout(() => r(), N))`, +// with or without a type argument or parentheses around the parameter. +const SLEEP = /new Promise\s*(?:<[^>]*>)?\(\s*\(?\s*([A-Za-z_$][\w$]*)\s*\)?\s*=>\s*\{?\s*setTimeout\(\s*(?:\1|\(\)\s*=>\s*\1\(\))\s*,\s*([^)]+?)\s*\)/g; + +function testSources(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return testSources(full); + return entry.name.endsWith('.ts') ? [full] : []; + }); +} + +function findSleeps(source: string): { line: number; delay: string }[] { + const found: { line: number; delay: string }[] = []; + for (const match of source.matchAll(SLEEP)) { + const delay = match[2] ?? ''; + if (/^0+$/.test(delay)) continue; + found.push({ line: source.slice(0, match.index).split('\n').length, delay }); + } + return found; +} + +describe('no wall-clock sleeps in unit tests (issue #36)', () => { + test('the matcher flags non-zero sleeps and allows the zero-delay flush', () => { + expect(findSleeps('await new Promise(resolve => setTimeout(resolve, 50));')).toEqual([{ line: 1, delay: '50' }]); + expect(findSleeps('await new Promise((r) => setTimeout(r, 10));')).toHaveLength(1); + expect(findSleeps('const tick = (): Promise => new Promise((r) => setTimeout(r, 10));')).toHaveLength(1); + expect(findSleeps('await new Promise((done) => setTimeout(() => done(), 2100));')).toHaveLength(1); + expect(findSleeps('await new Promise(r => setTimeout(r, DELAY_MS));')).toEqual([{ line: 1, delay: 'DELAY_MS' }]); + expect(findSleeps('\nawait new Promise(r => { setTimeout(r, 5); });')).toEqual([{ line: 2, delay: '5' }]); + expect(findSleeps('await new Promise((resolve) => setTimeout(resolve, 0));')).toEqual([]); + expect(findSleeps('setTimeout(() => closeModal(modal), 2000);')).toEqual([]); + }); + + test('no test file sleeps for a fixed time to wait for async work', () => { + // This file is skipped: its matcher samples above are sleeps on purpose. + const offenders = testSources(testsDir).filter((file) => file !== __filename).flatMap((file) => + findSleeps(fs.readFileSync(file, 'utf8')) + .map(({ line, delay }) => `${path.relative(testsDir, file)}:${line} sleeps ${delay}ms`)); + expect(offenders).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/plans-ramp-label.test.ts b/frontend/src/__tests__/plans-ramp-label.test.ts index 792a73ac8..e76fe2136 100644 --- a/frontend/src/__tests__/plans-ramp-label.test.ts +++ b/frontend/src/__tests__/plans-ramp-label.test.ts @@ -44,6 +44,7 @@ jest.mock('../toast', () => ({ showToast: jest.fn(() => ({ dismiss: jest.fn() }) jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn(() => Promise.resolve(true)) })); import * as api from '../api'; +import { flushPromises } from './flush-promises'; function loadRealIndexBody(): string { const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); @@ -89,7 +90,7 @@ describe('plan list ramp label (#1185)', () => { expect(listedRampLabel()).toBe(label); (document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement).click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect((document.querySelector('input[name="ramp-schedule"]:checked') as HTMLInputElement | null)?.value).toBe(radio); }); }); @@ -137,7 +138,7 @@ describe('custom ramp inputs when Edit is the first plan modal (#1536)', () => { await loadPlans(); (document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement).click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(stepInput().disabled).toBe(true); const custom = document.querySelector('input[name="ramp-schedule"][value="custom"]') as HTMLInputElement; @@ -158,7 +159,7 @@ describe('custom ramp inputs when Edit is the first plan modal (#1536)', () => { setupPlanHandlers(); await loadPlans(); (document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement).click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); } test('Edit-first leaves an absent stored term unset', async () => { diff --git a/frontend/src/__tests__/plans-range-validation.test.ts b/frontend/src/__tests__/plans-range-validation.test.ts index a0fa76a6c..789d9d9f5 100644 --- a/frontend/src/__tests__/plans-range-validation.test.ts +++ b/frontend/src/__tests__/plans-range-validation.test.ts @@ -17,6 +17,7 @@ import { openNewPlanModal, closePlanModal, openAddPurchasesModal } from '../plans'; import type { ToastOptions } from '../toast'; import * as api from '../api'; +import { flushPromises } from './flush-promises'; jest.mock('../api', () => ({ getPlans: jest.fn(), @@ -671,7 +672,7 @@ describe('Add Purchases modal: inline range validation (#771)', () => { const form = document.getElementById('add-purchases-form') as HTMLFormElement; form.dispatchEvent(new Event('submit')); - await new Promise(resolve => setTimeout(resolve, 20)); + await flushPromises(); expect(require('../api').createPlannedPurchases).not.toHaveBeenCalled(); const errDiv = document.getElementById('add-purchases-error'); @@ -689,7 +690,7 @@ describe('Add Purchases modal: inline range validation (#771)', () => { const form = document.getElementById('add-purchases-form') as HTMLFormElement; form.dispatchEvent(new Event('submit')); - await new Promise(resolve => setTimeout(resolve, 20)); + await flushPromises(); expect(require('../api').createPlannedPurchases).not.toHaveBeenCalled(); }); diff --git a/frontend/src/__tests__/plans-service-ids.test.ts b/frontend/src/__tests__/plans-service-ids.test.ts index c8b53b23a..88440a284 100644 --- a/frontend/src/__tests__/plans-service-ids.test.ts +++ b/frontend/src/__tests__/plans-service-ids.test.ts @@ -44,6 +44,7 @@ jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn(() => Promise.reso import * as api from '../api'; import { showToast } from '../toast'; import { CANONICAL_PLAN_SERVICES } from '../lib/plan-service-ids'; +import { flushPromises } from './flush-promises'; function loadRealIndexBody(): string { const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); @@ -114,7 +115,7 @@ describe('plan modal service ids (#608)', () => { await loadPlans(); (document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement).click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const select = document.getElementById('plan-service') as HTMLSelectElement; expect((document.getElementById('plan-provider') as HTMLSelectElement).value).toBe('azure'); @@ -132,7 +133,7 @@ describe('plan modal service ids (#608)', () => { (api.getPlan as jest.Mock).mockResolvedValue(plan); await loadPlans(); (document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement).click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); } test.each([ diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 8975fa880..d6f038821 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -130,6 +130,7 @@ import * as api from '../api'; import * as state from '../state'; import { viewPlanHistory } from '../history'; import { populateTermSelect, populatePaymentSelect, isValidCombination } from '../commitmentOptions'; +import { flushPromises } from './flush-promises'; describe('Plans Module', () => { beforeEach(() => { @@ -996,7 +997,7 @@ describe('Plans Module', () => { const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement; runBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(mockConfirmDialog).toHaveBeenCalled(); expect(api.runPlannedPurchase).toHaveBeenCalledWith('purchase-1'); @@ -1010,7 +1011,7 @@ describe('Plans Module', () => { const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement; runBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.runPlannedPurchase).not.toHaveBeenCalled(); }); @@ -1020,7 +1021,7 @@ describe('Plans Module', () => { mockConfirmDialog.mockResolvedValue(false); (document.querySelector('[data-action="run"]') as HTMLButtonElement).click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const opts = mockConfirmDialog.mock.calls[0]![0] as { title: string; body: HTMLElement }; expect(opts.title).toBe('Run step 1/4 of "Test Plan" now?'); @@ -1036,11 +1037,11 @@ describe('Plans Module', () => { const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement; runBtn.click(); runBtn.click(); - await new Promise(resolve => setTimeout(resolve, 20)); + await flushPromises(); expect(mockConfirmDialog).toHaveBeenCalledTimes(1); resolveDialog(false); - await new Promise(resolve => setTimeout(resolve, 20)); + await flushPromises(); }); test('pause action pauses purchase', async () => { @@ -1050,7 +1051,7 @@ describe('Plans Module', () => { const pauseBtn = document.querySelector('[data-action="pause"]') as HTMLButtonElement; pauseBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.pausePlannedPurchase).toHaveBeenCalledWith('purchase-1'); // Pause must give visible feedback via a success toast. @@ -1072,7 +1073,7 @@ describe('Plans Module', () => { const disableBtn = document.querySelector('[data-action="disable"]') as HTMLButtonElement; disableBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); // The action cancels this scheduled purchase AND disables its plan // (backend deletePlannedPurchase), so the dialog must say both. @@ -1094,7 +1095,7 @@ describe('Plans Module', () => { const disableBtn = document.querySelector('[data-action="disable"]') as HTMLButtonElement; disableBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.deletePlannedPurchase).not.toHaveBeenCalled(); }); @@ -1106,7 +1107,7 @@ describe('Plans Module', () => { const pauseBtn = document.querySelector('[data-action="pause"]') as HTMLButtonElement; pauseBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to pause purchase: API Error' })); }); @@ -1125,7 +1126,7 @@ describe('Plans Module', () => { }); (document.querySelector('[data-action="edit"]') as HTMLButtonElement)?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect((document.getElementById('plan-coverage') as HTMLInputElement).value).toBe('55'); }); @@ -1149,7 +1150,7 @@ describe('Plans Module', () => { const editBtn = document.querySelector('[data-action="edit"]') as HTMLButtonElement; editBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); // Must use the plan FK, not the purchase PK. expect(api.getPlan).toHaveBeenCalledWith('plan-1'); @@ -1171,7 +1172,7 @@ describe('Plans Module', () => { container?.appendChild(btn); btn.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); // getPlan must NOT be called when planId is empty. expect(api.getPlan).not.toHaveBeenCalled(); @@ -1193,7 +1194,7 @@ describe('Plans Module', () => { const editBtn = document.querySelector('[data-action="edit"]') as HTMLButtonElement; editBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); // The right plan FK was used (guards against a #773-class regression). expect(api.getPlan).toHaveBeenCalledWith('plan-1'); @@ -1239,7 +1240,7 @@ describe('Plans Module', () => { const resumeBtn = document.querySelector('[data-action="resume"]') as HTMLButtonElement; resumeBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.resumePlannedPurchase).toHaveBeenCalledWith('purchase-1'); // Resume must give visible feedback via a success toast. @@ -2074,7 +2075,7 @@ describe('Plans Module', () => { const toggleInput = document.querySelector('[data-action="toggle-plan"]') as HTMLInputElement; toggleInput?.dispatchEvent(new Event('change')); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.patchPlan).toHaveBeenCalledWith('plan-1', expect.objectContaining({ enabled: true })); expect(api.getPlans).toHaveBeenCalled(); @@ -2110,7 +2111,7 @@ describe('Plans Module', () => { const toggleInput = document.querySelector('[data-action="toggle-plan"]') as HTMLInputElement; toggleInput?.dispatchEvent(new Event('change')); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to update plan' })); }); @@ -2165,7 +2166,7 @@ describe('Plans Module', () => { const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; editBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.getPlan).toHaveBeenCalledWith('plan-1'); const modal = document.getElementById('plan-modal'); @@ -2213,7 +2214,7 @@ describe('Plans Module', () => { const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; editBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const weeklyRadio = document.querySelector('input[value="weekly-25pct"]') as HTMLInputElement; expect(weeklyRadio.checked).toBe(true); @@ -2259,7 +2260,7 @@ describe('Plans Module', () => { const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; editBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const monthlyRadio = document.querySelector('input[value="monthly-10pct"]') as HTMLInputElement; expect(monthlyRadio.checked).toBe(true); @@ -2315,7 +2316,7 @@ describe('Plans Module', () => { const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; editBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect((document.getElementById('ramp-step-percent') as HTMLInputElement).value).toBe('25'); expect((document.getElementById('ramp-interval-days') as HTMLInputElement).value).toBe('14'); @@ -2351,7 +2352,7 @@ describe('Plans Module', () => { const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; editBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to load plan details' })); }); @@ -2375,7 +2376,7 @@ describe('Plans Module', () => { const historyBtn = document.querySelector('[data-action="view-history"]') as HTMLButtonElement; historyBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(viewPlanHistory).toHaveBeenCalledWith('plan-1'); }); @@ -2415,7 +2416,7 @@ describe('Plans Module', () => { const deleteBtn = document.querySelector('[data-action="delete-plan"]') as HTMLButtonElement; deleteBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.deletePlan).toHaveBeenCalledWith('plan-1'); expect(api.getPlans).toHaveBeenCalled(); @@ -2450,7 +2451,7 @@ describe('Plans Module', () => { const deleteBtn = document.querySelector('[data-action="delete-plan"]') as HTMLButtonElement; deleteBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.deletePlan).not.toHaveBeenCalled(); }); @@ -2487,7 +2488,7 @@ describe('Plans Module', () => { const deleteBtn = document.querySelector('[data-action="delete-plan"]') as HTMLButtonElement; deleteBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to delete plan' })); }); @@ -2511,7 +2512,7 @@ describe('Plans Module', () => { const addBtn = document.querySelector('[data-action="add-purchases"]') as HTMLButtonElement; addBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const modal = document.getElementById('add-purchases-modal'); expect(modal).toBeTruthy(); @@ -2540,7 +2541,7 @@ describe('Plans Module', () => { const addBtn = document.querySelector('[data-action="add-purchases"]') as HTMLButtonElement; addBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); }); test('default date leaves the displayed plan notification window intact', () => { @@ -2554,7 +2555,7 @@ describe('Plans Module', () => { const cancelBtn = document.getElementById('add-purchases-cancel') as HTMLButtonElement; cancelBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const modal = document.getElementById('add-purchases-modal'); expect(modal).toBeNull(); @@ -2570,7 +2571,7 @@ describe('Plans Module', () => { const form = document.getElementById('add-purchases-form') as HTMLFormElement; form.dispatchEvent(new Event('submit')); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(api.createPlannedPurchases).toHaveBeenCalledWith('plan-1', 3, expect.any(String)); expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Successfully scheduled 3 purchases' })); @@ -2586,7 +2587,7 @@ describe('Plans Module', () => { const form = document.getElementById('add-purchases-form') as HTMLFormElement; form.dispatchEvent(new Event('submit')); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Successfully scheduled 1 purchase' })); }); @@ -2597,7 +2598,7 @@ describe('Plans Module', () => { const form = document.getElementById('add-purchases-form') as HTMLFormElement; form.dispatchEvent(new Event('submit')); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const errorDiv = document.getElementById('add-purchases-error'); expect(errorDiv?.textContent).toBe('API Error'); @@ -2611,7 +2612,7 @@ describe('Plans Module', () => { const form = document.getElementById('add-purchases-form') as HTMLFormElement; form.dispatchEvent(new Event('submit')); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const errorDiv = document.getElementById('add-purchases-error'); expect(errorDiv?.textContent).toBe(refusal); @@ -3252,7 +3253,7 @@ describe('Plans Module', () => { await loadPlans(); const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; editBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const termSelect = document.getElementById('plan-term') as HTMLSelectElement; // Pre-fix: termSelect.value === '3' (hardcoded fallback) @@ -3301,7 +3302,7 @@ describe('Plans Module', () => { await loadPlans(); const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; editBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const paymentSelect = document.getElementById('plan-payment') as HTMLSelectElement; // Pre-fix: paymentSelect.value === 'no-upfront' (hardcoded default) @@ -3329,7 +3330,7 @@ describe('Plans Module', () => { await loadPlans(); const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; editBtn?.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); const paymentSelect = document.getElementById('plan-payment') as HTMLSelectElement; // When payment IS present, it should be correctly pre-selected diff --git a/frontend/src/__tests__/riexchange.test.ts b/frontend/src/__tests__/riexchange.test.ts index 6996fb30a..f25ebc87e 100644 --- a/frontend/src/__tests__/riexchange.test.ts +++ b/frontend/src/__tests__/riexchange.test.ts @@ -70,6 +70,7 @@ import { } from '../riexchange'; import * as api from '../api'; import * as navigation from '../navigation'; +import { flushPromises } from './flush-promises'; function createModal(): HTMLDivElement { const modal = document.createElement('div'); @@ -606,7 +607,7 @@ describe('openExchangeModal', () => { ]); openExchangeModal('ri-abc', 2); // Let the async populateAwsOfferings() settle. - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushPromises(); const picker = modal.querySelector('.modal-exchange-target-select'); const options = picker ? Array.from(picker.querySelectorAll('option')) : []; @@ -619,7 +620,7 @@ describe('openExchangeModal', () => { const mockListOfferings = api.listTargetOfferings as jest.Mock; mockListOfferings.mockRejectedValueOnce(new Error('throttled')); openExchangeModal('ri-abc', 2); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushPromises(); const picker = modal.querySelector('.modal-exchange-target-select'); expect(picker?.options[0]?.textContent).toBe('Could not load offerings'); @@ -635,7 +636,7 @@ describe('openExchangeModal', () => { }]); Array.from(errorRow?.querySelectorAll('button') ?? []).find((b) => b.textContent === 'Retry')?.click(); expect(picker?.options[0]?.textContent).toBe('Loading offerings...'); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushPromises(); expect(mockListOfferings).toHaveBeenCalledTimes(2); expect(errorRow?.classList.contains('hidden')).toBe(true); @@ -647,7 +648,7 @@ describe('openExchangeModal', () => { openExchangeModal('ri-abc', 2, undefined, [ { instance_type: 'm5.large', offering_id: '4b2293b4-5fbc-4017-9c75-d5a9d3aa8c91', effective_monthly_cost: 42.5 }, ]); - await new Promise((resolve) => setTimeout(resolve, 10)); + await flushPromises(); const picker = modal.querySelector('.modal-exchange-target-select'); expect(picker?.options[0]?.textContent).toBe('AWS offerings unavailable -- pick a recommendation or retry'); expect(Array.from(picker?.options ?? []).map((o) => o.value)).toContain('4b2293b4-5fbc-4017-9c75-d5a9d3aa8c91'); @@ -723,8 +724,19 @@ describe('openExchangeModal', () => { }); it('keeps the exchange ID on screen until the user closes the modal', async () => { - await executeExchange(); - await new Promise((resolve) => setTimeout(resolve, 2100)); + // Fake timers so every timer the exchange schedules (the old bug was a + // 2s auto-close) runs to completion without a real wall-clock wait. + // runAllTimersAsync also fires executeExchange's zero-delay flushes + // and lets the promise callbacks between them settle. + jest.useFakeTimers(); + try { + const done = executeExchange(); + await jest.runAllTimersAsync(); + await done; + await jest.runAllTimersAsync(); + } finally { + jest.useRealTimers(); + } expect(modal.classList.contains('hidden')).toBe(false); expect(modal.querySelector('#modal-exchange-result')?.textContent).toBe('Exchange completed. ID: exch-42'); // The form is frozen so an edit cannot wipe the receipt, and only Close remains. diff --git a/frontend/src/__tests__/savings-history.test.ts b/frontend/src/__tests__/savings-history.test.ts index 3d00a3690..8fca67384 100644 --- a/frontend/src/__tests__/savings-history.test.ts +++ b/frontend/src/__tests__/savings-history.test.ts @@ -40,6 +40,7 @@ import { loadSavingsHistory, initSavingsHistory, savingsChart, convertFromMonthl import { getSavingsAnalytics } from '../api'; import * as state from '../state'; import { Chart } from 'chart.js'; +import { flushPromises } from './flush-promises'; describe('Savings History Module', () => { beforeEach(() => { @@ -597,7 +598,7 @@ describe('Savings History Module', () => { periodSelect.value = '30d'; periodSelect.dispatchEvent(new Event('change')); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(getSavingsAnalytics).toHaveBeenCalled(); }); @@ -610,7 +611,7 @@ describe('Savings History Module', () => { const refreshBtn = document.getElementById('refresh-savings-btn') as HTMLButtonElement; refreshBtn.click(); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(getSavingsAnalytics).toHaveBeenCalled(); }); @@ -1447,7 +1448,7 @@ describe('Savings History Module', () => { unitSelect.value = 'hourly'; unitSelect.dispatchEvent(new Event('change')); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(getSavingsAnalytics).toHaveBeenCalled(); }); @@ -1461,7 +1462,7 @@ describe('Savings History Module', () => { (getSavingsAnalytics as jest.Mock).mockClear(); unitSelect.dispatchEvent(new Event('change')); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); // Should fire exactly once (no stacked duplicate listeners) expect(getSavingsAnalytics).toHaveBeenCalledTimes(1); }); @@ -1544,7 +1545,7 @@ describe('Savings History Module', () => { const series = document.getElementById('savings-series') as HTMLSelectElement; series.value = 'cumulative'; series.dispatchEvent(new Event('change')); - await new Promise(resolve => setTimeout(resolve, 50)); + await flushPromises(); expect(getSavingsAnalytics).toHaveBeenCalledTimes(1); }); });