845 lines
25 KiB
TypeScript
845 lines
25 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { defineComponent, h } from 'vue'
|
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
|
import UApp from '@nuxt/ui/components/App.vue'
|
|
import router from '@/router'
|
|
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
|
import type { CacheStats } from '@/composables/useVariantCache'
|
|
import { initializeLocale } from '@/i18n'
|
|
import { useSessionStore } from '@/stores/session'
|
|
import CachePage from '../CachePage.vue'
|
|
import { accessFor, customAccess } from '@/test/access'
|
|
import type { Access } from '@/lib/permissions'
|
|
|
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
|
return { ...actual, useApi: vi.fn() }
|
|
})
|
|
|
|
const toasts = vi.hoisted(() => ({ add: vi.fn() }))
|
|
|
|
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
|
return { ...actual, useToast: () => toasts }
|
|
})
|
|
|
|
const STATS = 'GET /v1/image-variants/cache'
|
|
const PURGE = 'POST /v1/image-variants/cache/purge'
|
|
const MB = 1048576
|
|
|
|
type Handler = (body?: Record<string, unknown>) => Promise<unknown>
|
|
|
|
const TOP_IMAGES: [string, number, number][] = [
|
|
['north-wall.jpg', 6, 6082765],
|
|
['lime-plaster.jpg', 6, 5138022],
|
|
['kitchen-after.webp', 5, 4613734],
|
|
['kitchen-before.webp', 5, 4089446],
|
|
['floorboards.jpg', 5, 3774873],
|
|
['IMG_4471.jpg', 4, 3250585],
|
|
['stone-wall-detail.jpg', 4, 2936012],
|
|
['bathroom-tiles.png', 4, 2726297],
|
|
['roof-lantern.jpg', 3, 2411724],
|
|
['chimney-stack.jpg', 3, 2202009],
|
|
]
|
|
|
|
function stats(overrides: Partial<CacheStats> = {}): CacheStats {
|
|
return {
|
|
generated_files: 1284,
|
|
disk_used: 222298112,
|
|
variants_defined: 6,
|
|
variants_enabled: 5,
|
|
variants_disabled: 1,
|
|
rejected_requests: 37,
|
|
rejected_window_days: 7,
|
|
unused: { files: 312, bytes: 42991616, days: 30 },
|
|
by_variant: [
|
|
{ name: 'hero', enabled: true, files: 244, bytes: 88080384 },
|
|
{ name: 'card', enabled: true, files: 318, bytes: 60817408 },
|
|
{ name: 'print', enabled: false, files: 74, bytes: 22020096 },
|
|
{ name: 'thumb', enabled: true, files: 412, bytes: 22020096 },
|
|
{ name: 'square', enabled: true, files: 140, bytes: 19922944 },
|
|
{ name: 'og', enabled: true, files: 96, bytes: 9437184 },
|
|
],
|
|
top_images: TOP_IMAGES.map(([filename, files, bytes], index) => ({
|
|
id: index + 1,
|
|
filename,
|
|
url: `/static/uploadedImages/${filename}`,
|
|
files,
|
|
bytes,
|
|
})),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
function emptyStats() {
|
|
return stats({
|
|
generated_files: 0,
|
|
disk_used: 0,
|
|
unused: { files: 0, bytes: 0, days: 30 },
|
|
by_variant: [],
|
|
top_images: [],
|
|
})
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void
|
|
let reject!: (reason: unknown) => void
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res
|
|
reject = rej
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
let requestMock: ReturnType<typeof vi.fn>
|
|
let wrapper: VueWrapper | null = null
|
|
|
|
function setViewport(desktop: boolean) {
|
|
window.matchMedia = ((query: string) => ({
|
|
matches: desktop,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: () => undefined,
|
|
removeEventListener: () => undefined,
|
|
addListener: () => undefined,
|
|
removeListener: () => undefined,
|
|
dispatchEvent: () => false,
|
|
})) as unknown as typeof window.matchMedia
|
|
}
|
|
|
|
async function mountPage(
|
|
options: {
|
|
desktop?: boolean
|
|
role?: string | null
|
|
access?: Access
|
|
routes?: Record<string, Handler>
|
|
} = {},
|
|
) {
|
|
const { desktop = true, role = 'owner', routes = {} } = options
|
|
setViewport(desktop)
|
|
setActivePinia(createPinia())
|
|
const session = useSessionStore()
|
|
const sessionRole = role ?? undefined
|
|
session.session = {
|
|
verified: true,
|
|
username: 'marta',
|
|
id: 1,
|
|
displayName: 'Marta Alves',
|
|
access: options.access ?? accessFor(sessionRole),
|
|
}
|
|
session.status = 'ready'
|
|
requestMock = vi.fn((path: string, init?: RequestInit) => {
|
|
const key = `${init?.method ?? 'GET'} ${path}`
|
|
const handler = routes[key] ?? (key === STATS ? () => Promise.resolve(stats()) : null)
|
|
if (!handler) {
|
|
throw new Error(`Unexpected request: ${key}`)
|
|
}
|
|
return handler(
|
|
init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : undefined,
|
|
)
|
|
})
|
|
vi.mocked(useApi).mockReturnValue({
|
|
getMe: vi.fn().mockResolvedValue({
|
|
verified: true,
|
|
username: 'marta',
|
|
id: 1,
|
|
access: accessFor(sessionRole),
|
|
}),
|
|
request: requestMock,
|
|
})
|
|
await router.push('/settings/media/cache')
|
|
const Host = defineComponent({
|
|
render: () => h(UApp, null, () => h(CachePage)),
|
|
})
|
|
wrapper = mount(Host, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, uiPlugin] },
|
|
})
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
function calls(key: string) {
|
|
return requestMock.mock.calls.filter(
|
|
([path, init]) => `${(init as RequestInit | undefined)?.method ?? 'GET'} ${path}` === key,
|
|
)
|
|
}
|
|
|
|
function bodyOf(key: string, index = 0) {
|
|
return JSON.parse(String((calls(key)[index]![1] as RequestInit).body)) as Record<
|
|
string,
|
|
unknown
|
|
>
|
|
}
|
|
|
|
function buttonIn(root: ParentNode, text: string) {
|
|
return Array.from(root.querySelectorAll('button')).find(
|
|
(button) => button.textContent?.trim() === text,
|
|
)
|
|
}
|
|
|
|
function findButton(text: string) {
|
|
return buttonIn(document.body, text)
|
|
}
|
|
|
|
function buttonByLabel(label: string) {
|
|
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
|
}
|
|
|
|
async function click(element: HTMLElement | null | undefined) {
|
|
element?.click()
|
|
await flushPromises()
|
|
}
|
|
|
|
function textOf(element: Element | null | undefined) {
|
|
if (!element) {
|
|
return ''
|
|
}
|
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
|
const parts: string[] = []
|
|
while (walker.nextNode()) {
|
|
const text = walker.currentNode.textContent?.trim()
|
|
if (text) {
|
|
parts.push(text)
|
|
}
|
|
}
|
|
return parts.join(' ')
|
|
}
|
|
|
|
function alertDialog() {
|
|
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
|
|
}
|
|
|
|
function tiles() {
|
|
return Array.from(document.body.querySelectorAll('[data-testid="cache-tile"]')).map((tile) =>
|
|
textOf(tile),
|
|
)
|
|
}
|
|
|
|
function toastCalls() {
|
|
return toasts.add.mock.calls.map(([source]) => source as Record<string, unknown>)
|
|
}
|
|
|
|
async function typeWord(value: string) {
|
|
const input = alertDialog()!.querySelector<HTMLInputElement>('input')!
|
|
input.value = value
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
}
|
|
|
|
function confirmButton() {
|
|
return buttonIn(alertDialog()!, 'Purge all variants')!
|
|
}
|
|
|
|
async function unmountPage() {
|
|
wrapper?.unmount()
|
|
wrapper = null
|
|
vi.useRealTimers()
|
|
document.body.innerHTML = ''
|
|
await router.push('/login')
|
|
}
|
|
|
|
beforeEach(() => {
|
|
toasts.add.mockClear()
|
|
localStorage.clear()
|
|
initializeLocale()
|
|
vi.mocked(useApi).mockReset()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await unmountPage()
|
|
localStorage.clear()
|
|
initializeLocale()
|
|
})
|
|
|
|
describe('Rule: Cache screen, access and header', () => {
|
|
it.each([
|
|
['no role', null],
|
|
['role owner but the API answers 403', 'owner'],
|
|
])(
|
|
'A session whose role is not owner or admin gets the Forbidden state: %s',
|
|
async (_, role) => {
|
|
await mountPage({
|
|
role,
|
|
routes: role ? { [STATS]: () => Promise.reject(new ApiError(403)) } : {},
|
|
})
|
|
|
|
expect(document.body.textContent).toContain("You don't have access to this page")
|
|
expect(document.body.textContent).toContain(
|
|
"doesn't include it. Ask an owner or admin if you need it.",
|
|
)
|
|
expect(findButton('Go to Home') ?? findButton('Go to Articles')).toBeDefined()
|
|
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
|
|
expect(document.body.querySelector('h1:not([data-slot="title"])')).toBeNull()
|
|
expect(calls(STATS)).toHaveLength(role ? 1 : 0)
|
|
},
|
|
)
|
|
|
|
it('The header and the tabs', async () => {
|
|
await mountPage()
|
|
|
|
expect(document.body.querySelector('h1:not([data-slot="title"])')?.textContent).toBe(
|
|
'Cache',
|
|
)
|
|
expect(document.body.textContent).toContain(
|
|
'Generated variant files. Purged files are generated again on the next request.',
|
|
)
|
|
expect(findButton('Purge unused')).toBeDefined()
|
|
expect(findButton('Purge all variants')).toBeDefined()
|
|
const tabs = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
|
|
expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(['Variants', 'Cache'])
|
|
expect(tabs[1]!.getAttribute('aria-selected')).toBe('true')
|
|
expect(tabs[1]!.className).toContain('border-primary')
|
|
expect(tabs[0]!.getAttribute('href')).toBe('/settings/media/variants')
|
|
expect(calls(STATS)).toHaveLength(1)
|
|
})
|
|
|
|
it('Activating Variants routes to the variants page', async () => {
|
|
await mountPage()
|
|
|
|
await click(document.body.querySelector<HTMLElement>('[role="tab"]'))
|
|
|
|
await vi.waitFor(() =>
|
|
expect(router.currentRoute.value.path).toBe('/settings/media/variants'),
|
|
)
|
|
})
|
|
|
|
it('The desktop breadcrumb reads Settings, Media, Cache', async () => {
|
|
await mountPage()
|
|
|
|
const items = Array.from(
|
|
document.body.querySelectorAll('nav[aria-label="breadcrumb"] li'),
|
|
).map((item) => item.textContent?.trim())
|
|
expect(items.filter(Boolean)).toEqual(['Settings', 'Media', 'Cache'])
|
|
})
|
|
})
|
|
|
|
describe('Rule: Cache screen, loaded', () => {
|
|
it('The four stat tiles', async () => {
|
|
await mountPage()
|
|
|
|
expect(tiles()).toEqual([
|
|
'Generated files 1,284 Across 6 variants',
|
|
'Disk used 212 MB Originals not included',
|
|
'Variants defined 6 5 enabled, 1 disabled',
|
|
'Rejected requests 37 Last 7 days',
|
|
])
|
|
})
|
|
|
|
it('The By variant list', async () => {
|
|
await mountPage()
|
|
|
|
const rows = Array.from(document.body.querySelectorAll('[data-testid="cache-variant-row"]'))
|
|
expect(rows.map((row) => textOf(row))).toEqual([
|
|
'hero 244 files · 84 MB',
|
|
'card 318 files · 58 MB',
|
|
'print Disabled 74 files · 21 MB',
|
|
'thumb 412 files · 21 MB',
|
|
'square 140 files · 19 MB',
|
|
'og 96 files · 9 MB',
|
|
])
|
|
const widths = rows.map((row) =>
|
|
row.querySelector('[role="progressbar"]')?.getAttribute('aria-valuenow'),
|
|
)
|
|
expect(widths).toEqual(['100', '69', '25', '25', '23', '11'])
|
|
const printBar = rows[2]!.querySelector('[data-slot="indicator"]')!
|
|
expect(printBar.className).toContain('border-accented')
|
|
expect(rows[0]!.querySelector('[data-slot="indicator"]')!.className).toContain('bg-primary')
|
|
expect(rows[0]!.querySelector('span.font-mono')!.className).toContain('font-medium')
|
|
})
|
|
|
|
it('The Top 10 list on desktop', async () => {
|
|
await mountPage()
|
|
|
|
const rows = Array.from(document.body.querySelectorAll('[data-testid="cache-image-row"]'))
|
|
expect(rows).toHaveLength(10)
|
|
expect(textOf(rows[0])).toBe('1 north-wall.jpg 6 variants 5.8 MB Purge')
|
|
expect(textOf(rows[9])).toBe('10 chimney-stack.jpg 3 variants 2.1 MB Purge')
|
|
expect(rows[0]!.querySelector('[data-testid="media-thumb"]')).not.toBeNull()
|
|
})
|
|
|
|
it('The Top 10 list on mobile', async () => {
|
|
await mountPage({ desktop: false })
|
|
|
|
const rows = Array.from(document.body.querySelectorAll('[data-testid="cache-image-row"]'))
|
|
expect(textOf(rows[0])).toBe('1 north-wall.jpg 6 variants · 5.8 MB')
|
|
const purge = buttonByLabel('Purge north-wall.jpg')!
|
|
expect(purge.textContent?.trim()).toBe('')
|
|
expect(purge.className).toContain('size-11')
|
|
})
|
|
|
|
it('The singular reads 1 file and 1 variant', async () => {
|
|
await mountPage({
|
|
routes: {
|
|
[STATS]: () =>
|
|
Promise.resolve(
|
|
stats({
|
|
by_variant: [
|
|
{
|
|
name: 'hero',
|
|
enabled: true,
|
|
files: 1,
|
|
bytes: MB,
|
|
},
|
|
],
|
|
top_images: [
|
|
{
|
|
id: 1,
|
|
filename: 'a.jpg',
|
|
url: '/static/uploadedImages/a.jpg',
|
|
files: 1,
|
|
bytes: MB,
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
},
|
|
})
|
|
|
|
expect(textOf(document.body.querySelector('[data-testid="cache-variant-row"]'))).toBe(
|
|
'hero 1 file · 1 MB',
|
|
)
|
|
expect(textOf(document.body.querySelector('[data-testid="cache-image-row"]'))).toContain(
|
|
'1 variant',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('Rule: Cache screen, loading, empty and error', () => {
|
|
it('Loading', async () => {
|
|
vi.useFakeTimers()
|
|
const pending = deferred<CacheStats>()
|
|
await mountPage({ routes: { [STATS]: () => pending.promise } })
|
|
|
|
await vi.advanceTimersByTimeAsync(149)
|
|
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
|
|
const region = document.body.querySelector('[aria-busy="true"]')!
|
|
expect(region.getAttribute('aria-label')).toBe('Loading cache')
|
|
expect(region.querySelectorAll('[data-testid="cache-tile"]')).toHaveLength(4)
|
|
expect(region.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0)
|
|
pending.resolve(stats())
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
})
|
|
|
|
it('A response at 100 ms never draws skeletons', async () => {
|
|
vi.useFakeTimers()
|
|
const pending = deferred<CacheStats>()
|
|
await mountPage({ routes: { [STATS]: () => pending.promise } })
|
|
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
pending.resolve(stats())
|
|
await vi.advanceTimersByTimeAsync(100)
|
|
|
|
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
|
|
expect(tiles()).toHaveLength(4)
|
|
})
|
|
|
|
it('Empty cache', async () => {
|
|
await mountPage({
|
|
routes: { [STATS]: () => Promise.resolve(emptyStats()) },
|
|
})
|
|
|
|
expect(tiles()).toEqual([
|
|
'Generated files 0 Nothing cached',
|
|
'Disk used 0 MB Nothing cached',
|
|
'Variants defined 6 5 enabled, 1 disabled',
|
|
'Rejected requests 37 Last 7 days',
|
|
])
|
|
expect(document.body.textContent).toContain('Nothing generated yet')
|
|
expect(document.body.textContent).toContain(
|
|
"Variants are generated on the first request with ?v=<name>, or from an image's Variants tab.",
|
|
)
|
|
expect(findButton('Purge unused')!.disabled).toBe(true)
|
|
expect(findButton('Purge all variants')!.disabled).toBe(true)
|
|
expect(document.body.querySelector('[data-testid="cache-by-variant"]')).toBeNull()
|
|
expect(document.body.querySelector('[data-testid="cache-top-images"]')).toBeNull()
|
|
})
|
|
|
|
it.each([
|
|
['a 500', () => Promise.reject(new ApiError(500))],
|
|
['a network error', () => Promise.reject(new NetworkError())],
|
|
])('Error loading: %s', async (_, failure) => {
|
|
let attempt = 0
|
|
await mountPage({
|
|
routes: {
|
|
[STATS]: () => (attempt++ === 0 ? failure() : Promise.resolve(stats())),
|
|
},
|
|
})
|
|
|
|
const alert = document.body.querySelector<HTMLElement>('[role="alert"]')!
|
|
expect(textOf(alert)).toContain("Couldn't load cache stats")
|
|
expect(textOf(alert)).toContain('Check your connection and try again.')
|
|
expect(alert.querySelector('[data-slot="close"]')).toBeNull()
|
|
expect(tiles()).toHaveLength(0)
|
|
|
|
await click(buttonIn(alert, 'Try again'))
|
|
|
|
expect(calls(STATS)).toHaveLength(2)
|
|
expect(tiles()).toHaveLength(4)
|
|
})
|
|
|
|
it('Purge unused is disabled when nothing is unused', async () => {
|
|
await mountPage({
|
|
routes: {
|
|
[STATS]: () => Promise.resolve(stats({ unused: { files: 0, bytes: 0, days: 30 } })),
|
|
},
|
|
})
|
|
|
|
expect(findButton('Purge unused')!.disabled).toBe(true)
|
|
expect(findButton('Purge all variants')!.disabled).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Rule: Cache screen, purge all with a typed word', () => {
|
|
it('The dialog opens with the button disabled', async () => {
|
|
await mountPage()
|
|
|
|
await click(findButton('Purge all variants'))
|
|
|
|
const dialog = alertDialog()!
|
|
expect(textOf(dialog)).toContain('Purge all variants?')
|
|
expect(textOf(dialog)).toContain(
|
|
"This removes 1,284 generated files (212 MB) for every variant. Originals aren't touched. Files are generated again on request.",
|
|
)
|
|
expect(textOf(dialog)).toContain('Type purge to confirm')
|
|
expect(dialog.querySelector('input')!.className).toContain('font-mono')
|
|
expect(buttonIn(dialog, 'Cancel')).toBeDefined()
|
|
expect(confirmButton().disabled).toBe(true)
|
|
expect(calls(PURGE)).toHaveLength(0)
|
|
})
|
|
|
|
it.each([
|
|
['pur', true],
|
|
['Purge', true],
|
|
['purge', false],
|
|
['purged', true],
|
|
])('The word unlocks the button: %s', async (typed, disabled) => {
|
|
await mountPage()
|
|
await click(findButton('Purge all variants'))
|
|
|
|
await typeWord(typed)
|
|
|
|
expect(confirmButton().disabled).toBe(disabled)
|
|
})
|
|
|
|
it('Cancel sends nothing', async () => {
|
|
await mountPage()
|
|
await click(findButton('Purge all variants'))
|
|
|
|
await click(buttonIn(alertDialog()!, 'Cancel'))
|
|
|
|
await vi.waitFor(() => expect(alertDialog()).toBeNull())
|
|
expect(calls(PURGE)).toHaveLength(0)
|
|
})
|
|
|
|
it('Confirming sends the purge', async () => {
|
|
await mountPage({
|
|
routes: {
|
|
[PURGE]: () =>
|
|
Promise.resolve({
|
|
scope: 'all',
|
|
purged_files: 1284,
|
|
purged_bytes: 222298112,
|
|
failed_files: 0,
|
|
}),
|
|
},
|
|
})
|
|
await click(findButton('Purge all variants'))
|
|
await typeWord('purge')
|
|
|
|
await click(confirmButton())
|
|
|
|
await vi.waitFor(() => expect(calls(PURGE)).toHaveLength(1))
|
|
expect(bodyOf(PURGE)).toEqual({ scope: 'all' })
|
|
})
|
|
})
|
|
|
|
describe('Rule: Cache screen, purge unused', () => {
|
|
it('The dialog states the count from the stats and confirming sends the purge', async () => {
|
|
await mountPage({
|
|
routes: {
|
|
[PURGE]: () =>
|
|
Promise.resolve({
|
|
scope: 'unused',
|
|
purged_files: 312,
|
|
purged_bytes: 42991616,
|
|
failed_files: 0,
|
|
}),
|
|
},
|
|
})
|
|
|
|
await click(findButton('Purge unused'))
|
|
|
|
const dialog = alertDialog()!
|
|
expect(textOf(dialog)).toContain('Purge unused variants?')
|
|
expect(textOf(dialog)).toContain(
|
|
"This removes 312 files (41 MB) from enabled variants that haven't been requested for 30 days. Files are generated again on request.",
|
|
)
|
|
expect(dialog.querySelector('input')).toBeNull()
|
|
expect(buttonIn(dialog, 'Cancel')).toBeDefined()
|
|
|
|
await click(buttonIn(dialog, 'Purge 312 files'))
|
|
|
|
await vi.waitFor(() => expect(calls(PURGE)).toHaveLength(1))
|
|
expect(bodyOf(PURGE)).toEqual({ scope: 'unused' })
|
|
})
|
|
})
|
|
|
|
describe('Rule: Cache screen, purging, done and failed', () => {
|
|
async function startPurgeAll(routes: Record<string, Handler>) {
|
|
await mountPage({ routes })
|
|
await click(findButton('Purge all variants'))
|
|
await typeWord('purge')
|
|
await click(confirmButton())
|
|
}
|
|
|
|
it('Purging', async () => {
|
|
const pending = deferred<unknown>()
|
|
await startPurgeAll({ [PURGE]: () => pending.promise })
|
|
|
|
const banner = document.body.querySelector('[data-testid="cache-purge-banner"]')!
|
|
expect(textOf(banner)).toContain('Purging 1,284 files')
|
|
expect(banner.getAttribute('aria-live')).toBe('polite')
|
|
expect(banner.querySelector('[role="progressbar"]')).not.toBeNull()
|
|
expect(findButton('Purge unused')!.disabled).toBe(true)
|
|
expect(findButton('Purge all variants')!.disabled).toBe(true)
|
|
expect(buttonByLabel('Purge north-wall.jpg')!.disabled).toBe(true)
|
|
expect(document.body.querySelector('.opacity-60')).not.toBeNull()
|
|
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
|
|
})
|
|
|
|
it('Purge done after purge all', async () => {
|
|
let reads = 0
|
|
await startPurgeAll({
|
|
[STATS]: () => Promise.resolve(reads++ === 0 ? stats() : emptyStats()),
|
|
[PURGE]: () =>
|
|
Promise.resolve({
|
|
scope: 'all',
|
|
purged_files: 1284,
|
|
purged_bytes: 222298112,
|
|
failed_files: 0,
|
|
}),
|
|
})
|
|
|
|
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
|
expect(toastCalls()[0]).toMatchObject({
|
|
color: 'success',
|
|
title: 'Purged 1,284 files, freed 212 MB',
|
|
})
|
|
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
|
await vi.waitFor(() => expect(document.body.textContent).toContain('Nothing generated yet'))
|
|
})
|
|
|
|
it('Purge done after purge unused', async () => {
|
|
await mountPage({
|
|
routes: {
|
|
[PURGE]: () =>
|
|
Promise.resolve({
|
|
scope: 'unused',
|
|
purged_files: 312,
|
|
purged_bytes: 42991616,
|
|
failed_files: 0,
|
|
}),
|
|
},
|
|
})
|
|
await click(findButton('Purge unused'))
|
|
await click(buttonIn(alertDialog()!, 'Purge 312 files'))
|
|
|
|
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
|
expect(toastCalls()[0]).toMatchObject({
|
|
title: 'Purged 312 files, freed 41 MB',
|
|
})
|
|
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
|
})
|
|
|
|
it('Purge failed offers Try again without asking for the word again', async () => {
|
|
await startPurgeAll({
|
|
[PURGE]: () =>
|
|
Promise.resolve({
|
|
scope: 'all',
|
|
purged_files: 640,
|
|
purged_bytes: 111149056,
|
|
failed_files: 644,
|
|
}),
|
|
})
|
|
|
|
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
|
const toast = toastCalls()[0]!
|
|
expect(toast).toMatchObject({
|
|
color: 'error',
|
|
title: "Purge didn't finish",
|
|
description: '640 of 1,284 files were removed. Try again.',
|
|
})
|
|
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
|
const [action] = toast.actions as {
|
|
label: string
|
|
onClick: () => void
|
|
}[]
|
|
expect(action!.label).toBe('Try again')
|
|
|
|
action!.onClick()
|
|
await flushPromises()
|
|
|
|
await vi.waitFor(() => expect(calls(PURGE)).toHaveLength(2))
|
|
expect(bodyOf(PURGE, 1)).toEqual({ scope: 'all' })
|
|
expect(alertDialog()).toBeNull()
|
|
})
|
|
|
|
it.each([
|
|
[
|
|
'a 400',
|
|
new ApiError(400),
|
|
'Check the request',
|
|
"A value in the request isn't valid. Reload the page and try again.",
|
|
],
|
|
[
|
|
'a 403',
|
|
new ApiError(403),
|
|
"You don't have access",
|
|
'Only admins can manage image variants.',
|
|
],
|
|
[
|
|
'a 500',
|
|
new ApiError(500),
|
|
'Something went wrong',
|
|
"The server couldn't finish. Try again. If it keeps happening, check the server log.",
|
|
],
|
|
[
|
|
'a network error',
|
|
new NetworkError(),
|
|
'Something went wrong',
|
|
"The server couldn't finish. Try again. If it keeps happening, check the server log.",
|
|
],
|
|
])('A purge request that errors: %s', async (_, failure, title, description) => {
|
|
await startPurgeAll({ [PURGE]: () => Promise.reject(failure) })
|
|
|
|
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
|
expect(toastCalls()[0]).toMatchObject({
|
|
color: 'error',
|
|
title,
|
|
description,
|
|
})
|
|
expect(document.body.querySelector('[data-testid="cache-purge-banner"]')).toBeNull()
|
|
expect(tiles()).toHaveLength(4)
|
|
expect(calls(STATS)).toHaveLength(1)
|
|
})
|
|
|
|
it('A 401 is left to the API client', async () => {
|
|
await startPurgeAll({
|
|
[PURGE]: () => Promise.reject(new ApiError(401)),
|
|
})
|
|
|
|
expect(toastCalls()).toHaveLength(0)
|
|
expect(document.body.querySelector('[data-testid="cache-purge-banner"]')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Rule: Cache screen, purging the variants of one image', () => {
|
|
it('A row Purge sends the image scope and is done', async () => {
|
|
await mountPage({
|
|
routes: {
|
|
[PURGE]: () =>
|
|
Promise.resolve({
|
|
scope: 'image',
|
|
purged_files: 6,
|
|
purged_bytes: 6082765,
|
|
failed_files: 0,
|
|
}),
|
|
},
|
|
})
|
|
|
|
await click(buttonByLabel('Purge north-wall.jpg'))
|
|
|
|
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
|
expect(bodyOf(PURGE)).toEqual({ scope: 'image', image_id: 1 })
|
|
expect(alertDialog()).toBeNull()
|
|
expect(toastCalls()[0]).toMatchObject({
|
|
title: 'Purged 6 files (5.8 MB) for north-wall.jpg',
|
|
})
|
|
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
|
})
|
|
|
|
it('While it runs the row shows a loader and the other Purge buttons stay enabled', async () => {
|
|
const pending = deferred<unknown>()
|
|
await mountPage({ routes: { [PURGE]: () => pending.promise } })
|
|
|
|
await click(buttonByLabel('Purge north-wall.jpg'))
|
|
|
|
expect(buttonByLabel('Purge north-wall.jpg')!.querySelector('.animate-spin')).not.toBeNull()
|
|
expect(buttonByLabel('Purge lime-plaster.jpg')!.disabled).toBe(false)
|
|
expect(findButton('Purge all variants')!.disabled).toBe(false)
|
|
})
|
|
|
|
it('The image no longer exists', async () => {
|
|
await mountPage({
|
|
routes: { [PURGE]: () => Promise.reject(new ApiError(404)) },
|
|
})
|
|
|
|
await click(buttonByLabel('Purge north-wall.jpg'))
|
|
|
|
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
|
expect(toastCalls()[0]).toMatchObject({
|
|
title: 'Not found',
|
|
description: 'This variant or image no longer exists. Refresh the page.',
|
|
})
|
|
await vi.waitFor(() => expect(calls(STATS)).toHaveLength(2))
|
|
})
|
|
|
|
it('Failed', async () => {
|
|
await mountPage({
|
|
routes: {
|
|
[PURGE]: () =>
|
|
Promise.resolve({
|
|
scope: 'image',
|
|
purged_files: 4,
|
|
purged_bytes: 1000,
|
|
failed_files: 2,
|
|
}),
|
|
},
|
|
})
|
|
|
|
await click(buttonByLabel('Purge north-wall.jpg'))
|
|
|
|
await vi.waitFor(() => expect(toastCalls()).toHaveLength(1))
|
|
expect(toastCalls()[0]).toMatchObject({
|
|
title: "Purge didn't finish",
|
|
description: '4 of 6 files were removed. Try again.',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Rule: Accessibility and vocabulary', () => {
|
|
it('Roles, labels and vocabulary', async () => {
|
|
await mountPage()
|
|
|
|
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
|
|
expect(document.body.querySelectorAll('[role="tab"]')).toHaveLength(2)
|
|
const text = document.body.textContent ?? ''
|
|
for (const banned of ['thumbnail preset', 'version', 'rendition']) {
|
|
expect(text).not.toContain(banned)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Rule: Cache screen, settings.edit_general', () => {
|
|
it('lets a session with settings.edit_general purge and shows no read-only banner', async () => {
|
|
await mountPage({ access: customAccess('settings.view, settings.edit_general') })
|
|
|
|
expect(document.body.textContent).not.toContain(
|
|
'You can read these settings but not change them',
|
|
)
|
|
expect(findButton('Purge all variants')?.disabled).toBe(false)
|
|
})
|
|
|
|
it('locks the purge controls behind the banner with settings.view alone', async () => {
|
|
await mountPage({ access: customAccess('settings.view') })
|
|
|
|
expect(document.body.textContent).toContain('You can read these settings but not change them')
|
|
expect(findButton('Purge all variants')?.disabled).toBe(true)
|
|
})
|
|
})
|