import React from 'react' import { render, waitFor, act } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { describe, it, expect, beforeEach, vi } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from '../tests/helpers/msw/server' import { useAuthStore } from './store/authStore' import { resetAllStores } from '../tests/helpers/store' import App from './App' /* * #2458: an installed PWA kept serving the previous UI after a server upgrade. * * The launch probe compares the server's version with a marker in localStorage * and, on a mismatch, asks the service worker to fetch the new build and reloads * once it has taken over. These tests drive that probe across consecutive * launches against a fake service-worker container, so a handover that never * completed can be told apart from one that did. * * The fake is installed before App is imported (vi.hoisted), so code that * notes the controlling worker when its module loads sees the worker that * served this page, exactly as in the browser. */ const sw = vi.hoisted(() => { class FakeWorker extends EventTarget { state: string readonly scriptURL = '/sw.js' constructor(state: string) { super() this.state = state } become(state: string) { this.state = state this.dispatchEvent(new Event('statechange')) } } class FakeRegistration extends EventTarget { active: FakeWorker | null = null installing: FakeWorker | null = null waiting: FakeWorker | null = null readonly scope = '/' update = vi.fn() } class FakeContainer extends EventTarget { controller: FakeWorker | null = null getRegistration = vi.fn() // Listeners a page adds die with that page. Those added while modules load // (before `trackPage` is switched on) stand for module-level code and stay. trackPage = false private pageListeners: Array<[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]> = [] addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) { super.addEventListener(type, listener, options) if (this.trackPage && listener) this.pageListeners.push([type, listener, options]) } dropPageListeners() { for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options) this.pageListeners = [] } } const oldWorker = new FakeWorker('activated') const registration = new FakeRegistration() const container = new FakeContainer() registration.active = oldWorker container.controller = oldWorker container.getRegistration.mockImplementation(async () => registration) Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container }) return { FakeWorker, registration, container, oldWorker } }) vi.mock('./pages/LoginPage', () => ({ default: () =>
Login
})) vi.mock('./pages/DashboardPage', () => ({ default: () =>
Dashboard
})) vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() })) // Every import above has been evaluated by now; from here on a listener belongs to a page. sw.container.trackPage = true const reload = vi.fn() let configHits = 0 function serveVersion(version: string, gate?: Promise) { server.use( http.get('/api/auth/app-config', async () => { configHits++ if (gate) await gate return HttpResponse.json({ version }) }), ) } /** The browser's handover: the new worker installs, activates and claims the page. */ function handOver() { const next = new sw.FakeWorker('activated') sw.registration.installing = null sw.registration.active = next sw.container.controller = next sw.container.dispatchEvent(new Event('controllerchange')) } /** update() finds a new sw.js and starts installing it; `then` decides how the install ends. */ function updateStartsInstall(then: 'activates' | 'fails') { sw.registration.update.mockImplementation(async () => { const next = new sw.FakeWorker('installing') sw.registration.installing = next setTimeout(() => { if (then === 'activates') { next.become('activated') handOver() } else { // Workbox precaching is all-or-nothing: one failed fetch out of ~463 // rejects the install event and the new worker is thrown away. sw.registration.installing = null next.become('redundant') } }, 0) return sw.registration }) } /** One cold start of the installed app: fresh page, fresh session, same localStorage. */ async function launch() { sessionStorage.clear() const hitsBefore = configHits const view = render( , ) await waitFor(() => expect(configHits).toBe(hitsBefore + 1)) // Let the probe, update() and the fake install run to completion. await act(async () => { await new Promise(r => setTimeout(r, 50)) }) view.unmount() sw.container.dropPageListeners() } beforeEach(() => { resetAllStores() vi.clearAllMocks() configHits = 0 sw.registration.active = sw.oldWorker sw.registration.installing = null sw.registration.waiting = null sw.registration.update.mockReset() sw.registration.update.mockResolvedValue(sw.registration) sw.container.controller = sw.oldWorker sw.container.dropPageListeners() sw.container.getRegistration.mockReset() sw.container.getRegistration.mockImplementation(async () => sw.registration) reload.mockReset() Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } }) server.use( http.get('/api/health/features', () => HttpResponse.json({ bookingImport: false, aiParsing: false })), http.get('/api/system-notices/active', () => HttpResponse.json([])), ) useAuthStore.setState({ isLoading: false, isAuthenticated: false, user: null, appRequireMfa: false, loadUser: vi.fn().mockResolvedValue(undefined), }) }) describe('#2458 version handover: behaviour that must survive the fix', () => { it('FE-COMP-APP-2458-P1: a confirmed handover reloads once and is not repeated on the next launch', async () => { localStorage.setItem('trek_app_version', '4.3.0') serveVersion('4.3.1') updateStartsInstall('activates') await launch() expect(sw.registration.update).toHaveBeenCalledTimes(1) expect(reload).toHaveBeenCalledTimes(1) expect(localStorage.getItem('trek_app_version')).toBe('4.3.1') await launch() expect(sw.registration.update).toHaveBeenCalledTimes(1) expect(reload).toHaveBeenCalledTimes(1) }) it('FE-COMP-APP-2458-P2: nothing to hand over (sw.js unchanged, same worker in control) neither reloads nor retries', async () => { // A release that changed nothing the precache holds leaves sw.js byte-identical, // so update() installs nothing. The bundle this page runs is already current. localStorage.setItem('trek_app_version', '4.3.0') serveVersion('4.3.1') await launch() expect(sw.registration.update).toHaveBeenCalledTimes(1) expect(reload).not.toHaveBeenCalled() await launch() expect(sw.registration.update).toHaveBeenCalledTimes(1) expect(reload).not.toHaveBeenCalled() }) it.each([ ['a self-built image without the build arg', 'dev'], ['a test instance pinned to an older tag', '4.0.0'], ['a prerelease image, whose bundle keeps the last stable version', '4.4.0-pre.3'], ])('FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops', async (_label, serverVersion) => { // The server reports APP_VERSION, the bundle carries client/package.json. On // these setups the two never agree, so a check against the bundle's own // version would see an update on every launch. serveVersion(serverVersion) await launch() await launch() await launch() expect(reload).not.toHaveBeenCalled() expect(sw.registration.update).not.toHaveBeenCalled() }) }) describe('#2458 version handover: the reported defect', () => { it('FE-COMP-APP-2458-01: an update() that rejects is retried on the next launch', async () => { localStorage.setItem('trek_app_version', '4.3.0') serveVersion('4.3.1') sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error')) await launch() expect(sw.registration.update).toHaveBeenCalledTimes(1) // Next cold start, network fine this time. updateStartsInstall('activates') await launch() expect(sw.registration.update).toHaveBeenCalledTimes(2) expect(reload).toHaveBeenCalled() }) it('FE-COMP-APP-2458-02: a failed update() does not reload into the shell the same worker serves again', async () => { // The old worker still controls the page, so a plain reload is answered from // its precache: the same old UI, now with the marker saying it is current. localStorage.setItem('trek_app_version', '4.3.0') serveVersion('4.3.1') sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error')) await launch() expect(reload).not.toHaveBeenCalled() }) it('FE-COMP-APP-2458-03: an install that goes redundant is retried on the next launch', async () => { localStorage.setItem('trek_app_version', '4.3.0') serveVersion('4.3.1') updateStartsInstall('fails') await launch() expect(sw.registration.update).toHaveBeenCalledTimes(1) expect(reload).not.toHaveBeenCalled() updateStartsInstall('activates') await launch() expect(sw.registration.update).toHaveBeenCalledTimes(2) expect(reload).toHaveBeenCalledTimes(1) }) // Kept last: it swaps the controlling worker mid-launch, which code that // remembers "the controller changed since this page loaded" keeps seeing. it('FE-COMP-APP-2458-04: a worker that took over before the probe answered still reloads the page', async () => { // The browser checks sw.js on every navigation by itself. When that check // installs and activates the new worker before /api/auth/app-config has // answered, the takeover happens while nobody listens, and update() then // finds nothing newer. The page keeps running the bundle the old worker served. localStorage.setItem('trek_app_version', '4.3.0') let open!: () => void const gate = new Promise(r => { open = r }) serveVersion('4.3.1', gate) sessionStorage.clear() const view = render( , ) await waitFor(() => expect(configHits).toBe(1)) act(() => handOver()) open() await act(async () => { await new Promise(r => setTimeout(r, 50)) }) view.unmount() sw.container.dropPageListeners() expect(reload).toHaveBeenCalledTimes(1) }) })