import { afterEach, 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 { useSessionStore } from '@/stores/session' import SettingsView from '../SettingsView.vue' import { CHECK_PATH, DOWNLOAD_PATH, NOTE_ITEMS, RUNNING_VERSION, STATUS_PATH, downloading, failedWith, findButton, newerAvailable, ready, startable, verifying, visibleButtonLabels, } from './updateTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn(), } }) let toastAdd: ReturnType vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => ({ add: toastAdd }), } }) type Outcome = UpdateStatus | Error type Handler = () => Promise let requestMock: ReturnType let getMeMock: ReturnType let wrapper: VueWrapper | null = null 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) } } interface MountOptions { role: UserRole status: Handler | UpdateStatus download?: Handler | UpdateStatus | Error meAfterRefresh?: UserRole } function asHandler(value: Handler | Outcome): Handler { if (typeof value === 'function') { return value } return sequence(value) } async function mountSettings(options: MountOptions) { setActivePinia(createPinia()) toastAdd = vi.fn() const session = useSessionStore() session.session = { verified: true, username: 'marta', id: 1, role: options.role } session.status = 'ready' const statusHandler = asHandler(options.status) const downloadHandler = asHandler(options.download ?? new ApiError(500)) requestMock = vi.fn((path: string) => { if (path === '/v1/settings/') { return Promise.resolve({ version: RUNNING_VERSION }) } if (path === STATUS_PATH) { return statusHandler() } if (path === DOWNLOAD_PATH) { return downloadHandler() } throw new Error(`Unexpected request: ${path}`) }) getMeMock = vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, displayName: '', role: options.meAfterRefresh ?? options.role, }) vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, 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'], }) } function updatesCard() { return document.body.querySelector('[data-testid="updates-card"]')! } function cardText() { return updatesCard().textContent ?? '' } function cardButtons() { return visibleButtonLabels(updatesCard()) } function callsTo(path: string) { return requestMock.mock.calls.filter(([called]) => called === path) } function progressbar() { return updatesCard().querySelector('[role="progressbar"]') } function announcer() { return updatesCard().querySelector('[data-testid="update-announcer"]')! } async function pressUpdate() { findButton('Update')!.click() await flushPromises() } async function tick(milliseconds: number) { await vi.advanceTimersByTimeAsync(milliseconds) } afterEach(async () => { wrapper?.unmount() wrapper = null vi.useRealTimers() await router.push('/login') document.body.innerHTML = '' }) describe('Settings, owner, Update available with the helper detected', () => { it('shows the info block, collapsed notes, the one Update button and the hint', async () => { await mountSettings({ role: 'owner', status: startable() }) expect(cardText()).toContain('Version 2.3.0-beta is available') expect(cardText()).toContain( 'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.', ) expect(cardText()).toMatch(/Last checked (today|yesterday) at \d\d:\d\d/) expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false') expect(cardButtons()).toEqual(["What's new in 2.3.0-beta", 'Update']) expect(cardText()).toContain('Takes about 2 minutes. Your site stays online.') expect(updatesCard().querySelector('code')).toBeNull() expect(cardText()).not.toContain('How to update') expect(cardText()).not.toContain('For whoever manages the server') expect(callsTo(DOWNLOAD_PATH)).toHaveLength(0) expect(callsTo(CHECK_PATH)).toHaveLength(0) }) it('opens the notes with the release date and the plain-text list', async () => { await mountSettings({ role: 'owner', status: startable() }) const toggle = findButton("What's new in 2.3.0-beta")! toggle.click() await flushPromises() expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(cardText()).toContain('Released 24 Sep 2026') for (const item of NOTE_ITEMS) { expect(cardText()).toContain(item) } }) }) describe('Settings, owner, Downloading', () => { it('shows the card copy, the progressbar, the text and a disabled Updating button', async () => { useTimers() await mountSettings({ role: 'owner', status: startable(), download: downloading() }) await pressUpdate() expect(callsTo(DOWNLOAD_PATH)).toHaveLength(1) expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, { method: 'POST' }) expect(cardText()).toContain('Downloading version 2.3.0-beta…') expect(cardText()).toContain( 'You can leave this page. The download continues and your site stays online.', ) const bar = progressbar()! expect(bar.getAttribute('aria-label')).toBe('Downloading version 2.3.0-beta') expect(bar.getAttribute('aria-valuenow')).toBe('42') expect(cardText()).toContain('42% · 11 of 26 MB') const updating = findButton('Updating…') as HTMLButtonElement expect(updating.disabled).toBe(true) expect(cardButtons()).toEqual(['Updating…']) }) it('reads the status once a second while the job runs', async () => { useTimers() await mountSettings({ role: 'owner', status: sequence(startable(), downloading(42)), download: downloading(), }) await pressUpdate() const before = callsTo(STATUS_PATH).length await tick(1000) await tick(1000) await tick(1000) expect(callsTo(STATUS_PATH)).toHaveLength(before + 3) }) }) describe('Settings, progress is announced at 25 percent steps only', () => { it('changes the announcer at 25, 50 and 75 while the bar updates on every poll', async () => { useTimers() const percents = [3, 24, 25, 26, 49, 50, 75, 100] await mountSettings({ role: 'owner', status: sequence(startable(), ...percents.map((percent) => downloading(percent))), download: downloading(0), }) await pressUpdate() const announced: string[] = [] const valueNow: string[] = [] for (let index = 0; index < percents.length; index++) { await tick(1000) announced.push(announcer().textContent!.trim()) valueNow.push(progressbar()!.getAttribute('aria-valuenow')!) } expect(announced).toEqual([ 'Downloading version 2.3.0-beta', 'Downloading version 2.3.0-beta', 'Downloading version 2.3.0-beta, 25 percent', 'Downloading version 2.3.0-beta, 25 percent', 'Downloading version 2.3.0-beta, 25 percent', 'Downloading version 2.3.0-beta, 50 percent', 'Downloading version 2.3.0-beta, 75 percent', 'Downloading version 2.3.0-beta, 75 percent', ]) expect(valueNow).toEqual(percents.map(String)) expect(announcer().getAttribute('role')).toBe('status') expect(updatesCard().querySelector('[aria-live="polite"]')).toBeNull() }) }) describe('Settings, owner, Verifying', () => { it('shows an indeterminate bar, Almost there and announces the step once', async () => { useTimers() await mountSettings({ role: 'owner', status: sequence(startable(), verifying()), download: downloading(100), }) await pressUpdate() await tick(1000) expect(cardText()).toContain('Checking the download…') expect(cardText()).toContain( 'Making sure the file is complete and safe to install. This takes a few seconds.', ) const bar = progressbar()! expect(bar.hasAttribute('aria-valuenow')).toBe(false) expect(bar.getAttribute('aria-label')).toBe('Checking the download') expect(cardText()).toContain('Almost there') expect((findButton('Updating…') as HTMLButtonElement).disabled).toBe(true) expect(announcer().textContent!.trim()).toBe('Checking the download') }) }) describe('Settings, owner, Ready to install', () => { it('shows the success block, an enabled Restart, the notes and the hint, and stops polling', async () => { useTimers() await mountSettings({ role: 'owner', status: sequence(startable(), ready()), download: downloading(100), }) await pressUpdate() await tick(1000) expect(cardText()).toContain('Version 2.3.0-beta is ready to install') expect(cardText()).toContain( 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.', ) expect(cardText()).toContain('Nothing has changed yet.') expect(cardText()).toContain( 'Not now? Leave this page. The update waits here, and the banner on Home reminds you.', ) expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false') const restart = findButton('Restart') as HTMLButtonElement expect(restart.disabled).toBe(false) const tag = updatesCard().querySelector('[data-testid="update-tag"]') expect(tag?.textContent?.trim()).toBe('Pending') expect(updatesCard().querySelector('svg.text-warning')).not.toBeNull() expect(updatesCard().querySelector('svg.text-success')).toBeNull() const block = updatesCard().querySelector('[aria-live]') as HTMLElement expect(block.className).toContain('--rc-warning-soft') expect(block.contains(restart)).toBe(true) expect(restart.className).toContain('h-11') expect(restart.className).toContain('md:h-8') expect(updatesCard().querySelector('[class*="md:flex-row"]')).toBeNull() const readsBefore = callsTo(STATUS_PATH).length await tick(3000) expect(callsTo(STATUS_PATH)).toHaveLength(readsBefore) expect(requestMock.mock.calls.map(([path]) => path)).not.toContain( '/v1/system/update/restart', ) }) }) describe('Settings, Download failed', () => { function activeId() { return document.activeElement?.id } it.each([ { name: 'a failed record on a poll', status: sequence(startable(), failedWith('download')), download: downloading(), }, { name: 'a 500', status: startable(), download: new ApiError(500) }, { name: 'a network error', status: startable(), download: new NetworkError() }, ])('shows the error card and focuses the heading after $name', async (scenario) => { useTimers() await mountSettings({ role: 'owner', status: scenario.status, download: scenario.download, }) await pressUpdate() await tick(1000) await flushPromises() expect(cardText()).toContain("The download didn't finish") expect(cardText()).toContain( 'The connection dropped before the file was complete. Nothing was changed and your site is running as before.', ) expect(cardButtons()).toEqual(['Try again']) expect(activeId()).toBe('updates-h') }) it('posts the download again from Try again', async () => { await mountSettings({ role: 'owner', status: startable(), download: sequence(new ApiError(500), downloading()), }) await pressUpdate() findButton('Try again')!.click() await flushPromises() expect(callsTo(DOWNLOAD_PATH)).toHaveLength(2) expect(callsTo(CHECK_PATH)).toHaveLength(0) expect(cardText()).toContain('Downloading version 2.3.0-beta…') }) }) describe('Settings, Verification failed', () => { function assertUntrustedCard() { expect(cardText()).toContain("We didn't use this update") expect(cardText()).toContain( "The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.", ) expect(cardButtons()).toEqual(['Try again']) expect(document.activeElement?.id).not.toBe('updates-h') expect(cardText()).not.toMatch(/signature|checksum|sha/i) } it('shows the warning card from a failed record', async () => { useTimers() await mountSettings({ role: 'owner', status: sequence(startable(), failedWith('verification')), download: downloading(), }) await pressUpdate() await tick(1000) assertUntrustedCard() }) it('shows the warning card after a 422', async () => { await mountSettings({ role: 'owner', status: startable(), download: new ApiError(422) }) await pressUpdate() assertUntrustedCard() }) }) describe('Settings, 409 already running', () => { it('toasts and shows the other run while it downloads', async () => { await mountSettings({ role: 'owner', status: sequence(startable(), downloading(42)), download: new ApiError(409), }) await pressUpdate() 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…') expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42') }) it('shows Ready to install without a toast when the reload answers ready', async () => { await mountSettings({ role: 'owner', status: sequence(startable(), ready()), download: new ApiError(409), }) await pressUpdate() expect(toastAdd).not.toHaveBeenCalled() expect(cardText()).toContain('Version 2.3.0-beta is ready to install') }) it('shows the Not prepared card without a toast when the reload answers can_apply false', async () => { await mountSettings({ role: 'owner', status: sequence(startable(), newerAvailable()), download: new ApiError(409), }) await pressUpdate() expect(toastAdd).not.toHaveBeenCalled() expect(cardText()).toContain("This site can't update itself from the browser yet.") }) }) describe('Settings, 400 nothing to update', () => { it('toasts and reloads the status once', async () => { await mountSettings({ role: 'owner', status: startable(), download: new ApiError(400), }) const readsBefore = callsTo(STATUS_PATH).length await pressUpdate() expect(toastAdd).toHaveBeenCalledWith( expect.objectContaining({ color: 'info', title: 'Nothing to update', description: 'You already have the latest version.', }), ) expect(callsTo(STATUS_PATH)).toHaveLength(readsBefore + 1) }) }) describe('Settings, 403 role changed on Update', () => { it('toasts, reloads and renders the admin view', async () => { await mountSettings({ role: 'owner', meAfterRefresh: 'admin', status: sequence(startable(), newerAvailable()), download: new ApiError(403), }) await pressUpdate() 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(getMeMock).toHaveBeenCalled() expect(cardText()).toContain('Only the owner can update RenewCMS.') expect(cardButtons()).toEqual([]) }) }) describe('Settings, 429 on Update', () => { it('counts down through useRetryAfter and posts the download again at zero', async () => { useTimers() await mountSettings({ role: 'owner', status: startable(), download: sequence(new ApiError(429, 42), downloading()), }) await pressUpdate() expect(cardText()).toContain('Too many tries') expect(cardText()).toContain('Wait a moment before trying again.') expect((findButton('Try again in 0:42') as HTMLButtonElement).disabled).toBe(true) await tick(1000) expect(findButton('Try again in 0:41')).toBeTruthy() await tick(41000) const again = findButton('Try again') as HTMLButtonElement expect(again.disabled).toBe(false) again.click() await flushPromises() expect(callsTo(DOWNLOAD_PATH)).toHaveLength(2) expect(callsTo(CHECK_PATH)).toHaveLength(0) expect(cardText()).toContain('Downloading version 2.3.0-beta…') }) }) describe('Settings, reload keeps progress', () => { it('stops polling on leave and resumes it on the next open', async () => { useTimers() await mountSettings({ role: 'owner', status: downloading(42) }) await tick(1000) const beforeLeaving = callsTo(STATUS_PATH).length expect(beforeLeaving).toBeGreaterThan(1) wrapper!.unmount() wrapper = null await tick(5000) expect(callsTo(STATUS_PATH)).toHaveLength(beforeLeaving) document.body.innerHTML = '' await mountSettings({ role: 'owner', status: downloading(42) }) expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42') const afterOpening = callsTo(STATUS_PATH).length await tick(2000) expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening + 2) }) it.each([ { name: 'idle', status: startable() }, { name: 'ready', status: ready() }, { name: 'failed', status: failedWith('download') }, ])('does not poll while the state is $name', async ({ status }) => { useTimers() await mountSettings({ role: 'owner', status }) const afterOpening = callsTo(STATUS_PATH).length await tick(5000) expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening) }) }) describe('Settings, a poll that fails keeps the card', () => { it.each([ { name: 'a 500', failure: new ApiError(500) }, { name: 'a network error', failure: new NetworkError() }, ])('keeps the last progress and polls again after $name', async ({ failure }) => { useTimers() await mountSettings({ role: 'owner', status: sequence(downloading(42), failure, failure, downloading(60)), }) await tick(1000) expect(cardText()).toContain('Downloading version 2.3.0-beta…') expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42') expect(cardText()).not.toContain("The download didn't finish") await tick(1000) expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42') await tick(1000) expect(progressbar()!.getAttribute('aria-valuenow')).toBe('60') }) }) describe('Settings, admin view during a job', () => { it('shows the admin copy with no button, progressbar or poll', async () => { useTimers() await mountSettings({ role: 'admin', status: { ...downloading(42), can_apply: false }, }) const afterOpening = callsTo(STATUS_PATH).length await tick(5000) expect(cardText()).toContain('Version 2.3.0-beta is available') expect(cardText()).toContain( 'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.', ) expect(cardButtons()).toEqual([]) expect(progressbar()).toBeNull() expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening) }) })