test(boardwalk): cover the build id, the reload banner and the chunk reload guard (quick-261028-rqb)
This commit is contained in:
236
admin/tests/app/buildUpdate.test.ts
Normal file
236
admin/tests/app/buildUpdate.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user