import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { BUILD_CHECK_INTERVAL, BUILD_CHECK_MIN_GAP, dismissNewBuild, fetchBuild, isNewBuild, newBuildAvailable, noteBuild, resetBuildUpdate, startBuildWatch, } from '../../src/app/buildUpdate' const BOOTED = 'aaaaaaaaaaaa' const NEXT = 'bbbbbbbbbbbb' const THIRD = 'cccccccccccc' /** A document stand-in: an event target with a settable visibilityState. */ function docStub(visibilityState: DocumentVisibilityState = 'visible') { const target = new EventTarget() return Object.assign(target, { visibilityState }) } function jsonResponse(body: unknown, status = 200): Response { return new Response(typeof body === 'string' ? body : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) } beforeEach(() => { resetBuildUpdate() }) describe('isNewBuild', () => { it.each([ ['', NEXT, false], [BOOTED, '', false], [BOOTED, BOOTED, false], [BOOTED, NEXT, true], ])('(%j, %j) is %s', (booted, seen, want) => { expect(isNewBuild(booted, seen)).toBe(want) }) it('uses a 5 minute interval and a 30 second gap', () => { expect(BUILD_CHECK_INTERVAL).toBe(300_000) expect(BUILD_CHECK_MIN_GAP).toBe(30_000) }) }) describe('fetchBuild', () => { const original = globalThis.fetch afterEach(() => { globalThis.fetch = original }) it('asks build.json under the admin base without the cache', async () => { const fetch = vi.fn().mockResolvedValue(jsonResponse({ build: NEXT })) globalThis.fetch = fetch expect(await fetchBuild()).toBe(NEXT) expect(fetch).toHaveBeenCalledTimes(1) const [url, init] = fetch.mock.calls[0] as [string, RequestInit] expect(url).toBe('/admin-test/build.json') expect(init.cache).toBe('no-store') }) it.each([ ['a 500', () => Promise.resolve(jsonResponse({ build: NEXT }, 500))], ['invalid JSON', () => Promise.resolve(jsonResponse('not json'))], ['a non-string build', () => Promise.resolve(jsonResponse({ build: 12 }))], ['a JSON null body', () => Promise.resolve(jsonResponse('null'))], ['a rejected fetch', () => Promise.reject(new TypeError('offline'))], ])('is empty for %s', async (_label, answer) => { globalThis.fetch = vi.fn(answer) as typeof fetch expect(await fetchBuild()).toBe('') }) }) describe('noteBuild and dismissNewBuild', () => { it('shows a different build and ignores the booted one', () => { noteBuild(BOOTED, BOOTED) expect(newBuildAvailable.value).toBe(false) noteBuild('', BOOTED) expect(newBuildAvailable.value).toBe(false) noteBuild(NEXT, BOOTED) expect(newBuildAvailable.value).toBe(true) }) it('keeps a dismissed build hidden until a third build appears', () => { noteBuild(NEXT, BOOTED) dismissNewBuild() expect(newBuildAvailable.value).toBe(false) noteBuild(NEXT, BOOTED) expect(newBuildAvailable.value).toBe(false) noteBuild(THIRD, BOOTED) expect(newBuildAvailable.value).toBe(true) }) it('takes the booted build from the runtime by default, which is empty in tests', () => { noteBuild(NEXT) expect(newBuildAvailable.value).toBe(false) }) }) describe('startBuildWatch', () => { let clock = 0 const now = () => clock beforeEach(() => { clock = 1_000_000 }) afterEach(() => { vi.useRealTimers() }) function watch(check: () => Promise, doc = docStub(), win: EventTarget & Partial = window) { const stop = startBuildWatch({ booted: BOOTED, check, doc: doc as unknown as Document, win: win as unknown as Window, now, interval: 1000, minGap: 100, }) return { stop, doc } } it('installs nothing without a booted build', async () => { const check = vi.fn().mockResolvedValue(NEXT) const doc = docStub() const add = vi.spyOn(doc, 'addEventListener') const stop = startBuildWatch({ booted: '', check, doc: doc as unknown as Document, now }) doc.dispatchEvent(new Event('visibilitychange')) window.dispatchEvent(new Event('focus')) await Promise.resolve() expect(add).not.toHaveBeenCalled() expect(check).not.toHaveBeenCalled() stop() }) it('checks when the tab becomes visible, never while hidden', async () => { const check = vi.fn().mockResolvedValue(BOOTED) const { stop, doc } = watch(check, docStub('hidden')) doc.dispatchEvent(new Event('visibilitychange')) await Promise.resolve() expect(check).not.toHaveBeenCalled() doc.visibilityState = 'visible' doc.dispatchEvent(new Event('visibilitychange')) await Promise.resolve() expect(check).toHaveBeenCalledTimes(1) stop() }) it('checks on window focus', async () => { const check = vi.fn().mockResolvedValue(BOOTED) const { stop } = watch(check) window.dispatchEvent(new Event('focus')) await Promise.resolve() expect(check).toHaveBeenCalledTimes(1) stop() }) it('checks on the interval', async () => { vi.useFakeTimers() const check = vi.fn().mockResolvedValue(BOOTED) const { stop } = watch(check) expect(check).not.toHaveBeenCalled() clock += 1000 await vi.advanceTimersByTimeAsync(1000) expect(check).toHaveBeenCalledTimes(1) clock += 1000 await vi.advanceTimersByTimeAsync(1000) expect(check).toHaveBeenCalledTimes(2) stop() }) it('runs one check for a focus and a visibility change within the gap', async () => { const check = vi.fn().mockResolvedValue(BOOTED) const { stop, doc } = watch(check) window.dispatchEvent(new Event('focus')) await Promise.resolve() clock += 50 doc.dispatchEvent(new Event('visibilitychange')) await Promise.resolve() expect(check).toHaveBeenCalledTimes(1) clock += 100 window.dispatchEvent(new Event('focus')) await Promise.resolve() expect(check).toHaveBeenCalledTimes(2) stop() }) it('runs one check at a time', async () => { let resolve: (build: string) => void = () => undefined const check = vi.fn(() => new Promise((done) => (resolve = done))) const { stop } = watch(check) window.dispatchEvent(new Event('focus')) clock += 500 window.dispatchEvent(new Event('focus')) expect(check).toHaveBeenCalledTimes(1) resolve(BOOTED) await Promise.resolve() await Promise.resolve() window.dispatchEvent(new Event('focus')) expect(check).toHaveBeenCalledTimes(2) stop() }) it('shows the banner for a different build and not for the same one', async () => { const check = vi.fn().mockResolvedValueOnce(BOOTED).mockResolvedValueOnce(NEXT) const { stop } = watch(check) window.dispatchEvent(new Event('focus')) await vi.waitFor(() => expect(check).toHaveBeenCalledTimes(1)) await Promise.resolve() expect(newBuildAvailable.value).toBe(false) clock += 100 window.dispatchEvent(new Event('focus')) await vi.waitFor(() => expect(newBuildAvailable.value).toBe(true)) stop() }) it('stops listening and clears the interval', async () => { vi.useFakeTimers() const check = vi.fn().mockResolvedValue(BOOTED) const { stop, doc } = watch(check) stop() window.dispatchEvent(new Event('focus')) doc.dispatchEvent(new Event('visibilitychange')) clock += 5000 await vi.advanceTimersByTimeAsync(5000) expect(check).not.toHaveBeenCalled() }) })