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 { 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 {
|
function docWith(content: string | null): Document {
|
||||||
const doc = document.implementation.createHTMLDocument('test')
|
const doc = document.implementation.createHTMLDocument('test')
|
||||||
@@ -39,3 +39,36 @@ describe('runtime base', () => {
|
|||||||
expect(DEFAULT_BASE).toBe('/backend')
|
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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
6
admin/tests/fixtures/lang.json
vendored
6
admin/tests/fixtures/lang.json
vendored
@@ -878,6 +878,12 @@
|
|||||||
},
|
},
|
||||||
"backend::lang.shortcuts.title": {
|
"backend::lang.shortcuts.title": {
|
||||||
"other": "Skróty klawiaturowe"
|
"other": "Skróty klawiaturowe"
|
||||||
|
},
|
||||||
|
"backend::lang.version.available": {
|
||||||
|
"other": "Dostępna jest nowa wersja panelu."
|
||||||
|
},
|
||||||
|
"backend::lang.version.reload": {
|
||||||
|
"other": "Odśwież"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
|
|||||||
53
admin/tests/ui/UpdateBanner.test.ts
Normal file
53
admin/tests/ui/UpdateBanner.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { noteBuild, resetBuildUpdate } from '../../src/app/buildUpdate'
|
||||||
|
import UpdateBanner from '../../src/components/ui/UpdateBanner.vue'
|
||||||
|
import { page } from '../../src/state/usePreferences'
|
||||||
|
import { resetState } from '../helpers'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetState()
|
||||||
|
resetBuildUpdate()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
describe('UpdateBanner', () => {
|
||||||
|
it('is hidden while no new build is known', () => {
|
||||||
|
const wrapper = mount(UpdateBanner)
|
||||||
|
expect(wrapper.find('[data-update-banner]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the new-version text and reloads only on Reload', async () => {
|
||||||
|
const reload = vi.spyOn(page, 'reload').mockImplementation(() => undefined)
|
||||||
|
const wrapper = mount(UpdateBanner)
|
||||||
|
noteBuild('bbbbbbbbbbbb', 'aaaaaaaaaaaa')
|
||||||
|
await nextTick()
|
||||||
|
const banner = wrapper.find('[data-update-banner]')
|
||||||
|
expect(banner.exists()).toBe(true)
|
||||||
|
expect(banner.attributes('role')).toBe('status')
|
||||||
|
expect(banner.attributes('aria-live')).toBe('polite')
|
||||||
|
expect(banner.text()).toContain('Dostępna jest nowa wersja panelu.')
|
||||||
|
expect(wrapper.find('[data-update-reload]').text()).toBe('Odśwież')
|
||||||
|
expect(reload).not.toHaveBeenCalled()
|
||||||
|
await wrapper.find('[data-update-reload]').trigger('click')
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides on Close', async () => {
|
||||||
|
const reload = vi.spyOn(page, 'reload').mockImplementation(() => undefined)
|
||||||
|
const wrapper = mount(UpdateBanner)
|
||||||
|
noteBuild('bbbbbbbbbbbb', 'aaaaaaaaaaaa')
|
||||||
|
await nextTick()
|
||||||
|
const close = wrapper.find('[data-update-close]')
|
||||||
|
expect(close.attributes('aria-label')).toBe('Zamknij')
|
||||||
|
await close.trigger('click')
|
||||||
|
expect(wrapper.find('[data-update-banner]').exists()).toBe(false)
|
||||||
|
expect(reload).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package boardwalk
|
package boardwalk
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime/debug"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
@@ -530,3 +533,292 @@ func TestBuildSmoke(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var hexID = regexp.MustCompile(`^[0-9a-f]{12}$`)
|
||||||
|
|
||||||
|
func mustBuildID(t *testing.T, root fs.FS, info *debug.BuildInfo) string {
|
||||||
|
t.Helper()
|
||||||
|
id, err := BuildID(root, info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !hexID.MatchString(id) {
|
||||||
|
t.Fatalf("build id %q is not 12 lowercase hex characters", id)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildID(t *testing.T) {
|
||||||
|
root := buildFixture()
|
||||||
|
base := mustBuildID(t, root, nil)
|
||||||
|
if again := mustBuildID(t, buildFixture(), nil); again != base {
|
||||||
|
t.Fatalf("same tree gave %q and %q", base, again)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := buildFixture()
|
||||||
|
changed["assets/app.js"] = &fstest.MapFile{Data: []byte(`console.log("acme!")`)}
|
||||||
|
added := buildFixture()
|
||||||
|
added["assets/extra.css"] = &fstest.MapFile{Data: []byte(`body{}`)}
|
||||||
|
renamed := buildFixture()
|
||||||
|
renamed["assets/app2.js"] = renamed["assets/app.js"]
|
||||||
|
delete(renamed, "assets/app.js")
|
||||||
|
for name, tree := range map[string]fstest.MapFS{"changed file": changed, "added file": added, "renamed file": renamed} {
|
||||||
|
if id := mustBuildID(t, tree, nil); id == base {
|
||||||
|
t.Fatalf("%s kept the id %q", name, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := BuildID(fstest.MapFS{}, nil); err != nil {
|
||||||
|
t.Fatalf("empty tree: %v", err)
|
||||||
|
}
|
||||||
|
mustBuildID(t, fstest.MapFS{}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildInfoFixture is a hand-built build info; tests never depend on the test
|
||||||
|
// binary's real build information for exact values.
|
||||||
|
func buildInfoFixture() *debug.BuildInfo {
|
||||||
|
return &debug.BuildInfo{
|
||||||
|
GoVersion: "go1.26.0",
|
||||||
|
Path: "example.com/acme/cmd/acme",
|
||||||
|
Main: debug.Module{Path: "example.com/acme", Version: "v1.2.3", Sum: "h1:main="},
|
||||||
|
Deps: []*debug.Module{
|
||||||
|
{Path: "example.com/blog", Version: "v0.4.0", Sum: "h1:blog="},
|
||||||
|
{
|
||||||
|
Path: "git.golem15.com/golem15/summercms", Version: "v0.9.0", Sum: "h1:framework=",
|
||||||
|
Replace: &debug.Module{Path: "../summercms.go", Version: "", Sum: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Settings: []debug.BuildSetting{
|
||||||
|
{Key: "GOOS", Value: "linux"},
|
||||||
|
{Key: "vcs.revision", Value: "abc123"},
|
||||||
|
{Key: "vcs.modified", Value: "false"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildIDBuildInfo(t *testing.T) {
|
||||||
|
root := buildFixture()
|
||||||
|
distOnly := mustBuildID(t, root, nil)
|
||||||
|
base := mustBuildID(t, root, buildInfoFixture())
|
||||||
|
if base == distOnly {
|
||||||
|
t.Fatal("build info did not change the id")
|
||||||
|
}
|
||||||
|
if again := mustBuildID(t, root, buildInfoFixture()); again != base {
|
||||||
|
t.Fatalf("same info gave %q and %q", base, again)
|
||||||
|
}
|
||||||
|
reversed := buildInfoFixture()
|
||||||
|
slices.Reverse(reversed.Deps)
|
||||||
|
if id := mustBuildID(t, root, reversed); id != base {
|
||||||
|
t.Fatalf("dependency order changed the id: %q vs %q", id, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
setting := func(info *debug.BuildInfo, key, value string) {
|
||||||
|
for i := range info.Settings {
|
||||||
|
if info.Settings[i].Key == key {
|
||||||
|
info.Settings[i].Value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changes := map[string]func(*debug.BuildInfo){
|
||||||
|
"vcs.revision": func(i *debug.BuildInfo) { setting(i, "vcs.revision", "def456") },
|
||||||
|
"vcs.modified": func(i *debug.BuildInfo) { setting(i, "vcs.modified", "true") },
|
||||||
|
"main version": func(i *debug.BuildInfo) { i.Main.Version = "v1.2.4" },
|
||||||
|
"main sum": func(i *debug.BuildInfo) { i.Main.Sum = "h1:other=" },
|
||||||
|
"go version": func(i *debug.BuildInfo) { i.GoVersion = "go1.26.1" },
|
||||||
|
"dependency version": func(i *debug.BuildInfo) { i.Deps[0].Version = "v0.4.1" },
|
||||||
|
"dependency sum": func(i *debug.BuildInfo) { i.Deps[0].Sum = "h1:blog2=" },
|
||||||
|
"added dependency": func(i *debug.BuildInfo) {
|
||||||
|
i.Deps = append(i.Deps, &debug.Module{Path: "example.com/shop", Version: "v1.0.0", Sum: "h1:shop="})
|
||||||
|
},
|
||||||
|
"replace version": func(i *debug.BuildInfo) { i.Deps[1].Replace.Version = "v0.9.1" },
|
||||||
|
}
|
||||||
|
for name, change := range changes {
|
||||||
|
info := buildInfoFixture()
|
||||||
|
change(info)
|
||||||
|
if id := mustBuildID(t, root, info); id == base {
|
||||||
|
t.Fatalf("%s kept the id %q", name, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unrelated := buildInfoFixture()
|
||||||
|
setting(unrelated, "GOOS", "darwin")
|
||||||
|
unrelated.Settings = append(unrelated.Settings, debug.BuildSetting{Key: "-tags", Value: "acme"})
|
||||||
|
if id := mustBuildID(t, root, unrelated); id != base {
|
||||||
|
t.Fatalf("an unrelated setting changed the id: %q vs %q", id, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
novcs := buildInfoFixture()
|
||||||
|
novcs.Settings = []debug.BuildSetting{{Key: "GOOS", Value: "linux"}}
|
||||||
|
mustBuildID(t, root, novcs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubBuildInfo(t *testing.T, info *debug.BuildInfo, ok bool) {
|
||||||
|
t.Helper()
|
||||||
|
saved := readBuildInfo
|
||||||
|
readBuildInfo = func() (*debug.BuildInfo, bool) { return info, ok }
|
||||||
|
t.Cleanup(func() { readBuildInfo = saved })
|
||||||
|
}
|
||||||
|
|
||||||
|
// servedBuild returns the build id the handler serves in the meta, the
|
||||||
|
// header and build.json, failing when they disagree.
|
||||||
|
func servedBuild(t *testing.T, h http.Handler) string {
|
||||||
|
t.Helper()
|
||||||
|
index := get(h, testPrefix+"/")
|
||||||
|
meta := regexp.MustCompile(`<meta name="summer-admin-build" content="([^"]*)"`).FindStringSubmatch(index.Body.String())
|
||||||
|
if meta == nil {
|
||||||
|
t.Fatalf("index has no build meta: %s", index.Body.String())
|
||||||
|
}
|
||||||
|
rec := get(h, testPrefix+"/"+BuildPath)
|
||||||
|
var body struct{ Build string }
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("build.json %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
header := index.Header().Get(BuildHeader)
|
||||||
|
if meta[1] != body.Build || header != body.Build {
|
||||||
|
t.Fatalf("meta %q, build.json %q and header %q disagree", meta[1], body.Build, header)
|
||||||
|
}
|
||||||
|
return body.Build
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInfoFallback(t *testing.T) {
|
||||||
|
root := buildFixture()
|
||||||
|
|
||||||
|
stubBuildInfo(t, nil, false)
|
||||||
|
h, err := newHandler(root, testPrefix, &apiSpy{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := servedBuild(t, h), mustBuildID(t, root, nil); got != want {
|
||||||
|
t.Fatalf("without build info served %q, want the dist id %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
fixture := buildInfoFixture()
|
||||||
|
stubBuildInfo(t, fixture, true)
|
||||||
|
h, err = newHandler(root, testPrefix, &apiSpy{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := servedBuild(t, h)
|
||||||
|
if want := mustBuildID(t, root, fixture); first != want {
|
||||||
|
t.Fatalf("with build info served %q, want %q", first, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
redeploy := buildInfoFixture()
|
||||||
|
redeploy.Settings[1].Value = "def456"
|
||||||
|
stubBuildInfo(t, redeploy, true)
|
||||||
|
h, err = newHandler(root, testPrefix, &apiSpy{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second := servedBuild(t, h); second == first {
|
||||||
|
t.Fatalf("a new vcs.revision served the same id %q", second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHeaderOnEveryResponse(t *testing.T) {
|
||||||
|
h, spy := newTestHandler(t)
|
||||||
|
root, err := Dist()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := mustBuildID(t, root, buildInfo())
|
||||||
|
entries, err := fs.ReadDir(root, "assets")
|
||||||
|
if err != nil || len(entries) == 0 {
|
||||||
|
t.Fatalf("embedded assets: %v", err)
|
||||||
|
}
|
||||||
|
for _, target := range []string{
|
||||||
|
testPrefix + "/",
|
||||||
|
testPrefix + "/index.html",
|
||||||
|
testPrefix + "/acme/demo/widgets",
|
||||||
|
testPrefix + "/assets/" + entries[0].Name(),
|
||||||
|
testPrefix + "/missing.js",
|
||||||
|
testPrefix + "/" + BuildPath,
|
||||||
|
testPrefix + "/api/v1/nope",
|
||||||
|
} {
|
||||||
|
if got := get(h, target).Header().Get(BuildHeader); got != want {
|
||||||
|
t.Fatalf("%s %s=%q, want %q", target, BuildHeader, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spy.calls != 1 {
|
||||||
|
t.Fatalf("api miss reached the spy %d times", spy.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEndpoint(t *testing.T) {
|
||||||
|
root := buildFixture()
|
||||||
|
h, err := newHandler(root, testPrefix, &apiSpy{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := get(h, testPrefix+"/"+BuildPath)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Content-Type"); got != "application/json" {
|
||||||
|
t.Fatalf("Content-Type=%q", got)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Fatalf("Cache-Control=%q", got)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("body %s: %v", rec.Body.String(), err)
|
||||||
|
}
|
||||||
|
if len(body) != 1 || body["build"] != mustBuildID(t, root, buildInfo()) {
|
||||||
|
t.Fatalf("body = %v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The build route wins over a dist file of the same name.
|
||||||
|
shadowed := buildFixture()
|
||||||
|
shadowed[BuildPath] = &fstest.MapFile{Data: []byte(`{"build":"stale"}`)}
|
||||||
|
h, err = newHandler(shadowed, testPrefix, &apiSpy{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := get(h, testPrefix+"/"+BuildPath).Body.String(); strings.Contains(got, "stale") {
|
||||||
|
t.Fatalf("dist build.json shadowed the route: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildToken(t *testing.T) {
|
||||||
|
root := buildFixture()
|
||||||
|
h, err := newHandler(root, testPrefix, &apiSpy{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := get(h, testPrefix+"/").Body.String()
|
||||||
|
if strings.Contains(body, BuildToken) {
|
||||||
|
t.Fatalf("token left in the index: %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `content="`+mustBuildID(t, root, buildInfo())+`"`) {
|
||||||
|
t.Fatalf("meta not replaced: %s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := []byte(`<html><meta name="summer-admin-base" content="` + BaseToken + `" /><script src="./assets/app.js"></script></html>`)
|
||||||
|
plain := fstest.MapFS{
|
||||||
|
"index.html": &fstest.MapFile{Data: raw},
|
||||||
|
"assets/app.js": &fstest.MapFile{Data: []byte(`1`)},
|
||||||
|
}
|
||||||
|
h, err = newHandler(plain, testPrefix, &apiSpy{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("index without the build token: %v", err)
|
||||||
|
}
|
||||||
|
want, err := RewriteIndex(raw, testPrefix)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := get(h, testPrefix+"/").Body.String(); got != string(want) {
|
||||||
|
t.Fatalf("index without the token changed:\n%s\nwant\n%s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmbeddedBuild(t *testing.T) {
|
||||||
|
h, _ := newTestHandler(t)
|
||||||
|
root, err := Dist()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := servedBuild(t, h), mustBuildID(t, root, buildInfo()); got != want {
|
||||||
|
t.Fatalf("embedded build served %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user