feat: signed releases, release feed, notification (read-only)

This commit is contained in:
fsylvain committed 2026-10-01 15:01:53 +02:00
1 parent a49eec909c
commit 834493a4f5
49 files changed
+4191 -18

No files matched your search

+26 -3
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
import { useApi } from '@/composables/useApi'
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue'
@@ -9,6 +10,7 @@ import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
import UpdatePill from '@/components/UpdatePill.vue'
import UserMenu from '@/components/UserMenu.vue'
const props = withDefaults(
@@ -43,6 +45,7 @@ const bodyUi = computed(() => {
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const update = useUpdateStore()
const api = useApi()
const navItems = computed(() =>
@@ -52,7 +55,7 @@ const navItems = computed(() =>
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings', slot: 'settings' },
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
@@ -154,7 +157,11 @@ async function onLogout() {
:collapsed="sidebarCollapsed"
aria-label="Main"
:ui="navUi"
/>
>
<template #settings-trailing>
<UpdatePill v-if="update.showCue && !sidebarCollapsed" />
</template>
</UNavigationMenu>
<template #footer>
<UserMenu
@@ -172,7 +179,6 @@ async function onLogout() {
<UDashboardPanel :ui="{ body: bodyUi }">
<template #header>
<UDashboardNavbar
:toggle="{ ui: { leadingIcon: 'size-5' } }"
:title="pageTitle"
:ui="{
root: 'h-14 gap-1 bg-default px-1.5 sm:px-1.5 lg:h-12 lg:gap-2 lg:px-6',
@@ -181,6 +187,23 @@ async function onLogout() {
toggle: 'size-11 justify-center',
}"
>
<template #toggle>
<span class="relative lg:hidden">
<UDashboardSidebarToggle
data-slot="toggle"
color="neutral"
variant="ghost"
class="size-11 justify-center"
:ui="{ leadingIcon: 'size-5' }"
/>
<span
v-if="update.showCue"
role="img"
aria-label="Update available"
class="absolute top-2.25 right-2.25 size-2.5 border-2 border-(--ui-bg) bg-info"
/>
</span>
</template>
<template #leading>
<UDashboardSidebarCollapse
color="neutral"
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
version: string
date: string
items: string[]
}>()
const open = ref(false)
</script>
<template>
<UCollapsible
v-model:open="open"
class="flex flex-col gap-2.5"
:ui="{ content: 'data-[state=closed]:hidden' }"
>
<UButton
color="neutral"
variant="link"
:icon="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
class="min-h-11 self-start gap-1.5 p-0 font-medium text-highlighted md:min-h-8"
:ui="{ leadingIcon: 'size-3.5' }"
>
What's new in {{ version }}
</UButton>
<template #content>
<div class="flex flex-col gap-2 pl-5">
<span class="text-xs text-muted">{{ date }}</span>
<ul class="flex flex-col gap-2">
<li v-for="item in items" :key="item" class="flex gap-2">
<span aria-hidden="true" class="mt-2 size-1 shrink-0 bg-muted" />
<span class="text-sm text-pretty text-toned">{{ item }}</span>
</li>
</ul>
</div>
</template>
</UCollapsible>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const props = defineProps<{
line: string
}>()
const emit = defineEmits<{
copy: []
}>()
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
async function copyLine() {
try {
await copy(props.line)
} catch {
return
}
emit('copy')
}
</script>
<template>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-highlighted">For whoever manages the server</span>
<div class="flex flex-col gap-2 md:flex-row md:items-start">
<code
class="min-w-0 flex-1 truncate bg-elevated px-3 py-2 font-mono text-code/5.5 text-highlighted"
>{{ line }}</code
>
<UButton
color="neutral"
variant="outline"
:icon="buttonIcon"
aria-live="polite"
class="h-11 justify-center gap-1.5 border border-accented bg-default px-3 ring-0 md:h-8"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-4' }"
@click="copyLine"
>
{{ buttonLabel }}
</UButton>
</div>
<span class="text-xs/5 text-muted"
>They run this once on the server. After that, Update and Restart appear here.</span
>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
export type UpdateBannerState = 'available'
defineProps<{
state: UpdateBannerState
version: string
}>()
const emit = defineEmits<{
'go-to-settings': []
dismiss: []
}>()
</script>
<template>
<UAlert
role="region"
aria-label="Update available"
color="info"
variant="soft"
orientation="horizontal"
icon="i-lucide-circle-arrow-up"
:title="`Version ${version} is available`"
description="Update it from Settings. It takes about 2 minutes and the site stays online."
:ui="{
root: 'flex-wrap items-start gap-x-2.5 gap-y-3 border border-default py-3 ring-0 md:flex-nowrap md:items-center',
wrapper: 'basis-0 gap-0.5',
title: 'text-sm text-highlighted',
description: 'mt-0 text-sm/5 text-toned',
actions: 'max-md:w-full max-md:basis-full',
}"
>
<template #actions>
<div class="flex w-full items-center gap-2">
<UButton
color="neutral"
variant="outline"
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-highlighted ring-0 md:h-8 md:flex-none"
@click="emit('go-to-settings')"
>
Go to Settings
</UButton>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
aria-label="Dismiss"
class="size-11 shrink-0 justify-center text-muted md:size-8"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('dismiss')"
/>
</div>
</template>
</UAlert>
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<UBadge label="Update" color="info" variant="solid" class="ml-auto h-4.5 px-1.5 py-0 text-xs" />
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
export type UpdateTone = 'neutral' | 'success' | 'info' | 'warning' | 'error'
const TONE_CLASSES: Record<UpdateTone, { block: string; icon: string }> = {
neutral: { block: 'bg-muted', icon: 'text-toned' },
success: { block: 'bg-(--rc-success-soft)', icon: 'text-success' },
info: { block: 'bg-(--rc-info-soft)', icon: 'text-info' },
warning: { block: 'bg-(--rc-warning-soft)', icon: 'text-warning' },
error: { block: 'bg-(--rc-error-soft)', icon: 'text-error' },
}
const props = defineProps<{
tone: UpdateTone
icon: string
title: string
text: string
meta?: string
}>()
const toneClasses = computed(() => TONE_CLASSES[props.tone])
const spinning = computed(() => props.icon === 'i-lucide-loader-circle')
</script>
<template>
<div aria-live="polite" class="flex gap-3 p-3.5" :class="toneClasses.block">
<span class="flex pt-0.5">
<UIcon
:name="icon"
class="size-4.5 shrink-0"
:class="[
toneClasses.icon,
spinning ? 'animate-spin motion-reduce:animate-none' : '',
]"
/>
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-base font-semibold text-highlighted">{{ title }}</span>
<span class="text-sm text-pretty text-toned">{{ text }}</span>
<span v-if="meta" class="text-xs/5 text-muted">{{ meta }}</span>
</div>
</div>
</template>
+210
View File
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed } from 'vue'
import ReleaseNotes from '@/components/ReleaseNotes.vue'
import ServerCommand from '@/components/ServerCommand.vue'
import UpdateStatusBlock, { type UpdateTone } from '@/components/UpdateStatusBlock.vue'
import type { UpdateStatus } from '@/composables/useUpdate'
import type { UserRole } from '@/composables/useApi'
import type { UpdateCheckPhase } from '@/stores/update'
import {
ENABLE_BROWSER_UPDATES_COMMAND,
displayVersion,
formatLastChecked,
formatReleased,
} from '@/lib/updateFormat'
interface Block {
tone: UpdateTone
icon: string
title: string
text: string
meta?: string
}
interface ActionButton {
label: string
icon: string
primary: boolean
disabled: boolean
}
const props = defineProps<{
status: UpdateStatus
role: UserRole | undefined
phase: UpdateCheckPhase
retryLabel: string
retryActive: boolean
}>()
const emit = defineEmits<{
check: []
}>()
const isOwner = computed(() => props.role === 'owner')
const newVersion = computed(() => displayVersion(props.status.latest))
const lastChecked = computed(() =>
props.status.checked_at ? formatLastChecked(props.status.checked_at) : undefined,
)
const block = computed<Block>(() => {
if (isOwner.value && props.phase === 'checking') {
return {
tone: 'neutral',
icon: 'i-lucide-loader-circle',
title: 'Checking for updates…',
text: 'This usually takes a few seconds.',
}
}
if (isOwner.value && props.phase === 'limited') {
return {
tone: 'info',
icon: 'i-lucide-info',
title: 'Too many tries',
text: 'Wait a moment before trying again.',
}
}
if (isOwner.value && props.phase === 'failed') {
return {
tone: 'warning',
icon: 'i-lucide-wifi-off',
title: "Couldn't check for updates",
text: "RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.",
meta: lastChecked.value,
}
}
if (props.status.available && isOwner.value) {
return {
tone: 'info',
icon: 'i-lucide-info',
title: `Version ${newVersion.value} is available`,
text: "This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
}
}
if (props.status.available) {
return {
tone: 'info',
icon: 'i-lucide-circle-arrow-up',
title: `Version ${newVersion.value} is available`,
text: `You have ${props.status.current}. Only the owner can update RenewCMS. Ask the owner of this site.`,
}
}
return {
tone: 'success',
icon: 'i-lucide-circle-check',
title: "You're up to date",
text: isOwner.value
? 'You have the latest version of RenewCMS.'
: 'Only the owner can update or restart the site.',
meta: isOwner.value ? lastChecked.value : undefined,
}
})
const showServerGuidance = computed(
() => isOwner.value && props.status.available && props.phase === 'idle',
)
const actionButton = computed<ActionButton | null>(() => {
if (!isOwner.value) {
return null
}
if (props.phase === 'checking') {
return {
label: 'Checking…',
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
}
}
if (props.phase === 'failed') {
return { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: true, disabled: false }
}
if (props.phase === 'limited') {
return props.retryActive
? {
label: `Try again in ${props.retryLabel}`,
icon: 'i-lucide-clock',
primary: false,
disabled: true,
}
: { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: false, disabled: false }
}
if (props.status.available) {
return null
}
return {
label: 'Check for updates',
icon: 'i-lucide-refresh-cw',
primary: false,
disabled: false,
}
})
const notes = computed(() => {
const items = props.status.notes?.items ?? []
if (!showServerGuidance.value || items.length === 0 || !props.status.notes) {
return null
}
return {
items,
date: formatReleased(props.status.notes.released_at),
}
})
</script>
<template>
<section
aria-labelledby="updates-h"
data-testid="updates-card"
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
>
<h2
id="updates-h"
tabindex="-1"
class="self-start text-lg font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
>
Updates
</h2>
<UpdateStatusBlock
:tone="block.tone"
:icon="block.icon"
:title="block.title"
:text="block.text"
:meta="block.meta"
/>
<ReleaseNotes v-if="notes" :version="newVersion" :date="notes.date" :items="notes.items" />
<ServerCommand v-if="showServerGuidance" :line="ENABLE_BROWSER_UPDATES_COMMAND" />
<div v-if="actionButton" class="flex flex-col gap-2 md:flex-row md:items-center">
<UButton
:color="actionButton.primary ? 'primary' : 'neutral'"
:variant="actionButton.primary ? 'solid' : 'outline'"
:icon="actionButton.icon"
:disabled="actionButton.disabled"
class="h-11 justify-center gap-1.5 px-3.5 md:h-8"
:class="
actionButton.primary
? ''
: 'border border-accented bg-default text-highlighted ring-0'
"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('check')"
>
{{ actionButton.label }}
</UButton>
</div>
<a
v-if="showServerGuidance && status.release_url"
:href="status.release_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
>
How to update
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
</section>
</template>
+44
View File
@@ -0,0 +1,44 @@
import { useApi } from '@/composables/useApi'
export type UpdateState =
| 'idle'
| 'downloading'
| 'verifying'
| 'ready'
| 'restarting'
| 'succeeded'
| 'failed'
export interface UpdateNotes {
version: string
released_at: string
items: string[]
}
export interface UpdateStatus {
current: string
latest: string
available: boolean
release_url: string | null
notes: UpdateNotes | null
checked_at: string | null
can_apply: boolean
state: UpdateState
progress: unknown
error: string | null
started_at: string | null
}
export function useUpdate() {
const { request } = useApi()
function getUpdateStatus(): Promise<UpdateStatus> {
return request<UpdateStatus>('/v1/system/update')
}
function checkForUpdate(): Promise<UpdateStatus> {
return request<UpdateStatus>('/v1/system/update/check', { method: 'POST' })
}
return { getUpdateStatus, checkForUpdate }
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import {
AUTO_CHECK_INTERVAL_MS,
displayVersion,
formatLastChecked,
formatReleased,
isCheckDue,
} from '@/lib/updateFormat'
describe('displayVersion', () => {
it.each([
['v2.3.0-beta', '2.3.0-beta'],
['2.0.0-beta', '2.0.0-beta'],
['v2.5.0', '2.5.0'],
])('shows %s as %s', (tag, expected) => {
expect(displayVersion(tag)).toBe(expected)
})
})
describe('formatLastChecked', () => {
const now = new Date(2026, 8, 30, 12, 0, 0)
it('reads today at the local time', () => {
expect(formatLastChecked(new Date(2026, 8, 30, 9, 41).toISOString(), now)).toBe(
'Last checked today at 09:41',
)
})
it('reads yesterday at the local time', () => {
expect(formatLastChecked(new Date(2026, 8, 29, 18, 12).toISOString(), now)).toBe(
'Last checked yesterday at 18:12',
)
})
it('reads an older day as an absolute date', () => {
expect(formatLastChecked(new Date(2026, 8, 20, 7, 5).toISOString(), now)).toBe(
'Last checked 20 Sep 2026 at 07:05',
)
})
})
describe('isCheckDue', () => {
const now = Date.parse('2026-09-30T12:00:00Z')
it('is due when never checked', () => {
expect(isCheckDue(null, now)).toBe(true)
})
it('is due once the last check is older than 24 hours', () => {
expect(isCheckDue(new Date(now - AUTO_CHECK_INTERVAL_MS - 1000).toISOString(), now)).toBe(
true,
)
})
it('is not due when the last check is under 24 hours old', () => {
expect(isCheckDue(new Date(now - AUTO_CHECK_INTERVAL_MS + 1000).toISOString(), now)).toBe(
false,
)
})
})
describe('formatReleased', () => {
it('reads the release date as an absolute date', () => {
expect(formatReleased('2026-09-24T08:00:00Z')).toBe('Released 24 Sep 2026')
})
})
+46
View File
@@ -0,0 +1,46 @@
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export const ENABLE_BROWSER_UPDATES_COMMAND = 'renewcms enable-browser-updates'
export const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
export function displayVersion(tag: string) {
return tag.replace(/^v/, '')
}
function isSameLocalDay(a: Date, b: Date) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
function formatClock(date: Date) {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
export function formatLastChecked(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (isSameLocalDay(date, now)) {
return `Last checked today at ${formatClock(date)}`
}
if (isSameLocalDay(date, yesterday)) {
return `Last checked yesterday at ${formatClock(date)}`
}
return `Last checked ${formatAbsoluteDateFromIso(iso)} at ${formatClock(date)}`
}
export function isCheckDue(checkedAt: string | null, now: number = Date.now()) {
if (checkedAt === null) {
return true
}
return now - new Date(checkedAt).getTime() > AUTO_CHECK_INTERVAL_MS
}
export function formatReleased(iso: string) {
return `Released ${formatAbsoluteDateFromIso(iso)}`
}
+138
View File
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update'
import { CHECK_PATH, STATUS_PATH, newerAvailable, upToDate } from '@/views/__tests__/updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
let requestMock: ReturnType<typeof vi.fn>
function setup(role: 'owner' | 'admin', handlers: Record<string, () => Promise<unknown>>) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, displayName: '', role }
session.status = 'ready'
requestMock = vi.fn((path: string) => handlers[path]!())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
return useUpdateStore()
}
beforeEach(() => {
localStorage.clear()
})
describe('load', () => {
it('shares one request between concurrent callers', async () => {
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(upToDate()) })
await Promise.all([store.load(), store.load()])
expect(requestMock).toHaveBeenCalledTimes(1)
expect(store.loadState).toBe('ready')
expect(store.status?.latest).toBe('v2.2.0-beta')
})
it('reports an error when nothing was ever loaded, and keeps the last status otherwise', async () => {
const store = setup('owner', { [STATUS_PATH]: () => Promise.reject(new ApiError(500)) })
await store.load()
expect(store.loadState).toBe('error')
const kept = setup('owner', { [STATUS_PATH]: () => Promise.resolve(upToDate()) })
await kept.load()
requestMock.mockImplementation(() => Promise.reject(new NetworkError()))
await kept.load()
expect(kept.loadState).toBe('ready')
expect(kept.status).not.toBeNull()
})
})
describe('check', () => {
it('stores the result and returns to idle', async () => {
const store = setup('owner', { [CHECK_PATH]: () => Promise.resolve(newerAvailable()) })
const result = await store.check()
expect(result).toBe('updated')
expect(store.phase).toBe('idle')
expect(store.available).toBe(true)
expect(requestMock).toHaveBeenCalledWith(CHECK_PATH, { method: 'POST' })
})
it('answers busy and sends nothing while a check is running', async () => {
const store = setup('owner', { [CHECK_PATH]: () => new Promise(() => undefined) })
store.check()
expect(await store.check()).toBe('busy')
expect(requestMock).toHaveBeenCalledTimes(1)
})
it.each([
{
name: '429 with Retry-After',
error: new ApiError(429, 42),
result: 'limited',
phase: 'limited',
retry: 42,
},
{
name: '429 without Retry-After',
error: new ApiError(429),
result: 'limited',
phase: 'limited',
retry: 60,
},
{ name: '403', error: new ApiError(403), result: 'forbidden', phase: 'idle', retry: 0 },
{ name: '500', error: new ApiError(500), result: 'failed', phase: 'failed', retry: 0 },
{
name: 'a network failure',
error: new NetworkError(),
result: 'failed',
phase: 'failed',
retry: 0,
},
])('maps $name', async ({ error, result, phase, retry }) => {
const store = setup('owner', { [CHECK_PATH]: () => Promise.reject(error) })
expect(await store.check()).toBe(result)
expect(store.phase).toBe(phase)
expect(store.retryAfter).toBe(retry)
})
})
describe('banner dismissal', () => {
it('hides the banner for the dismissed version only and keeps the cue', async () => {
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()) })
await store.load()
expect(store.bannerVisible).toBe(true)
store.dismissBanner()
expect(store.bannerVisible).toBe(false)
expect(store.showCue).toBe(true)
expect(localStorage.getItem(DISMISSED_VERSION_KEY)).toBe('v2.3.0-beta')
})
it('reads the remembered version when created', async () => {
localStorage.setItem(DISMISSED_VERSION_KEY, 'v2.3.0-beta')
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()) })
await store.load()
expect(store.bannerVisible).toBe(false)
})
it('shows no cue for an admin', async () => {
const store = setup('admin', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()) })
await store.load()
expect(store.showCue).toBe(false)
expect(store.bannerVisible).toBe(false)
})
})
+10 -1
View File
@@ -51,6 +51,15 @@ export const useSessionStore = defineStore('session', () => {
return check()
}
async function refresh() {
const { getMe } = useApi()
try {
session.value = await getMe()
} catch {
return
}
}
function clear() {
session.value = null
status.value = 'ready'
@@ -62,5 +71,5 @@ export const useSessionStore = defineStore('session', () => {
}
}
return { status, session, check, retry, clear, setDisplayName }
return { status, session, check, retry, refresh, clear, setDisplayName }
})
+113
View File
@@ -0,0 +1,113 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useUpdate, type UpdateStatus } from '@/composables/useUpdate'
import { useSessionStore } from '@/stores/session'
export type UpdateLoadState = 'idle' | 'loading' | 'ready' | 'error'
export type UpdateCheckPhase = 'idle' | 'checking' | 'failed' | 'limited'
export type UpdateCheckResult = 'updated' | 'failed' | 'limited' | 'forbidden' | 'busy'
export const DISMISSED_VERSION_KEY = 'renewcms:update-dismissed'
const DEFAULT_RETRY_SECONDS = 60
function readDismissedVersion() {
try {
return localStorage.getItem(DISMISSED_VERSION_KEY)
} catch {
return null
}
}
function writeDismissedVersion(version: string) {
try {
localStorage.setItem(DISMISSED_VERSION_KEY, version)
} catch {
return
}
}
export const useUpdateStore = defineStore('update', () => {
const session = useSessionStore()
const status = ref<UpdateStatus | null>(null)
const loadState = ref<UpdateLoadState>('idle')
const phase = ref<UpdateCheckPhase>('idle')
const retryAfter = ref(0)
const dismissedVersion = ref<string | null>(readDismissedVersion())
let pendingLoad: Promise<void> | null = null
const isOwner = computed(() => session.session?.role === 'owner')
const available = computed(() => status.value?.available === true)
const showCue = computed(() => isOwner.value && available.value)
const bannerVisible = computed(
() => showCue.value && status.value?.latest !== dismissedVersion.value,
)
function load() {
if (pendingLoad) {
return pendingLoad
}
const { getUpdateStatus } = useUpdate()
loadState.value = 'loading'
pendingLoad = Promise.resolve()
.then(getUpdateStatus)
.then((result) => {
status.value = result
loadState.value = 'ready'
})
.catch(() => {
loadState.value = status.value === null ? 'error' : 'ready'
})
.finally(() => {
pendingLoad = null
})
return pendingLoad
}
async function check(): Promise<UpdateCheckResult> {
if (phase.value === 'checking') {
return 'busy'
}
const { checkForUpdate } = useUpdate()
phase.value = 'checking'
try {
status.value = await checkForUpdate()
loadState.value = 'ready'
phase.value = 'idle'
return 'updated'
} catch (error) {
if (error instanceof ApiError && error.status === 429) {
retryAfter.value = error.retryAfter ?? DEFAULT_RETRY_SECONDS
phase.value = 'limited'
return 'limited'
}
if (error instanceof ApiError && error.status === 403) {
phase.value = 'idle'
return 'forbidden'
}
phase.value = 'failed'
return 'failed'
}
}
function dismissBanner() {
if (status.value) {
dismissedVersion.value = status.value.latest
writeDismissedVersion(status.value.latest)
}
}
return {
status,
loadState,
phase,
retryAfter,
isOwner,
available,
showCue,
bannerVisible,
load,
check,
dismissBanner,
}
})
+23 -1
View File
@@ -1,14 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import UpdateBanner from '@/components/UpdateBanner.vue'
import { useArticles, type Article } from '@/composables/useArticles'
import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
import { accountName } from '@/lib/accountName'
import { formatRelativeDate } from '@/lib/relativeDate'
import { displayVersion } from '@/lib/updateFormat'
const SKELETON_DELAY_MS = 150
const RECENT_LIST_SIZE = 5
@@ -18,6 +21,7 @@ type LoadState = 'loading' | 'default' | 'empty' | 'error'
const router = useRouter()
const toast = useToast()
const session = useSessionStore()
const update = useUpdateStore()
const { listArticles } = useArticles()
const loadState = ref<LoadState>('loading')
@@ -111,6 +115,16 @@ function onArticleCreated(article: Article) {
})
}
watch(
() => update.isOwner,
(isOwner) => {
if (isOwner) {
update.load()
}
},
{ immediate: true },
)
onMounted(loadArticles)
</script>
@@ -145,6 +159,14 @@ onMounted(loadArticles)
</UButton>
</div>
<UpdateBanner
v-if="update.bannerVisible && update.status"
state="available"
:version="displayVersion(update.status.latest)"
@go-to-settings="router.push('/settings')"
@dismiss="update.dismissBanner"
/>
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
<div class="grid grid-cols-3 gap-3">
<div
+99 -3
View File
@@ -1,8 +1,15 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import UpdatesCard from '@/components/UpdatesCard.vue'
import { ApiError } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { useSettings } from '@/composables/useSettings'
import type { UpdateStatus } from '@/composables/useUpdate'
import { isCheckDue } from '@/lib/updateFormat'
import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
const SKELETON_DELAY_MS = 150
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
@@ -10,6 +17,10 @@ const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try
type LoadState = 'loading' | 'ready' | 'error'
const { getSettings } = useSettings()
const toast = useToast()
const session = useSessionStore()
const update = useUpdateStore()
const retry = useRetryAfter()
const loadState = ref<LoadState>('loading')
const version = ref('')
@@ -49,13 +60,88 @@ async function loadSettings() {
}
}
onMounted(loadSettings)
function neverCheckedStatus(): UpdateStatus {
return {
current: version.value,
latest: '',
available: false,
release_url: null,
notes: null,
checked_at: null,
can_apply: false,
state: 'idle',
progress: null,
error: null,
started_at: null,
}
}
const updatesStatus = computed(() => {
if (update.status) {
return update.status
}
return update.loadState === 'error' && update.isOwner ? neverCheckedStatus() : null
})
const updatesPhase = computed(() => (update.status ? update.phase : 'failed'))
async function onCheck() {
const result = await update.check()
if (result !== 'forbidden') {
return
}
toast.add({
color: 'error',
icon: 'i-lucide-circle-x',
title: 'Only the owner can do this',
description: 'Ask the owner of this site to update RenewCMS.',
})
await session.refresh()
await update.load()
}
const updateRequested = ref(false)
let autoCheckHandled = false
watch(
() => [session.status, updateRequested.value, update.loadState] as const,
([sessionStatus, requested, loadStateOfUpdate]) => {
if (
autoCheckHandled ||
!requested ||
sessionStatus !== 'ready' ||
loadStateOfUpdate !== 'ready'
) {
return
}
autoCheckHandled = true
if (update.isOwner && isCheckDue(update.status?.checked_at ?? null)) {
onCheck()
}
},
{ immediate: true },
)
watch(
() => update.phase,
(phase) => {
if (phase === 'limited') {
retry.start(update.retryAfter)
}
},
)
onMounted(async () => {
loadSettings()
await update.load()
updateRequested.value = true
})
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<DashboardShell>
<div class="flex max-w-180 flex-col gap-5">
<div class="flex max-w-164 flex-col gap-5">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
<p class="text-muted">Workspace information.</p>
@@ -106,6 +192,16 @@ onBeforeUnmount(clearSkeletonTimer)
>
</div>
</div>
<UpdatesCard
v-if="updatesStatus"
:status="updatesStatus"
:role="session.session?.role"
:phase="updatesPhase"
:retry-label="retry.formatted.value"
:retry-active="retry.active.value"
@check="onCheck"
/>
</div>
</DashboardShell>
</template>
@@ -0,0 +1,193 @@
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 type { UserRole } from '@/composables/useApi'
import type { UpdateStatus } from '@/composables/useUpdate'
import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import HomeView from '../HomeView.vue'
import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit'
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let wrapper: VueWrapper | null = null
interface MountOptions {
role: UserRole
status: UpdateStatus
width?: number
}
async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions) {
setViewportWidth(width)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/') {
return Promise.resolve(mockResponse(200, []))
}
if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status))
}
throw new Error(`Unexpected fetch call: ${url}`)
}) as unknown as typeof fetch
await router.push('/home')
wrapper = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function banner() {
return document.body.querySelector('[aria-label="Update available"][role="region"]')
}
function settingsNavItem() {
return document.body.querySelector('nav[aria-label="Main"] a[href="/settings"]')!
}
function mobileDot() {
return document.body.querySelector('[role="img"][aria-label="Update available"]')
}
function buttonByLabel(label: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) =>
button.textContent?.trim() === label || button.getAttribute('aria-label') === label,
)
}
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
await router.push('/login')
document.body.innerHTML = ''
})
describe('Home banner for the owner, update available', () => {
it('shows the info region above the content with copy and both buttons', async () => {
await mountHome({ role: 'owner', status: newerAvailable() })
const region = banner()!
expect(region).not.toBeNull()
expect(region.textContent).toContain('Version 2.3.0-beta is available')
expect(region.textContent).toContain(
'Update it from Settings. It takes about 2 minutes and the site stays online.',
)
expect(region.textContent).toContain('Go to Settings')
expect(region.querySelector('button[aria-label="Dismiss"]')).not.toBeNull()
const stats = document.body.querySelector('[data-testid="greeting"]')!
expect(
stats.compareDocumentPosition(region) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
it('goes to /settings from Go to Settings', async () => {
await mountHome({ role: 'owner', status: newerAvailable() })
buttonByLabel('Go to Settings')!.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/settings'))
})
it('hides on dismiss, remembers the version, and stays hidden after a remount', async () => {
await mountHome({ role: 'owner', status: newerAvailable() })
buttonByLabel('Dismiss')!.click()
await flushPromises()
expect(banner()).toBeNull()
expect(localStorage.getItem(DISMISSED_VERSION_KEY)).toBe(NEW_TAG)
wrapper?.unmount()
document.body.innerHTML = ''
await mountHome({ role: 'owner', status: newerAvailable() })
expect(banner()).toBeNull()
})
it('shows again when latest changes to a newer version', async () => {
localStorage.setItem(DISMISSED_VERSION_KEY, NEW_TAG)
await mountHome({ role: 'owner', status: newerAvailable({ latest: 'v2.4.0-beta' }) })
expect(banner()).not.toBeNull()
expect(banner()!.textContent).toContain('Version 2.4.0-beta is available')
})
it('is absent for an admin and when nothing is newer', async () => {
await mountHome({ role: 'admin', status: newerAvailable() })
expect(banner()).toBeNull()
wrapper?.unmount()
document.body.innerHTML = ''
await mountHome({ role: 'owner', status: upToDate() })
expect(banner()).toBeNull()
})
})
describe('Settings sidebar pill and mobile dot', () => {
it('shows the Update pill and the dot for the owner when available', async () => {
await mountHome({ role: 'owner', status: newerAvailable() })
expect(settingsNavItem().textContent).toContain('Update')
expect(mobileDot()).not.toBeNull()
})
it('keeps the pill and the dot after the banner is dismissed', async () => {
await mountHome({ role: 'owner', status: newerAvailable() })
buttonByLabel('Dismiss')!.click()
await flushPromises()
expect(banner()).toBeNull()
expect(settingsNavItem().textContent).toContain('Update')
expect(mobileDot()).not.toBeNull()
})
it('shows the dot on the mobile menu icon', async () => {
await mountHome({ role: 'owner', status: newerAvailable(), width: MOBILE_WIDTH })
expect(mobileDot()).not.toBeNull()
expect(banner()).not.toBeNull()
})
it.each([
{ name: 'an admin', role: 'admin' as const, status: newerAvailable() },
{ name: 'an owner who is up to date', role: 'owner' as const, status: upToDate() },
])('shows no pill and no dot for $name', async ({ role, status }) => {
await mountHome({ role, status })
expect(settingsNavItem().textContent).not.toContain('Update')
expect(mobileDot()).toBeNull()
expect(banner()).toBeNull()
})
})
describe('Update store cue rules', () => {
it('does not request the status for a non-owner on any page', async () => {
await mountHome({ role: 'admin', status: newerAvailable() })
const requested = vi
.mocked(global.fetch)
.mock.calls.some(([url]) => url === '/v1/system/update')
expect(requested).toBe(false)
expect(useUpdateStore().status).toBeNull()
})
})
@@ -0,0 +1,459 @@
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,
NEW_TAG,
NOTE_ITEMS,
RELEASE_URL,
RUNNING_VERSION,
STATUS_PATH,
findButton,
hoursAgo,
newerAvailable,
stubClipboard,
upToDate,
visibleButtonLabels,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useToast: () => ({ add: toastAdd }),
}
})
type Handler = () => Promise<unknown>
let requestMock: ReturnType<typeof vi.fn>
let getMeMock: ReturnType<typeof vi.fn>
let wrapper: VueWrapper | null = null
interface MountOptions {
role: UserRole
status?: Handler | UpdateStatus
check?: Handler | UpdateStatus
meAfterRefresh?: UserRole
}
function asHandler(value: Handler | UpdateStatus | undefined, fallback: Handler): Handler {
if (value === undefined) {
return fallback
}
return typeof value === 'function' ? value : () => Promise.resolve(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, () => Promise.resolve(upToDate()))
const checkHandler = asHandler(options.check, () => Promise.resolve(upToDate()))
requestMock = vi.fn((path: string) => {
if (path === '/v1/settings/') {
return Promise.resolve({ version: RUNNING_VERSION })
}
if (path === STATUS_PATH) {
return statusHandler()
}
if (path === CHECK_PATH) {
return checkHandler()
}
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 remount() {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
}
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 pendingForever(): Handler {
return () => new Promise(() => undefined)
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Settings, owner, Up to date', () => {
it("shows the success block, the last checked line and only 'Check for updates'", async () => {
await mountSettings({ role: 'owner', status: upToDate() })
const card = updatesCard()
expect(card.getAttribute('aria-labelledby')).toBe('updates-h')
expect(card.querySelector('#updates-h')?.textContent?.trim()).toBe('Updates')
expect(cardText()).toContain("You're up to date")
expect(cardText()).toContain('You have the latest version of RenewCMS.')
expect(cardText()).toMatch(/Last checked (today|yesterday) at \d\d:\d\d/)
expect(cardButtons()).toEqual(['Check for updates'])
expect(card.querySelector('[aria-live="polite"]')).not.toBeNull()
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
it('keeps the running version exactly as reported in the Version card', async () => {
await mountSettings({ role: 'owner', status: upToDate() })
expect(
document.body.querySelector('[data-testid="settings-version"]')?.textContent?.trim(),
).toBe('v2.2.0-beta')
})
})
describe('Settings, owner, Checking', () => {
it('shows the polite block, a disabled Checking button and sends one POST', async () => {
await mountSettings({ role: 'owner', status: upToDate(), check: pendingForever() })
findButton('Check for updates')!.click()
await flushPromises()
expect(cardText()).toContain('Checking for updates…')
expect(cardText()).toContain('This usually takes a few seconds.')
expect(updatesCard().querySelector('[aria-live="polite"]')?.textContent).toContain(
'Checking for updates…',
)
const checking = findButton('Checking…') as HTMLButtonElement
expect(checking.disabled).toBe(true)
expect(cardButtons()).toEqual(['Checking…'])
expect(callsTo(CHECK_PATH)).toHaveLength(1)
})
it('shows the result once the check resolves', async () => {
await mountSettings({ role: 'owner', status: upToDate(), check: newerAvailable() })
findButton('Check for updates')!.click()
await flushPromises()
expect(cardText()).toContain(`Version ${NEW_TAG.slice(1)} is available`)
})
})
describe('Settings, owner, Check failed', () => {
it.each([
{ name: 'a 500', failure: () => Promise.reject(new ApiError(500)) },
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
])('keeps the last result and offers only Try again after $name', async ({ failure }) => {
await mountSettings({ role: 'owner', status: upToDate(), check: failure })
findButton('Check for updates')!.click()
await flushPromises()
expect(cardText()).toContain("Couldn't check for updates")
expect(cardText()).toContain(
"RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.",
)
expect(cardText()).toMatch(/Last checked (today|yesterday) at \d\d:\d\d/)
expect(cardButtons()).toEqual(['Try again'])
})
it('runs the check again from Try again', async () => {
let attempt = 0
await mountSettings({
role: 'owner',
status: upToDate(),
check: () =>
attempt++ === 0 ? Promise.reject(new ApiError(500)) : Promise.resolve(upToDate()),
})
findButton('Check for updates')!.click()
await flushPromises()
findButton('Try again')!.click()
await flushPromises()
expect(callsTo(CHECK_PATH)).toHaveLength(2)
expect(cardText()).toContain("You're up to date")
})
it('treats a failed status load as a failed check for the owner, never for an admin', async () => {
await mountSettings({
role: 'owner',
status: () => Promise.reject(new ApiError(500)),
check: pendingForever(),
})
expect(cardText()).toContain("Couldn't check for updates")
expect(cardButtons()).toEqual(['Try again'])
remount()
await mountSettings({ role: 'admin', status: () => Promise.reject(new ApiError(500)) })
expect(document.body.querySelector('[data-testid="updates-card"]')).toBeNull()
})
})
describe('Settings, owner, newer version, not prepared', () => {
it('shows the notice, the server command, the note and the link, with no other button', async () => {
await mountSettings({
role: 'owner',
status: newerAvailable({
notes: { version: NEW_TAG, released_at: '2026-09-24T08:00:00Z', items: [] },
}),
})
expect(cardText()).toContain('Version 2.3.0-beta is available')
expect(cardText()).toContain(
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
)
expect(cardText()).toContain('For whoever manages the server')
expect(updatesCard().querySelector('code')?.textContent).toBe(
'renewcms enable-browser-updates',
)
expect(cardText()).toContain(
'They run this once on the server. After that, Update and Restart appear here.',
)
const link = Array.from(updatesCard().querySelectorAll('a')).find(
(anchor) => anchor.textContent?.trim() === 'How to update',
)!
expect(link.getAttribute('href')).toBe(RELEASE_URL)
expect(link.getAttribute('target')).toBe('_blank')
expect(link.getAttribute('rel')).toContain('noopener')
expect(cardButtons()).toEqual(['Copy'])
})
it('shows the version without its leading v while the Version card keeps the running one', async () => {
await mountSettings({ role: 'owner', status: newerAvailable() })
expect(cardText()).toContain('Version 2.3.0-beta is available')
expect(cardText()).not.toContain('Version v2.3.0-beta')
expect(
document.body.querySelector('[data-testid="settings-version"]')?.textContent?.trim(),
).toBe('v2.2.0-beta')
})
})
describe('Settings, owner, newer version, notes open', () => {
it('is collapsed by default and toggles with aria-expanded, date and plain-text list', async () => {
await mountSettings({ role: 'owner', status: newerAvailable() })
const toggle = findButton("What's new in 2.3.0-beta")!
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(cardText()).not.toContain('Released 24 Sep 2026')
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)
}
expect(updatesCard().querySelectorAll('li')).toHaveLength(NOTE_ITEMS.length)
toggle.click()
await flushPromises()
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(cardText()).not.toContain('Released 24 Sep 2026')
})
})
describe('Settings, command copied', () => {
it('writes the command, reads Copied for 2 s, then Copy again', async () => {
const writeText = stubClipboard()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountSettings({ role: 'owner', status: newerAvailable() })
findButton('Copy')!.click()
await flushPromises()
expect(writeText).toHaveBeenCalledWith('renewcms enable-browser-updates')
expect(findButton('Copied')).toBeTruthy()
expect(findButton('Copied')!.getAttribute('aria-live')).toBe('polite')
await vi.advanceTimersByTimeAsync(1999)
expect(findButton('Copied')).toBeTruthy()
await vi.advanceTimersByTimeAsync(1)
expect(findButton('Copy')).toBeTruthy()
expect(findButton('Copied')).toBeUndefined()
})
})
describe('Settings, admin view', () => {
it('shows the newer version and the owner-only note, with no button', async () => {
await mountSettings({ role: 'admin', status: newerAvailable() })
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(callsTo(CHECK_PATH)).toHaveLength(0)
})
it('shows up to date with the owner-only note and no button, and never checks', async () => {
await mountSettings({ role: 'admin', status: upToDate({ checked_at: null }) })
expect(cardText()).toContain("You're up to date")
expect(cardText()).toContain('Only the owner can update or restart the site.')
expect(cardButtons()).toEqual([])
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
})
describe('Settings, Role changed (403)', () => {
it('toasts, reloads the status and renders the admin view', async () => {
let reads = 0
await mountSettings({
role: 'owner',
meAfterRefresh: 'admin',
status: () => {
reads++
return Promise.resolve(reads === 1 ? upToDate() : newerAvailable())
},
check: () => Promise.reject(new ApiError(403)),
})
findButton('Check for updates')!.click()
await flushPromises()
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(callsTo(STATUS_PATH)).toHaveLength(2)
expect(cardText()).toContain('Only the owner can update RenewCMS.')
expect(cardButtons()).toEqual([])
})
})
describe('Settings, Too many requests (429)', () => {
it('shows the notice and a Try again button counting down to enabled', async () => {
vi.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
})
await mountSettings({
role: 'owner',
status: upToDate(),
check: () => Promise.reject(new ApiError(429, 42)),
})
findButton('Check for updates')!.click()
await flushPromises()
expect(cardText()).toContain('Too many tries')
expect(cardText()).toContain('Wait a moment before trying again.')
const waiting = findButton('Try again in 0:42') as HTMLButtonElement
expect(waiting.disabled).toBe(true)
await vi.advanceTimersByTimeAsync(1000)
expect(findButton('Try again in 0:41')).toBeTruthy()
await vi.advanceTimersByTimeAsync(41000)
const ready = findButton('Try again') as HTMLButtonElement
expect(ready.disabled).toBe(false)
})
})
describe('A check runs on the first open after 24 hours, for the owner only', () => {
it('checks once when checked_at is null', async () => {
await mountSettings({ role: 'owner', status: upToDate({ checked_at: null }) })
expect(callsTo(CHECK_PATH)).toHaveLength(1)
})
it('checks once when checked_at is older than 24 hours', async () => {
await mountSettings({ role: 'owner', status: upToDate({ checked_at: hoursAgo(25) }) })
expect(callsTo(CHECK_PATH)).toHaveLength(1)
})
it('shows Checking while the automatic check runs', async () => {
await mountSettings({
role: 'owner',
status: upToDate({ checked_at: null }),
check: pendingForever(),
})
expect(cardText()).toContain('Checking for updates…')
})
it('sends no check when checked_at is under 24 hours old', async () => {
await mountSettings({ role: 'owner', status: upToDate({ checked_at: hoursAgo(23) }) })
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
it('never sends one for an admin', async () => {
await mountSettings({ role: 'admin', status: upToDate({ checked_at: null }) })
expect(callsTo(CHECK_PATH)).toHaveLength(0)
remount()
await mountSettings({ role: 'admin', status: upToDate({ checked_at: hoursAgo(48) }) })
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
})
describe('No Update, Restart or progress element exists in this slice', () => {
it.each([
{ name: 'up to date', role: 'owner' as const, status: upToDate() },
{ name: 'not prepared', role: 'owner' as const, status: newerAvailable() },
{ name: 'admin available', role: 'admin' as const, status: newerAvailable() },
{ name: 'admin up to date', role: 'admin' as const, status: upToDate() },
])('renders none for $name', async ({ role, status }) => {
await mountSettings({ role, status })
const labels = cardButtons()
expect(labels).not.toContain('Update')
expect(labels).not.toContain('Updating…')
expect(labels).not.toContain('Restart')
expect(updatesCard().querySelector('[role="progressbar"]')).toBeNull()
})
})
+66
View File
@@ -0,0 +1,66 @@
import { vi } from 'vitest'
import type { UpdateStatus } from '@/composables/useUpdate'
export const RUNNING_VERSION = 'v2.2.0-beta'
export const NEW_TAG = 'v2.3.0-beta'
export const RELEASE_URL = 'https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta'
export const RELEASED_AT = '2026-09-24T08:00:00Z'
export const NOTE_ITEMS = [
'Images upload faster, and large images no longer stall.',
'Fixed a problem where a draft could show an old title after saving.',
'Small improvements to the Users page.',
]
export const STATUS_PATH = '/v1/system/update'
export const CHECK_PATH = '/v1/system/update/check'
export function hoursAgo(hours: number) {
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString()
}
export function upToDate(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
return {
current: RUNNING_VERSION,
latest: RUNNING_VERSION,
available: false,
release_url: RELEASE_URL,
notes: { version: RUNNING_VERSION, released_at: RELEASED_AT, items: [] },
checked_at: hoursAgo(1),
can_apply: false,
state: 'idle',
progress: null,
error: null,
started_at: null,
...overrides,
}
}
export function newerAvailable(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
return upToDate({
latest: NEW_TAG,
available: true,
notes: { version: NEW_TAG, released_at: RELEASED_AT, items: NOTE_ITEMS },
...overrides,
})
}
export function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
export function visibleButtonLabels(root: ParentNode = document.body) {
return Array.from(root.querySelectorAll('button'))
.map((button) => button.textContent?.trim() ?? '')
.filter((label) => label !== '')
}
export function findButton(label: string, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === label,
)
}
+134 -4
View File
@@ -164,6 +164,65 @@ const verifiedSession: ApiMock = {
},
}
const updateRunningVersion = 'v2.2.0-beta'
const updateLatestTag = 'v2.3.0'
const updateReleaseUrl = 'https://git.floriansylvain.fr/fsylvain/RenewCMS/releases/tag/v2.3.0'
const updateReleasedAt = '2026-09-24T08:00:00Z'
const adminSession: ApiMock = {
status: 200,
body: { ...(verifiedSession.body as object), role: 'admin' },
}
function updateStatusBody(overrides: Record<string, unknown> = {}) {
return {
current: updateRunningVersion,
latest: updateRunningVersion,
available: false,
release_url: updateReleaseUrl,
notes: { version: updateRunningVersion, released_at: updateReleasedAt, items: [] },
checked_at: new Date().toISOString(),
can_apply: false,
state: 'idle',
progress: null,
error: null,
started_at: null,
...overrides,
}
}
function updateNeverChecked() {
return updateStatusBody({
latest: '',
release_url: null,
notes: null,
checked_at: null,
})
}
function updateAvailable() {
return updateStatusBody({
latest: updateLatestTag,
available: true,
notes: { version: updateLatestTag, released_at: updateReleasedAt, items: [] },
})
}
const updateCheckHangs: ApiMock = { status: 200, hang: true }
function updateApi(
role: 'owner' | 'admin',
status: unknown,
check: ApiMock = { status: 200, body: updateStatusBody() },
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': role === 'owner' ? verifiedSession : adminSession,
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
'GET /v1/system/update': { status: 200, body: status },
'POST /v1/system/update/check': check,
}
}
const editorNow = '2026-09-26T11:02:00Z'
const bathroomBody =
@@ -1981,13 +2040,71 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
},
settings: {
Default: {
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
Checking: {
path: '/settings',
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/settings/': { status: 200, body: { version: 'v2.2.0-beta' } },
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
},
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
'Update available': { path: '/settings', skip: 'pending' },
'Update available, notes open': { path: '/settings', skip: 'pending' },
Downloading: { path: '/settings', skip: 'pending' },
Verifying: { path: '/settings', skip: 'pending' },
'Ready to install': { path: '/settings', skip: 'pending' },
'Restart: confirm': { path: '/settings', skip: 'pending' },
'Restart: request failed': { path: '/settings', skip: 'pending' },
'Restarting to install': { path: '/settings', skip: 'pending' },
'Restarting, taking longer': { path: '/settings', skip: 'pending' },
'Update done': { path: '/settings', skip: 'pending' },
'Update failed, rolled back': { path: '/settings', skip: 'pending' },
'Download failed': { path: '/settings', skip: 'pending' },
'Verification failed': { path: '/settings', skip: 'pending' },
'Too many requests (429)': {
path: '/settings',
api: updateApi('owner', updateNeverChecked(), {
status: 429,
headers: { 'Retry-After': '42' },
}),
},
'Not prepared': {
path: '/settings',
api: updateApi('owner', updateAvailable()),
},
'Not prepared: command copied': {
path: '/settings',
origin: integrationOrigin,
api: updateApi('owner', updateAvailable()),
steps: async (page) => {
await page.getByRole('button', { name: 'Copy', exact: true }).click()
await page.mouse.move(0, 0)
},
},
'Plain restart: confirm': { path: '/settings', skip: 'pending' },
'Plain restart: restarting': { path: '/settings', skip: 'pending' },
'Plain restart: done': { path: '/settings', skip: 'pending' },
'Admin view': {
path: '/settings',
api: updateApi('admin', updateAvailable()),
},
'Role changed (403)': {
path: '/settings',
api: {
...updateApi('owner', updateNeverChecked(), { status: 403 }),
'GET /v1/auth/me': {
status: 200,
bodyFor: (_url, call) => ({
...(verifiedSession.body as object),
role: call === 1 ? 'owner' : 'admin',
}),
},
'GET /v1/system/update': {
status: 200,
bodyFor: (_url, call) =>
call === 1 ? updateNeverChecked() : updateAvailable(),
},
},
},
'Update already running (409)': { path: '/settings', skip: 'pending' },
},
integration: {
Default: {
@@ -2222,6 +2339,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
},
},
@@ -2230,6 +2348,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
},
},
@@ -2238,6 +2357,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: [] },
},
},
@@ -2246,9 +2366,19 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 500 },
},
},
'Update available (owner)': {
path: '/home',
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateAvailable() },
},
},
'Update ready to install (owner)': { path: '/home', skip: 'pending' },
},
'log-in': {
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },