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()
|
||||
})
|
||||
})
|
||||
99
admin/tests/app/chunkReload.test.ts
Normal file
99
admin/tests/app/chunkReload.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user