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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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