Files
RenewCMS/web/src/views/__tests__/VariantsForm.spec.ts
T

1238 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import UIcon from '@nuxt/ui/components/Icon.vue'
import USelect from '@nuxt/ui/components/Select.vue'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import {
CREATE,
LIST,
alertDialog,
blur,
bodyOf,
buttonByLabel,
buttonIn,
calls,
click,
clickBackdrop,
defaultFixture,
deferred,
field,
fieldHelp,
fieldMessage,
findButton,
menuItem,
mountPage,
openAddForm,
openEditForm,
openMenu,
pressEscape,
pressKey,
rows,
slideover,
stubBrowser,
stubClipboard,
textOf,
toastCalls,
typeInto,
unmountPage,
variant,
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 PUT_HERO = 'PUT /v1/image-variants/hero'
const NAME_ERROR = 'Use lowercase letters, numbers and dashes, 2 to 32 characters.'
const RANGE_ERROR = 'Enter a value from 1 to 8000.'
const QUALITY_ERROR = 'Enter a value from 1 to 100.'
const DIMENSION_ERROR = 'Set a width, a height, or both.'
const DIMENSION_HELP = 'Set a width, a height, or both. 1–8000 px. Images are never enlarged.'
const SAVE_ERROR_TITLE = "Couldn't save the variant"
const URL_PREFIX = '/static/uploadedImages/north-wall.jpg?v='
const WARNING_ALERT =
'Changing dimensions, fit, format or quality purges 244 generated files; they will be regenerated on next request.'
function form() {
return slideover()!
}
function iconNames(root: ParentNode) {
return wrapper!
.findAllComponents(UIcon)
.filter((icon) => root.contains(icon.element))
.map((icon) => icon.props('name'))
}
function saveButton(label = 'Add variant') {
return buttonIn(form(), label)!
}
function footerStatus() {
return form().querySelector('[data-slot="footer"] div.flex-1')?.textContent?.trim() ?? ''
}
function radio(value: string) {
return form().querySelector<HTMLButtonElement>(`button[role="radio"][value="${value}"]`)!
}
function checkedFit() {
return form().querySelector('button[role="radio"][aria-checked="true"]')?.getAttribute('value')
}
function formatSelect() {
return wrapper!.findComponent(USelect)
}
async function setFormat(value: string) {
formatSelect().vm.$emit('update:modelValue', value)
await flushPromises()
}
function formatShown() {
return form().querySelector('[role="combobox"]')?.textContent?.trim()
}
function qualityInput() {
return field('quality')
}
function sliderThumb() {
return form().querySelector<HTMLElement>('[data-field="quality"] [role="slider"]')!
}
function sliderRange() {
return form().querySelector<HTMLElement>('[data-field="quality"] [data-slot="range"]')!
}
function compareInput() {
return form().querySelector<HTMLInputElement>('input[type="range"]')!
}
function switchByLabel(labelId: string) {
return form().querySelector<HTMLButtonElement>(
`button[role="switch"][aria-labelledby="${labelId}"]`,
)!
}
function previewText() {
return textOf(form().querySelector('[data-testid="image-compare"]')!.parentElement!)
}
async function fillHero() {
await typeInto(field('name'), 'hero')
await typeInto(field('width'), '1200')
await typeInto(field('height'), '630')
await setFormat('webp')
await typeInto(qualityInput(), '82')
await click(switchByLabel('generate-on-upload-label'))
}
async function submit(label = 'Add variant') {
await click(saveButton(label))
}
function saved(overrides: Record<string, unknown> = {}) {
return { ...variant({ name: 'hero' }), generated_files: 0, cache_size: 0, ...overrides }
}
beforeEach(() => {
toasts.add.mockClear()
localStorage.clear()
initializeLocale()
stubBrowser()
vi.mocked(useApi).mockReset()
})
afterEach(async () => {
vi.unstubAllGlobals()
await unmountPage()
localStorage.clear()
initializeLocale()
})
describe('Rule: Slide-over opening and closing', () => {
it('Add variant opens the slide-over on desktop', async () => {
await mountPage()
await openAddForm()
const panel = form()
expect(panel.className).toContain('sm:max-w-240')
expect(panel.querySelector('[data-slot="title"]')?.textContent?.trim()).toBe('Add variant')
expect(panel.querySelector('[data-slot="header"]')!.className).toContain('h-14')
const close = buttonByLabel('Close')!
expect(iconNames(close)).toContain('i-lucide-x')
expect(panel.querySelector('[data-slot="body"] form')!.className).toContain(
'sm:grid-cols-[440px_minmax(0,1fr)]',
)
expect(panel.querySelector('[data-slot="footer"]')!.className).toContain('sm:h-16')
expect(buttonIn(panel, 'Cancel')!.disabled).toBe(false)
expect(saveButton().className).toContain('sm:h-8')
expect(footerStatus()).toBe('')
expect(document.activeElement).toBe(field('name'))
expect(document.body.querySelector('[data-slot="overlay"]')).not.toBeNull()
expect(rows()).toHaveLength(6)
})
it('Add variant opens a full page on mobile', async () => {
await mountPage({ desktop: false })
await openAddForm()
const panel = form()
expect(buttonByLabel('Close')).toBeNull()
const back = buttonByLabel('Back')!
expect(iconNames(back)).toContain('i-lucide-arrow-left')
expect(panel.querySelector('[data-slot="title"]')?.textContent?.trim()).toBe('Add variant')
expect(panel.className).toContain('w-full')
expect(panel.querySelector('[data-slot="footer"]')!.className).toContain('h-18')
expect(buttonIn(panel, 'Cancel')!.className).toContain('flex-1')
expect(saveButton().className).toContain('flex-1')
expect(panel.textContent).toContain('Preview')
const sections = panel.querySelectorAll('[data-slot="body"] form > div')
expect(sections[1]!.className).toContain('border-t')
})
it('Edit opens with the stored values', async () => {
await mountPage()
await openEditForm('hero')
const panel = form()
expect(panel.querySelector('[data-slot="title"]')?.textContent?.trim()).toBe(
'Edit variant: hero',
)
expect(field('name')!.value).toBe('hero')
expect(field('name')!.readOnly).toBe(true)
expect(field('name')!.className).toContain('bg-elevated')
expect(iconNames(panel.querySelector('[data-field="name"]')!)).toContain('i-lucide-lock')
expect(fieldHelp('name')).toBe("The name can't be changed.")
expect([field('width')!.value, field('height')!.value]).toEqual(['1200', '630'])
expect(checkedFit()).toBe('cover')
expect(formatShown()).toBe('WebP')
expect(qualityInput()!.value).toBe('82')
expect(switchByLabel('generate-on-upload-label').getAttribute('aria-checked')).toBe('false')
expect(switchByLabel('variant-enabled-label').getAttribute('aria-checked')).toBe('true')
expect(textOf(panel.querySelector('[data-testid="edit-warning"]')!)).toBe(WARNING_ALERT)
expect(saveButton('Save changes').disabled).toBe(true)
expect(footerStatus()).toBe('')
})
it('Dismissing a pristine form closes immediately', async () => {
await mountPage()
const opener = document.body.querySelector<HTMLElement>('[data-testid="add-variant"]')!
await openAddForm()
await click(buttonIn(form(), 'Cancel'))
expect(slideover()).toBeNull()
expect(alertDialog()).toBeNull()
expect(document.activeElement).toBe(opener)
})
it('Dismissing a pristine form with the close button or Escape closes immediately', async () => {
await mountPage()
await openAddForm()
await click(buttonByLabel('Close'))
expect(slideover()).toBeNull()
await openAddForm()
await pressEscape()
expect(slideover()).toBeNull()
expect(alertDialog()).toBeNull()
})
it('Closing a pristine edit returns focus to the ellipsis button', async () => {
await mountPage()
await openEditForm('hero')
await click(buttonIn(form(), 'Cancel'))
await vi.waitFor(() =>
expect(document.activeElement).toBe(buttonByLabel('Actions for hero')),
)
})
it.each([
['Cancel', async () => click(buttonIn(form(), 'Cancel'))],
['the close button', async () => click(buttonByLabel('Close'))],
['Escape', async () => pressEscape()],
])('Dismissing a dirty form asks first: %s', async (_name, dismiss) => {
await mountPage()
await openEditForm('hero')
await typeInto(qualityInput(), '70')
await dismiss()
const dialog = alertDialog()!
expect(textOf(dialog)).toContain('Discard changes?')
expect(textOf(dialog)).toContain(
"You have unsaved changes to “hero”. If you close now, they're lost.",
)
expect(
Array.from(dialog.querySelectorAll('button')).map((b) => b.textContent?.trim()),
).toEqual(['Keep editing', 'Discard changes'])
expect(dialog.querySelectorAll('button')[1]!.className).toContain('bg-error')
})
it('Dismissing a dirty form on mobile with the back arrow asks first', async () => {
await mountPage({ desktop: false })
await openAddForm()
await typeInto(field('name'), 'banner')
await click(buttonByLabel('Back'))
expect(textOf(alertDialog()!)).toContain('Discard changes?')
})
it('Keep editing returns to the form with its values', async () => {
await mountPage()
await openAddForm()
await typeInto(field('name'), 'banner')
await click(buttonIn(form(), 'Cancel'))
await click(buttonIn(alertDialog()!, 'Keep editing'))
expect(alertDialog()).toBeNull()
expect(slideover()).not.toBeNull()
expect(field('name')!.value).toBe('banner')
})
it('Discard changes closes the slide-over and discards the values', async () => {
await mountPage()
await openEditForm('hero')
await typeInto(qualityInput(), '70')
await click(buttonIn(form(), 'Cancel'))
await click(buttonIn(alertDialog()!, 'Discard changes'))
expect(slideover()).toBeNull()
expect(alertDialog()).toBeNull()
expect(calls(PUT_HERO)).toHaveLength(0)
await openEditForm('hero')
expect(qualityInput()!.value).toBe('82')
})
it('The discard text names the new variant in create mode', async () => {
await mountPage()
await openAddForm()
await typeInto(field('name'), 'banner')
await click(buttonIn(form(), 'Cancel'))
expect(textOf(alertDialog()!)).toContain(
"You have unsaved changes to “banner”. If you close now, they're lost.",
)
})
it('The discard text has no name when the name is empty', async () => {
await mountPage()
await openAddForm()
await typeInto(field('width'), '800')
await click(buttonIn(form(), 'Cancel'))
expect(textOf(alertDialog()!)).toContain(
"You have unsaved changes. If you close now, they're lost.",
)
})
})
describe('Rule: Form states', () => {
it('Create: pristine', async () => {
await mountPage()
await openAddForm()
const panel = form()
expect(document.activeElement).toBe(field('name'))
expect(field('name')!.value).toBe('')
expect(field('name')!.placeholder).toBe('e.g. hero')
expect(fieldHelp('name')).toBe(
"Lowercase letters, numbers and dashes. Can't be changed later.",
)
expect([field('width')!.placeholder, field('height')!.placeholder]).toEqual([
'auto',
'auto',
])
expect(textOf(panel.querySelector('[data-field="width"]')!)).toContain('px')
expect(panel.textContent).toContain(DIMENSION_HELP)
expect(panel.querySelector('[role="radiogroup"]')).not.toBeNull()
expect(checkedFit()).toBe('cover')
expect(panel.textContent).toContain('Fills the box and crops what overflows.')
expect(formatShown()).toBe('Original')
expect(panel.textContent).toContain('Keeps the format of each source image.')
expect(sliderThumb().getAttribute('aria-valuenow')).toBe('80')
expect(qualityInput()!.value).toBe('80')
expect(fieldHelp('quality')).toBe('1–100. Higher is sharper and larger.')
expect(switchByLabel('generate-on-upload-label').getAttribute('aria-checked')).toBe('false')
expect(switchByLabel('variant-enabled-label').getAttribute('aria-checked')).toBe('true')
expect(panel.textContent).toContain('Creates this variant when an image is uploaded.')
expect(panel.textContent).toContain('Disabled variants return 400 on the public site.')
expect(saveButton().disabled).toBe(true)
expect(panel.textContent).toContain('Set a width or height to see a preview.')
expect(panel.querySelector('[data-testid="edit-warning"]')).toBeNull()
})
it.each([
['contain', 'Whole image inside the box, padded to the box size.'],
['inside', 'Whole image inside the box, no padding.'],
['cover', 'Fills the box and crops what overflows.'],
])('The Fit help follows the selection: %s', async (fit, help) => {
await mountPage()
await openAddForm()
await click(radio('inside'))
await click(radio(fit))
expect(checkedFit()).toBe(fit)
expect(form().textContent).toContain(help)
})
it('The Name counter', async () => {
await mountPage()
await openAddForm()
await typeInto(field('name'), 'hero')
expect(form().querySelector('[data-testid="name-counter"]')?.textContent).toBe('4/32')
})
it('Edit mode has no counter', async () => {
await mountPage()
await openEditForm('hero')
expect(form().querySelector('[data-testid="name-counter"]')).toBeNull()
})
it('Create: dirty with valid values', async () => {
await mountPage()
await openAddForm()
await fillHero()
expect(saveButton().disabled).toBe(false)
expect(footerStatus()).toBe('Unsaved changes')
expect(iconNames(form().querySelector('[data-slot="footer"]')!)).toContain(
'i-lucide-circle-dot',
)
expect(previewText()).toContain('hero')
expect(previewText()).toContain('1200×630 · about 96 KB')
})
it.each([
['name', 'Hero banner', NAME_ERROR],
['name', 'a', NAME_ERROR],
['name', 'a'.repeat(33), NAME_ERROR],
['width', '9000', RANGE_ERROR],
['width', '0', RANGE_ERROR],
['height', '8001', RANGE_ERROR],
['quality', '140', QUALITY_ERROR],
['quality', '0', QUALITY_ERROR],
])('Field errors show on blur: %s %s', async (name, value, message) => {
await mountPage()
await openAddForm()
const input = name === 'quality' ? qualityInput() : field(name)
await typeInto(input, value)
await blur(input)
expect(fieldMessage(name)).toBe(message)
expect(iconNames(form().querySelector(`[data-field="${name}"]`)!)).toContain(
'i-lucide-circle-alert',
)
expect(input!.getAttribute('aria-invalid')).toBe('true')
expect(input!.className).toContain('ring-error')
if (name === 'name') {
expect(fieldHelp('name')).toBeUndefined()
}
})
it('Once an error shows it is revalidated on every input and clears when valid', async () => {
await mountPage()
await openAddForm()
await typeInto(field('name'), 'Hero')
await blur(field('name'))
expect(fieldMessage('name')).toBe(NAME_ERROR)
await typeInto(field('name'), 'hero')
expect(fieldMessage('name')).toBeUndefined()
expect(fieldHelp('name')).toBe(
"Lowercase letters, numbers and dashes. Can't be changed later.",
)
await typeInto(field('name'), 'h')
expect(fieldMessage('name')).toBe(NAME_ERROR)
})
it.each([
['name', 'ab'],
['name', 'a-b-c'],
['width', '1'],
['height', '8000'],
['quality', '1'],
['quality', '100'],
])('Valid boundary values show no error: %s %s', async (name, value) => {
await mountPage()
await openAddForm()
const input = name === 'quality' ? qualityInput() : field(name)
await typeInto(input, value)
await blur(input)
expect(fieldMessage(name)).toBeUndefined()
})
it('Submitting with several errors sends nothing', async () => {
await mountPage()
await openAddForm()
await typeInto(field('name'), 'Hero banner')
await typeInto(field('width'), '9000')
await typeInto(field('height'), '630')
await setFormat('webp')
await typeInto(qualityInput(), '140')
await submit()
expect(fieldMessage('name')).toBe(NAME_ERROR)
expect(fieldMessage('width')).toBe(RANGE_ERROR)
expect(fieldMessage('quality')).toBe(QUALITY_ERROR)
expect(calls(CREATE)).toHaveLength(0)
expect(document.activeElement).toBe(field('name'))
})
it('No dimension', async () => {
await mountPage()
await openAddForm()
await typeInto(field('name'), 'banner')
await setFormat('webp')
await submit()
expect(fieldMessage('width')).toBe(DIMENSION_ERROR)
expect(fieldMessage('height')).toBeUndefined()
expect(field('height')!.className).toContain('ring-error')
expect(form().textContent).not.toContain(DIMENSION_HELP)
expect(calls(CREATE)).toHaveLength(0)
expect(document.activeElement).toBe(field('width'))
})
it('PNG hides Quality', async () => {
await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved({ name: 'logo' })) } })
await openAddForm()
await typeInto(field('name'), 'logo')
await typeInto(field('width'), '300')
await setFormat('png')
expect(form().querySelector('[data-field="quality"]')).toBeNull()
expect(form().querySelector('[role="slider"]')).toBeNull()
expect(form().textContent).not.toContain('Higher is sharper')
expect(previewText()).toContain('about')
await submit()
expect('quality' in bodyOf(CREATE)).toBe(false)
})
it('Switching back from PNG restores the previous quality', async () => {
await mountPage()
await openAddForm()
await setFormat('webp')
await typeInto(qualityInput(), '70')
await setFormat('png')
await setFormat('webp')
expect(qualityInput()!.value).toBe('70')
})
it('The Quality slider and field stay in sync', async () => {
await mountPage()
await openAddForm()
await setFormat('webp')
sliderThumb().focus()
for (let step = 0; step < 10; step++) {
await pressKey(sliderThumb(), 'ArrowLeft')
}
expect(qualityInput()!.value).toBe('70')
await typeInto(qualityInput(), '90')
expect(sliderThumb().getAttribute('aria-valuenow')).toBe('90')
expect(Number.parseFloat(sliderRange().style.right)).toBeCloseTo(10, 0)
})
})
describe('Rule: Saving a new variant', () => {
it('Add sends POST with the form values', async () => {
await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved()) } })
await openAddForm()
await fillHero()
await submit()
expect(bodyOf(CREATE)).toEqual({
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: true,
enabled: true,
})
})
it('An empty Width or Height is sent as null', async () => {
await mountPage({ routes: { [CREATE]: () => Promise.resolve(saved({ name: 'card' })) } })
await openAddForm()
await typeInto(field('name'), 'card')
await typeInto(field('width'), '800')
await submit()
expect(bodyOf(CREATE).width).toBe(800)
expect(bodyOf(CREATE).height).toBeNull()
})
it('While saving, the form is locked', async () => {
const pending = deferred<unknown>()
await mountPage({ routes: { [CREATE]: () => pending.promise } })
await openAddForm()
await fillHero()
await submit()
const panel = form()
expect(
Array.from(panel.querySelectorAll<HTMLInputElement>('input:not([type="range"])')).every(
(input) => input.disabled,
),
).toBe(true)
expect(panel.querySelector('[role="radiogroup"].opacity-60')).not.toBeNull()
expect(
Array.from(panel.querySelectorAll('button[role="switch"]')).every(
(item) => (item as HTMLButtonElement).disabled,
),
).toBe(true)
expect((panel.querySelector('[role="combobox"]') as HTMLButtonElement).disabled).toBe(true)
expect(buttonIn(panel, 'Cancel')!.disabled).toBe(true)
expect(saveButton('Saving…').disabled).toBe(true)
expect(iconNames(saveButton('Saving…'))).toContain('i-lucide-loader-circle')
await pressEscape()
await click(buttonByLabel('Close'))
await clickBackdrop()
expect(slideover()).not.toBeNull()
expect(alertDialog()).toBeNull()
pending.resolve(saved())
await flushPromises()
})
it('A successful create', async () => {
await mountPage({
routes: { [CREATE]: () => Promise.resolve(saved({ name: 'banner' })) },
})
await openAddForm()
const opener = document.body.querySelector<HTMLElement>('[data-testid="add-variant"]')!
await typeInto(field('name'), 'banner')
await typeInto(field('width'), '1200')
await submit()
expect(slideover()).toBeNull()
expect(rows().map((row) => textOf(row).split(' ')[0])).toEqual([
'banner',
'card',
'hero',
'og',
'print',
'square',
'thumb',
])
expect(textOf(rows()[0]!)).toContain('0 0 KB')
expect(
document.body.querySelector('[data-testid="variants-summary"]')?.textContent?.trim(),
).toBe('7 variants · 1,284 generated files · 212 MB')
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{ title: 'Variant “banner” added', description: undefined, color: 'success' },
])
await vi.waitFor(() => expect(document.activeElement).toBe(opener))
})
it('A create from the empty state leaves the empty state', async () => {
await mountPage({
routes: {
[LIST]: () => Promise.resolve({ items: [] }),
[CREATE]: () => Promise.resolve(saved({ name: 'thumb' })),
},
})
await click(findButton('Add variant'))
await typeInto(field('name'), 'thumb')
await typeInto(field('width'), '400')
await submit()
expect(rows()).toHaveLength(1)
expect(
document.body.querySelector('[data-testid="variants-summary"]')?.textContent?.trim(),
).toBe('1 variant · 0 generated files · 0 KB')
expect(document.body.textContent).not.toContain('No variants yet')
})
it('Name already taken (409)', async () => {
await mountPage({
routes: {
[CREATE]: () =>
Promise.reject(
new ApiError(
409,
undefined,
'A variant named “thumb” already exists. Choose another name.',
),
),
},
})
await openAddForm()
await typeInto(field('name'), 'thumb')
await typeInto(field('width'), '400')
field('width')!.focus()
await submit()
expect(slideover()).not.toBeNull()
expect(fieldMessage('name')).toBe(
'A variant named “thumb” already exists. Choose another name.',
)
expect(field('name')!.className).toContain('ring-error')
expect([field('name')!.value, field('width')!.value]).toEqual(['thumb', '400'])
expect(document.activeElement).toBe(field('name'))
expect(field('name')!.disabled).toBe(false)
expect(form().querySelector('[role="alert"]')).toBeNull()
expect(toasts.add).not.toHaveBeenCalled()
await typeInto(field('name'), 'thumb2')
expect(fieldMessage('name')).toBeUndefined()
})
it.each([
['status 500', () => Promise.reject(new ApiError(500, undefined, 'boom'))],
['a network failure', () => Promise.reject(new NetworkError())],
])('Save error: %s', async (_name, failure) => {
let attempt = 0
await mountPage({
routes: {
[CREATE]: () => (++attempt === 1 ? failure() : Promise.resolve(saved())),
},
})
await openAddForm()
await fillHero()
await submit()
expect(slideover()).not.toBeNull()
expect(field('name')!.value).toBe('hero')
expect(field('name')!.disabled).toBe(false)
const alert = form().querySelector<HTMLElement>('[role="alert"]')!
expect(textOf(alert)).toContain(SAVE_ERROR_TITLE)
expect(textOf(alert)).toContain('Check your connection and try again.')
expect(textOf(alert)).not.toContain('boom')
expect(
form().querySelector('[data-slot="body"] form')!.firstElementChild!.contains(alert),
).toBe(true)
await click(buttonIn(alert, 'Try again'))
expect(calls(CREATE)).toHaveLength(2)
expect(bodyOf(CREATE, 1)).toEqual(bodyOf(CREATE, 0))
expect(form()?.querySelector('[role="alert"]') ?? null).toBeNull()
expect(slideover()).toBeNull()
})
it('Try again removes the alert as soon as the request starts', async () => {
const retry = deferred<unknown>()
let attempt = 0
await mountPage({
routes: {
[CREATE]: () =>
++attempt === 1 ? Promise.reject(new ApiError(500)) : retry.promise,
},
})
await openAddForm()
await fillHero()
await submit()
await click(buttonIn(form().querySelector('[role="alert"]')!, 'Try again'))
expect(form().querySelector('[role="alert"]')).toBeNull()
retry.resolve(saved())
await flushPromises()
})
it.each([
[
400,
'Check the request',
"A value in the request isn't valid. Reload the page and try again.",
],
[403, "You don't have access", 'Only admins can manage image variants.'],
[404, 'Not found', 'This variant or image no longer exists. Refresh the page.'],
])('Other statuses on save: %s', async (status, title, text) => {
await mountPage({ routes: { [CREATE]: () => Promise.reject(new ApiError(status)) } })
await openAddForm()
await fillHero()
await submit()
expect(slideover()).not.toBeNull()
expect(field('name')!.value).toBe('hero')
expect(toastCalls(toasts)).toEqual([{ title, description: text, color: 'error' }])
})
it.each([
[NAME_ERROR, 'name', NAME_ERROR],
[
'Width and height must be between 1 and 8000, and one of them is required.',
'width',
DIMENSION_ERROR,
],
['Quality must be between 1 and 100.', 'quality', QUALITY_ERROR],
])('A 422 from the server is shown inline: %s', async (serverText, fieldName, inline) => {
await mountPage({
routes: { [CREATE]: () => Promise.reject(new ApiError(422, undefined, serverText)) },
})
await openAddForm()
await fillHero()
await submit()
expect(fieldMessage(fieldName)).toBe(inline)
expect(form().querySelector('[role="alert"]')).toBeNull()
})
it('A 422 about the fit shows the save error alert', async () => {
await mountPage({
routes: {
[CREATE]: () =>
Promise.reject(
new ApiError(422, undefined, 'Fit must be cover, contain or inside.'),
),
},
})
await openAddForm()
await fillHero()
await submit()
expect(textOf(form().querySelector('[role="alert"]')!)).toContain(SAVE_ERROR_TITLE)
})
})
describe('Rule: Saving an edit', () => {
it('Edit: dirty', async () => {
await mountPage()
await openEditForm('hero')
expect(previewText()).toContain('1200×630 · about 96 KB')
await typeInto(qualityInput(), '70')
expect(saveButton('Save changes').disabled).toBe(false)
expect(footerStatus()).toBe('Unsaved changes')
expect(qualityInput()!.value).toBe('70')
expect(previewText()).toContain('1200×630 · about 71 KB')
expect(form().querySelector('[data-testid="edit-warning"]')).not.toBeNull()
})
it('Save sends PUT with the complete definition and a successful edit reports the purge', async () => {
await mountPage({
routes: {
[PUT_HERO]: () =>
Promise.resolve({
...variant({ quality: 70, generated_files: 0, cache_size: 0 }),
purged_files: 244,
}),
},
})
await openEditForm('hero')
await typeInto(qualityInput(), '70')
await submit('Save changes')
expect(bodyOf(PUT_HERO)).toEqual({
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 70,
generate_on_upload: false,
enabled: true,
})
expect(slideover()).toBeNull()
const hero = rows().find((row) => textOf(row).startsWith('hero'))!
expect(textOf(hero)).toBe('hero 1200×630 cover WebP 70 0 0 KB Enabled')
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{
title: 'Variant “hero” saved. 244 files purged.',
description: undefined,
color: 'success',
},
])
})
it('A switch-only edit says nothing about purging', async () => {
await mountPage({
routes: {
[PUT_HERO]: () =>
Promise.resolve({ ...variant({ generate_on_upload: true }), purged_files: 0 }),
},
})
await openEditForm('hero')
await click(switchByLabel('generate-on-upload-label'))
await submit('Save changes')
expect(bodyOf(PUT_HERO).generate_on_upload).toBe(true)
expect(toastCalls(toasts)).toEqual([
{ title: 'Variant “hero” saved.', description: undefined, color: 'success' },
])
})
it('Saving while saving and the save error behave as in create', async () => {
const pending = deferred<unknown>()
await mountPage({ routes: { [PUT_HERO]: () => pending.promise } })
await openEditForm('hero')
await typeInto(qualityInput(), '70')
await submit('Save changes')
expect(saveButton('Saving…').disabled).toBe(true)
expect(buttonIn(form(), 'Cancel')!.disabled).toBe(true)
await pressEscape()
expect(slideover()).not.toBeNull()
pending.reject(new ApiError(500))
await flushPromises()
expect(textOf(form().querySelector('[role="alert"]')!)).toContain(SAVE_ERROR_TITLE)
expect(qualityInput()!.value).toBe('70')
})
})
describe('Rule: Preview and ImageCompare', () => {
async function openHero() {
await mountPage()
await openAddForm()
await typeInto(field('name'), 'hero')
await typeInto(field('width'), '1200')
await typeInto(field('height'), '630')
await setFormat('webp')
await typeInto(qualityInput(), '82')
}
it('Preview layout', async () => {
await openHero()
const panel = form()
expect(panel.querySelector('h3')?.textContent).toBe('Preview')
expect(panel.textContent).toContain('Sample: north-wall.jpg')
const box = panel.querySelector('[data-testid="image-compare"]')!
expect(box.className).toContain('aspect-3/2')
expect(box.className).toContain('border')
expect(box.className).toContain('overflow-hidden')
const labels = box.nextElementSibling!
expect(textOf(labels)).toBe('Original 4032 × 3024 · 3.9 MB hero 1200×630 · about 96 KB')
expect(panel.textContent).toContain(
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.',
)
})
it('Compare box content', async () => {
await openHero()
const box = form().querySelector<HTMLElement>('[data-testid="image-compare"]')!
expect(
box.querySelector<HTMLElement>('[data-testid="compare-variant"]')!.style.clipPath,
).toBe('inset(0 0 0 50%)')
const chips = Array.from(box.querySelectorAll('span.absolute.top-2'))
expect(chips.map((chip) => chip.textContent?.trim())).toEqual(['Original', 'hero'])
expect(chips[0]!.className).toContain('left-2')
expect(chips[1]!.className).toContain('right-2')
expect(chips[1]!.className).toContain('font-mono')
expect(box.querySelector<HTMLElement>('[data-testid="compare-divider"]')!.style.left).toBe(
'50%',
)
expect(box.querySelector('[data-testid="compare-divider"]')!.className).toContain('w-0.5')
const handle = box.querySelector<HTMLElement>('[data-testid="compare-handle"]')!
expect(handle.className).toContain('size-8')
expect(handle.className).toContain('max-sm:size-11')
expect(iconNames(handle)).toContain('i-lucide-chevrons-left-right')
})
it('ImageCompare is a native range input', async () => {
await openHero()
const input = compareInput()
expect(input.type).toBe('range')
expect([input.min, input.max, input.step, input.value]).toEqual(['0', '100', '1', '50'])
expect(input.getAttribute('aria-label')).toBe('Compare original and hero')
expect(input.getAttribute('aria-valuetext')).toBe('50% hero')
expect(input.getAttribute('aria-valuenow')).toBe('50')
})
it.each([
[50, 'ArrowRight', {}, 51],
[50, 'ArrowUp', {}, 51],
[50, 'ArrowLeft', {}, 49],
[50, 'ArrowDown', {}, 49],
[50, 'ArrowRight', { shiftKey: true }, 60],
[50, 'ArrowLeft', { shiftKey: true }, 40],
[50, 'Home', {}, 0],
[50, 'End', {}, 100],
[100, 'ArrowRight', {}, 100],
[0, 'ArrowLeft', {}, 0],
[95, 'ArrowRight', { shiftKey: true }, 100],
[5, 'ArrowLeft', { shiftKey: true }, 0],
])('Keyboard behaviour: from %s press %s %j gives %s', async (start, key, init, end) => {
await openHero()
const input = compareInput()
input.value = String(start)
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
input.focus()
await pressKey(input, key, init)
expect(compareInput().value).toBe(String(end))
expect(compareInput().getAttribute('aria-valuetext')).toBe(`${end}% hero`)
expect(
form().querySelector<HTMLElement>('[data-testid="compare-divider"]')!.style.left,
).toBe(`${end}%`)
expect(
form().querySelector<HTMLElement>('[data-testid="compare-variant"]')!.style.clipPath,
).toBe(`inset(0 0 0 ${end}%)`)
})
it('Focus ring and the slider keeps its value when the form fields change', async () => {
await openHero()
const handle = form().querySelector<HTMLElement>('[data-testid="compare-handle"]')!
expect(handle.className).toContain('peer-focus-visible:outline-2')
expect(handle.className).toContain('peer-focus-visible:outline-offset-2')
expect(handle.className).toContain('peer-focus-visible:outline-primary')
expect(compareInput().className).toContain('peer')
await pressKey(compareInput(), 'End')
await typeInto(field('width'), '800')
expect(compareInput().value).toBe('100')
})
it('Pointer dragging', async () => {
await openHero()
const input = compareInput()
input.value = '75'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
expect(input.getAttribute('aria-valuetext')).toBe('75% hero')
expect(
form().querySelector<HTMLElement>('[data-testid="compare-divider"]')!.style.left,
).toBe('75%')
})
it('No dimension, no comparison', async () => {
await mountPage()
await openAddForm()
const box = form().querySelector<HTMLElement>('[data-testid="image-compare"]')!
expect(box.querySelector('[data-testid="compare-variant"]')).toBeNull()
expect(box.querySelector('[data-testid="compare-divider"]')).toBeNull()
expect(box.querySelector('[data-testid="compare-handle"]')).toBeNull()
expect(box.querySelector('input[type="range"]')).toBeNull()
expect(Array.from(box.querySelectorAll('span.absolute.top-2')).map(textOf)).toEqual([
'Original',
])
const overlay = box.querySelector('p')!
expect(overlay.textContent).toBe('Set a width or height to see a preview.')
expect(overlay.className).toContain('right-3')
expect(overlay.className).toContain('bottom-3')
expect(overlay.className).toContain('left-3')
expect(textOf(box.nextElementSibling!)).toBe(
'Original 4032 × 3024 · 3.9 MB name No output yet',
)
})
it('The sample name and slider labels follow the name', async () => {
await mountPage()
await openAddForm()
await typeInto(field('width'), '800')
expect(compareInput().getAttribute('aria-label')).toBe('Compare original and name')
expect(previewText()).toContain('name 800×600 · about')
expect(
Array.from(form().querySelectorAll('span.absolute.top-2')).map(
(chip) => chip.textContent,
),
).toEqual(['Original', 'name'])
})
it('The estimate follows quality and format', async () => {
await openHero()
const labels: string[] = []
for (const quality of ['30', '60', '90']) {
await typeInto(qualityInput(), quality)
labels.push(previewText().match(/about (\d+) KB/)![1]!)
}
expect(labels.map(Number)).toEqual([...labels.map(Number)].sort((a, b) => a - b))
await setFormat('png')
expect(previewText()).toContain('1200×630 · about 310 KB')
})
})
describe('Rule: URL and copy', () => {
it('The URL row', async () => {
await mountPage()
await openAddForm()
await typeInto(field('name'), 'hero')
const url = form().querySelector('[data-testid="variant-url"]')!
expect(url.textContent?.trim()).toBe(`${URL_PREFIX}hero`)
expect(url.className).toContain('font-mono')
expect(url.className).toContain('bg-elevated')
expect(url.className).toContain('text-highlighted')
const copy = buttonIn(form(), 'Copy')!
expect(copy.disabled).toBe(false)
expect(iconNames(copy)).toContain('i-lucide-copy')
})
it('Without a name', async () => {
await mountPage()
await openAddForm()
const url = form().querySelector('[data-testid="variant-url"]')!
expect(url.textContent?.trim()).toBe(`${URL_PREFIX}<name>`)
expect(url.className).toContain('text-muted')
expect(buttonIn(form(), 'Copy')!.disabled).toBe(true)
})
it('Copy writes the URL, shows Copied, then returns to Copy', async () => {
const writeText = stubClipboard()
await mountPage()
await openAddForm()
await typeInto(field('name'), 'hero')
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await click(buttonIn(form(), 'Copy'))
expect(writeText).toHaveBeenCalledWith(`${URL_PREFIX}hero`)
const copied = buttonIn(form(), 'Copied')!
expect(copied.textContent).toContain('Copied')
expect(iconNames(copied)).toContain('i-lucide-check')
vi.advanceTimersByTime(2500)
await flushPromises()
expect(buttonIn(form(), 'Copied')).toBeUndefined()
expect(iconNames(buttonIn(form(), 'Copy')!)).toContain('i-lucide-copy')
})
it('A clipboard failure does not claim success', async () => {
const writeText = stubClipboard()
writeText.mockRejectedValue(new Error('denied'))
await mountPage()
await openAddForm()
await typeInto(field('name'), 'hero')
await click(buttonIn(form(), 'Copy'))
expect(buttonIn(form(), 'Copy')!.textContent).toContain('Copy')
expect(buttonIn(form(), 'Copied')).toBeUndefined()
expect(form().querySelector('[role="alert"]')).toBeNull()
expect(toasts.add).not.toHaveBeenCalled()
})
it('In edit mode the URL uses the locked name', async () => {
await mountPage()
await openEditForm('hero')
expect(form().querySelector('[data-testid="variant-url"]')!.textContent?.trim()).toBe(
`${URL_PREFIX}hero`,
)
expect(buttonIn(form(), 'Copy')!.disabled).toBe(false)
})
})
describe('Rule: Menu keyboard behaviour', () => {
it('arrow keys move between items and Enter activates the focused item', async () => {
await mountPage()
const trigger = buttonByLabel('Actions for hero')!
trigger.focus()
await click(trigger)
const menu = document.body.querySelector<HTMLElement>('[role="menu"]')!
await pressKey(menu, 'ArrowDown')
const first = document.activeElement as HTMLElement
await pressKey(first, 'ArrowDown')
const second = document.activeElement as HTMLElement
await pressKey(second, 'ArrowUp')
expect(first.textContent?.trim()).toBe('Edit')
expect(second.textContent?.trim()).toBe('Purge cache')
expect((document.activeElement as HTMLElement).textContent?.trim()).toBe('Edit')
await pressKey(document.activeElement!, 'Enter')
expect(menuItem('Edit') ?? null).toBeNull()
expect(slideover()?.textContent).toContain('Edit variant: hero')
await openMenu('card')
})
})
describe('Rule: French strings on the form', () => {
it('renders the form in French', async () => {
setLocale('fr')
await mountPage()
await click(buttonByLabel('Actions pour hero'))
await click(menuItem('Modifier'))
const text = form().textContent!
expect(form().querySelector('[data-slot="title"]')?.textContent?.trim()).toBe(
'Modifier la variante : hero',
)
for (const french of [
'Aperçu',
'Ajustement',
'Qualité',
'Générer à l’import',
'Enregistrer les modifications',
'Annuler',
'Copier',
]) {
expect(text).toContain(french)
}
for (const english of [
'Preview',
'Quality',
'Save changes',
'Cancel',
'Generate on upload',
'Enabled',
'Copy',
'Drag the divider',
]) {
expect(text).not.toContain(english)
}
expect(defaultFixture()).toHaveLength(6)
})
})