diff --git a/admin/tests/app/buildUpdate.test.ts b/admin/tests/app/buildUpdate.test.ts new file mode 100644 index 00000000..0d5a1919 --- /dev/null +++ b/admin/tests/app/buildUpdate.test.ts @@ -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, doc = docStub(), win: EventTarget & Partial = 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((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() + }) +}) diff --git a/admin/tests/app/chunkReload.test.ts b/admin/tests/app/chunkReload.test.ts new file mode 100644 index 00000000..64b90908 --- /dev/null +++ b/admin/tests/app/chunkReload.test.ts @@ -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() + 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() + }) +}) diff --git a/admin/tests/app/runtime.test.ts b/admin/tests/app/runtime.test.ts index 82ec570a..6fb8681d 100644 --- a/admin/tests/app/runtime.test.ts +++ b/admin/tests/app/runtime.test.ts @@ -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', '0123456789ab'], + ['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('') + }) +}) diff --git a/admin/tests/fixtures/lang.json b/admin/tests/fixtures/lang.json index da81412c..f1ba3148 100644 --- a/admin/tests/fixtures/lang.json +++ b/admin/tests/fixtures/lang.json @@ -878,6 +878,12 @@ }, "backend::lang.shortcuts.title": { "other": "Skróty klawiaturowe" + }, + "backend::lang.version.available": { + "other": "Dostępna jest nowa wersja panelu." + }, + "backend::lang.version.reload": { + "other": "Odśwież" } }, "meta": { diff --git a/admin/tests/ui/UpdateBanner.test.ts b/admin/tests/ui/UpdateBanner.test.ts new file mode 100644 index 00000000..1ebdb457 --- /dev/null +++ b/admin/tests/ui/UpdateBanner.test.ts @@ -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() + }) +}) diff --git a/modules/boardwalk/boardwalk_test.go b/modules/boardwalk/boardwalk_test.go index 25270545..2268bdca 100644 --- a/modules/boardwalk/boardwalk_test.go +++ b/modules/boardwalk/boardwalk_test.go @@ -1,11 +1,14 @@ package boardwalk import ( + "encoding/json" "io/fs" "net/http" "net/http/httptest" "path" "regexp" + "runtime/debug" + "slices" "strings" "testing" "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(``) + 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) + } +}