test(boardwalk): cover the build id, the reload banner and the chunk reload guard (quick-261028-rqb)

This commit is contained in:
Jakub Zych
2026-10-28 21:26:20 +01:00
parent 75e19dc153
commit c8a22e3e84
6 changed files with 720 additions and 1 deletions

View File

@@ -0,0 +1,236 @@
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<string>, doc = docStub(), win: EventTarget & Partial<Window> = 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<string>((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()
})
})

View File

@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from 'vitest'
import {
CHUNK_RELOAD_KEY,
CHUNK_RELOAD_WINDOW,
installChunkReloadGuard,
shouldReload,
} from '../../src/app/chunkReload'
/** An in-memory Storage. */
function memoryStorage(): Storage {
const items = new Map<string, string>()
return {
get length() {
return items.size
},
clear: () => items.clear(),
getItem: (key) => items.get(key) ?? null,
key: (index) => [...items.keys()][index] ?? null,
removeItem: (key) => void items.delete(key),
setItem: (key, value) => void items.set(key, String(value)),
}
}
describe('shouldReload', () => {
it('reloads once and records the time', () => {
const storage = memoryStorage()
expect(shouldReload(storage, 1000)).toBe(true)
expect(storage.getItem(CHUNK_RELOAD_KEY)).toBe('1000')
})
it('declines a second reload within the window and allows one after it', () => {
const storage = memoryStorage()
expect(shouldReload(storage, 1000)).toBe(true)
expect(shouldReload(storage, 1000 + CHUNK_RELOAD_WINDOW - 1)).toBe(false)
expect(shouldReload(storage, 1000 + CHUNK_RELOAD_WINDOW)).toBe(true)
expect(storage.getItem(CHUNK_RELOAD_KEY)).toBe(String(1000 + CHUNK_RELOAD_WINDOW))
})
it('reloads over a stored value that is not a timestamp', () => {
const storage = memoryStorage()
storage.setItem(CHUNK_RELOAD_KEY, 'garbage')
expect(shouldReload(storage, 1000)).toBe(true)
})
it('never reloads without storage', () => {
expect(shouldReload(null, 1000)).toBe(false)
})
it('never reloads when storage throws', () => {
const reading = memoryStorage()
reading.getItem = () => {
throw new Error('denied')
}
expect(shouldReload(reading, 1000)).toBe(false)
const writing = memoryStorage()
writing.setItem = () => {
throw new Error('quota')
}
expect(shouldReload(writing, 1000)).toBe(false)
})
it('keys the flag under summer-admin.* with a 60 second window', () => {
expect(CHUNK_RELOAD_KEY).toBe('summer-admin.chunk-reload')
expect(CHUNK_RELOAD_WINDOW).toBe(60_000)
})
})
describe('installChunkReloadGuard', () => {
it('reloads once on vite:preloadError and leaves the event to the importer', () => {
const target = new EventTarget()
const storage = memoryStorage()
const reload = vi.fn()
let clock = 5000
const uninstall = installChunkReloadGuard({ target, storage: () => storage, reload, now: () => clock })
const first = new Event('vite:preloadError', { cancelable: true })
target.dispatchEvent(first)
expect(reload).toHaveBeenCalledTimes(1)
expect(first.defaultPrevented).toBe(false)
clock += 1000
target.dispatchEvent(new Event('vite:preloadError', { cancelable: true }))
expect(reload).toHaveBeenCalledTimes(1)
uninstall()
clock += CHUNK_RELOAD_WINDOW
target.dispatchEvent(new Event('vite:preloadError', { cancelable: true }))
expect(reload).toHaveBeenCalledTimes(1)
})
it('does not reload when no storage is available', () => {
const target = new EventTarget()
const reload = vi.fn()
const uninstall = installChunkReloadGuard({ target, storage: () => null, reload })
target.dispatchEvent(new Event('vite:preloadError'))
expect(reload).not.toHaveBeenCalled()
uninstall()
})
})

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_BASE, readBase, runtime } from '../../src/app/runtime'
import { DEFAULT_BASE, readBase, readBuild, runtime } from '../../src/app/runtime'
function docWith(content: string | null): Document {
const doc = document.implementation.createHTMLDocument('test')
@@ -39,3 +39,36 @@ describe('runtime base', () => {
expect(DEFAULT_BASE).toBe('/backend')
})
})
function docWithBuild(content: string | null): Document {
const doc = document.implementation.createHTMLDocument('test')
if (content !== null) {
const meta = doc.createElement('meta')
meta.setAttribute('name', 'summer-admin-build')
meta.setAttribute('content', content)
doc.head.appendChild(meta)
}
return doc
}
describe('runtime build', () => {
it('reads a hex build id from the served meta', () => {
expect(readBuild(docWithBuild('0123456789ab'))).toBe('0123456789ab')
expect(readBuild(docWithBuild(' 0123456789ab '))).toBe('0123456789ab')
})
it.each([
['a missing meta', null],
['an empty value', ''],
['the unreplaced build token', '__SUMMER_ADMIN_BUILD__'],
['uppercase hex', '0123456789AB'],
['a value with markup', '<b>0123456789ab</b>'],
['a 65-character hex value', 'a'.repeat(65)],
])('is empty for %s', (_label, content) => {
expect(readBuild(docWithBuild(content))).toBe('')
})
it('is empty in the test document, which carries no build', () => {
expect(runtime.build).toBe('')
})
})