feat: image variants settings screens

This commit is contained in:
fsylvain committed 2026-10-08 13:57:41 +02:00
1 parent f1d56ef50e
commit f567866161
42 files changed
+7192 -11

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,933 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import UButton from '@nuxt/ui/components/Button.vue'
import UIcon from '@nuxt/ui/components/Icon.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import {
LIST,
MB,
alertDialog,
buttonByLabel,
buttonIn,
calls,
click,
defaultFixture,
deferred,
findButton,
menuItem,
mountPage,
pageTitle,
openMenu,
pressEscape,
rowOf,
rows,
stubBrowser,
textOf,
toastCalls,
unmountPage,
variant,
variantsTable,
wrapper,
} from './variantsTestKit'
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 FORBIDDEN_TEXT =
'Only admins can manage image variants. Ask an admin if you need one changed.'
const FOOTER = '6 variants · 1,284 generated files · 212 MB'
const SERVER_TEXT =
"The server couldn't finish. Try again. If it keeps happening, check the server log."
const NOT_FOUND_TEXT = 'This variant or image no longer exists. Refresh the page.'
const PATCH_HERO = 'PATCH /v1/image-variants/hero'
const PATCH_PRINT = 'PATCH /v1/image-variants/print'
const PURGE_HERO = 'POST /v1/image-variants/hero/purge'
const DELETE_HERO = 'DELETE /v1/image-variants/hero'
function summary() {
return document.body.querySelector('[data-testid="variants-summary"]')?.textContent?.trim()
}
function iconNames(root: ParentNode) {
return wrapper!
.findAllComponents(UIcon)
.filter((icon) => root.contains(icon.element))
.map((icon) => icon.props('name'))
}
function iconOf(button: HTMLElement) {
return wrapper!
.findAllComponents(UButton)
.find((component) => component.element === button)
?.props('icon')
}
function switchOf(name: string) {
return document.body.querySelector<HTMLButtonElement>(
`button[role="switch"][aria-label="Enable variant ${name}"]`,
)!
}
function statusOf(name: string) {
return textOf(rowOf(name)).match(/(Enabled|Disabled|Purging…)/)?.[1]
}
function expectForbidden() {
expect(document.body.textContent).toContain("You don't have access")
expect(document.body.textContent).toContain(FORBIDDEN_TEXT)
expect(findButton('Back to Home')!.disabled).toBe(false)
expect(pageTitle()).toBeNull()
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
expect(variantsTable()).toBeUndefined()
expect(document.body.querySelector('[data-testid="variant-card"]')).toBeNull()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
expect(findButton('Add variant')).toBeUndefined()
expect(document.body.querySelector('nav[aria-label="Main"] [aria-current="page"]')).toBeNull()
}
beforeEach(() => {
toasts.add.mockClear()
localStorage.clear()
initializeLocale()
stubBrowser()
vi.mocked(useApi).mockReset()
})
afterEach(async () => {
vi.unstubAllGlobals()
await unmountPage()
localStorage.clear()
initializeLocale()
})
describe('Rule: Only admins and owners see the page', () => {
it('A session whose role is not owner or admin gets the Forbidden state: no role', async () => {
await mountPage({ role: null })
expectForbidden()
expect(calls(LIST)).toHaveLength(0)
expect(iconNames(document.body)).toContain('i-lucide-lock')
const back = findButton('Back to Home')!
expect(iconOf(back)).toBe('i-lucide-arrow-left')
})
it('A session whose role is not owner or admin gets the Forbidden state: owner answered 403', async () => {
await mountPage({ routes: { [LIST]: () => Promise.reject(new ApiError(403)) } })
expectForbidden()
expect(calls(LIST)).toHaveLength(1)
})
it('Back to Home leaves the page', async () => {
await mountPage({ role: null })
await click(findButton('Back to Home'))
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
it.each(['owner', 'admin'])('An owner or an admin sees the list: %s', async (role) => {
await mountPage({ role })
expect(calls(LIST)).toHaveLength(1)
expect(pageTitle()?.textContent).toBe('Image variants')
expect(
document.body.querySelector('nav[aria-label="Main"] [aria-current="page"]')
?.textContent,
).toContain('Variants')
})
})
describe('Rule: Header and tabs', () => {
it('The page header and the tabs', async () => {
await mountPage()
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.textContent).toContain(
'Named sizes and formats generated from your original images. Originals are never changed.',
)
const add = findButton('Add variant')!
expect(iconOf(add)).toBe('i-lucide-plus')
const tabs = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(['Variants', 'Cache'])
expect(tabs[0]!.getAttribute('aria-selected')).toBe('true')
expect(tabs[0]!.className).toContain('border-primary')
expect(tabs[0]!.className).toContain('font-medium')
expect(iconOf(tabs[0]!)).toBe('i-lucide-layers')
expect(iconOf(tabs[1]!)).toBe('i-lucide-database')
expect(tabs[1]!.getAttribute('aria-disabled')).toBe('true')
expect(tabs[1]!.className).toContain('text-toned')
})
it('Activating Cache changes nothing', async () => {
await mountPage()
const before = router.currentRoute.value.fullPath
const cache = document.body.querySelectorAll<HTMLElement>('[role="tab"]')[1]!
await click(cache)
expect(router.currentRoute.value.fullPath).toBe(before)
expect(cache.getAttribute('aria-selected')).toBe('false')
expect(calls(LIST)).toHaveLength(1)
})
it('The desktop breadcrumb reads Settings, Media, Variants', 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', 'Variants'])
})
it('The mobile top bar title is Variants', async () => {
await mountPage({ desktop: false })
expect(
document.body.querySelector('header h1, [data-slot="title"]')?.textContent,
).toContain('Variants')
})
it('The Add variant button sits below the description on mobile', async () => {
await mountPage({ desktop: false })
const add = findButton('Add variant')!
expect(add.className).toContain('h-11')
expect(add.parentElement!.className).toContain('flex-col')
})
})
describe('Rule: Loaded, table on desktop and cards on mobile', () => {
it('Desktop table', async () => {
await mountPage()
const headers = Array.from(variantsTable()!.querySelectorAll('thead th')).map((cell) =>
cell.textContent?.trim(),
)
expect(headers).toEqual([
'Name',
'Dimensions',
'Fit',
'Format',
'Quality',
'Generated',
'Cache size',
'Status',
'',
])
expect(rows().map(textOf)).toEqual([
'card 800 × auto inside WebP 80 318 58 MB Enabled',
'hero 1200×630 cover WebP 82 244 84 MB Enabled',
'og 1200×630 cover JPEG 80 96 9 MB Enabled',
'print auto × 2400 inside PNG — 74 21 MB Disabled',
'square 600×600 cover JPEG 85 140 19 MB Enabled',
'thumb 400×300 cover WebP 80 412 21 MB Enabled',
])
expect(summary()).toBe(FOOTER)
expect(document.body.querySelector('[data-testid="request-reference"]')).not.toBeNull()
})
it('The name and dimensions cells are IBM Plex Mono and the disabled row is muted', async () => {
await mountPage()
const cells = rowOf('hero').querySelectorAll('td')
expect(cells[0]!.querySelector('span')!.className).toContain('font-mono')
expect(cells[0]!.querySelector('span')!.className).toContain('font-medium')
expect(cells[1]!.querySelector('span')!.className).toContain('font-mono')
expect(rowOf('print').className).toContain('text-muted')
expect(rowOf('hero').className).toContain('text-default')
})
it('Mobile cards', async () => {
await mountPage({ desktop: false })
const cards = rows()
expect(cards).toHaveLength(6)
expect(textOf(rowOf('hero'))).toBe(
'hero Enabled 1200×630 · cover · WebP · q82 244 generated · 84 MB',
)
expect(textOf(rowOf('print'))).toContain('auto × 2400 · inside · PNG ')
expect(textOf(rowOf('print'))).not.toContain('q')
expect(summary()).toBe(FOOTER)
const footer = document.body.querySelector('[data-testid="variants-summary"]')!
const reference = document.body.querySelector('[data-testid="request-reference"]')!
expect(cards[5]!.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
expect(footer.compareDocumentPosition(reference) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
})
it('Singular footer', async () => {
await mountPage({
routes: {
[LIST]: () =>
Promise.resolve({
items: [
variant({ name: 'thumb', generated_files: 1, cache_size: 18 * 1024 }),
],
}),
},
})
expect(summary()).toBe('1 variant · 1 generated file · 18 KB')
})
it('Footer sums are computed from the list', async () => {
await mountPage()
expect(summary()).toBe(FOOTER)
})
})
describe('Rule: Loading', () => {
it('Skeletons appear only after 150 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ routes: { [LIST]: () => new Promise(() => undefined) } })
vi.advanceTimersByTime(149)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
const region = document.body.querySelector('section[aria-busy="true"]')!
expect(region.getAttribute('aria-label')).toBe('Loading variants')
expect(region.querySelectorAll('.h-12')).toHaveLength(5)
expect(region.querySelectorAll('.h-9')).toHaveLength(1)
expect(region.querySelectorAll('.h-12 [class*="animate-pulse"]')).toHaveLength(30)
expect(textOf(region)).toBe('')
})
it('Skeleton cards on mobile hold three bars', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ desktop: false, routes: { [LIST]: () => new Promise(() => undefined) } })
vi.advanceTimersByTime(150)
await flushPromises()
const cards = document.body.querySelectorAll('section[aria-busy="true"] > div > div')
expect(cards).toHaveLength(4)
expect(
Array.from(cards[0]!.children).map(
(bar) => (bar as HTMLElement).className.match(/w-\[\d+%\]/)?.[0],
),
).toEqual(['w-[40%]', 'w-[70%]', 'w-[55%]'])
})
it('A fast answer never flashes skeletons', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferred<unknown>()
await mountPage({ routes: { [LIST]: () => pending.promise } })
vi.advanceTimersByTime(100)
pending.resolve({ items: defaultFixture() })
await flushPromises()
vi.advanceTimersByTime(500)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(rows()).toHaveLength(6)
})
})
describe('Rule: Empty', () => {
const emptyRoutes = { [LIST]: () => Promise.resolve({ items: [] }) }
it('No variants', async () => {
await mountPage({ routes: emptyRoutes })
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
expect(document.body.textContent).toContain('No variants yet')
expect(document.body.textContent).toContain(
'A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.',
)
expect(iconNames(document.body)).toContain('i-lucide-layers')
const templates = Array.from(document.body.querySelectorAll('button')).filter((button) =>
button.textContent?.includes('Use template'),
)
expect(templates.map(textOf)).toEqual([
'thumb 400×300 · cover · WebP Use template',
'hero 1200×630 · cover · WebP Use template',
])
expect(templates.map((button) => button.className.includes('min-h-16'))).toEqual([
true,
true,
])
const adds = Array.from(document.body.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Add variant',
)
expect(adds).toHaveLength(2)
expect(variantsTable()).toBeUndefined()
expect(document.body.querySelector('[data-testid="variants-summary"]')).toBeNull()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
})
it.each([
['thumb', '400', '300', '80'],
['hero', '1200', '630', '82'],
])('A template opens the form pre-filled: %s', async (name, width, height, quality) => {
await mountPage({ routes: emptyRoutes })
const template = Array.from(document.body.querySelectorAll('button')).find((button) =>
button.textContent?.startsWith(name),
)!
await click(template)
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain('Add variant')
const form = document.body.querySelector('[role="dialog"]')!
expect((form.querySelector('[data-field="name"] input') as HTMLInputElement).value).toBe(
name,
)
expect((form.querySelector('[data-field="width"] input') as HTMLInputElement).value).toBe(
width,
)
expect((form.querySelector('[data-field="height"] input') as HTMLInputElement).value).toBe(
height,
)
expect((form.querySelector('[data-field="quality"] input') as HTMLInputElement).value).toBe(
quality,
)
expect(buttonIn(form, 'Add variant')!.disabled).toBe(false)
})
})
describe('Rule: Error loading', () => {
it.each([
['status 500', () => Promise.reject(new ApiError(500, undefined, 'boom'))],
['a network failure', () => Promise.reject(new NetworkError())],
])('A failed list request: %s', async (_name, failure) => {
await mountPage({ routes: { [LIST]: failure } })
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
const alert = document.body.querySelector<HTMLElement>('[role="alert"]')!
expect(textOf(alert)).toContain("Couldn't load variants")
expect(textOf(alert)).toContain('Check your connection and try again.')
expect(textOf(alert)).not.toContain('boom')
const retry = buttonIn(alert, 'Try again')!
expect(iconOf(retry)).toBe('i-lucide-rotate-cw')
expect(alert.querySelector('[data-slot="close"]')).toBeNull()
expect(variantsTable()).toBeUndefined()
expect(document.body.querySelector('[data-testid="variant-card"]')).toBeNull()
expect(document.body.querySelector('[data-testid="variants-summary"]')).toBeNull()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
})
it('Try again reloads', async () => {
let attempt = 0
await mountPage({
routes: {
[LIST]: () =>
++attempt === 1
? Promise.reject(new ApiError(500))
: Promise.resolve({ items: defaultFixture() }),
},
})
await click(findButton('Try again'))
expect(calls(LIST)).toHaveLength(2)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(rows()).toHaveLength(6)
})
})
describe('Rule: Status switch', () => {
it('Each row has a labelled switch paired with an icon and a word', async () => {
await mountPage()
expect(document.body.querySelectorAll('button[role="switch"]')).toHaveLength(6)
expect(switchOf('hero').getAttribute('aria-checked')).toBe('true')
expect(switchOf('print').getAttribute('aria-checked')).toBe('false')
expect(iconNames(rowOf('hero'))).toContain('i-lucide-circle-check')
expect(iconNames(rowOf('print'))).toContain('i-lucide-circle-off')
expect(switchOf('hero').className).toContain('w-8')
expect(switchOf('hero').className).toContain('h-4.5')
})
it.each([
['hero', 'enabled', PATCH_HERO, false, 'Disabled'],
['print', 'disabled', PATCH_PRINT, true, 'Enabled'],
])(
'Toggling sends PATCH and updates the row: %s is %s',
async (name, _before, route, after, label) => {
const pending = deferred<unknown>()
const original = defaultFixture().find((item) => item.name === name)!
await mountPage({ routes: { [route]: () => pending.promise } })
await click(switchOf(name))
expect(JSON.parse(String((calls(route)[0]![1] as RequestInit).body))).toEqual({
enabled: after,
})
expect(switchOf(name).disabled).toBe(true)
pending.resolve({ ...original, enabled: after })
await flushPromises()
expect(statusOf(name)).toBe(label)
expect(switchOf(name).disabled).toBe(false)
expect(textOf(rowOf(name))).toContain(`${original.generated_files}`)
expect(calls(`POST /v1/image-variants/${name}/purge`)).toHaveLength(0)
expect(toasts.add).not.toHaveBeenCalled()
},
)
it('A toggle shows the response generated_files and cache_size', async () => {
await mountPage({
routes: {
[PATCH_HERO]: () =>
Promise.resolve(
variant({ enabled: false, generated_files: 5, cache_size: 2 * MB }),
),
},
})
await click(switchOf('hero'))
expect(textOf(rowOf('hero'))).toBe('hero 1200×630 cover WebP 82 5 2 MB Disabled')
})
it.each([
[403, "You don't have access", 'Only admins can manage image variants.'],
[404, 'Not found', NOT_FOUND_TEXT],
[500, 'Something went wrong', SERVER_TEXT],
])('A failed toggle reverts and reports: %s', async (status, title, text) => {
await mountPage({ routes: { [PATCH_HERO]: () => Promise.reject(new ApiError(status)) } })
await click(switchOf('hero'))
expect(statusOf('hero')).toBe('Enabled')
expect(switchOf('hero').getAttribute('aria-checked')).toBe('true')
expect(switchOf('hero').disabled).toBe(false)
expect(toastCalls(toasts)).toEqual([{ title, description: text, color: 'error' }])
})
})
describe('Rule: Row menu', () => {
it('The ellipsis opens a three-item menu', async () => {
await mountPage()
const button = buttonByLabel('Actions for hero')!
expect(button.getAttribute('aria-label')).toBe('Actions for hero')
await click(button)
const menu = document.body.querySelector<HTMLElement>('[role="menu"]')!
expect(menu.className).toContain('w-44')
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
expect(items.map((item) => item.textContent?.trim())).toEqual([
'Edit',
'Purge cache',
'Delete',
])
expect(items.every((item) => item.className.includes('h-8'))).toBe(true)
expect(items.every((item) => item.className.includes('max-sm:h-11'))).toBe(true)
expect(items[2]!.className).toContain('text-error')
expect(button.className).toContain('data-[state=open]:outline-primary')
expect(button.getAttribute('data-state')).toBe('open')
})
it('Menu keyboard behaviour: Escape closes the menu with focus back on the ellipsis button', async () => {
await mountPage()
const button = buttonByLabel('Actions for hero')!
button.focus()
await click(button)
await pressEscape()
await vi.waitFor(() => expect(document.body.querySelector('[role="menu"]')).toBeNull())
await vi.waitFor(() => expect(document.activeElement).toBe(button))
})
it('Menu items: Edit opens the slide-over in edit mode', async () => {
await mountPage()
await openMenu('hero')
await click(menuItem('Edit'))
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
'Edit variant: hero',
)
})
it('Menu items: Purge cache opens the purge dialog', async () => {
await mountPage()
await openMenu('hero')
await click(menuItem('Purge cache'))
expect(alertDialog()?.textContent).toContain('Purge cache for “hero”?')
})
it('Menu items: Delete opens the delete dialog', async () => {
await mountPage()
await openMenu('hero')
await click(menuItem('Delete'))
expect(alertDialog()?.textContent).toContain('Delete variant “hero”?')
})
})
describe('Rule: Purge cache', () => {
async function openPurge() {
await openMenu('hero')
await click(menuItem('Purge cache'))
}
it('The purge dialog', async () => {
await mountPage()
await openPurge()
const dialog = alertDialog()!
expect(textOf(dialog)).toContain('Purge cache for “hero”?')
expect(textOf(dialog)).toContain(
'This removes 244 generated files (84 MB). Files are generated again on the next request.',
)
const buttons = Array.from(dialog.querySelectorAll('button')).map((button) =>
button.textContent?.trim(),
)
expect(buttons).toEqual(['Cancel', 'Purge 244 files'])
expect(dialog.contains(document.activeElement)).toBe(true)
})
it('Cancel closes without a request', async () => {
await mountPage()
await openPurge()
await click(buttonIn(alertDialog()!, 'Cancel'))
expect(alertDialog()).toBeNull()
expect(calls(PURGE_HERO)).toHaveLength(0)
expect(textOf(rowOf('hero'))).toContain('244 84 MB')
expect(document.activeElement).toBe(buttonByLabel('Actions for hero'))
})
it('Escape closes the dialog without a request', async () => {
await mountPage()
await openPurge()
await pressEscape()
expect(alertDialog()).toBeNull()
expect(calls(PURGE_HERO)).toHaveLength(0)
})
it('Confirming purges while the row is dimmed', async () => {
const pending = deferred<unknown>()
await mountPage({ routes: { [PURGE_HERO]: () => pending.promise } })
await openPurge()
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
expect(calls(PURGE_HERO)).toHaveLength(1)
expect(alertDialog()).toBeNull()
expect(rowOf('hero').className).toContain('bg-elevated')
expect(switchOf('hero').disabled).toBe(true)
expect(buttonByLabel('Actions for hero')!.disabled).toBe(true)
expect(statusOf('hero')).toBe('Purging…')
expect(iconNames(rowOf('hero'))).toContain('i-lucide-loader-circle')
expect(rowOf('hero').querySelector('.text-info')).not.toBeNull()
expect(switchOf('card').disabled).toBe(false)
expect(buttonByLabel('Actions for card')!.disabled).toBe(false)
pending.resolve({ purged_files: 244, purged_bytes: 88080384 })
await flushPromises()
})
it('Purge succeeds', async () => {
await mountPage({
routes: {
[PURGE_HERO]: () => Promise.resolve({ purged_files: 244, purged_bytes: 88080384 }),
},
})
await openPurge()
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
expect(textOf(rowOf('hero'))).toBe('hero 1200×630 cover WebP 82 0 0 KB Enabled')
expect(switchOf('hero').disabled).toBe(false)
expect(buttonByLabel('Actions for hero')!.disabled).toBe(false)
expect(summary()).toBe('6 variants · 1,040 generated files · 128 MB')
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{ title: 'Purged 244 files from “hero”', description: undefined, color: 'success' },
])
})
it.each([
[404, 'Not found', NOT_FOUND_TEXT],
[500, 'Something went wrong', SERVER_TEXT],
])('Purge fails: %s', async (status, title, text) => {
await mountPage({ routes: { [PURGE_HERO]: () => Promise.reject(new ApiError(status)) } })
await openPurge()
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
expect(textOf(rowOf('hero'))).toBe('hero 1200×630 cover WebP 82 244 84 MB Enabled')
expect(switchOf('hero').disabled).toBe(false)
expect(buttonByLabel('Actions for hero')!.disabled).toBe(false)
expect(toastCalls(toasts)).toEqual([{ title, description: text, color: 'error' }])
})
})
describe('Rule: Delete variant', () => {
async function openDelete(name = 'hero') {
await openMenu(name)
await click(menuItem('Delete'))
}
it('The delete dialog', async () => {
await mountPage()
await openDelete()
const dialog = alertDialog()!
expect(textOf(dialog)).toContain('Delete variant “hero”?')
expect(textOf(dialog)).toContain(
"This removes the variant and its 244 generated files (84 MB). Articles that use ?v=hero will start returning 400. This can't be undone.",
)
const buttons = Array.from(dialog.querySelectorAll('button'))
expect(buttons.map((button) => button.textContent?.trim())).toEqual([
'Cancel',
'Delete variant',
])
expect(buttons[1]!.className).toContain('bg-error')
})
it('Confirming shows a spinner and blocks dismissal', async () => {
const pending = deferred<unknown>()
await mountPage({ routes: { [DELETE_HERO]: () => pending.promise } })
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(calls(DELETE_HERO)).toHaveLength(1)
const dialog = alertDialog()!
expect(buttonIn(dialog, 'Cancel')!.disabled).toBe(true)
const confirm = buttonIn(dialog, 'Delete variant')!
expect(iconNames(confirm)).toContain('i-lucide-loader-circle')
await pressEscape()
expect(alertDialog()).not.toBeNull()
pending.resolve(undefined)
await flushPromises()
})
it('Delete succeeds', async () => {
await mountPage({ routes: { [DELETE_HERO]: () => Promise.resolve(undefined) } })
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(alertDialog()).toBeNull()
expect(rows().map((row) => textOf(row).split(' ')[0])).toEqual([
'card',
'og',
'print',
'square',
'thumb',
])
expect(summary()).toBe('5 variants · 1,040 generated files · 128 MB')
expect(toastCalls(toasts)).toEqual([
{ title: 'Variant “hero” deleted', description: undefined, color: 'success' },
])
})
it('Deleting the last variant shows the empty state', async () => {
await mountPage({
routes: {
[LIST]: () => Promise.resolve({ items: [variant({ name: 'thumb' })] }),
'DELETE /v1/image-variants/thumb': () => Promise.resolve(undefined),
},
})
await openDelete('thumb')
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(document.body.textContent).toContain('No variants yet')
expect(variantsTable()).toBeUndefined()
})
it('Delete fails: 404 reloads the list and reports', async () => {
let listCalls = 0
await mountPage({
routes: {
[LIST]: () => {
listCalls++
return Promise.resolve({
items:
listCalls === 1
? defaultFixture()
: defaultFixture().filter((v) => v.name !== 'hero'),
})
},
[DELETE_HERO]: () => Promise.reject(new ApiError(404)),
},
})
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(alertDialog()).toBeNull()
expect(calls(LIST)).toHaveLength(2)
expect(toastCalls(toasts)).toEqual([
{ title: 'Not found', description: NOT_FOUND_TEXT, color: 'error' },
])
})
it('Delete fails: 500 keeps the row and reports', async () => {
await mountPage({ routes: { [DELETE_HERO]: () => Promise.reject(new ApiError(500)) } })
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(alertDialog()).toBeNull()
expect(rows()).toHaveLength(6)
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{ title: 'Something went wrong', description: SERVER_TEXT, color: 'error' },
])
})
})
describe('Rule: Request reference card', () => {
it('The card under the table', async () => {
await mountPage()
const card = document.body.querySelector<HTMLElement>('[data-testid="request-reference"]')!
expect(card.querySelector('h2')?.textContent).toBe('How requests are handled')
const cardRows = Array.from(card.querySelectorAll('tbody tr')).map(textOf)
expect(cardRows).toEqual([
'/static/uploadedImages/photo.png?v=thumb 200 Image',
'Unknown or disabled variant 400 Unknown variant',
'width, height, format or quality 400 Unsupported parameter',
'Missing source image 404 Not found',
])
expect(card.querySelector('tbody tr td span')!.className).toContain('font-mono')
const badges = Array.from(card.querySelectorAll('tbody tr td:nth-child(2) > *'))
expect(badges.map((badge) => badge.className.includes('bg-(--rc-success-soft)'))).toEqual([
true,
false,
false,
false,
])
expect(
badges.slice(1).every((badge) => badge.className.includes('bg-(--rc-error-soft)')),
).toBe(true)
expect(iconNames(card)).toEqual(
expect.arrayContaining(['i-lucide-circle-check', 'i-lucide-circle-x']),
)
expect(textOf(card.querySelector('div.flex.flex-col.gap-1\\.5')!)).toContain(
'Body of a 400 response',
)
expect(card.querySelector('pre')!.textContent).toBe(
'{\n "status": 400,\n "error": "unknown_variant",\n "message": "Unknown variant",\n "detail": "banner"\n}',
)
expect(card.querySelector('p')!.textContent).toBe(
'unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.',
)
})
it('The card has one column on mobile', async () => {
await mountPage({ desktop: false })
const grid = document.body.querySelector<HTMLElement>(
'[data-testid="request-reference"] .grid',
)!
expect(grid.className).toContain('grid-cols-1')
expect(grid.className).toContain('sm:grid-cols-')
})
it.each([
['Loading', { [LIST]: () => new Promise(() => undefined) }],
['Empty', { [LIST]: () => Promise.resolve({ items: [] }) }],
['Error', { [LIST]: () => Promise.reject(new ApiError(500)) }],
])('The card is not drawn when the state is %s', async (_state, routes) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ routes })
vi.advanceTimersByTime(200)
await flushPromises()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
})
})
describe('Rule: Accessibility and hygiene', () => {
it('Status is never colour alone', async () => {
await mountPage()
for (const item of defaultFixture()) {
const row = rowOf(item.name)
expect(statusOf(item.name)).toMatch(/^(Enabled|Disabled|Purging…)$/)
expect(iconNames(row).some((name) => /circle-(check|off)/.test(name))).toBe(true)
}
})
it('Touch targets on mobile', async () => {
await mountPage({ desktop: false })
expect(buttonByLabel('Actions for hero')!.className).toContain('max-sm:size-11')
expect(findButton('Add variant')!.className).toContain('h-11')
expect(switchOf('hero').className).toContain('before:-inset-3.5')
expect(document.body.querySelectorAll('[role="tab"]')[0]!.className).toContain(
'max-sm:h-11',
)
})
it('Vocabulary', async () => {
await mountPage()
await openMenu('hero')
const text = document.body.textContent!.toLowerCase()
expect(text).not.toContain('thumbnail preset')
expect(text).not.toContain('rendition')
expect(text).not.toMatch(/\bversion\b/)
})
})
describe('Rule: French strings', () => {
it('renders the list in French with no English left over', async () => {
const { setLocale } = await import('@/i18n')
setLocale('fr')
await mountPage()
expect(pageTitle()?.textContent).toBe('Variantes d’image')
expect(rows().map((row) => textOf(row).split(' ')[0])).toEqual([
'card',
'hero',
'og',
'print',
'square',
'thumb',
])
expect(summary()).toBe('6 variantes · 1 284 fichiers générés · 212 Mo')
expect(textOf(rowOf('print'))).toContain('Désactivée')
expect(document.body.textContent).toContain('Traitement des requêtes')
const text = document.body.textContent!
for (const english of [
'Image variants',
'generated files',
'How requests',
'Enabled',
'Disabled',
'Add variant',
'Cache size',
]) {
expect(text).not.toContain(english)
}
})
})
+350
View File
@@ -0,0 +1,350 @@
import { 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 { useApi } from '@/composables/useApi'
import type { ImageVariant } from '@/composables/useImageVariants'
import { useSessionStore } from '@/stores/session'
import VariantsPage from '../VariantsPage.vue'
export const MB = 1048576
export const LIST = 'GET /v1/image-variants'
export const CREATE = 'POST /v1/image-variants'
export type Handler = (body?: Record<string, unknown>) => Promise<unknown>
export interface MountOptions {
desktop?: boolean
role?: string | null
routes?: Record<string, Handler>
}
export function variant(overrides: Partial<ImageVariant>): ImageVariant {
return {
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: false,
enabled: true,
generated_files: 244,
cache_size: 84 * MB,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-03-12T09:00:00Z',
...overrides,
}
}
export function defaultFixture(): ImageVariant[] {
return [
variant({
name: 'card',
width: 800,
height: null,
fit: 'inside',
quality: 80,
generated_files: 318,
cache_size: 58 * MB,
}),
variant({}),
variant({
name: 'og',
format: 'jpeg',
quality: 80,
generated_files: 96,
cache_size: 9 * MB,
}),
variant({
name: 'print',
width: null,
height: 2400,
fit: 'inside',
format: 'png',
quality: null,
enabled: false,
generated_files: 74,
cache_size: 21 * MB,
}),
variant({
name: 'square',
width: 600,
height: 600,
format: 'jpeg',
quality: 85,
generated_files: 140,
cache_size: 19 * MB,
}),
variant({
name: 'thumb',
width: 400,
height: 300,
quality: 80,
generated_files: 412,
cache_size: 21 * MB,
}),
]
}
export 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 }
}
export 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
}
export let requestMock: ReturnType<typeof vi.fn>
export let wrapper: VueWrapper | null = null
export async function mountPage(options: MountOptions = {}) {
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',
role: sessionRole as 'owner' | 'admin' | undefined,
}
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${path}`
const handler =
routes[key] ??
(key === LIST ? () => Promise.resolve({ items: defaultFixture() }) : 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, role: sessionRole }),
request: requestMock,
})
await router.push('/settings/media/variants')
const Host = defineComponent({ render: () => h(UApp, null, () => h(VariantsPage)) })
wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
export async function unmountPage() {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
document.body.innerHTML = ''
await router.push('/login')
}
export function textOf(element: Element) {
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(' ')
}
export function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('tbody tr, [data-testid="variant-card"]'),
).filter((row) => !row.closest('[data-testid="request-reference"]'))
}
export function pageTitle() {
return document.body.querySelector('h1:not([data-slot="title"])')
}
export function variantsTable() {
return Array.from(document.body.querySelectorAll('table')).find(
(table) => !table.closest('[data-testid="request-reference"]'),
)
}
export function rowOf(name: string) {
return rows().find((row) => row.textContent?.includes(name))!
}
export function buttonIn(root: ParentNode, text: string) {
return Array.from(root.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
export function findButton(text: string) {
return buttonIn(document.body, text)
}
export function buttonByLabel(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
}
export async function click(element: HTMLElement | null | undefined) {
element?.click()
await flushPromises()
}
export function calls(key: string) {
return requestMock.mock.calls.filter(
([path, init]) => `${(init as RequestInit | undefined)?.method ?? 'GET'} ${path}` === key,
)
}
export function bodyOf(key: string, index = 0) {
return JSON.parse(String((calls(key)[index]![1] as RequestInit).body)) as Record<
string,
unknown
>
}
export function toastCalls(toasts: { add: ReturnType<typeof vi.fn> }) {
return toasts.add.mock.calls.map(([source]) => ({
title: (source as { title?: string }).title,
description: (source as { description?: string }).description,
color: (source as { color?: string }).color,
}))
}
export function dispatch(element: Element, type: string) {
element.dispatchEvent(new Event(type, { bubbles: true }))
}
export async function typeInto(input: HTMLInputElement | null | undefined, value: string) {
input!.value = value
dispatch(input!, 'input')
await flushPromises()
}
export async function blur(input: HTMLInputElement | null | undefined) {
dispatch(input!, 'blur')
await flushPromises()
}
export async function pressKey(element: Element, key: string, init: KeyboardEventInit = {}) {
element.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }),
)
await flushPromises()
}
export async function pressEscape() {
const target = document.activeElement ?? document.body
target.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
}
export function dialogs() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"]'),
)
}
export function alertDialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
export function slideover() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
export function field(name: string) {
return document.body.querySelector<HTMLInputElement>(
`[data-field="${name}"] input:not([aria-hidden="true"])`,
)
}
export function fieldMessage(name: string) {
return document.body
.querySelector(`[data-field="${name}"] [data-slot="error"]`)
?.textContent?.trim()
}
export function fieldHelp(name: string) {
return document.body
.querySelector(`[data-field="${name}"] [data-slot="help"]`)
?.textContent?.trim()
}
export async function openMenu(name: string) {
await click(buttonByLabel(`Actions for ${name}`))
}
export function menuItem(label: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
(item) => item.textContent?.trim() === label,
)
}
export async function openAddForm() {
const opener = document.body.querySelector<HTMLElement>('[data-testid="add-variant"]')!
opener.focus()
opener.click()
await flushPromises()
}
export async function openEditForm(name: string) {
await openMenu(name)
await click(menuItem('Edit'))
}
export function stubBrowser() {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
}
export async function clickBackdrop() {
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
await new Promise((resolve) => setTimeout(resolve, 20))
const down = new MouseEvent('pointerdown', { bubbles: true, button: 0 })
Object.defineProperty(down, 'pointerType', { value: 'mouse' })
overlay.dispatchEvent(down)
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
}
export function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
return writeText
}