import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import UButton from '@nuxt/ui/components/Button.vue' import router from '@/router' import { ApiError, NetworkError } from '@/composables/useApi' import { MOBILE_WIDTH } from '@/components/__tests__/viewport' import { NORTH_WALL_BYTES, callsTo, cardFor, cards, deferred, findButton, kit, listBody, fixture, mountPage, panel, press, tab, tabs, textOf, unmountPage, variantItems, variantList, } from './mediaTestKit' import { savedPercent, signedPercent } from '@/lib/variants/variantSaving' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() })) vi.mock('@nuxt/ui/composables', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useToast: () => toasts } }) const PARTLY = ['card', 'hero', 'square', 'thumb'] const SVG_LIST = { unsupported: 'svg', items: [] } const writeText = vi.fn() function mountTab(...[handlers, url, width, options]: Parameters) { return mountPage(handlers, url, width, { ...options, app: true }) } function toastWith(id: string) { return toasts.add.mock.calls.map(([content]) => content).find((content) => content.id === id) } function lastToast() { return toasts.add.mock.calls.at(-1)![0] } async function openVariants(filename = 'north-wall.jpg') { cardFor(filename).click() await flushPromises() activateTab('Variants') await flushPromises() } function activateTab(label: string) { const target = tab(label) target.focus() press(target, 'Enter') } function body() { return panel()!.querySelector('[data-testid="variants-tab"]')! } function rows() { return Array.from(body().querySelectorAll('[data-testid="variant-row"]')) } function rowOf(name: string) { return rows().find((row) => row.getAttribute('data-variant') === name)! } function rowAction(name: string, label: string) { return Array.from(rowOf(name).querySelectorAll('button')).find( (button) => (button.getAttribute('aria-label') ?? textOf(button)) === label, ) } function summary() { return textOf( Array.from(body().querySelectorAll('span')).find((span) => /of \d+ generated/.test(span.textContent ?? ''), ), ) } function generateAllButton() { return findButton('Generate all', body()) ?? findButton('Generating…', body()) } function variantRequests(method = 'GET') { return callsTo(method, /\/v1\/media\/1\/variants$/) } beforeEach(() => { toasts.add.mockClear() writeText.mockReset().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true) }) afterEach(unmountPage) describe('Saving percent', () => { it.each([ [18432, '-99%'], [65536, '-98%'], [98304, '-98%'], [145408, '-96%'], [3250586, '-21%'], [4089446, '0%'], [4500000, '+10%'], ])('Saving against the original for %i bytes reads %s', (size, text) => { expect(signedPercent(NORTH_WALL_BYTES, size)).toBe(text) }) it('never shows -100% for a non-empty file', () => { expect(savedPercent(NORTH_WALL_BYTES, 1)).toBe(-99) expect(savedPercent(NORTH_WALL_BYTES, 0)).toBe(-100) }) it('rounds half away from zero', () => { expect(savedPercent(200, 101)).toBe(-50) expect(savedPercent(200, 301)).toBe(51) expect(savedPercent(0, 10)).toBe(0) }) }) describe('The panel tabs', () => { it('gives an owner two tabs under the header with the first selected', async () => { await mountTab({ variants: () => variantList() }) cardFor('north-wall.jpg').click() await flushPromises() const labels = tabs() .map(textOf) .filter((label) => label === 'Details' || label === 'Variants') expect(labels).toEqual(['Details', 'Variants']) expect(tab('Details').getAttribute('aria-selected')).toBe('true') expect(tab('Variants').className).toContain('h-10') expect(tab('Variants').className).toContain('max-md:h-11') }) it('gives an admin the tabs too', async () => { await mountTab({ variants: () => variantList() }, '/media', undefined, { role: 'admin' }) cardFor('north-wall.jpg').click() await flushPromises() expect(tab('Variants')).toBeDefined() }) it('A session that is not owner or admin sees no tab and sends no request', async () => { await mountTab({}, '/media', undefined, { role: 'user' }) cardFor('north-wall.jpg').click() await flushPromises() expect(tabs().filter((entry) => textOf(entry) === 'Variants')).toHaveLength(0) expect(textOf(panel())).toContain('Copy URL') expect(variantRequests()).toHaveLength(0) }) it('Selecting the tab requests the variants once per open panel', async () => { await mountTab({ variants: () => variantList(PARTLY) }) cardFor('north-wall.jpg').click() await flushPromises() expect(variantRequests()).toHaveLength(0) activateTab('Variants') await flushPromises() activateTab('Details') await flushPromises() activateTab('Variants') await flushPromises() expect(variantRequests()).toHaveLength(1) expect(textOf(panel())).toContain('Original · kept untouched') }) it('keeps the Details body unchanged', async () => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() activateTab('Details') await flushPromises() const details = panel()!.querySelector('dl')! expect(textOf(details)).toContain('JPEG') expect(details.closest('[style*="display: none"]')).toBeNull() }) }) describe('Loading and failing', () => { it('draws nothing at 149 ms and five skeleton rows with aria-busy at 150 ms', async () => { const pending = deferred() await mountTab({ variants: () => pending.promise }) cardFor('north-wall.jpg').click() await flushPromises() vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) activateTab('Variants') await flushPromises() vi.advanceTimersByTime(149) await flushPromises() expect(body().children).toHaveLength(1) expect(body().querySelector('[aria-busy="true"]')).toBeNull() vi.advanceTimersByTime(1) await flushPromises() const region = body().querySelector('[aria-busy="true"]')! expect(region.getAttribute('aria-label')).toBe('Loading variants') expect(region.querySelectorAll('[data-testid="variant-skeleton-row"]')).toHaveLength(5) vi.useRealTimers() }) it.each([ ['status 500', () => Promise.reject(new ApiError(500))], ['a network failure', () => Promise.reject(new NetworkError())], ])('The variants request fails with %s and Try again sends it again', async (_label, fail) => { await mountTab({ variants: fail }) await openVariants() const alert = body().querySelector('[role="alert"]')! expect(textOf(alert)).toContain("Couldn't load variants") expect(textOf(alert)).toContain('Check your connection and try again.') const retry = findButton('Try again', alert)! expect(retry).toBeDefined() retry.click() await flushPromises() expect(variantRequests()).toHaveLength(2) }) }) describe('The list', () => { it('shows the Original block, the summary and the list when partly generated', async () => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() const children = Array.from(body().children) expect(children[0]!.getAttribute('data-testid')).toBe('variants-original') expect(textOf(children[0])).toBe( 'Original · kept untouched north-wall.jpg JPEG · 4032 × 3024 · 3.9 MB', ) expect(summary()).toBe('4 of 5 generated') expect(findButton('Generate all', body())).toBeDefined() expect(rows().map((row) => row.getAttribute('data-variant'))).toEqual([ 'card', 'hero', 'og', 'square', 'thumb', ]) }) it.each([ ['thumb', '400×300 · WebP · 18 KB · -99% vs original'], ['card', '800×600 · WebP · 64 KB · -98% vs original'], ['hero', '1200×630 · WebP · 96 KB · -98% vs original'], ['square', '600×600 · JPEG · 82 KB · -98% vs original'], ])('A generated row: %s', async (name, meta) => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() const row = rowOf(name) expect(textOf(row)).toContain('Generated') expect(textOf(row)).toContain(meta) const saving = Array.from(row.querySelectorAll('span')).find((span) => span.textContent?.endsWith('vs original'), )! expect(saving.className).toContain('text-success') expect(row.querySelector('.font-mono')!.className).toContain('font-medium') }) it('A not generated row', async () => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() const row = rowOf('og') expect(textOf(row)).toContain('Not generated') expect(textOf(row)).toContain('1200×630 · JPEG') expect(textOf(row)).not.toContain('vs original') expect(textOf(row)).not.toContain('KB') }) it('Nothing generated yet', async () => { await mountTab({ variants: () => variantList([]) }) await openVariants() expect(textOf(body())).toContain('0 of 5 generated') expect(textOf(body())).toContain('Nothing generated yet.') expect(rows().every((row) => textOf(row).includes('Not generated'))).toBe(true) }) it('No variants defined', async () => { await mountTab({ variants: () => ({ unsupported: null, items: [] }) }) await openVariants() expect(textOf(body())).toContain('No variants defined') expect(textOf(body())).toContain( 'Define a variant in Settings to get resized copies of this image.', ) const link = body().querySelector('a')! expect(textOf(link)).toBe('Settings → Media → Variants') expect(link.getAttribute('href')).toBe('/settings/media/variants') expect(rows()).toHaveLength(0) expect(generateAllButton()).toBeUndefined() expect(body().querySelector('[data-testid="variants-original"]')).not.toBeNull() }) it('An SVG source', async () => { await mountTab({ variants: () => SVG_LIST }) cardFor('logo-renew.svg').click() await flushPromises() activateTab('Variants') await flushPromises() expect(textOf(body().firstElementChild)).toContain('SVG · Vector · 8 KB') const alert = body().querySelector('[class*="bg-(--rc-info-soft)"], [data-slot="root"]') expect(textOf(body())).toContain("Variants aren't generated for this format.") expect(textOf(body())).toContain('SVG images are served as they are.') expect(alert).not.toBeNull() expect(rows()).toHaveLength(0) expect(generateAllButton()).toBeUndefined() }) }) describe('Row actions', () => { it('shows icon buttons with accessible names on desktop', async () => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() const generated = Array.from(rowOf('thumb').querySelectorAll('button')).map((button) => button.getAttribute('aria-label'), ) expect(generated).toEqual(['Copy URL', 'Preview', 'Purge']) const missing = Array.from(rowOf('og').querySelectorAll('button')).map((button) => button.getAttribute('aria-label'), ) expect(missing).toEqual(['Copy URL', 'Generate now']) expect(rowAction('thumb', 'Copy URL')!.className).toContain('size-7') }) it('shows labelled 44 px buttons on their own line below 640 px', async () => { await mountTab({ variants: () => variantList(PARTLY) }, '/media', MOBILE_WIDTH) await openVariants() const buttons = Array.from(rowOf('thumb').querySelectorAll('button')) expect(buttons.map(textOf)).toEqual(['Copy URL', 'Preview', 'Purge']) expect(buttons.every((button) => button.className.includes('h-11'))).toBe(true) expect(textOf(rowAction('og', 'Generate now'))).toBe('Generate now') }) it('disables Generate all when everything is generated', async () => { await mountTab({ variants: () => variantList() }) await openVariants() expect(generateAllButton()!.disabled).toBe(true) }) it('shows an icon and a word for every status', async () => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() const icons = kit .wrapper!.findAllComponents({ name: 'Badge' }) .filter((badge) => badge.element.hasAttribute('data-status')) .map((badge) => [badge.attributes('data-status'), badge.props('icon')]) expect(icons).toContainEqual(['generated', 'i-lucide-circle-check']) expect(icons).toContainEqual(['missing', 'i-lucide-circle-dashed']) }) }) describe('Generating', () => { it('Generate now shows the row in flight and then generated with a toast', async () => { const pending = deferred() await mountTab({ variants: () => variantList(PARTLY), generate: () => pending.promise, }) await openVariants() rowAction('og', 'Generate now')!.click() await flushPromises() expect(callsTo('POST', /\/v1\/media\/1\/variants\/og$/)).toHaveLength(1) expect(textOf(rowOf('og'))).toContain('Generating') expect(rowOf('og').querySelectorAll('button')).toHaveLength(0) expect(rowOf('og').className).toContain('bg-elevated') expect(rowOf('hero').className).not.toContain('bg-elevated') expect(textOf(body())).toContain('Generating variants…') const all = generateAllButton()! expect(textOf(all)).toBe('Generating…') expect(all.disabled).toBe(true) pending.resolve(variantItems().find((item) => item.name === 'og')) await flushPromises() expect(textOf(rowOf('og'))).toContain('Generated') expect(summary()).toBe('5 of 5 generated') expect(toastWith('media-variant-generated-1-og')).toMatchObject({ icon: 'i-lucide-circle-check', title: 'Generated og', }) }) it('Generate all shows the rows in flight and toasts the count', async () => { const pending = deferred() await mountTab({ variants: () => variantList(['thumb']), generateAll: () => pending.promise, }) await openVariants() generateAllButton()!.click() await flushPromises() expect(variantRequests('POST')).toHaveLength(1) expect( rows() .filter((row) => textOf(row).includes('Generating')) .map((row) => row.getAttribute('data-variant')), ).toEqual(['card', 'hero', 'og', 'square']) expect(textOf(rowOf('thumb'))).toContain('Generated') pending.resolve({ generated: 4, failed: 0, failures: [], items: variantItems() }) await flushPromises() expect(rows().every((row) => textOf(row).includes('Generated'))).toBe(true) expect(toastWith('media-variants-generated-1')).toMatchObject({ title: 'Generated 4 variants', }) }) it('A single generated variant reads in the singular', async () => { await mountTab({ variants: () => variantList(PARTLY), generateAll: () => ({ generated: 1, failed: 0, failures: [], items: variantItems() }), }) await openVariants() generateAllButton()!.click() await flushPromises() expect(toastWith('media-variants-generated-1')).toMatchObject({ title: 'Generated 1 variant', }) }) it('Generate all with a failure marks the row with its cause and Retry', async () => { const items = variantItems().map((item) => item.name === 'hero' ? { ...item, generated: false, size: null } : item, ) await mountTab({ variants: () => variantList(['thumb']), generateAll: () => ({ generated: 4, failed: 1, failures: [{ name: 'hero', cause: 'too_large' }], items, }), }) await openVariants() generateAllButton()!.click() await flushPromises() const row = rowOf('hero') expect(textOf(row)).toContain('Failed') expect(textOf(row)).toContain('Image too large') const retry = findButton('Retry', row)! expect(retry.className).toContain('h-7') expect(retry.className).toContain('text-xs') expect(summary()).toBe('4 of 5 generated') expect(toastWith('media-variants-generated-1')).toMatchObject({ title: 'Generated 4 variants', }) }) it.each([ [413, 'Image too large'], [415, 'Unsupported source format'], ])('A single generation fails with %i', async (status, cause) => { await mountTab({ variants: () => variantList(PARTLY), generate: () => Promise.reject(new ApiError(status, undefined, 'plain text body')), }) await openVariants() rowAction('og', 'Generate now')!.click() await flushPromises() expect(textOf(rowOf('og'))).toContain('Failed') expect(textOf(rowOf('og'))).toContain(cause) expect(findButton('Retry', rowOf('og'))).toBeDefined() expect(lastToast()).toMatchObject({ color: 'error', title: "Couldn't generate og.", description: `${cause}.`, }) expect(textOf(document.body)).not.toContain('plain text body') }) it('does not remember a failed state when the panel is closed and opened again', async () => { await mountTab({ variants: () => variantList(PARTLY), generate: () => Promise.reject(new ApiError(413)), }) await openVariants() rowAction('og', 'Generate now')!.click() await flushPromises() expect(textOf(rowOf('og'))).toContain('Failed') press(panel()!, 'Escape') document.body .querySelector('[role="dialog"] button[aria-label="Close"]') ?.click() await flushPromises() await new Promise((resolve) => setTimeout(resolve, 20)) await flushPromises() await openVariants() expect(textOf(rowOf('og'))).toContain('Not generated') }) it('Retry sends the same request and shows Generating', async () => { const pending = deferred() let calls = 0 await mountTab({ variants: () => variantList(PARTLY), generate: () => (++calls === 1 ? Promise.reject(new ApiError(413)) : pending.promise), }) await openVariants() rowAction('og', 'Generate now')!.click() await flushPromises() findButton('Retry', rowOf('og'))!.click() await flushPromises() expect(callsTo('POST', /\/v1\/media\/1\/variants\/og$/)).toHaveLength(2) expect(textOf(rowOf('og'))).toContain('Generating') }) it.each([ ['500', new ApiError(500), 'Something went wrong'], ['403', new ApiError(403), "You don't have access"], ['network failure', new NetworkError(), 'Something went wrong'], ])( 'Other failures of a generation: %s returns the row to Not generated', async (_l, error, title) => { await mountTab({ variants: () => variantList(PARTLY), generate: () => Promise.reject(error), }) await openVariants() rowAction('og', 'Generate now')!.click() await flushPromises() expect(textOf(rowOf('og'))).toContain('Not generated') expect(lastToast()).toMatchObject({ color: 'error', title }) }, ) it('a 404 returns the row, toasts and requests the tab again', async () => { await mountTab({ variants: () => variantList(PARTLY), generate: () => Promise.reject(new ApiError(404)), }) await openVariants() rowAction('og', 'Generate now')!.click() await flushPromises() expect(textOf(rowOf('og'))).toContain('Not generated') expect(lastToast()).toMatchObject({ title: 'Not found', description: 'This variant or image no longer exists. Refresh the page.', }) expect(variantRequests()).toHaveLength(2) }) it('closes the panel and drops the image when the second request is a 404', async () => { let variantCalls = 0 let imageGone = false await mountTab({ variants: () => { if (++variantCalls === 1) { return variantList(PARTLY) } imageGone = true return Promise.reject(new ApiError(404)) }, generate: () => Promise.reject(new ApiError(404)), list: () => listBody(fixture().filter((item) => !imageGone || item.id !== 1)), }) await openVariants() rowAction('og', 'Generate now')!.click() await flushPromises() await new Promise((resolve) => setTimeout(resolve, 20)) await flushPromises() expect(cards().some((card) => textOf(card).includes('north-wall.jpg'))).toBe(false) expect(toastWith('media-not-found-1')).toMatchObject({ title: 'That image no longer exists', }) }) it('announces the progress in a polite live region', async () => { const pending = deferred() await mountTab({ variants: () => variantList(PARTLY), generate: () => pending.promise }) await openVariants() rowAction('og', 'Generate now')!.click() await flushPromises() const region = body().querySelector('[aria-live="polite"]')! expect(textOf(region)).toBe('Generating variants') pending.resolve(variantItems().find((item) => item.name === 'og')) await flushPromises() expect(textOf(region)).toBe('Generated og') }) }) describe('Copy URL, Purge and Preview', () => { it('copies the absolute variant url, also for a row that is not generated', async () => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() rowAction('thumb', 'Copy URL')!.click() await flushPromises() rowAction('og', 'Copy URL')!.click() await flushPromises() expect(writeText).toHaveBeenNthCalledWith( 1, `${window.location.origin}/static/uploadedImages/1.jpg?v=thumb`, ) expect(writeText).toHaveBeenNthCalledWith( 2, `${window.location.origin}/static/uploadedImages/1.jpg?v=og`, ) expect(toastWith('media-variant-url-copied-1-thumb')).toMatchObject({ title: 'URL copied', description: 'Paste it wherever you need the image address.', }) }) it('shows no success toast when the clipboard rejects', async () => { writeText.mockRejectedValue(new Error('denied')) await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() rowAction('thumb', 'Copy URL')!.click() await flushPromises() expect(toastWith('media-variant-url-copied-1-thumb')).toBeUndefined() }) it('Purge removes the file, updates the row and the summary, and toasts', async () => { await mountTab({ variants: () => variantList(PARTLY), purgeVariant: () => ({ purged_files: 1, purged_bytes: 18432 }), }) await openVariants() rowAction('thumb', 'Purge')!.click() await flushPromises() expect(callsTo('DELETE', /\/v1\/media\/1\/variants\/thumb$/)).toHaveLength(1) expect(textOf(rowOf('thumb'))).toContain('Not generated') expect(textOf(rowOf('thumb'))).not.toContain('KB') expect(summary()).toBe('3 of 5 generated') expect(toastWith('media-variants-purged-1-thumb')).toMatchObject({ title: 'Purged 1 file (18 KB)', }) }) it.each([ [403, "You don't have access"], [404, 'Not found'], [500, 'Something went wrong'], ])('Purge fails with %i and the row stays Generated', async (status, title) => { await mountTab({ variants: () => variantList(PARTLY), purgeVariant: () => Promise.reject(new ApiError(status)), }) await openVariants() rowAction('thumb', 'Purge')!.click() await flushPromises() expect(textOf(rowOf('thumb'))).toContain('Generated') expect(lastToast()).toMatchObject({ color: 'error', title }) }) it('Preview opens a dialog with both panes at actual size', async () => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() rowAction('thumb', 'Preview')!.click() await flushPromises() const dialogs = Array.from(document.body.querySelectorAll('[role="dialog"]')) const preview = dialogs.find((entry) => textOf(entry).includes('Preview: thumb'))! const content = textOf(preview) expect(content).toContain('thumb · 400×300 · WebP') expect(content).toContain('18 KB · saves 99%') expect(content).toContain('Original · 4032×3024 · JPEG') expect(content).toContain('3.9 MB') expect(content).toContain('Both images are shown at actual size. Scroll to pan.') expect(findButton('Close', preview)).toBeDefined() expect(preview.querySelector('[data-testid="preview-variant-pane"]')!.className).toContain( 'sm:h-75', ) expect(preview.querySelector('[data-testid="preview-variant-pane"]')!.className).toContain( 'h-45', ) const sources = Array.from(preview.querySelectorAll('img')).map((image) => image.getAttribute('src'), ) expect(sources).toEqual(['/static/uploadedImages/1.jpg?v=thumb', '/media/north-wall.jpg']) }) it('returns focus to the Preview button when the dialog closes', async () => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() const opener = rowAction('thumb', 'Preview')! opener.focus() opener.click() await flushPromises() const preview = Array.from( document.body.querySelectorAll('[role="dialog"]'), ).find((entry) => textOf(entry).includes('Preview: thumb'))! findButton('Close', preview)!.click() await flushPromises() await new Promise((resolve) => setTimeout(resolve, 20)) await flushPromises() expect(document.activeElement).toBe(opener) }) it('uses the sheet layout from the same markup below 640 px', async () => { await mountTab({ variants: () => variantList(PARTLY) }, '/media', MOBILE_WIDTH) await openVariants() rowAction('thumb', 'Preview')!.click() await flushPromises() const preview = Array.from( document.body.querySelectorAll('[role="dialog"]'), ).find((entry) => textOf(entry).includes('Preview: thumb'))! expect(preview.className).toContain('max-sm:!bottom-4') expect(findButton('Close', preview)!.className).toContain('h-11') }) }) describe('Vocabulary', () => { it('never says thumbnail preset, version or rendition', async () => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() expect(textOf(body())).not.toMatch(/thumbnail preset|version|rendition/i) }) it('keeps the buttons buttons', async () => { await mountTab({ variants: () => variantList(PARTLY) }) await openVariants() expect(kit.wrapper!.findAllComponents(UButton).length).toBeGreaterThan(0) expect(router.currentRoute.value.path).toBe('/media') }) })