Files
summercms/admin/tests/app/buildUpdate.test.ts

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()
})
})