1041 lines
31 KiB
TypeScript
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)
|
|
})
|
|
})
|