237 lines
7.3 KiB
TypeScript
237 lines
7.3 KiB
TypeScript
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()
|
|
})
|
|
})
|