Files
RenewCMS/web/src/views/__tests__/SettingsRestart.spec.ts
T

1041 lines
31 KiB
TypeScript

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<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@/lib/reloadPage', () => ({ reloadPage: vi.fn() }))
let toastAdd: ReturnType<typeof vi.fn>
function createOverlayMock() {
let props: Record<string, unknown> | 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<string, unknown> }) => {
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<string, unknown>,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
let overlayMock: ReturnType<typeof createOverlayMock>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }), useOverlay: () => overlayMock }
})
type Outcome = UpdateStatus | Error
type Handler = () => Promise<unknown>
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<typeof vi.fn>
let fetchMock: ReturnType<typeof vi.fn>
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<string, Handler> = {
'/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<void>
}
}
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<typeof dialogProps>) {
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)
})
})