feat: signed releases, release feed, notification (read-only)
This commit is contained in:
1 parent
a49eec909c
commit
834493a4f5
49 files changed
+4191
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user