import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import router from '@/router' import { ApiError, NetworkError, useApi, type UserRole } from '@/composables/useApi' import type { UpdateStatus } from '@/composables/useUpdate' import { reloadPage } from '@/lib/reloadPage' import { RESTART_MEMORY_KEY } from '@/lib/restartMemory' import { useSessionStore } from '@/stores/session' import ConfirmDialog from '@/components/ConfirmDialog.vue' import SettingsView from '../SettingsView.vue' import { CHECK_PATH, DOWNLOAD_PATH, RESTART_FINISHED_AT, RELEASE_URL, RESTART_PATH, RESTART_STARTED_AT, RUNNING_VERSION, STATUS_PATH, downloading, findButton, hoursAgo, newerAvailable, plainRestartDone, ready, restarting, rolledBack, startable, upToDate, updateSucceeded, verifying, visibleButtonLabels, DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH, } from './updateTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) vi.mock('@/lib/reloadPage', () => ({ reloadPage: vi.fn() })) let toastAdd: ReturnType function createOverlayMock() { let props: Record | null = null let resolveOpen: ((value: unknown) => void) | null = null const state = { settled: false, confirmed: null as unknown } const create = vi.fn((_component: unknown, options: { props: Record }) => { props = options.props return { open: () => new Promise((resolve) => { resolveOpen = (value) => { state.settled = true state.confirmed = value resolve(value) } }), } }) return { create, state, getProps: () => props as Record, resolveOpen: (value: unknown) => resolveOpen?.(value), } } let overlayMock: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => ({ add: toastAdd }), useOverlay: () => overlayMock } }) type Outcome = UpdateStatus | Error type Handler = () => Promise const RESTART_FAILED_COPY = "Couldn't start the restart. Nothing was changed and your site is still running. Try again in a moment." const INSTALL_TITLE = 'Restart to finish the update?' const INSTALL_TEXT = "RenewCMS will install version 2.3.0-beta and restart. Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you're editing first." const PLAIN_TITLE = 'Restart the site?' const PLAIN_TEXT = "Your site will be unavailable for about 10 seconds. This page reloads on its own when the site is back. Save anything you're editing first." let requestMock: ReturnType let fetchMock: ReturnType let wrapper: VueWrapper | null = null let siteUp = false function sequence(...outcomes: Outcome[]): Handler { let index = 0 return () => { const outcome = outcomes[Math.min(index, outcomes.length - 1)]! index++ return outcome instanceof Error ? Promise.reject(outcome) : Promise.resolve(outcome) } } function asHandler(value: Handler | Outcome): Handler { return typeof value === 'function' ? value : sequence(value) } interface MountOptions { role: UserRole status: Handler | UpdateStatus restart?: Handler | Outcome download?: Handler | Outcome check?: Handler | Outcome meAfterRefresh?: UserRole } async function mountSettings(options: MountOptions) { setActivePinia(createPinia()) toastAdd = vi.fn() overlayMock = createOverlayMock() const session = useSessionStore() session.session = { verified: true, username: 'marta', id: 1, role: options.role } session.status = 'ready' const statusHandler = asHandler(options.status) const restartHandler = asHandler(options.restart ?? new ApiError(500)) const downloadHandler = asHandler(options.download ?? new ApiError(500)) const checkHandler = asHandler(options.check ?? new ApiError(500)) requestMock = vi.fn((path: string) => { const handlers: Record = { '/v1/settings/': () => Promise.resolve({ version: RUNNING_VERSION }), [STATUS_PATH]: statusHandler, [RESTART_PATH]: restartHandler, [DOWNLOAD_PATH]: downloadHandler, [CHECK_PATH]: checkHandler, } const handler = handlers[path] if (!handler) { if (path === PURGE_SETTINGS_PATH) { return Promise.resolve(DEFAULT_PURGE_SETTINGS) } throw new Error(`Unexpected request: ${path}`) } return handler() }) fetchMock = vi.fn((url: string) => { if (url !== '/healthz') { throw new Error(`Unexpected fetch: ${url}`) } return siteUp ? Promise.resolve({ status: 200 } as Response) : Promise.reject(new TypeError('Failed to fetch')) }) global.fetch = fetchMock as unknown as typeof fetch const getMe = vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, displayName: '', role: options.meAfterRefresh ?? options.role, }) vi.mocked(useApi).mockReturnValue({ getMe, request: requestMock }) await router.push('/settings') wrapper = mount(SettingsView, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return wrapper } function useTimers() { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }) } async function tick(milliseconds: number) { await vi.advanceTimersByTimeAsync(milliseconds) } function updatesCard() { return document.body.querySelector('[data-testid="updates-card"]')! } function cardText() { return updatesCard().textContent ?? '' } function cardButtons() { return visibleButtonLabels(updatesCard()) } function dialogProps() { return overlayMock.getProps() as { title: string description: string confirmLabel: string cancelLabel: string pendingLabel: string tone: string action: () => Promise } } function dialogOpen() { return overlayMock.create.mock.calls.length > 0 && !overlayMock.state.settled } async function confirmRestart() { let completed = false try { await dialogProps().action() completed = true } catch { completed = false } if (completed) { overlayMock.resolveOpen(true) } await flushPromises() } async function dismissRestartDialog() { overlayMock.resolveOpen(false) await flushPromises() await flushPromises() } function mountRealDialog(props: ReturnType) { return mount(ConfirmDialog, { attachTo: document.body, props: { ...props } as never, global: { plugins: [uiPlugin] }, }) } function realDialog() { return document.body.querySelector('[role="alertdialog"]') } function overlayElement() { return document.body.querySelector('[data-testid="restart-overlay"]') } function callsTo(path: string) { return requestMock.mock.calls.filter(([called]) => called === path) } function healthCalls() { return fetchMock.mock.calls.filter(([url]) => url === '/healthz') } async function press(label: string) { findButton(label)!.click() await flushPromises() } async function pressEscape() { document.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }), ) await flushPromises() } function silenceStatusReads() { requestMock.mockImplementation((path: string) => { if (path === STATUS_PATH) { return Promise.reject(new NetworkError()) } if (path === PURGE_SETTINGS_PATH) { return Promise.resolve(DEFAULT_PURGE_SETTINGS) } throw new Error(`Unexpected request: ${path}`) }) } function remembered() { const raw = sessionStorage.getItem(RESTART_MEMORY_KEY) return raw === null ? null : JSON.parse(raw) } function remember(kind: 'update' | 'restart', startedAt = RESTART_STARTED_AT) { sessionStorage.setItem(RESTART_MEMORY_KEY, JSON.stringify({ kind, started_at: startedAt })) } function toastTitles() { return toastAdd.mock.calls.map(([toast]) => toast.title) } beforeEach(() => { siteUp = false sessionStorage.clear() localStorage.clear() vi.mocked(reloadPage).mockClear() }) afterEach(async () => { wrapper?.unmount() wrapper = null vi.useRealTimers() await router.push('/login') document.body.innerHTML = '' sessionStorage.clear() }) describe('Settings, owner, Restart buttons', () => { it('ready: Restart is the enabled primary button', async () => { await mountSettings({ role: 'owner', status: ready() }) expect(cardButtons()).toEqual(['Restart', "What's new in 2.3.0-beta"]) const restart = findButton('Restart') as HTMLButtonElement expect(restart.disabled).toBe(false) expect(restart.className).not.toContain('border-accented') }) it('idle (Up to date): Restart is an enabled secondary button next to Check for updates', async () => { await mountSettings({ role: 'owner', status: upToDate({ can_apply: true }) }) expect(cardButtons()).toEqual(['Check for updates', 'Restart']) const restart = findButton('Restart') as HTMLButtonElement expect(restart.disabled).toBe(false) expect(restart.className).toContain('border-accented') expect(cardText()).toContain( 'Restart takes about 10 seconds. Your site is unavailable while it restarts.', ) }) it('idle (Check failed): Try again is primary and Restart is secondary', async () => { await mountSettings({ role: 'owner', status: upToDate({ can_apply: true, checked_at: hoursAgo(48) }), check: new ApiError(500), }) expect(cardText()).toContain("Couldn't check for updates") expect(cardButtons()).toEqual(['Try again', 'Restart']) expect((findButton('Restart') as HTMLButtonElement).disabled).toBe(false) }) it.each([ { name: 'downloading', status: downloading() }, { name: 'verifying', status: verifying() }, ])('$name: no Restart is drawn', async ({ status }) => { useTimers() await mountSettings({ role: 'owner', status }) expect(cardButtons()).toEqual(['Updating…']) }) it.each([ { name: 'Not prepared', role: 'owner' as const, status: newerAvailable() }, { name: 'an admin, up to date', role: 'admin' as const, status: upToDate() }, { name: 'an admin, helper installed', role: 'admin' as const, status: upToDate({ can_apply: true }), }, ])('$name: no Restart is rendered', async ({ role, status }) => { await mountSettings({ role, status }) expect(cardButtons()).not.toContain('Restart') }) }) describe('Settings, Restart: confirm (Install dialog)', () => { it('opens the dialog with the exact copy, a neutral confirm and sends nothing yet', async () => { await mountSettings({ role: 'owner', status: ready() }) await press('Restart') expect(overlayMock.create).toHaveBeenCalledTimes(1) expect(dialogProps()).toMatchObject({ title: INSTALL_TITLE, description: INSTALL_TEXT, cancelLabel: 'Restart later', confirmLabel: 'Restart now', tone: 'neutral', }) expect(callsTo(RESTART_PATH)).toHaveLength(0) expect(overlayElement()).toBeNull() }) it('renders with focus on the first button; Escape and Restart later close it', async () => { await mountSettings({ role: 'owner', status: ready() }) await press('Restart') const props = dialogProps() const dialog = mountRealDialog(props) await flushPromises() expect(realDialog()!.textContent).toContain(INSTALL_TITLE) expect(realDialog()!.textContent).toContain(INSTALL_TEXT) expect(visibleButtonLabels(realDialog()!)).toEqual(['Restart later', 'Restart now']) expect(document.activeElement).toBe(findButton('Restart later', realDialog()!)) expect(findButton('Restart now', realDialog()!)!.className).not.toContain('error') await pressEscape() expect(dialog.emitted('close')).toEqual([[false]]) dialog.unmount() const second = mountRealDialog(props) await flushPromises() await press('Restart later') expect(second.emitted('close')).toEqual([[false]]) second.unmount() expect(callsTo(RESTART_PATH)).toHaveLength(0) }) it('closing without confirming returns focus to Restart and sends nothing', async () => { await mountSettings({ role: 'owner', status: ready() }) await press('Restart') await dismissRestartDialog() expect(callsTo(RESTART_PATH)).toHaveLength(0) expect((document.activeElement as HTMLElement).dataset.testid).toBe('restart-button') }) it('Restart now sends {"kind":"update"}', async () => { useTimers() await mountSettings({ role: 'owner', status: ready(), restart: restarting() }) await press('Restart') await confirmRestart() expect(requestMock).toHaveBeenCalledWith( RESTART_PATH, expect.objectContaining({ method: 'POST', body: JSON.stringify({ kind: 'update' }) }), ) }) }) describe('Settings, Plain restart: confirm', () => { it('opens the dialog with the exact copy and sends {"kind":"restart"} only on confirm', async () => { useTimers() await mountSettings({ role: 'owner', status: upToDate({ can_apply: true }), restart: restarting(), }) await press('Restart') expect(dialogProps()).toMatchObject({ title: PLAIN_TITLE, description: PLAIN_TEXT, cancelLabel: 'Cancel', confirmLabel: 'Restart site', tone: 'neutral', }) expect(callsTo(RESTART_PATH)).toHaveLength(0) await confirmRestart() expect(requestMock).toHaveBeenCalledWith( RESTART_PATH, expect.objectContaining({ method: 'POST', body: JSON.stringify({ kind: 'restart' }) }), ) }) it('renders with focus on Cancel and closing returns focus to Restart', async () => { await mountSettings({ role: 'owner', status: upToDate({ can_apply: true }) }) await press('Restart') const dialog = mountRealDialog(dialogProps()) await flushPromises() expect(visibleButtonLabels(realDialog()!)).toEqual(['Cancel', 'Restart site']) expect(document.activeElement).toBe(findButton('Cancel', realDialog()!)) await pressEscape() expect(dialog.emitted('close')).toEqual([[false]]) dialog.unmount() await dismissRestartDialog() expect(callsTo(RESTART_PATH)).toHaveLength(0) expect((document.activeElement as HTMLElement).dataset.testid).toBe('restart-button') }) }) describe('Settings, Restart: request failed', () => { it.each([ { name: 'a 500', error: new ApiError(500) }, { name: 'a network failure', error: new NetworkError() }, { name: 'a 400', error: new ApiError(400) }, ])( '$name keeps both dialogs open with the error and re-enables the confirm', async ({ error }) => { for (const scenario of [ { status: ready(), confirm: 'Restart now' }, { status: upToDate({ can_apply: true }), confirm: 'Restart site' }, ]) { await mountSettings({ role: 'owner', status: scenario.status, restart: error }) await press('Restart') const dialog = mountRealDialog(dialogProps()) await flushPromises() findButton(scenario.confirm, realDialog()!)!.click() await flushPromises() expect(realDialog()).not.toBeNull() expect(realDialog()!.textContent).toContain(RESTART_FAILED_COPY) expect(dialog.emitted('close')).toBeUndefined() expect( (findButton(scenario.confirm, realDialog()!) as HTMLButtonElement).disabled, ).toBe(false) expect(dialogOpen()).toBe(true) expect(overlayElement()).toBeNull() expect(sessionStorage.length).toBe(0) dialog.unmount() wrapper?.unmount() document.body.innerHTML = '' } }, ) it('never carries a raw error body into the dialog', async () => { await mountSettings({ role: 'owner', status: ready(), restart: new ApiError(500, undefined, 'Internal server error'), }) await press('Restart') await expect(dialogProps().action()).rejects.toThrow(RESTART_FAILED_COPY) }) }) describe('Settings, this tab remembers that it started the restart', () => { it('stores one sessionStorage entry with the kind and the server started_at, and nothing else', async () => { useTimers() await mountSettings({ role: 'owner', status: ready(), restart: startable({ state: 'restarting', started_at: '2026-09-30T09:51:07Z' }), }) const storageBefore = JSON.stringify({ ...localStorage }) await press('Restart') await confirmRestart() expect(sessionStorage.length).toBe(1) expect(remembered()).toEqual({ kind: 'update', started_at: '2026-09-30T09:51:07Z' }) expect(JSON.stringify({ ...localStorage })).toBe(storageBefore) expect(overlayElement()).not.toBeNull() }) }) describe('Settings, Restarting to install', () => { async function startInstallRestart() { useTimers() await mountSettings({ role: 'owner', status: ready(), restart: restarting() }) await press('Restart') await confirmRestart() } it('shows the status region with the copy and the moving bar', async () => { await startInstallRestart() const overlay = overlayElement()! expect(overlay.getAttribute('role')).toBe('status') expect(overlay.textContent).toContain('Installing version 2.3.0-beta') expect(overlay.textContent).toContain( 'Your site is restarting with the new version. This takes about 10 seconds. This page reloads on its own when the site is back.', ) expect(overlay.textContent).toMatch(/Started at \d\d:\d\d/) expect(overlay.querySelector('[role="progressbar"]')).not.toBeNull() expect(overlay.textContent).not.toContain('Reload page') }) it('survives failing requests and polls /healthz and GET every second', async () => { await startInstallRestart() const healthBefore = healthCalls().length const readsBefore = callsTo(STATUS_PATH).length await tick(3000) expect(healthCalls().length - healthBefore).toBe(3) expect(callsTo(STATUS_PATH).length - readsBefore).toBe(3) expect(overlayElement()).not.toBeNull() expect(reloadPage).not.toHaveBeenCalled() }) it('ignores the offline state and non-200 answers from /healthz', async () => { await startInstallRestart() window.dispatchEvent(new Event('offline')) fetchMock.mockImplementation(() => Promise.resolve({ status: 502 } as Response)) await tick(2000) expect(overlayElement()).not.toBeNull() expect(reloadPage).not.toHaveBeenCalled() }) }) describe('Settings, Plain restart: restarting', () => { it('shows the shorter copy and polls the same way', async () => { useTimers() await mountSettings({ role: 'owner', status: upToDate({ can_apply: true }), restart: restarting(), }) await press('Restart') await confirmRestart() const overlay = overlayElement()! expect(overlay.textContent).toContain('Restarting your site') expect(overlay.textContent).toContain( 'This takes about 10 seconds. This page reloads on its own when the site is back.', ) const before = healthCalls().length await tick(2000) expect(healthCalls().length - before).toBe(2) }) }) describe('Settings, Restarting, taking longer', () => { it('after 60 seconds without an answer shows Still restarting and a Reload page button', async () => { useTimers() await mountSettings({ role: 'owner', status: ready(), restart: restarting() }) await press('Restart') await confirmRestart() silenceStatusReads() await tick(59000) expect(overlayElement()!.textContent).toContain('Installing version 2.3.0-beta') await tick(1000) const overlay = overlayElement()! expect(overlay.textContent).toContain('Still restarting') expect(overlay.textContent).toContain( 'This is taking longer than usual. Keep this page open. It reloads on its own when the site is back, or you can reload it now.', ) expect(overlay.textContent).toContain(' · ') expect(visibleButtonLabels(overlay)).toEqual(['Reload page']) await press('Reload page') expect(reloadPage).toHaveBeenCalledTimes(1) expect(overlayElement()).not.toBeNull() const before = healthCalls().length await tick(2000) expect(healthCalls().length - before).toBe(2) }) }) describe('Settings, the overlay ends when the site answers', () => { async function runOverlay(answers: Handler, healthy: boolean) { useTimers() await mountSettings({ role: 'owner', status: sequence(ready()) as Handler, restart: restarting(), }) await press('Restart') await confirmRestart() requestMock.mockImplementation((path: string) => { if (path === STATUS_PATH) { return answers() } if (path === PURGE_SETTINGS_PATH) { return Promise.resolve(DEFAULT_PURGE_SETTINGS) } throw new Error(`Unexpected request: ${path}`) }) siteUp = healthy await tick(1000) } it('reloads once when /healthz is 200 and GET answers a later finished_at in another state', async () => { await runOverlay(() => Promise.resolve(updateSucceeded()), true) await tick(3000) expect(reloadPage).toHaveBeenCalledTimes(1) }) it.each([ { name: 'GET still says restarting', answer: () => Promise.resolve(restarting()), healthy: true, }, { name: 'finished_at is null', answer: () => Promise.resolve(upToDate({ state: 'idle', finished_at: null })), healthy: true, }, { name: 'finished_at is not later than the stored started_at', answer: () => Promise.resolve( upToDate({ state: 'succeeded', finished_at: RESTART_STARTED_AT, can_apply: true, }), ), healthy: true, }, { name: 'GET is rejected', answer: () => Promise.reject(new ApiError(502)), healthy: true, }, { name: '/healthz answers but GET does not', answer: () => Promise.reject(new NetworkError()), healthy: true, }, { name: 'GET answers but /healthz does not', answer: () => Promise.resolve(updateSucceeded()), healthy: false, }, ])('does not reload when $name', async ({ answer, healthy }) => { await runOverlay(answer, healthy) await tick(3000) expect(reloadPage).not.toHaveBeenCalled() expect(overlayElement()).not.toBeNull() }) }) describe('Settings, Update done (the tab that started it)', () => { it('clears the entry, focuses the Updates heading, announces the toast and shows the card', async () => { remember('update') await mountSettings({ role: 'owner', status: updateSucceeded() }) expect(sessionStorage.length).toBe(0) expect(document.activeElement?.id).toBe('updates-h') expect(toastAdd).toHaveBeenCalledTimes(1) expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ color: 'success', icon: 'i-lucide-circle-check', title: 'Update installed', description: 'RenewCMS is now on version 2.3.0-beta.', }), ) expect(cardText()).toContain("You're on version 2.3.0-beta") expect(cardText()).toContain('The update is installed and your site is running again.') expect(cardText()).toMatch(/Updated (today|yesterday|\d+ \w+ \d{4}) at \d\d:\d\d/) const link = Array.from(updatesCard().querySelectorAll('a')).find( (anchor) => anchor.textContent?.trim() === 'Read the release notes', ) expect(link?.getAttribute('href')).toBe(RELEASE_URL) expect(cardButtons()).toEqual(['Check for updates', 'Restart']) }) }) describe('Settings, Update failed, rolled back (the tab that started it)', () => { it.each([{ error: 'rolled_back' as const }, { error: 'install' as const }])( 'error $error: sticky error toast, focus, card copy and Try again posts download', async ({ error }) => { remember('update') await mountSettings({ role: 'owner', status: rolledBack(error), download: downloading(), }) expect(sessionStorage.length).toBe(0) expect(document.activeElement?.id).toBe('updates-h') expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ color: 'error', icon: 'i-lucide-circle-x', title: "The update didn't install", description: 'Your site is back on v2.2.0-beta.', duration: 0, }), ) expect(cardText()).toContain("The update didn't install") expect(cardText()).toContain( 'RenewCMS put the previous version back, so your site is running as before. Nothing was lost.', ) expect(cardText()).toContain( 'What to do next: try again in a few minutes. If it fails again, ask whoever manages your server to check the update.', ) expect(cardButtons()).toEqual(['Try again']) await press('Try again') expect(callsTo(DOWNLOAD_PATH)).toHaveLength(1) }, ) }) describe('Settings, Plain restart: done (the tab that started it)', () => { it('shows the Site restarted toast, moves focus and renders the Up to date card', async () => { remember('restart') await mountSettings({ role: 'owner', status: plainRestartDone() }) expect(sessionStorage.length).toBe(0) expect(document.activeElement?.id).toBe('updates-h') expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ color: 'success', icon: 'i-lucide-circle-check', title: 'Site restarted', description: 'Your site is running again.', }), ) expect(cardText()).toContain("You're up to date") expect(cardButtons()).toEqual(['Check for updates', 'Restart']) }) }) describe('A later visit shows the card with no toast and no focus move', () => { it.each([ { name: 'succeeded', status: updateSucceeded() }, { name: 'rolled_back', status: rolledBack('rolled_back') }, { name: 'install', status: rolledBack('install') }, { name: 'plain restart idle', status: plainRestartDone() }, ])('$name: no entry', async ({ status }) => { await mountSettings({ role: 'owner', status }) expect(toastAdd).not.toHaveBeenCalled() expect(document.activeElement?.id).not.toBe('updates-h') expect(sessionStorage.length).toBe(0) expect(updatesCard()).not.toBeNull() }) it.each([ { name: 'succeeded', status: updateSucceeded() }, { name: 'rolled_back', status: rolledBack('rolled_back') }, { name: 'plain restart idle', status: plainRestartDone() }, ])('$name: an entry not earlier than finished_at', async ({ status }) => { remember('update', RESTART_FINISHED_AT) await mountSettings({ role: 'owner', status }) expect(toastAdd).not.toHaveBeenCalled() expect(document.activeElement?.id).not.toBe('updates-h') expect(remembered()).toEqual({ kind: 'update', started_at: RESTART_FINISHED_AT }) }) }) describe("Settings, another owner's tab never takes the toast", () => { it('shows the overlay while restarting and the card afterwards, with no toast and no focus move', async () => { useTimers() siteUp = true await mountSettings({ role: 'owner', status: sequence(restarting(), restarting(), updateSucceeded()), }) expect(overlayElement()).not.toBeNull() expect(overlayElement()!.textContent).toContain('Restarting your site') expect(toastAdd).not.toHaveBeenCalled() await tick(3000) expect(reloadPage).toHaveBeenCalledTimes(1) expect(toastAdd).not.toHaveBeenCalled() expect(sessionStorage.length).toBe(0) }) it('an owner tab with an entry still gets the toast and the focus', async () => { remember('update') await mountSettings({ role: 'owner', status: updateSucceeded() }) expect(toastAdd).toHaveBeenCalledTimes(1) expect(document.activeElement?.id).toBe('updates-h') }) it('an admin sees the admin view, no overlay and no poll', async () => { useTimers() await mountSettings({ role: 'admin', status: restarting() }) expect(overlayElement()).toBeNull() expect(cardButtons()).toEqual([]) const before = callsTo(STATUS_PATH).length await tick(3000) expect(callsTo(STATUS_PATH)).toHaveLength(before) expect(healthCalls()).toHaveLength(0) }) }) describe('Settings, a stale sessionStorage entry', () => { it.each([ { name: 'finished_at is null', status: upToDate({ can_apply: true }) }, { name: 'finished_at is not later than the entry', status: updateSucceeded(), entryStart: RESTART_FINISHED_AT, }, ])('$name: no toast, no focus move, the entry stays', async ({ status, entryStart }) => { remember('update', entryStart ?? RESTART_STARTED_AT) await mountSettings({ role: 'owner', status }) expect(toastAdd).not.toHaveBeenCalled() expect(document.activeElement?.id).not.toBe('updates-h') expect(remembered()).toEqual({ kind: 'update', started_at: entryStart ?? RESTART_STARTED_AT, }) }) it('a new restart overwrites the entry', async () => { useTimers() remember('update', '2026-09-01T00:00:00Z') await mountSettings({ role: 'owner', status: upToDate({ can_apply: true }), restart: startable({ state: 'restarting', started_at: '2026-09-30T10:00:00Z' }), }) await press('Restart') await confirmRestart() expect(remembered()).toEqual({ kind: 'restart', started_at: '2026-09-30T10:00:00Z' }) }) }) describe('Settings, 409 on Restart', () => { it('reloading downloading gives the info toast and the progress', async () => { useTimers() await mountSettings({ role: 'owner', status: sequence(ready(), downloading()), restart: new ApiError(409), }) await press('Restart') await confirmRestart() expect(dialogOpen()).toBe(false) expect(sessionStorage.length).toBe(0) expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ color: 'info', title: 'An update is already running', description: 'Another owner started it. Progress shows here.', }), ) expect(cardText()).toContain('Downloading version 2.3.0-beta…') }) it('reloading restarting gives the toast and the overlay', async () => { useTimers() await mountSettings({ role: 'owner', status: sequence(ready(), restarting()), restart: new ApiError(409), }) await press('Restart') await confirmRestart() expect(toastTitles()).toContain('An update is already running') expect(overlayElement()).not.toBeNull() expect(sessionStorage.length).toBe(0) }) it('reloading can_apply false shows Not prepared and no toast', async () => { await mountSettings({ role: 'owner', status: sequence(ready(), newerAvailable()), restart: new ApiError(409), }) await press('Restart') await confirmRestart() expect(dialogOpen()).toBe(false) expect(toastAdd).not.toHaveBeenCalled() expect(cardText()).toContain("This site can't update itself from the browser yet.") }) it.each([ { name: 'ready (the kind did not match)', after: ready(), from: upToDate({ can_apply: true }), }, { name: 'idle (the kind did not match)', after: upToDate({ can_apply: true }), from: ready(), }, ])('reloading $name shows that card and no toast', async ({ after, from }) => { await mountSettings({ role: 'owner', status: sequence(from, after), restart: new ApiError(409), }) await press('Restart') await confirmRestart() expect(toastAdd).not.toHaveBeenCalled() expect(sessionStorage.length).toBe(0) expect(dialogOpen()).toBe(false) expect(cardText()).toContain( after.state === 'ready' ? 'is ready to install' : "You're up to date", ) }) }) describe('Settings, 403 and 401 on Restart', () => { it('403: the dialog closes, an error toast shows and the card reloads in the admin view', async () => { await mountSettings({ role: 'owner', status: sequence(ready(), newerAvailable()), restart: new ApiError(403), meAfterRefresh: 'admin', }) await press('Restart') await confirmRestart() expect(dialogOpen()).toBe(false) expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ color: 'error', title: 'Only the owner can do this', description: 'Ask the owner of this site to update RenewCMS.', }), ) expect(cardText()).toContain('Only the owner can update RenewCMS.') expect(cardButtons()).toEqual([]) expect(sessionStorage.length).toBe(0) }) it('401: the dialog closes and no local copy shows', async () => { await mountSettings({ role: 'owner', status: ready(), restart: new ApiError(401) }) await press('Restart') await confirmRestart() expect(dialogOpen()).toBe(false) expect(toastAdd).not.toHaveBeenCalled() expect(overlayElement()).toBeNull() expect(sessionStorage.length).toBe(0) }) })