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
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user