feat: image variants settings screens
This commit is contained in:
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user