feat: update button (download, verify, stage)
This commit is contained in:
1 parent
834493a4f5
commit
70460e4170
41 files changed
+5345
-97
No files matched your search
@@ -1,11 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
export type UpdateBannerState = 'available'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
export type UpdateBannerState = 'available' | 'ready'
|
||||
|
||||
const props = defineProps<{
|
||||
state: UpdateBannerState
|
||||
version: string
|
||||
}>()
|
||||
|
||||
const COPY = {
|
||||
available: {
|
||||
icon: 'i-lucide-circle-arrow-up',
|
||||
title: (version: string) => `Version ${version} is available`,
|
||||
description: 'Update it from Settings. It takes about 2 minutes and the site stays online.',
|
||||
},
|
||||
ready: {
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: (version: string) => `Version ${version} is ready to install`,
|
||||
description:
|
||||
'Restart from Settings to finish. The site is unavailable for about 10 seconds.',
|
||||
},
|
||||
} as const
|
||||
|
||||
const copy = computed(() => COPY[props.state])
|
||||
|
||||
const emit = defineEmits<{
|
||||
'go-to-settings': []
|
||||
dismiss: []
|
||||
@@ -19,9 +37,9 @@ const emit = defineEmits<{
|
||||
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."
|
||||
:icon="copy.icon"
|
||||
:title="copy.title(version)"
|
||||
:description="copy.description"
|
||||
: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',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
text: string
|
||||
value: number | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div aria-live="off" class="flex flex-col gap-1.5 pt-2">
|
||||
<UProgress
|
||||
:model-value="value"
|
||||
:max="100"
|
||||
:get-value-label="() => label"
|
||||
:ui="{
|
||||
base: 'h-1.5 bg-accented',
|
||||
indicator: value === null ? 'h-full w-7/20 bg-primary' : 'bg-primary',
|
||||
}"
|
||||
/>
|
||||
<span class="font-mono text-xs/4 text-muted">{{ text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,6 +17,7 @@ const props = defineProps<{
|
||||
title: string
|
||||
text: string
|
||||
meta?: string
|
||||
live?: 'polite' | 'off'
|
||||
}>()
|
||||
|
||||
const toneClasses = computed(() => TONE_CLASSES[props.tone])
|
||||
@@ -24,7 +25,7 @@ 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">
|
||||
<div :aria-live="live ?? 'polite'" class="flex gap-3 p-3.5" :class="toneClasses.block">
|
||||
<span class="flex pt-0.5">
|
||||
<UIcon
|
||||
:name="icon"
|
||||
@@ -38,6 +39,7 @@ const spinning = computed(() => props.icon === 'i-lucide-loader-circle')
|
||||
<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>
|
||||
<slot />
|
||||
<span v-if="meta" class="text-xs/5 text-muted">{{ meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import ReleaseNotes from '@/components/ReleaseNotes.vue'
|
||||
import ServerCommand from '@/components/ServerCommand.vue'
|
||||
import UpdateProgress from '@/components/UpdateProgress.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 type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
|
||||
import {
|
||||
ENABLE_BROWSER_UPDATES_COMMAND,
|
||||
VERIFYING_ANNOUNCEMENT,
|
||||
displayVersion,
|
||||
downloadAnnouncement,
|
||||
formatDownloadProgress,
|
||||
formatLastChecked,
|
||||
formatReleased,
|
||||
} from '@/lib/updateFormat'
|
||||
|
||||
type ApplyView =
|
||||
| 'available'
|
||||
| 'downloading'
|
||||
| 'verifying'
|
||||
| 'ready'
|
||||
| 'download-failed'
|
||||
| 'verification-failed'
|
||||
| 'limited'
|
||||
|
||||
type ButtonAction = 'check' | 'update'
|
||||
|
||||
interface Block {
|
||||
tone: UpdateTone
|
||||
icon: string
|
||||
@@ -26,26 +41,208 @@ interface ActionButton {
|
||||
icon: string
|
||||
primary: boolean
|
||||
disabled: boolean
|
||||
action: ButtonAction | null
|
||||
}
|
||||
|
||||
interface ProgressView {
|
||||
label: string
|
||||
text: string
|
||||
value: number | null
|
||||
}
|
||||
|
||||
const AVAILABLE_HINT = 'Takes about 2 minutes. Your site stays online.'
|
||||
const READY_HINT =
|
||||
'Not now? Leave this page. The update waits here, and the banner on Home reminds you.'
|
||||
const ZERO_PROGRESS = { percent: 0, downloaded_bytes: 0, total_bytes: 0 }
|
||||
|
||||
const props = defineProps<{
|
||||
status: UpdateStatus
|
||||
role: UserRole | undefined
|
||||
phase: UpdateCheckPhase
|
||||
applyPhase: UpdateApplyPhase
|
||||
retryLabel: string
|
||||
retryActive: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
check: []
|
||||
update: []
|
||||
}>()
|
||||
|
||||
const heading = ref<HTMLElement | null>(null)
|
||||
|
||||
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 canApplyView = computed(() => isOwner.value && props.status.can_apply)
|
||||
|
||||
const applyView = computed<ApplyView | null>(() => {
|
||||
if (!canApplyView.value || props.phase !== 'idle') {
|
||||
return null
|
||||
}
|
||||
if (props.applyPhase === 'limited') {
|
||||
return 'limited'
|
||||
}
|
||||
if (props.applyPhase === 'failed') {
|
||||
return 'download-failed'
|
||||
}
|
||||
if (props.applyPhase === 'untrusted') {
|
||||
return 'verification-failed'
|
||||
}
|
||||
const { state, error, available } = props.status
|
||||
if (state === 'downloading' || state === 'verifying' || state === 'ready') {
|
||||
return state
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return error === 'verification' ? 'verification-failed' : 'download-failed'
|
||||
}
|
||||
return available ? 'available' : null
|
||||
})
|
||||
|
||||
const isStarting = computed(() => props.applyPhase === 'starting')
|
||||
|
||||
const downloadFailed = computed(() => applyView.value === 'download-failed' && !isStarting.value)
|
||||
|
||||
watch(downloadFailed, async (failed) => {
|
||||
if (failed) {
|
||||
await nextTick()
|
||||
heading.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
const applyBlocks = computed<Record<ApplyView, Block>>(() => ({
|
||||
available: {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-circle-arrow-up',
|
||||
title: `Version ${newVersion.value} is available`,
|
||||
text: `You have ${props.status.current}. Update downloads the new version and checks it. Nothing changes until you restart.`,
|
||||
meta: lastChecked.value,
|
||||
},
|
||||
downloading: {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-download',
|
||||
title: `Downloading version ${newVersion.value}…`,
|
||||
text: 'You can leave this page. The download continues and your site stays online.',
|
||||
},
|
||||
verifying: {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-shield-check',
|
||||
title: 'Checking the download…',
|
||||
text: 'Making sure the file is complete and safe to install. This takes a few seconds.',
|
||||
},
|
||||
ready: {
|
||||
tone: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: `Version ${newVersion.value} is ready to install`,
|
||||
text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
|
||||
meta: 'Nothing has changed yet.',
|
||||
},
|
||||
'download-failed': {
|
||||
tone: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "The download didn't finish",
|
||||
text: 'The connection dropped before the file was complete. Nothing was changed and your site is running as before.',
|
||||
},
|
||||
'verification-failed': {
|
||||
tone: 'warning',
|
||||
icon: 'i-lucide-triangle-alert',
|
||||
title: "We didn't use this update",
|
||||
text: "The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.",
|
||||
},
|
||||
limited: {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'Too many tries',
|
||||
text: 'Wait a moment before trying again.',
|
||||
},
|
||||
}))
|
||||
|
||||
const progressView = computed<ProgressView | null>(() => {
|
||||
if (applyView.value === 'downloading') {
|
||||
const progress = props.status.progress ?? ZERO_PROGRESS
|
||||
return {
|
||||
label: `Downloading version ${newVersion.value}`,
|
||||
text: props.status.progress ? formatDownloadProgress(progress) : '0%',
|
||||
value: progress.percent,
|
||||
}
|
||||
}
|
||||
if (applyView.value === 'verifying') {
|
||||
return { label: VERIFYING_ANNOUNCEMENT, text: 'Almost there', value: null }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const announcement = computed(() => {
|
||||
if (applyView.value === 'downloading') {
|
||||
return downloadAnnouncement(newVersion.value, props.status.progress?.percent ?? 0)
|
||||
}
|
||||
return applyView.value === 'verifying' ? VERIFYING_ANNOUNCEMENT : ''
|
||||
})
|
||||
|
||||
const hint = computed(() => {
|
||||
if (applyView.value === 'available') {
|
||||
return AVAILABLE_HINT
|
||||
}
|
||||
return applyView.value === 'ready' ? READY_HINT : null
|
||||
})
|
||||
|
||||
function applyButton(view: ApplyView): ActionButton {
|
||||
if (isStarting.value || view === 'downloading' || view === 'verifying') {
|
||||
return {
|
||||
label: 'Updating…',
|
||||
icon: 'i-lucide-loader-circle',
|
||||
primary: false,
|
||||
disabled: true,
|
||||
action: null,
|
||||
}
|
||||
}
|
||||
if (view === 'ready') {
|
||||
return {
|
||||
label: 'Restart',
|
||||
icon: 'i-lucide-power',
|
||||
primary: true,
|
||||
disabled: true,
|
||||
action: null,
|
||||
}
|
||||
}
|
||||
if (view === 'limited') {
|
||||
return props.retryActive
|
||||
? {
|
||||
label: `Try again in ${props.retryLabel}`,
|
||||
icon: 'i-lucide-clock',
|
||||
primary: false,
|
||||
disabled: true,
|
||||
action: null,
|
||||
}
|
||||
: {
|
||||
label: 'Try again',
|
||||
icon: 'i-lucide-rotate-cw',
|
||||
primary: false,
|
||||
disabled: false,
|
||||
action: 'update',
|
||||
}
|
||||
}
|
||||
if (view === 'available') {
|
||||
return {
|
||||
label: 'Update',
|
||||
icon: 'i-lucide-download',
|
||||
primary: true,
|
||||
disabled: false,
|
||||
action: 'update',
|
||||
}
|
||||
}
|
||||
return {
|
||||
label: 'Try again',
|
||||
icon: 'i-lucide-rotate-cw',
|
||||
primary: true,
|
||||
disabled: false,
|
||||
action: 'update',
|
||||
}
|
||||
}
|
||||
|
||||
const block = computed<Block>(() => {
|
||||
if (isOwner.value && props.phase === 'checking') {
|
||||
return {
|
||||
@@ -72,6 +269,9 @@ const block = computed<Block>(() => {
|
||||
meta: lastChecked.value,
|
||||
}
|
||||
}
|
||||
if (applyView.value) {
|
||||
return applyBlocks.value[applyView.value]
|
||||
}
|
||||
if (props.status.available && isOwner.value) {
|
||||
return {
|
||||
tone: 'info',
|
||||
@@ -100,7 +300,11 @@ const block = computed<Block>(() => {
|
||||
})
|
||||
|
||||
const showServerGuidance = computed(
|
||||
() => isOwner.value && props.status.available && props.phase === 'idle',
|
||||
() =>
|
||||
isOwner.value &&
|
||||
!props.status.can_apply &&
|
||||
props.status.available &&
|
||||
props.phase === 'idle',
|
||||
)
|
||||
|
||||
const actionButton = computed<ActionButton | null>(() => {
|
||||
@@ -113,10 +317,17 @@ const actionButton = computed<ActionButton | null>(() => {
|
||||
icon: 'i-lucide-loader-circle',
|
||||
primary: false,
|
||||
disabled: true,
|
||||
action: null,
|
||||
}
|
||||
}
|
||||
if (props.phase === 'failed') {
|
||||
return { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: true, disabled: false }
|
||||
return {
|
||||
label: 'Try again',
|
||||
icon: 'i-lucide-rotate-cw',
|
||||
primary: true,
|
||||
disabled: false,
|
||||
action: 'check',
|
||||
}
|
||||
}
|
||||
if (props.phase === 'limited') {
|
||||
return props.retryActive
|
||||
@@ -125,8 +336,18 @@ const actionButton = computed<ActionButton | null>(() => {
|
||||
icon: 'i-lucide-clock',
|
||||
primary: false,
|
||||
disabled: true,
|
||||
action: null,
|
||||
}
|
||||
: { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: false, disabled: false }
|
||||
: {
|
||||
label: 'Try again',
|
||||
icon: 'i-lucide-rotate-cw',
|
||||
primary: false,
|
||||
disabled: false,
|
||||
action: 'check',
|
||||
}
|
||||
}
|
||||
if (applyView.value) {
|
||||
return applyButton(applyView.value)
|
||||
}
|
||||
if (props.status.available) {
|
||||
return null
|
||||
@@ -136,12 +357,28 @@ const actionButton = computed<ActionButton | null>(() => {
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
primary: false,
|
||||
disabled: false,
|
||||
action: 'check',
|
||||
}
|
||||
})
|
||||
|
||||
function onAction() {
|
||||
const action = actionButton.value?.action
|
||||
if (action === 'check') {
|
||||
emit('check')
|
||||
}
|
||||
if (action === 'update') {
|
||||
emit('update')
|
||||
}
|
||||
}
|
||||
|
||||
const showNotes = computed(
|
||||
() =>
|
||||
showServerGuidance.value || applyView.value === 'available' || applyView.value === 'ready',
|
||||
)
|
||||
|
||||
const notes = computed(() => {
|
||||
const items = props.status.notes?.items ?? []
|
||||
if (!showServerGuidance.value || items.length === 0 || !props.status.notes) {
|
||||
if (!showNotes.value || items.length === 0 || !props.status.notes) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
@@ -159,6 +396,7 @@ const notes = computed(() => {
|
||||
>
|
||||
<h2
|
||||
id="updates-h"
|
||||
ref="heading"
|
||||
tabindex="-1"
|
||||
class="self-start text-lg font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
|
||||
>
|
||||
@@ -171,7 +409,19 @@ const notes = computed(() => {
|
||||
:title="block.title"
|
||||
:text="block.text"
|
||||
:meta="block.meta"
|
||||
/>
|
||||
:live="progressView ? 'off' : 'polite'"
|
||||
>
|
||||
<UpdateProgress
|
||||
v-if="progressView"
|
||||
:label="progressView.label"
|
||||
:text="progressView.text"
|
||||
:value="progressView.value"
|
||||
/>
|
||||
</UpdateStatusBlock>
|
||||
|
||||
<p v-if="canApplyView" role="status" data-testid="update-announcer" class="sr-only">
|
||||
{{ announcement }}
|
||||
</p>
|
||||
|
||||
<ReleaseNotes v-if="notes" :version="newVersion" :date="notes.date" :items="notes.items" />
|
||||
|
||||
@@ -190,12 +440,14 @@ const notes = computed(() => {
|
||||
: 'border border-accented bg-default text-highlighted ring-0'
|
||||
"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="emit('check')"
|
||||
@click="onAction"
|
||||
>
|
||||
{{ actionButton.label }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<span v-if="hint" class="text-xs/4.5 text-pretty text-muted">{{ hint }}</span>
|
||||
|
||||
<a
|
||||
v-if="showServerGuidance && status.release_url"
|
||||
:href="status.release_url"
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface UpdateNotes {
|
||||
items: string[]
|
||||
}
|
||||
|
||||
export interface UpdateProgress {
|
||||
percent: number
|
||||
downloaded_bytes: number
|
||||
total_bytes: number
|
||||
}
|
||||
|
||||
export type UpdateErrorKind = 'download' | 'verification'
|
||||
|
||||
export interface UpdateStatus {
|
||||
current: string
|
||||
latest: string
|
||||
@@ -24,8 +32,8 @@ export interface UpdateStatus {
|
||||
checked_at: string | null
|
||||
can_apply: boolean
|
||||
state: UpdateState
|
||||
progress: unknown
|
||||
error: string | null
|
||||
progress: UpdateProgress | null
|
||||
error: UpdateErrorKind | null
|
||||
started_at: string | null
|
||||
}
|
||||
|
||||
@@ -40,5 +48,9 @@ export function useUpdate() {
|
||||
return request<UpdateStatus>('/v1/system/update/check', { method: 'POST' })
|
||||
}
|
||||
|
||||
return { getUpdateStatus, checkForUpdate }
|
||||
function startUpdateDownload(): Promise<UpdateStatus> {
|
||||
return request<UpdateStatus>('/v1/system/update/download', { method: 'POST' })
|
||||
}
|
||||
|
||||
return { getUpdateStatus, checkForUpdate, startUpdateDownload }
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AUTO_CHECK_INTERVAL_MS,
|
||||
displayVersion,
|
||||
downloadAnnouncement,
|
||||
formatDownloadProgress,
|
||||
formatLastChecked,
|
||||
formatReleased,
|
||||
isCheckDue,
|
||||
@@ -64,3 +66,29 @@ describe('formatReleased', () => {
|
||||
expect(formatReleased('2026-09-24T08:00:00Z')).toBe('Released 24 Sep 2026')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDownloadProgress', () => {
|
||||
it('reads the board sample as 42% and 11 of 26 MB', () => {
|
||||
expect(
|
||||
formatDownloadProgress({
|
||||
percent: 42,
|
||||
downloaded_bytes: 11534336,
|
||||
total_bytes: 27262976,
|
||||
}),
|
||||
).toBe('42% · 11 of 26 MB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadAnnouncement', () => {
|
||||
it.each([
|
||||
[0, 'Downloading version 2.3.0-beta'],
|
||||
[24, 'Downloading version 2.3.0-beta'],
|
||||
[25, 'Downloading version 2.3.0-beta, 25 percent'],
|
||||
[49, 'Downloading version 2.3.0-beta, 25 percent'],
|
||||
[50, 'Downloading version 2.3.0-beta, 50 percent'],
|
||||
[75, 'Downloading version 2.3.0-beta, 75 percent'],
|
||||
[100, 'Downloading version 2.3.0-beta, 75 percent'],
|
||||
])('reads %i percent as %s', (percent, expected) => {
|
||||
expect(downloadAnnouncement('2.3.0-beta', percent)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -44,3 +44,28 @@ export function isCheckDue(checkedAt: string | null, now: number = Date.now()) {
|
||||
export function formatReleased(iso: string) {
|
||||
return `Released ${formatAbsoluteDateFromIso(iso)}`
|
||||
}
|
||||
|
||||
const BYTES_PER_MB = 1024 * 1024
|
||||
const ANNOUNCEMENT_STEP_PERCENT = 25
|
||||
const LAST_ANNOUNCED_PERCENT = 75
|
||||
|
||||
export function formatDownloadProgress(progress: {
|
||||
percent: number
|
||||
downloaded_bytes: number
|
||||
total_bytes: number
|
||||
}) {
|
||||
const done = Math.round(progress.downloaded_bytes / BYTES_PER_MB)
|
||||
const total = Math.round(progress.total_bytes / BYTES_PER_MB)
|
||||
return `${progress.percent}% · ${done} of ${total} MB`
|
||||
}
|
||||
|
||||
export function downloadAnnouncement(version: string, percent: number) {
|
||||
const step = Math.min(
|
||||
Math.floor(percent / ANNOUNCEMENT_STEP_PERCENT) * ANNOUNCEMENT_STEP_PERCENT,
|
||||
LAST_ANNOUNCED_PERCENT,
|
||||
)
|
||||
const base = `Downloading version ${version}`
|
||||
return step > 0 ? `${base}, ${step} percent` : base
|
||||
}
|
||||
|
||||
export const VERIFYING_ANNOUNCEMENT = 'Checking the download'
|
||||
@@ -3,7 +3,16 @@ 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'
|
||||
import {
|
||||
CHECK_PATH,
|
||||
DOWNLOAD_PATH,
|
||||
STATUS_PATH,
|
||||
downloading,
|
||||
newerAvailable,
|
||||
ready,
|
||||
startable,
|
||||
upToDate,
|
||||
} from '@/views/__tests__/updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -136,3 +145,130 @@ describe('banner dismissal', () => {
|
||||
expect(store.bannerVisible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startDownload', () => {
|
||||
it('stores the 202 body and returns to idle', async () => {
|
||||
const store = setup('owner', { [DOWNLOAD_PATH]: () => Promise.resolve(downloading()) })
|
||||
|
||||
const result = await store.startDownload()
|
||||
|
||||
expect(result).toBe('started')
|
||||
expect(store.applyPhase).toBe('idle')
|
||||
expect(store.status?.state).toBe('downloading')
|
||||
expect(store.jobRunning).toBe(true)
|
||||
expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, { method: 'POST' })
|
||||
})
|
||||
|
||||
it('answers busy and sends nothing while a start is in flight', async () => {
|
||||
const store = setup('owner', { [DOWNLOAD_PATH]: () => new Promise(() => undefined) })
|
||||
store.startDownload()
|
||||
|
||||
expect(await store.startDownload()).toBe('busy')
|
||||
expect(store.applyPhase).toBe('starting')
|
||||
expect(requestMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: '400', error: new ApiError(400), result: 'nothing', phase: 'idle', retry: 0 },
|
||||
{ name: '403', error: new ApiError(403), result: 'forbidden', phase: 'idle', retry: 0 },
|
||||
{ name: '409', error: new ApiError(409), result: 'conflict', phase: 'idle', retry: 0 },
|
||||
{
|
||||
name: '422',
|
||||
error: new ApiError(422),
|
||||
result: 'untrusted',
|
||||
phase: 'untrusted',
|
||||
retry: 0,
|
||||
},
|
||||
{
|
||||
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: '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', { [DOWNLOAD_PATH]: () => Promise.reject(error) })
|
||||
|
||||
expect(await store.startDownload()).toBe(result)
|
||||
expect(store.applyPhase).toBe(phase)
|
||||
expect(store.retryAfter).toBe(retry)
|
||||
})
|
||||
|
||||
it('forgets a failed start when the status is loaded again', async () => {
|
||||
const store = setup('owner', {
|
||||
[DOWNLOAD_PATH]: () => Promise.reject(new ApiError(500)),
|
||||
[STATUS_PATH]: () => Promise.resolve(startable()),
|
||||
})
|
||||
await store.startDownload()
|
||||
|
||||
await store.load()
|
||||
|
||||
expect(store.applyPhase).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('poll', () => {
|
||||
it('replaces the status and keeps it when a poll fails', async () => {
|
||||
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(downloading(60)) })
|
||||
await store.poll()
|
||||
expect(store.status?.progress?.percent).toBe(60)
|
||||
|
||||
requestMock.mockImplementation(() => Promise.reject(new ApiError(500)))
|
||||
await store.poll()
|
||||
|
||||
expect(store.status?.progress?.percent).toBe(60)
|
||||
expect(store.loadState).toBe('idle')
|
||||
})
|
||||
|
||||
it('skips a poll while another is in flight', async () => {
|
||||
const store = setup('owner', { [STATUS_PATH]: () => new Promise(() => undefined) })
|
||||
store.poll()
|
||||
|
||||
await store.poll()
|
||||
|
||||
expect(requestMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('job state', () => {
|
||||
it.each([
|
||||
{ name: 'downloading', status: downloading(), running: true, readyToInstall: false },
|
||||
{ name: 'ready', status: ready(), running: false, readyToInstall: true },
|
||||
{ name: 'idle', status: startable(), running: false, readyToInstall: false },
|
||||
{
|
||||
name: 'downloading without the helper',
|
||||
status: { ...downloading(), can_apply: false },
|
||||
running: false,
|
||||
readyToInstall: false,
|
||||
},
|
||||
])('reads $name', async ({ status, running, readyToInstall }) => {
|
||||
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(status) })
|
||||
await store.load()
|
||||
|
||||
expect(store.jobRunning).toBe(running)
|
||||
expect(store.readyToInstall).toBe(readyToInstall)
|
||||
})
|
||||
|
||||
it('shows no ready state to an admin', async () => {
|
||||
const store = setup('admin', { [STATUS_PATH]: () => Promise.resolve(ready()) })
|
||||
await store.load()
|
||||
|
||||
expect(store.readyToInstall).toBe(false)
|
||||
expect(store.jobRunning).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,16 @@ 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 type UpdateApplyPhase = 'idle' | 'starting' | 'failed' | 'untrusted' | 'limited'
|
||||
export type UpdateStartResult =
|
||||
| 'started'
|
||||
| 'failed'
|
||||
| 'untrusted'
|
||||
| 'limited'
|
||||
| 'forbidden'
|
||||
| 'nothing'
|
||||
| 'conflict'
|
||||
| 'busy'
|
||||
|
||||
export const DISMISSED_VERSION_KEY = 'renewcms:update-dismissed'
|
||||
const DEFAULT_RETRY_SECONDS = 60
|
||||
@@ -32,6 +42,7 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
const status = ref<UpdateStatus | null>(null)
|
||||
const loadState = ref<UpdateLoadState>('idle')
|
||||
const phase = ref<UpdateCheckPhase>('idle')
|
||||
const applyPhase = ref<UpdateApplyPhase>('idle')
|
||||
const retryAfter = ref(0)
|
||||
const dismissedVersion = ref<string | null>(readDismissedVersion())
|
||||
let pendingLoad: Promise<void> | null = null
|
||||
@@ -42,6 +53,13 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
const bannerVisible = computed(
|
||||
() => showCue.value && status.value?.latest !== dismissedVersion.value,
|
||||
)
|
||||
const jobRunning = computed(
|
||||
() =>
|
||||
isOwner.value &&
|
||||
status.value?.can_apply === true &&
|
||||
(status.value.state === 'downloading' || status.value.state === 'verifying'),
|
||||
)
|
||||
const readyToInstall = computed(() => isOwner.value && status.value?.state === 'ready')
|
||||
|
||||
function load() {
|
||||
if (pendingLoad) {
|
||||
@@ -49,6 +67,9 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
}
|
||||
const { getUpdateStatus } = useUpdate()
|
||||
loadState.value = 'loading'
|
||||
if (applyPhase.value !== 'starting') {
|
||||
applyPhase.value = 'idle'
|
||||
}
|
||||
pendingLoad = Promise.resolve()
|
||||
.then(getUpdateStatus)
|
||||
.then((result) => {
|
||||
@@ -64,6 +85,60 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
return pendingLoad
|
||||
}
|
||||
|
||||
let pendingPoll = false
|
||||
|
||||
async function poll() {
|
||||
if (pendingPoll) {
|
||||
return
|
||||
}
|
||||
const { getUpdateStatus } = useUpdate()
|
||||
pendingPoll = true
|
||||
try {
|
||||
status.value = await getUpdateStatus()
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
pendingPoll = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startDownload(): Promise<UpdateStartResult> {
|
||||
if (applyPhase.value === 'starting') {
|
||||
return 'busy'
|
||||
}
|
||||
const { startUpdateDownload } = useUpdate()
|
||||
applyPhase.value = 'starting'
|
||||
try {
|
||||
status.value = await startUpdateDownload()
|
||||
loadState.value = 'ready'
|
||||
applyPhase.value = 'idle'
|
||||
return 'started'
|
||||
} catch (error) {
|
||||
return settleStartFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
function settleStartFailure(error: unknown): UpdateStartResult {
|
||||
const outcomes: Record<number, UpdateStartResult> = {
|
||||
400: 'nothing',
|
||||
403: 'forbidden',
|
||||
409: 'conflict',
|
||||
422: 'untrusted',
|
||||
429: 'limited',
|
||||
}
|
||||
const outcome = error instanceof ApiError ? outcomes[error.status] : undefined
|
||||
if (outcome === 'limited' && error instanceof ApiError) {
|
||||
retryAfter.value = error.retryAfter ?? DEFAULT_RETRY_SECONDS
|
||||
}
|
||||
const phases: Partial<Record<UpdateStartResult, UpdateApplyPhase>> = {
|
||||
limited: 'limited',
|
||||
untrusted: 'untrusted',
|
||||
}
|
||||
const result = outcome ?? 'failed'
|
||||
applyPhase.value = phases[result] ?? (result === 'failed' ? 'failed' : 'idle')
|
||||
return result
|
||||
}
|
||||
|
||||
async function check(): Promise<UpdateCheckResult> {
|
||||
if (phase.value === 'checking') {
|
||||
return 'busy'
|
||||
@@ -101,12 +176,17 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
status,
|
||||
loadState,
|
||||
phase,
|
||||
applyPhase,
|
||||
retryAfter,
|
||||
isOwner,
|
||||
available,
|
||||
showCue,
|
||||
bannerVisible,
|
||||
jobRunning,
|
||||
readyToInstall,
|
||||
load,
|
||||
poll,
|
||||
startDownload,
|
||||
check,
|
||||
dismissBanner,
|
||||
}
|
||||
|
||||
@@ -115,6 +115,23 @@ function onArticleCreated(article: Article) {
|
||||
})
|
||||
}
|
||||
|
||||
const readyBannerDismissed = ref(false)
|
||||
|
||||
const bannerState = computed(() => {
|
||||
if (update.readyToInstall) {
|
||||
return readyBannerDismissed.value ? null : 'ready'
|
||||
}
|
||||
return update.bannerVisible ? 'available' : null
|
||||
})
|
||||
|
||||
function dismissBanner() {
|
||||
if (bannerState.value === 'ready') {
|
||||
readyBannerDismissed.value = true
|
||||
return
|
||||
}
|
||||
update.dismissBanner()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => update.isOwner,
|
||||
(isOwner) => {
|
||||
@@ -160,11 +177,11 @@ onMounted(loadArticles)
|
||||
</div>
|
||||
|
||||
<UpdateBanner
|
||||
v-if="update.bannerVisible && update.status"
|
||||
state="available"
|
||||
v-if="bannerState && update.status"
|
||||
:state="bannerState"
|
||||
:version="displayVersion(update.status.latest)"
|
||||
@go-to-settings="router.push('/settings')"
|
||||
@dismiss="update.dismissBanner"
|
||||
@dismiss="dismissBanner"
|
||||
/>
|
||||
|
||||
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import { useUpdateStore } from '@/stores/update'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const POLL_INTERVAL_MS = 1000
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
|
||||
type LoadState = 'loading' | 'ready' | 'error'
|
||||
@@ -85,19 +86,60 @@ const updatesStatus = computed(() => {
|
||||
|
||||
const updatesPhase = computed(() => (update.status ? update.phase : 'failed'))
|
||||
|
||||
async function onCheck() {
|
||||
const result = await update.check()
|
||||
if (result !== 'forbidden') {
|
||||
return
|
||||
}
|
||||
async function reloadAfterRoleChange() {
|
||||
await session.refresh()
|
||||
await update.load()
|
||||
}
|
||||
|
||||
function notifyOnlyOwner() {
|
||||
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()
|
||||
}
|
||||
|
||||
async function onUpdate() {
|
||||
const result = await update.startDownload()
|
||||
if (result === 'limited') {
|
||||
return
|
||||
}
|
||||
if (result === 'forbidden') {
|
||||
notifyOnlyOwner()
|
||||
await reloadAfterRoleChange()
|
||||
return
|
||||
}
|
||||
if (result === 'nothing') {
|
||||
toast.add({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'Nothing to update',
|
||||
description: 'You already have the latest version.',
|
||||
})
|
||||
await update.load()
|
||||
return
|
||||
}
|
||||
if (result === 'conflict') {
|
||||
await update.load()
|
||||
if (update.jobRunning) {
|
||||
toast.add({
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'An update is already running',
|
||||
description: 'Another owner started it. Progress shows here.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onCheck() {
|
||||
const result = await update.check()
|
||||
if (result !== 'forbidden') {
|
||||
return
|
||||
}
|
||||
notifyOnlyOwner()
|
||||
await reloadAfterRoleChange()
|
||||
}
|
||||
|
||||
const updateRequested = ref(false)
|
||||
@@ -115,7 +157,8 @@ watch(
|
||||
return
|
||||
}
|
||||
autoCheckHandled = true
|
||||
if (update.isOwner && isCheckDue(update.status?.checked_at ?? null)) {
|
||||
const jobActive = update.jobRunning || update.readyToInstall
|
||||
if (update.isOwner && !jobActive && isCheckDue(update.status?.checked_at ?? null)) {
|
||||
onCheck()
|
||||
}
|
||||
},
|
||||
@@ -123,20 +166,44 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => update.phase,
|
||||
(phase) => {
|
||||
if (phase === 'limited') {
|
||||
() => [update.phase, update.applyPhase] as const,
|
||||
([phase, applyPhase]) => {
|
||||
if (phase === 'limited' || applyPhase === 'limited') {
|
||||
retry.start(update.retryAfter)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer !== null) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer === null) {
|
||||
pollTimer = setInterval(update.poll, POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => update.jobRunning,
|
||||
(running) => (running ? startPolling() : stopPolling()),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
loadSettings()
|
||||
await update.load()
|
||||
updateRequested.value = true
|
||||
})
|
||||
onBeforeUnmount(clearSkeletonTimer)
|
||||
onBeforeUnmount(() => {
|
||||
clearSkeletonTimer()
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -198,9 +265,11 @@ onBeforeUnmount(clearSkeletonTimer)
|
||||
:status="updatesStatus"
|
||||
:role="session.session?.role"
|
||||
:phase="updatesPhase"
|
||||
:apply-phase="update.applyPhase"
|
||||
:retry-label="retry.formatted.value"
|
||||
:retry-active="retry.active.value"
|
||||
@check="onCheck"
|
||||
@update="onUpdate"
|
||||
/>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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 HomeView from '../HomeView.vue'
|
||||
import { NEW_TAG, downloading, newerAvailable, ready, startable, verifying } 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
|
||||
|
||||
async function mountHome(role: UserRole, status: UpdateStatus) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
async function remount(role: UserRole, status: UpdateStatus) {
|
||||
wrapper?.unmount()
|
||||
document.body.innerHTML = ''
|
||||
await mountHome(role, status)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Home banner, ready to install (owner)', () => {
|
||||
it('shows the info region with the ready copy, Go to Settings and Dismiss', async () => {
|
||||
await mountHome('owner', ready())
|
||||
|
||||
const region = banner()!
|
||||
expect(region).not.toBeNull()
|
||||
expect(region.textContent).toContain('Version 2.3.0-beta is ready to install')
|
||||
expect(region.textContent).toContain(
|
||||
'Restart from Settings to finish. The site is unavailable for about 10 seconds.',
|
||||
)
|
||||
expect(region.textContent).not.toContain('is available')
|
||||
expect(region.textContent).toContain('Go to Settings')
|
||||
expect(region.querySelector('button[aria-label="Dismiss"]')).not.toBeNull()
|
||||
expect(region.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('goes to /settings from Go to Settings', async () => {
|
||||
await mountHome('owner', ready())
|
||||
|
||||
buttonByLabel('Go to Settings')!.click()
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/settings'))
|
||||
})
|
||||
|
||||
it('shows although the available banner for this version was dismissed earlier', async () => {
|
||||
localStorage.setItem(DISMISSED_VERSION_KEY, NEW_TAG)
|
||||
|
||||
await mountHome('owner', ready())
|
||||
|
||||
expect(banner()!.textContent).toContain('is ready to install')
|
||||
})
|
||||
|
||||
it('hides on dismiss for this visit only and writes nothing to localStorage', async () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, 'setItem')
|
||||
await mountHome('owner', ready())
|
||||
|
||||
const writesBefore = setItem.mock.calls.length
|
||||
buttonByLabel('Dismiss')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(banner()).toBeNull()
|
||||
expect(setItem.mock.calls).toHaveLength(writesBefore)
|
||||
expect(localStorage.getItem(DISMISSED_VERSION_KEY)).toBeNull()
|
||||
|
||||
await remount('owner', ready())
|
||||
expect(banner()!.textContent).toContain('is ready to install')
|
||||
setItem.mockRestore()
|
||||
})
|
||||
|
||||
it('is not shown to an admin', async () => {
|
||||
await mountHome('admin', ready())
|
||||
|
||||
expect(banner()).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'downloading', status: downloading() },
|
||||
{ name: 'verifying', status: verifying() },
|
||||
])('keeps the available banner while $name', async ({ status }) => {
|
||||
await mountHome('owner', status)
|
||||
|
||||
expect(banner()!.textContent).toContain('Version 2.3.0-beta is available')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sidebar pill and mobile dot during a job', () => {
|
||||
it.each([
|
||||
{ name: 'downloading', status: downloading() },
|
||||
{ name: 'verifying', status: verifying() },
|
||||
{ name: 'ready', status: ready() },
|
||||
{ name: 'available with the helper', status: startable() },
|
||||
])('stay for the owner while $name', async ({ status }) => {
|
||||
await mountHome('owner', status)
|
||||
|
||||
expect(settingsNavItem().textContent).toContain('Update')
|
||||
expect(mobileDot()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('are absent for an admin while ready', async () => {
|
||||
await mountHome('admin', { ...newerAvailable(), state: 'ready' })
|
||||
|
||||
expect(settingsNavItem().textContent).not.toContain('Update')
|
||||
expect(mobileDot()).toBeNull()
|
||||
expect(useUpdateStore().status).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,614 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError, useApi, type UserRole } from '@/composables/useApi'
|
||||
import type { UpdateStatus } from '@/composables/useUpdate'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import SettingsView from '../SettingsView.vue'
|
||||
import {
|
||||
CHECK_PATH,
|
||||
DOWNLOAD_PATH,
|
||||
NOTE_ITEMS,
|
||||
RUNNING_VERSION,
|
||||
STATUS_PATH,
|
||||
downloading,
|
||||
failedWith,
|
||||
findButton,
|
||||
newerAvailable,
|
||||
ready,
|
||||
startable,
|
||||
verifying,
|
||||
visibleButtonLabels,
|
||||
} from './updateTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<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 Outcome = UpdateStatus | Error
|
||||
type Handler = () => Promise<unknown>
|
||||
|
||||
let requestMock: ReturnType<typeof vi.fn>
|
||||
let getMeMock: ReturnType<typeof vi.fn>
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function sequence(...outcomes: Outcome[]): Handler {
|
||||
let index = 0
|
||||
return () => {
|
||||
const outcome = outcomes[Math.min(index, outcomes.length - 1)]!
|
||||
index++
|
||||
return outcome instanceof Error ? Promise.reject(outcome) : Promise.resolve(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
interface MountOptions {
|
||||
role: UserRole
|
||||
status: Handler | UpdateStatus
|
||||
download?: Handler | UpdateStatus | Error
|
||||
meAfterRefresh?: UserRole
|
||||
}
|
||||
|
||||
function asHandler(value: Handler | Outcome): Handler {
|
||||
if (typeof value === 'function') {
|
||||
return value
|
||||
}
|
||||
return sequence(value)
|
||||
}
|
||||
|
||||
async function mountSettings(options: MountOptions) {
|
||||
setActivePinia(createPinia())
|
||||
toastAdd = vi.fn()
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, role: options.role }
|
||||
session.status = 'ready'
|
||||
const statusHandler = asHandler(options.status)
|
||||
const downloadHandler = asHandler(options.download ?? new ApiError(500))
|
||||
requestMock = vi.fn((path: string) => {
|
||||
if (path === '/v1/settings/') {
|
||||
return Promise.resolve({ version: RUNNING_VERSION })
|
||||
}
|
||||
if (path === STATUS_PATH) {
|
||||
return statusHandler()
|
||||
}
|
||||
if (path === DOWNLOAD_PATH) {
|
||||
return downloadHandler()
|
||||
}
|
||||
throw new Error(`Unexpected request: ${path}`)
|
||||
})
|
||||
getMeMock = vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: options.meAfterRefresh ?? options.role,
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
|
||||
await router.push('/settings')
|
||||
wrapper = mount(SettingsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function useTimers() {
|
||||
vi.useFakeTimers({
|
||||
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
|
||||
})
|
||||
}
|
||||
|
||||
function updatesCard() {
|
||||
return document.body.querySelector('[data-testid="updates-card"]')!
|
||||
}
|
||||
|
||||
function cardText() {
|
||||
return updatesCard().textContent ?? ''
|
||||
}
|
||||
|
||||
function cardButtons() {
|
||||
return visibleButtonLabels(updatesCard())
|
||||
}
|
||||
|
||||
function callsTo(path: string) {
|
||||
return requestMock.mock.calls.filter(([called]) => called === path)
|
||||
}
|
||||
|
||||
function progressbar() {
|
||||
return updatesCard().querySelector('[role="progressbar"]')
|
||||
}
|
||||
|
||||
function announcer() {
|
||||
return updatesCard().querySelector('[data-testid="update-announcer"]')!
|
||||
}
|
||||
|
||||
async function pressUpdate() {
|
||||
findButton('Update')!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function tick(milliseconds: number) {
|
||||
await vi.advanceTimersByTimeAsync(milliseconds)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Settings, owner, Update available with the helper detected', () => {
|
||||
it('shows the info block, collapsed notes, the one Update button and the hint', async () => {
|
||||
await mountSettings({ role: 'owner', status: startable() })
|
||||
|
||||
expect(cardText()).toContain('Version 2.3.0-beta is available')
|
||||
expect(cardText()).toContain(
|
||||
'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.',
|
||||
)
|
||||
expect(cardText()).toMatch(/Last checked (today|yesterday) at \d\d:\d\d/)
|
||||
expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(cardButtons()).toEqual(["What's new in 2.3.0-beta", 'Update'])
|
||||
expect(cardText()).toContain('Takes about 2 minutes. Your site stays online.')
|
||||
expect(updatesCard().querySelector('code')).toBeNull()
|
||||
expect(cardText()).not.toContain('How to update')
|
||||
expect(cardText()).not.toContain('For whoever manages the server')
|
||||
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(0)
|
||||
expect(callsTo(CHECK_PATH)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('opens the notes with the release date and the plain-text list', async () => {
|
||||
await mountSettings({ role: 'owner', status: startable() })
|
||||
|
||||
const toggle = findButton("What's new in 2.3.0-beta")!
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(cardText()).toContain('Released 24 Sep 2026')
|
||||
for (const item of NOTE_ITEMS) {
|
||||
expect(cardText()).toContain(item)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, owner, Downloading', () => {
|
||||
it('shows the card copy, the progressbar, the text and a disabled Updating button', async () => {
|
||||
useTimers()
|
||||
await mountSettings({ role: 'owner', status: startable(), download: downloading() })
|
||||
|
||||
await pressUpdate()
|
||||
|
||||
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(1)
|
||||
expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, { method: 'POST' })
|
||||
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
||||
expect(cardText()).toContain(
|
||||
'You can leave this page. The download continues and your site stays online.',
|
||||
)
|
||||
const bar = progressbar()!
|
||||
expect(bar.getAttribute('aria-label')).toBe('Downloading version 2.3.0-beta')
|
||||
expect(bar.getAttribute('aria-valuenow')).toBe('42')
|
||||
expect(cardText()).toContain('42% · 11 of 26 MB')
|
||||
const updating = findButton('Updating…') as HTMLButtonElement
|
||||
expect(updating.disabled).toBe(true)
|
||||
expect(cardButtons()).toEqual(['Updating…'])
|
||||
})
|
||||
|
||||
it('reads the status once a second while the job runs', async () => {
|
||||
useTimers()
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: sequence(startable(), downloading(42)),
|
||||
download: downloading(),
|
||||
})
|
||||
await pressUpdate()
|
||||
const before = callsTo(STATUS_PATH).length
|
||||
|
||||
await tick(1000)
|
||||
await tick(1000)
|
||||
await tick(1000)
|
||||
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(before + 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, progress is announced at 25 percent steps only', () => {
|
||||
it('changes the announcer at 25, 50 and 75 while the bar updates on every poll', async () => {
|
||||
useTimers()
|
||||
const percents = [3, 24, 25, 26, 49, 50, 75, 100]
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: sequence(startable(), ...percents.map((percent) => downloading(percent))),
|
||||
download: downloading(0),
|
||||
})
|
||||
await pressUpdate()
|
||||
|
||||
const announced: string[] = []
|
||||
const valueNow: string[] = []
|
||||
for (let index = 0; index < percents.length; index++) {
|
||||
await tick(1000)
|
||||
announced.push(announcer().textContent!.trim())
|
||||
valueNow.push(progressbar()!.getAttribute('aria-valuenow')!)
|
||||
}
|
||||
|
||||
expect(announced).toEqual([
|
||||
'Downloading version 2.3.0-beta',
|
||||
'Downloading version 2.3.0-beta',
|
||||
'Downloading version 2.3.0-beta, 25 percent',
|
||||
'Downloading version 2.3.0-beta, 25 percent',
|
||||
'Downloading version 2.3.0-beta, 25 percent',
|
||||
'Downloading version 2.3.0-beta, 50 percent',
|
||||
'Downloading version 2.3.0-beta, 75 percent',
|
||||
'Downloading version 2.3.0-beta, 75 percent',
|
||||
])
|
||||
expect(valueNow).toEqual(percents.map(String))
|
||||
expect(announcer().getAttribute('role')).toBe('status')
|
||||
expect(updatesCard().querySelector('[aria-live="polite"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, owner, Verifying', () => {
|
||||
it('shows an indeterminate bar, Almost there and announces the step once', async () => {
|
||||
useTimers()
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: sequence(startable(), verifying()),
|
||||
download: downloading(100),
|
||||
})
|
||||
await pressUpdate()
|
||||
|
||||
await tick(1000)
|
||||
|
||||
expect(cardText()).toContain('Checking the download…')
|
||||
expect(cardText()).toContain(
|
||||
'Making sure the file is complete and safe to install. This takes a few seconds.',
|
||||
)
|
||||
const bar = progressbar()!
|
||||
expect(bar.hasAttribute('aria-valuenow')).toBe(false)
|
||||
expect(bar.getAttribute('aria-label')).toBe('Checking the download')
|
||||
expect(cardText()).toContain('Almost there')
|
||||
expect((findButton('Updating…') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(announcer().textContent!.trim()).toBe('Checking the download')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, owner, Ready to install', () => {
|
||||
it('shows the success block, a disabled Restart, the notes and the hint, and stops polling', async () => {
|
||||
useTimers()
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: sequence(startable(), ready()),
|
||||
download: downloading(100),
|
||||
})
|
||||
await pressUpdate()
|
||||
await tick(1000)
|
||||
|
||||
expect(cardText()).toContain('Version 2.3.0-beta is ready to install')
|
||||
expect(cardText()).toContain(
|
||||
'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
|
||||
)
|
||||
expect(cardText()).toContain('Nothing has changed yet.')
|
||||
expect(cardText()).toContain(
|
||||
'Not now? Leave this page. The update waits here, and the banner on Home reminds you.',
|
||||
)
|
||||
expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false')
|
||||
const restart = findButton('Restart') as HTMLButtonElement
|
||||
expect(restart.disabled).toBe(true)
|
||||
|
||||
const readsBefore = callsTo(STATUS_PATH).length
|
||||
restart.click()
|
||||
await flushPromises()
|
||||
await tick(3000)
|
||||
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(readsBefore)
|
||||
expect(requestMock.mock.calls.map(([path]) => path)).not.toContain(
|
||||
'/v1/system/update/restart',
|
||||
)
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, Download failed', () => {
|
||||
function activeId() {
|
||||
return document.activeElement?.id
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'a failed record on a poll',
|
||||
status: sequence(startable(), failedWith('download')),
|
||||
download: downloading(),
|
||||
},
|
||||
{ name: 'a 500', status: startable(), download: new ApiError(500) },
|
||||
{ name: 'a network error', status: startable(), download: new NetworkError() },
|
||||
])('shows the error card and focuses the heading after $name', async (scenario) => {
|
||||
useTimers()
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: scenario.status,
|
||||
download: scenario.download,
|
||||
})
|
||||
await pressUpdate()
|
||||
await tick(1000)
|
||||
await flushPromises()
|
||||
|
||||
expect(cardText()).toContain("The download didn't finish")
|
||||
expect(cardText()).toContain(
|
||||
'The connection dropped before the file was complete. Nothing was changed and your site is running as before.',
|
||||
)
|
||||
expect(cardButtons()).toEqual(['Try again'])
|
||||
expect(activeId()).toBe('updates-h')
|
||||
})
|
||||
|
||||
it('posts the download again from Try again', async () => {
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: startable(),
|
||||
download: sequence(new ApiError(500), downloading()),
|
||||
})
|
||||
await pressUpdate()
|
||||
|
||||
findButton('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(2)
|
||||
expect(callsTo(CHECK_PATH)).toHaveLength(0)
|
||||
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, Verification failed', () => {
|
||||
function assertUntrustedCard() {
|
||||
expect(cardText()).toContain("We didn't use this update")
|
||||
expect(cardText()).toContain(
|
||||
"The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support.",
|
||||
)
|
||||
expect(cardButtons()).toEqual(['Try again'])
|
||||
expect(document.activeElement?.id).not.toBe('updates-h')
|
||||
expect(cardText()).not.toMatch(/signature|checksum|sha/i)
|
||||
}
|
||||
|
||||
it('shows the warning card from a failed record', async () => {
|
||||
useTimers()
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: sequence(startable(), failedWith('verification')),
|
||||
download: downloading(),
|
||||
})
|
||||
await pressUpdate()
|
||||
await tick(1000)
|
||||
|
||||
assertUntrustedCard()
|
||||
})
|
||||
|
||||
it('shows the warning card after a 422', async () => {
|
||||
await mountSettings({ role: 'owner', status: startable(), download: new ApiError(422) })
|
||||
|
||||
await pressUpdate()
|
||||
|
||||
assertUntrustedCard()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, 409 already running', () => {
|
||||
it('toasts and shows the other run while it downloads', async () => {
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: sequence(startable(), downloading(42)),
|
||||
download: new ApiError(409),
|
||||
})
|
||||
|
||||
await pressUpdate()
|
||||
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
color: 'info',
|
||||
title: 'An update is already running',
|
||||
description: 'Another owner started it. Progress shows here.',
|
||||
}),
|
||||
)
|
||||
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
||||
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
|
||||
})
|
||||
|
||||
it('shows Ready to install without a toast when the reload answers ready', async () => {
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: sequence(startable(), ready()),
|
||||
download: new ApiError(409),
|
||||
})
|
||||
|
||||
await pressUpdate()
|
||||
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(cardText()).toContain('Version 2.3.0-beta is ready to install')
|
||||
})
|
||||
|
||||
it('shows the Not prepared card without a toast when the reload answers can_apply false', async () => {
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: sequence(startable(), newerAvailable()),
|
||||
download: new ApiError(409),
|
||||
})
|
||||
|
||||
await pressUpdate()
|
||||
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(cardText()).toContain("This site can't update itself from the browser yet.")
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, 400 nothing to update', () => {
|
||||
it('toasts and reloads the status once', async () => {
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: startable(),
|
||||
download: new ApiError(400),
|
||||
})
|
||||
const readsBefore = callsTo(STATUS_PATH).length
|
||||
|
||||
await pressUpdate()
|
||||
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
color: 'info',
|
||||
title: 'Nothing to update',
|
||||
description: 'You already have the latest version.',
|
||||
}),
|
||||
)
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(readsBefore + 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, 403 role changed on Update', () => {
|
||||
it('toasts, reloads and renders the admin view', async () => {
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
meAfterRefresh: 'admin',
|
||||
status: sequence(startable(), newerAvailable()),
|
||||
download: new ApiError(403),
|
||||
})
|
||||
|
||||
await pressUpdate()
|
||||
|
||||
expect(toastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
color: 'error',
|
||||
title: 'Only the owner can do this',
|
||||
description: 'Ask the owner of this site to update RenewCMS.',
|
||||
}),
|
||||
)
|
||||
expect(getMeMock).toHaveBeenCalled()
|
||||
expect(cardText()).toContain('Only the owner can update RenewCMS.')
|
||||
expect(cardButtons()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, 429 on Update', () => {
|
||||
it('counts down through useRetryAfter and posts the download again at zero', async () => {
|
||||
useTimers()
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: startable(),
|
||||
download: sequence(new ApiError(429, 42), downloading()),
|
||||
})
|
||||
|
||||
await pressUpdate()
|
||||
|
||||
expect(cardText()).toContain('Too many tries')
|
||||
expect(cardText()).toContain('Wait a moment before trying again.')
|
||||
expect((findButton('Try again in 0:42') as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
await tick(1000)
|
||||
expect(findButton('Try again in 0:41')).toBeTruthy()
|
||||
|
||||
await tick(41000)
|
||||
const again = findButton('Try again') as HTMLButtonElement
|
||||
expect(again.disabled).toBe(false)
|
||||
|
||||
again.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(2)
|
||||
expect(callsTo(CHECK_PATH)).toHaveLength(0)
|
||||
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, reload keeps progress', () => {
|
||||
it('stops polling on leave and resumes it on the next open', async () => {
|
||||
useTimers()
|
||||
await mountSettings({ role: 'owner', status: downloading(42) })
|
||||
await tick(1000)
|
||||
const beforeLeaving = callsTo(STATUS_PATH).length
|
||||
expect(beforeLeaving).toBeGreaterThan(1)
|
||||
|
||||
wrapper!.unmount()
|
||||
wrapper = null
|
||||
await tick(5000)
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(beforeLeaving)
|
||||
|
||||
document.body.innerHTML = ''
|
||||
await mountSettings({ role: 'owner', status: downloading(42) })
|
||||
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
|
||||
const afterOpening = callsTo(STATUS_PATH).length
|
||||
await tick(2000)
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening + 2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'idle', status: startable() },
|
||||
{ name: 'ready', status: ready() },
|
||||
{ name: 'failed', status: failedWith('download') },
|
||||
])('does not poll while the state is $name', async ({ status }) => {
|
||||
useTimers()
|
||||
await mountSettings({ role: 'owner', status })
|
||||
const afterOpening = callsTo(STATUS_PATH).length
|
||||
|
||||
await tick(5000)
|
||||
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, a poll that fails keeps the card', () => {
|
||||
it.each([
|
||||
{ name: 'a 500', failure: new ApiError(500) },
|
||||
{ name: 'a network error', failure: new NetworkError() },
|
||||
])('keeps the last progress and polls again after $name', async ({ failure }) => {
|
||||
useTimers()
|
||||
await mountSettings({
|
||||
role: 'owner',
|
||||
status: sequence(downloading(42), failure, failure, downloading(60)),
|
||||
})
|
||||
|
||||
await tick(1000)
|
||||
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
|
||||
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
|
||||
expect(cardText()).not.toContain("The download didn't finish")
|
||||
|
||||
await tick(1000)
|
||||
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
|
||||
|
||||
await tick(1000)
|
||||
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('60')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings, admin view during a job', () => {
|
||||
it('shows the admin copy with no button, progressbar or poll', async () => {
|
||||
useTimers()
|
||||
await mountSettings({
|
||||
role: 'admin',
|
||||
status: { ...downloading(42), can_apply: false },
|
||||
})
|
||||
const afterOpening = callsTo(STATUS_PATH).length
|
||||
|
||||
await tick(5000)
|
||||
|
||||
expect(cardText()).toContain('Version 2.3.0-beta is available')
|
||||
expect(cardText()).toContain(
|
||||
'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.',
|
||||
)
|
||||
expect(cardButtons()).toEqual([])
|
||||
expect(progressbar()).toBeNull()
|
||||
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,10 @@ export const NOTE_ITEMS = [
|
||||
|
||||
export const STATUS_PATH = '/v1/system/update'
|
||||
export const CHECK_PATH = '/v1/system/update/check'
|
||||
export const DOWNLOAD_PATH = '/v1/system/update/download'
|
||||
|
||||
export const BOARD_TOTAL_BYTES = 27262976
|
||||
export const BOARD_DONE_BYTES = 11534336
|
||||
|
||||
export function hoursAgo(hours: number) {
|
||||
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString()
|
||||
@@ -64,3 +68,29 @@ export function findButton(label: string, root: ParentNode = document.body) {
|
||||
(button) => button.textContent?.trim() === label,
|
||||
)
|
||||
}
|
||||
|
||||
export function startable(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
|
||||
return newerAvailable({ can_apply: true, ...overrides })
|
||||
}
|
||||
|
||||
export function downloading(percent = 42): UpdateStatus {
|
||||
const downloaded =
|
||||
percent === 42 ? BOARD_DONE_BYTES : Math.floor((BOARD_TOTAL_BYTES * percent) / 100)
|
||||
return startable({
|
||||
state: 'downloading',
|
||||
started_at: '2026-09-30T09:51:00Z',
|
||||
progress: { percent, downloaded_bytes: downloaded, total_bytes: BOARD_TOTAL_BYTES },
|
||||
})
|
||||
}
|
||||
|
||||
export function verifying(): UpdateStatus {
|
||||
return startable({ state: 'verifying', started_at: '2026-09-30T09:51:00Z' })
|
||||
}
|
||||
|
||||
export function ready(): UpdateStatus {
|
||||
return startable({ state: 'ready', started_at: '2026-09-30T09:51:00Z' })
|
||||
}
|
||||
|
||||
export function failedWith(error: 'download' | 'verification'): UpdateStatus {
|
||||
return startable({ state: 'failed', error, started_at: '2026-09-30T09:51:00Z' })
|
||||
}
|
||||
+60
-9
@@ -208,6 +208,44 @@ function updateAvailable() {
|
||||
})
|
||||
}
|
||||
|
||||
const updateBoardNotes = {
|
||||
version: updateLatestTag,
|
||||
released_at: updateReleasedAt,
|
||||
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.',
|
||||
],
|
||||
}
|
||||
|
||||
const updateBoardTotalBytes = 27262976
|
||||
const updateBoardDoneBytes = 11534336
|
||||
const updateJobStartedAt = '2026-09-30T09:51:00Z'
|
||||
|
||||
function updateStartable(overrides: Record<string, unknown> = {}) {
|
||||
return updateStatusBody({
|
||||
latest: updateLatestTag,
|
||||
available: true,
|
||||
notes: updateBoardNotes,
|
||||
can_apply: true,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function updateJob(state: string, overrides: Record<string, unknown> = {}) {
|
||||
return updateStartable({ state, started_at: updateJobStartedAt, ...overrides })
|
||||
}
|
||||
|
||||
function updateDownloading() {
|
||||
return updateJob('downloading', {
|
||||
progress: {
|
||||
percent: 42,
|
||||
downloaded_bytes: updateBoardDoneBytes,
|
||||
total_bytes: updateBoardTotalBytes,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const updateCheckHangs: ApiMock = { status: 200, hang: true }
|
||||
|
||||
function updateApi(
|
||||
@@ -2046,19 +2084,25 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
|
||||
},
|
||||
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
|
||||
'Update available': { path: '/settings', skip: 'pending' },
|
||||
'Update available, notes open': { path: '/settings', skip: 'pending' },
|
||||
Downloading: { path: '/settings', skip: 'pending' },
|
||||
Verifying: { path: '/settings', skip: 'pending' },
|
||||
'Ready to install': { path: '/settings', skip: 'pending' },
|
||||
'Update available': { path: '/settings', skip: 'clock-sensitive' },
|
||||
'Update available, notes open': { path: '/settings', skip: 'clock-sensitive' },
|
||||
Downloading: { path: '/settings', api: updateApi('owner', updateDownloading()) },
|
||||
Verifying: { path: '/settings', api: updateApi('owner', updateJob('verifying')) },
|
||||
'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) },
|
||||
'Restart: confirm': { path: '/settings', skip: 'pending' },
|
||||
'Restart: request failed': { path: '/settings', skip: 'pending' },
|
||||
'Restarting to install': { path: '/settings', skip: 'pending' },
|
||||
'Restarting, taking longer': { path: '/settings', skip: 'pending' },
|
||||
'Update done': { path: '/settings', skip: 'pending' },
|
||||
'Update failed, rolled back': { path: '/settings', skip: 'pending' },
|
||||
'Download failed': { path: '/settings', skip: 'pending' },
|
||||
'Verification failed': { path: '/settings', skip: 'pending' },
|
||||
'Download failed': {
|
||||
path: '/settings',
|
||||
api: updateApi('owner', updateJob('failed', { error: 'download' })),
|
||||
},
|
||||
'Verification failed': {
|
||||
path: '/settings',
|
||||
api: updateApi('owner', updateJob('failed', { error: 'verification' })),
|
||||
},
|
||||
'Too many requests (429)': {
|
||||
path: '/settings',
|
||||
api: updateApi('owner', updateNeverChecked(), {
|
||||
@@ -2104,7 +2148,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'Update already running (409)': { path: '/settings', skip: 'pending' },
|
||||
'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' },
|
||||
},
|
||||
integration: {
|
||||
Default: {
|
||||
@@ -2378,7 +2422,14 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'GET /v1/system/update': { status: 200, body: updateAvailable() },
|
||||
},
|
||||
},
|
||||
'Update ready to install (owner)': { path: '/home', skip: 'pending' },
|
||||
'Update ready to install (owner)': {
|
||||
path: '/home',
|
||||
api: {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
||||
'GET /v1/system/update': { status: 200, body: updateJob('ready') },
|
||||
},
|
||||
},
|
||||
},
|
||||
'log-in': {
|
||||
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
|
||||
|
||||
Reference in new issue
Block a user