From 17fa82ef2b2034ae27297160124aadeb81a7eaf7 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Sat, 10 Oct 2026 22:05:38 -0400 Subject: [PATCH] Deflake the Supply run offline-queue tests under host load (#186) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/shopping/offlineQueue.test.tsx | 179 +++++++++++++++---------- 1 file changed, 111 insertions(+), 68 deletions(-) diff --git a/web/src/shopping/offlineQueue.test.tsx b/web/src/shopping/offlineQueue.test.tsx index 36afc80..8c8706f 100644 --- a/web/src/shopping/offlineQueue.test.tsx +++ b/web/src/shopping/offlineQueue.test.tsx @@ -1,5 +1,5 @@ -import { QueryClientProvider } from '@tanstack/react-query' -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { defaultScheduler, notifyManager, QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, within } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' @@ -213,8 +213,46 @@ function reload(page: { unmount: () => void }) { vi.setSystemTime(now) } -async function advance(ms: number) { - await act(() => vi.advanceTimersByTimeAsync(ms)) +// advance jumps the clock past a deadline or a wait, firing the timers due +// by then; what they set off runs as later turns (until, settle) let it. +function advance(ms: number) { + act(() => vi.advanceTimersByTime(ms)) +} + +// The host's setImmediate, which these tests do not fake: it runs its +// callback on the next turn of the event loop, once every promise already +// resolved has run, whatever the clock says. +const nextTurn = (globalThis as unknown as { setImmediate: (callback: () => void) => void }).setImmediate + +// turn lets the page take one turn of the event loop, inside act: the +// promises it has resolved run, and React renders and runs the effects they +// cause. No time passes, real or fake. +async function turn() { + await act(() => new Promise((resolve) => nextTurn(resolve))) +} + +// TURNS bounds how many turns until waits and settle takes: far more than +// any change here needs to reach the page. +const TURNS = 50 + +// until lets the page take turns until check passes, and returns what check +// returns; it fails with check's error if check still fails after TURNS. +async function until(check: () => T): Promise { + for (let turns = 0; ; turns++) { + try { + return check() + } catch (error) { + if (turns === TURNS) throw error + } + await turn() + } +} + +// settle lets the page take TURNS turns, the clock standing still: whatever +// it would do without a deadline or a wait passing, such as send a PATCH too +// soon, it has done. +async function settle() { + for (let turns = 0; turns < TURNS; turns++) await turn() } // The page's setTimeout, watched: retrySecondTry waits for a timer to be set. @@ -224,8 +262,8 @@ let timers: MockInstance // doubled by its failed first try — once that wait's timer is set, so the jump // never lands before the timer it is meant to fire. async function retrySecondTry() { - await waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST)) - await advance(2 * RETRY_FIRST) + await until(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST)) + advance(2 * RETRY_FIRST) } function checkbox(name: string) { @@ -240,16 +278,21 @@ beforeEach(() => { onLine = true reachable = true mockViewport(390) - // setTimeout moves with real time, and a test can jump past a deadline or - // a wait (advance). - vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true }) + // The clock and setTimeout move only when a test jumps past a deadline or + // a wait (advance), never with real time: under load, real time would fire + // a deadline or a wait the test never reached. React Query sends its + // batched notices a turn later, as it does in the app on a setTimeout of 0, + // so the page renders its changes without the clock moving. + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) + notifyManager.setScheduler(nextTurn) timers = vi.spyOn(globalThis, 'setTimeout') }) afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() + notifyManager.setScheduler(defaultScheduler) vi.unstubAllGlobals() Reflect.deleteProperty(window, 'matchMedia') }) @@ -258,7 +301,7 @@ describe('offline toggle queue', () => { it('applies toggles made offline at once, saves them in order and sends them in that order once online', async () => { const { fetchMock } = stubServer() await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() goOffline() fireEvent.click(checkbox('Taco seasoning')) @@ -276,7 +319,7 @@ describe('offline toggle queue', () => { expect(patches(fetchMock)).toEqual([]) goOnline() - await waitFor(() => expect(queue()).toEqual([])) + await until(() => expect(queue()).toEqual([])) expect(patches(fetchMock)).toEqual([ `${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`, @@ -292,14 +335,14 @@ describe('offline toggle queue', () => { it('queues a toggle whose PATCH fails with a network error and sends it again once back online', async () => { const { fetchMock } = stubServer() await openPage() - fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' })) - await waitFor(() => expect(queue()).toEqual([])) + fireEvent.click(await until(() => screen.getByRole('checkbox', { name: 'Taco seasoning' }))) + await until(() => expect(queue()).toEqual([])) expect(screen.getByText('3 / 3')).toBeInTheDocument() // The browser still says online, but the server cannot be reached. reachable = false fireEvent.click(checkbox('Corn tortillas')) - expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument() + expect(await until(() => within(row('Corn tortillas')).getByText('will sync'))).toBeInTheDocument() expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`]) expect(checkbox('Corn tortillas')).not.toBeChecked() expect(screen.getByText('2 / 3')).toBeInTheDocument() @@ -307,7 +350,7 @@ describe('offline toggle queue', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument() goOnline() - await waitFor(() => expect(queue()).toEqual([])) + await until(() => expect(queue()).toEqual([])) expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`, `${ITEM('i2')} {"needed":false}`]) expect(screen.getByText('2 / 3')).toBeInTheDocument() expect(screen.queryByText('will sync')).not.toBeInTheDocument() @@ -316,7 +359,7 @@ describe('offline toggle queue', () => { it('opens the saved list after a reload offline and replays the saved toggles in order once online', async () => { const { fetchMock } = stubServer() const first = await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() goOffline() fireEvent.click(checkbox('Taco seasoning')) fireEvent.click(checkbox('Ground beef')) @@ -325,7 +368,7 @@ describe('offline toggle queue', () => { // Reload while offline: the settings, /me and the list cannot be read. reload(first) await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() expect(checkbox('Taco seasoning')).toBeChecked() expect(checkbox('Ground beef')).not.toBeChecked() expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument() @@ -333,9 +376,9 @@ describe('offline toggle queue', () => { expect(patches(fetchMock)).toEqual([]) goOnline() - await waitFor(() => expect(queue()).toEqual([])) + await until(() => expect(queue()).toEqual([])) expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`]) - await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) + await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) expect(screen.getByText('2 / 3')).toBeInTheDocument() expect(checkbox('Taco seasoning')).toBeChecked() expect(checkbox('Ground beef')).not.toBeChecked() @@ -344,7 +387,7 @@ describe('offline toggle queue', () => { it('discards a queued toggle the server refuses because the list was archived, puts the count back and says so', async () => { const server = stubServer(() => errorResponse(409, 'archived')) await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() goOffline() fireEvent.click(checkbox('Taco seasoning')) @@ -352,25 +395,25 @@ describe('offline toggle queue', () => { server.archive() goOnline() - const alert = await screen.findByRole('alert') + const alert = await until(() => screen.getByRole('alert')) expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message") - expect(await screen.findByText('Archived')).toBeInTheDocument() + expect(await until(() => screen.getByText('Archived'))).toBeInTheDocument() expect(screen.getByText('2 / 3')).toBeInTheDocument() expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument() expect(screen.queryByText('will sync')).not.toBeInTheDocument() expect(queue()).toEqual([]) // Sent once, never again. goOnline() - await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`])) + await until(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`])) }) it('does not queue a toggle the server refuses: it is put back and says why', async () => { const server = stubServer(() => errorResponse(400, 'invalid')) await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() fireEvent.click(checkbox('Ground beef')) - expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message") + expect(await until(() => screen.getByRole('alert'))).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message") expect(checkbox('Ground beef')).toBeChecked() expect(screen.getByText('2 / 3')).toBeInTheDocument() expect(screen.queryByText('will sync')).not.toBeInTheDocument() @@ -378,7 +421,7 @@ describe('offline toggle queue', () => { goOffline() goOnline() - await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`])) + await until(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`])) }) }) @@ -395,16 +438,16 @@ describe('offline toggle queue, replay', () => { localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued })) await openPage() - await waitFor(() => expect(answers.waiting()).toBe(1)) - fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' })) + await until(() => expect(answers.waiting()).toBe(1)) + fireEvent.click(await until(() => screen.getByRole('checkbox', { name: 'Corn tortillas' }))) for (let sent = 1; sent <= 4; sent++) { - await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent)) + await until(() => expect(patches(server.fetchMock)).toHaveLength(sent)) // The next one is not sent before this one is answered. - await new Promise((resolve) => setTimeout(resolve, 20)) + await settle() expect(patches(server.fetchMock)).toHaveLength(sent) act(() => answers.release()) } - await waitFor(() => expect(queue()).toEqual([])) + await until(() => expect(queue()).toEqual([])) expect(patches(server.fetchMock)).toEqual([ `${ITEM('i3')} {"needed":true}`, `${ITEM('n1')} {"needed":false}`, @@ -412,27 +455,27 @@ describe('offline toggle queue, replay', () => { `${ITEM('i2')} {"needed":false}`, ]) expect(server.mostInFlight()).toBe(1) - await waitFor(() => expect(screen.getByText('1 / 3')).toBeInTheDocument()) + await until(() => expect(screen.getByText('1 / 3')).toBeInTheDocument()) }) it('replays at least once: a toggle the server applied before a reload is sent again and leaves the item and count the same', async () => { const answers = held() const server = stubServer(undefined, answers.hold) const first = await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() fireEvent.click(checkbox('Taco seasoning')) // The server applied it; the page reloads before its answer arrives. - await waitFor(() => expect(answers.waiting()).toBe(1)) + await until(() => expect(answers.waiting()).toBe(1)) expect(queue()).toEqual(['i3 true']) reload(first) act(() => answers.release()) await openPage() - await waitFor(() => expect(answers.waiting()).toBe(1)) + await until(() => expect(answers.waiting()).toBe(1)) act(() => answers.release()) - await waitFor(() => expect(queue()).toEqual([])) + await until(() => expect(queue()).toEqual([])) expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`]) - await waitFor(() => expect(screen.getByText('3 / 3')).toBeInTheDocument()) + await until(() => expect(screen.getByText('3 / 3')).toBeInTheDocument()) expect(checkbox('Taco seasoning')).toBeChecked() expect(server.run('2026-10-05')).toMatchObject({ needed_count: 3, total_count: 3 }) expect(screen.queryByRole('alert')).not.toBeInTheDocument() @@ -442,18 +485,18 @@ describe('offline toggle queue, replay', () => { const answers = held() stubServer(undefined, answers.hold) await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() goOffline() fireEvent.click(checkbox('Ground beef')) expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() goOnline() - await waitFor(() => expect(answers.waiting()).toBe(1)) + await until(() => expect(answers.waiting()).toBe(1)) expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() expect(queue()).toEqual(['i1 false']) act(() => answers.release()) - await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) + await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) expect(queue()).toEqual([]) expect(screen.getByText('1 / 3')).toBeInTheDocument() }) @@ -463,29 +506,29 @@ describe('offline toggle queue, replay', () => { // The first PATCH never answers; every later one answers at once. const server = stubServer(undefined, () => (++tries === 1 ? new Promise(() => {}) : Promise.resolve())) await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() fireEvent.click(checkbox('Taco seasoning')) - await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(1)) + await until(() => expect(patches(server.fetchMock)).toHaveLength(1)) fireEvent.click(checkbox('Ground beef')) expect(queue()).toEqual(['i3 true', 'i1 false']) // Past the deadline the hung PATCH is aborted; both still wait, in order. - await advance(TOGGLE_DEADLINE) - expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument() + advance(TOGGLE_DEADLINE) + expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument() expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() expect(patches(server.fetchMock)).toHaveLength(1) expect(queue()).toEqual(['i3 true', 'i1 false']) await retrySecondTry() - await waitFor(() => expect(queue()).toEqual([])) + await until(() => expect(queue()).toEqual([])) expect(patches(server.fetchMock)).toEqual([ `${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`, ]) expect(server.mostInFlight()).toBe(1) - await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) + await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) expect(screen.getByText('2 / 3')).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) @@ -496,26 +539,26 @@ describe('offline toggle queue, replay', () => { // answers. Every later /me answers at once. const server = stubServer(undefined, undefined, () => (++tries === 2 ? new Promise(() => {}) : Promise.resolve())) await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() fireEvent.click(checkbox('Taco seasoning')) - await waitFor(() => expect(tries).toBe(2)) + await until(() => expect(tries).toBe(2)) fireEvent.click(checkbox('Ground beef')) expect(queue()).toEqual(['i3 true', 'i1 false']) // Past the deadline the hung /me is abandoned; both still wait, in order. - await advance(TOGGLE_DEADLINE) - expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument() + advance(TOGGLE_DEADLINE) + expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument() expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() expect(patches(server.fetchMock)).toEqual([]) // The next try sends a fresh /me, which the server answers. await retrySecondTry() - await waitFor(() => expect(queue()).toEqual([])) + await until(() => expect(queue()).toEqual([])) expect(mes(server.fetchMock)).toBe(3) expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`]) expect(server.mostInFlight()).toBe(1) - await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) + await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) expect(screen.getByText('2 / 3')).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) @@ -536,29 +579,29 @@ describe('offline toggle queue, replay', () => { () => (refusedAt !== null && ++tries === 1 ? new Promise(() => {}) : Promise.resolve()), ) await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() fireEvent.click(checkbox('Taco seasoning')) - await waitFor(() => expect(tries).toBe(1)) + await until(() => expect(tries).toBe(1)) fireEvent.click(checkbox('Ground beef')) expect(patches(server.fetchMock)).toHaveLength(1) // Past the deadline the hung refresh is abandoned; both still wait, in order. - await advance(TOGGLE_DEADLINE) - expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument() + advance(TOGGLE_DEADLINE) + expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument() expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() expect(queue()).toEqual(['i3 true', 'i1 false']) // The next try is refused again and sends a fresh refresh, which answers. await retrySecondTry() - await waitFor(() => expect(queue()).toEqual([])) + await until(() => expect(queue()).toEqual([])) expect(patches(server.fetchMock)).toEqual([ `${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`, ]) - await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) + await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) expect(screen.getByText('2 / 3')).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) @@ -566,21 +609,21 @@ describe('offline toggle queue, replay', () => { it('tries a toggle again once the server is back, with no online event from the browser', async () => { const { fetchMock } = stubServer() await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() // The browser says online throughout; the server cannot be reached, then can. reachable = false fireEvent.click(checkbox('Corn tortillas')) - expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument() + expect(await until(() => within(row('Corn tortillas')).getByText('will sync'))).toBeInTheDocument() reachable = true expect(patches(fetchMock)).toEqual([]) // Nothing says the server is back: only the wait's timer tries it again. await retrySecondTry() - await waitFor(() => expect(queue()).toEqual([])) + await until(() => expect(queue()).toEqual([])) // The first try failed at GET /api/v1/me, before its PATCH. expect(patches(fetchMock)).toEqual([`${ITEM('i2')} {"needed":false}`]) - await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) + await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) expect(screen.getByText('1 / 3')).toBeInTheDocument() }) @@ -588,20 +631,20 @@ describe('offline toggle queue, replay', () => { const answers = held() stubServer(undefined, answers.hold) const page = await openPage() - expect(await screen.findByText('2 / 3')).toBeInTheDocument() + expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument() goOffline() fireEvent.click(checkbox('Ground beef')) expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() page.leave() goOnline() - await waitFor(() => expect(answers.waiting()).toBe(1)) + await until(() => expect(answers.waiting()).toBe(1)) page.reopen() - expect(await within(row('Ground beef')).findByText('will sync')).toBeInTheDocument() + expect(await until(() => within(row('Ground beef')).getByText('will sync'))).toBeInTheDocument() expect(queue()).toEqual(['i1 false']) act(() => answers.release()) - await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) + await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument()) expect(queue()).toEqual([]) expect(screen.getByText('1 / 3')).toBeInTheDocument() }) @@ -613,10 +656,10 @@ describe('offline toggle queue, replay', () => { const getItem = vi.spyOn(Storage.prototype, 'getItem') await openPage() - expect(await screen.findByText(/Couldn't load the household settings/)).toBeInTheDocument() + expect(await until(() => screen.getByText(/Couldn't load the household settings/))).toBeInTheDocument() expect(screen.getByText(/can't be kept on this device while offline/)).toBeInTheDocument() const reads = getItem.mock.calls.filter(([key]) => key === SAVED_KEY).length - await new Promise((resolve) => setTimeout(resolve, 50)) + await settle() expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads) }) })