import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { defineComponent, h } from 'vue' import uiPlugin from '@nuxt/ui/vue-plugin' import UApp from '@nuxt/ui/components/App.vue' import router from '@/router' import { ApiError, NetworkError, useApi } from '@/composables/useApi' import type { ImageVariant } from '@/composables/useImageVariants' import { useSessionStore } from '@/stores/session' import { deferred, setViewport, stubBrowser, variant } from './variantsTestKit' import IntegrationView from '../IntegrationView.vue' import { accessFor, ownerAccess } from '@/test/access' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) const ORIGIN = 'https://cms.renew.example' const LIST_PATH = '/v1/image-variants' const BASE = `${ORIGIN}/media/north-wall.jpg` const SECTION_ORDER = ['images', 'settings', 'session', 'errors'] const BODY_UNKNOWN = '{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "unknown" }' const BODY_UNSUPPORTED = '{ "status": 400, "error": "unsupported_parameter", "message": "Unsupported parameter", "detail": "width" }' const BODY_HERO = '{ "status": 400, "error": "unknown_variant", "message": "Unknown variant", "detail": "hero" }' const CURL = `curl -O "${BASE}?v=thumb"` const HTML = [ '', ].join('\n') const JAVASCRIPT = [ `const res = await fetch("${BASE}?v=thumb")`, 'if (!res.ok) throw new Error(await res.text())', 'const blob = await res.blob()', ].join('\n') type Row = [string, number, number | null, ImageVariant['format']] const BOARD_ROWS: Row[] = [ ['thumb', 400, 300, 'webp'], ['card', 800, null, 'webp'], ['hero', 1200, 630, 'webp'], ['square', 600, 600, 'jpeg'], ['og', 1200, 630, 'jpeg'], ['banner', 1600, 400, 'webp'], ['teaser', 640, 360, 'webp'], ['sidebar', 320, null, 'webp'], ['gallery', 1000, 750, 'webp'], ['avatar', 96, 96, 'webp'], ['wide', 1920, 800, 'webp'], ['mini', 120, 90, 'webp'], ] function boardVariants() { return BOARD_ROWS.map(([name, width, height, format]) => variant({ name, width, height, format, quality: 82 }), ) } function withPrint() { return [...boardVariants(), variant({ name: 'print', enabled: false })] } type ListHandler = () => Promise interface MountOptions { role?: string | null list?: ListHandler desktop?: boolean } let wrapper: VueWrapper | null = null let requestMock: ReturnType let listHandler: ListHandler function listing(items: ImageVariant[]): ListHandler { return () => Promise.resolve({ items }) } function stubClipboard() { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText }, }) return writeText } async function mountPage(options: MountOptions = {}) { const { role = 'owner', list = listing(withPrint()), desktop = true } = options setViewport(desktop) stubBrowser() Element.prototype.scrollIntoView = vi.fn() vi.stubGlobal('location', { ...window.location, origin: ORIGIN }) listHandler = list setActivePinia(createPinia()) const session = useSessionStore() const sessionRole = (role ?? undefined) as 'owner' | 'admin' | undefined session.session = { verified: true, username: 'marta', id: 1, displayName: 'Marta Alves', access: accessFor(sessionRole), } session.status = 'ready' requestMock = vi.fn((path: string, init?: RequestInit) => { if (path === LIST_PATH && !init?.method) { return listHandler() } throw new Error(`Unexpected request: ${init?.method ?? 'GET'} ${path}`) }) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true, access: ownerAccess(), username: 'marta', id: 1, role, }), request: requestMock, }) await router.push('/integration') const Host = defineComponent({ render: () => h(UApp, null, () => h(IntegrationView)), }) wrapper = mount(Host, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() } function listCalls() { return requestMock.mock.calls.filter(([path]) => path === LIST_PATH) } function requestedPaths() { return requestMock.mock.calls .map(([path]) => path) .filter((path) => path !== '/v1/content-type') } function images() { return document.body.querySelector('[data-anchor="images"]')! } function headingBlock(title: string) { const heading = Array.from(images().querySelectorAll('h3')).find( (item) => item.textContent?.trim() === title, ) return heading?.closest('div.flex.flex-col') as HTMLElement | undefined } function normalized(element: Element | null | undefined) { return element?.textContent?.replace(/\s+/g, ' ').trim() ?? '' } function tabs(root: ParentNode) { return Array.from(root.querySelectorAll('[role="tab"]')) } function tabNamed(root: ParentNode, label: string) { return tabs(root).find((tab) => tab.textContent?.trim() === label)! } function copyButton(root: ParentNode) { return root.querySelector('button[aria-label="Copy code"]')! } function requestSnippet() { return headingBlock('Request an image')! } function builder() { return headingBlock('URL builder')! } function urlSnippet() { return builder().querySelector('pre')!.parentElement! } function variantTable() { return images().querySelector('[role="table"][aria-label="Variants"]') } function variantRows() { const body = variantTable()?.querySelectorAll('[role="rowgroup"]')[1] return Array.from(body?.querySelectorAll('[role="row"]') ?? []) } function cells(row: HTMLElement) { return Array.from(row.querySelectorAll('[role="cell"]')).map((cell) => cell.textContent?.trim()) } function responseRows() { const table = images().querySelector('[role="table"][aria-label="Responses"]') return Array.from(table?.querySelectorAll('[role="rowgroup"]')[1]!.children ?? []) } function alerts() { return Array.from(images().querySelectorAll('[role="alert"]')) } function button(label: string) { return Array.from(images().querySelectorAll('button, a')).find( (item) => item.textContent?.trim() === label, ) as HTMLElement | undefined } function preview() { return images().querySelector('div[role="img"]')! } function variantTrigger() { return builder().querySelector('button[aria-label="Variant"]')! } async function pickVariant(name: string) { variantTrigger().click() await flushPromises() const option = Array.from(document.body.querySelectorAll('[role="option"]')).find( (item) => item.textContent?.trim() === name, )! option.click() await flushPromises() } async function focusWindow() { window.dispatchEvent(new Event('focus')) await flushPromises() } afterEach(async () => { wrapper?.unmount() wrapper = null vi.unstubAllGlobals() vi.useRealTimers() document.body.innerHTML = '' await router.push('/login') }) beforeEach(() => { vi.mocked(useApi).mockReset() }) describe('I1. Many variants', () => { it('draws the section in order with the snippets, the table, the builder and the responses', async () => { await mountPage() const anchors = Array.from(document.body.querySelectorAll('[data-anchor]')).map((item) => item.getAttribute('data-anchor'), ) expect(anchors.filter((id) => SECTION_ORDER.includes(id!))).toEqual(SECTION_ORDER) expect(images().querySelector('h2')?.textContent?.trim()).toBe('Images and variants') expect( Array.from(document.body.querySelectorAll('nav[aria-label="On this page"] button')).map( (item) => item.textContent?.trim(), ), ).toEqual(expect.arrayContaining(['Images and variants'])) const labels = Array.from( document.body.querySelectorAll('nav[aria-label="On this page"] button'), ).map((item) => item.textContent?.trim()) expect(labels.indexOf('Images and variants')).toBe(labels.indexOf('Sites and authors') + 1) const request = requestSnippet() expect(tabs(request).map((tab) => tab.textContent?.trim())).toEqual([ 'curl', 'HTML', 'JavaScript', ]) expect( tabs(request).find((tab) => tab.getAttribute('aria-selected') === 'true')?.textContent, ).toBe('HTML') expect(request.querySelector('pre')?.textContent).toBe(HTML) tabNamed(request, 'curl').click() await flushPromises() expect(request.querySelector('pre')?.textContent).toBe(CURL) tabNamed(request, 'JavaScript').click() await flushPromises() expect(request.querySelector('pre')?.textContent).toBe(JAVASCRIPT) expect(normalized(headingBlock('Variants'))).toContain('12 enabled variants') const headers = Array.from(variantTable()!.querySelectorAll('[role="columnheader"]')).map( (item) => item.textContent?.trim(), ) expect(headers).toEqual(['Name', 'Dimensions', 'Format', 'Example URL']) const rows = variantRows() expect(rows).toHaveLength(12) expect(rows.map((row) => cells(row)[0])).toEqual(BOARD_ROWS.map(([name]) => name)) expect(cells(rows[0]!)).toEqual([ 'thumb', '400×300', 'WebP', '/media/north-wall.jpg?v=thumb', ]) expect(cells(rows[1]!)[1]).toBe('800 × auto') expect(normalized(variantTable())).not.toContain('print') expect(variantTable()!.querySelectorAll('[role="rowgroup"]')[1]!.className).toContain( 'max-h-66', ) expect(normalized(headingBlock('Variants'))).toContain( 'Disabled variants are not served. A request for one returns 400 "Unknown variant".', ) const fields = normalized(builder()) expect(fields).toContain('Image') expect(fields).toContain('north-wall.jpg') expect(fields).toContain('Variant') expect(variantTrigger().textContent?.trim()).toBe('thumb') expect(urlSnippet().querySelector('pre')?.textContent).toBe(`${BASE}?v=thumb`) expect(tabs(urlSnippet())).toHaveLength(0) expect(urlSnippet().querySelector('[role="tablist"]')?.textContent?.trim()).toBe('URL') expect(normalized(headingBlock('Responses'))).toContain('/media/photo.png?v=thumb') }) }) describe('I2. One variant', () => { it('uses the single variant everywhere and draws no scroll limit and no disabled note', async () => { await mountPage({ list: listing([variant({ name: 'thumb', width: 400, height: 300 })]), }) expect(normalized(headingBlock('Variants'))).toContain('1 enabled variant') expect(normalized(headingBlock('Variants'))).not.toContain('1 enabled variants') expect(variantRows()).toHaveLength(1) expect(variantTable()!.querySelectorAll('[role="rowgroup"]')[1]!.className).not.toContain( 'max-h-66', ) const request = requestSnippet() expect(request.querySelector('pre')?.textContent).toBe( HTML.replaceAll('?v=card', '?v=thumb').replaceAll('?v=hero', '?v=thumb'), ) expect(normalized(images())).not.toContain('Disabled variants are not served') }) }) describe('I3. No variants, admin', () => { it.each([ ['an empty list', listing([])], ['only disabled variants', listing([variant({ name: 'print', enabled: false })])], ])('shows the empty block with the link for %s', async (_name, list) => { await mountPage({ role: 'admin', list }) const text = normalized(images()) expect(text).toContain('No image variants yet') expect(text).toContain( 'Until you add a variant, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.', ) const link = button('Go to image variants') as HTMLAnchorElement expect(link.getAttribute('href')).toBe('/settings/media/variants') const request = requestSnippet() tabNamed(request, 'curl').click() await flushPromises() expect(request.querySelector('pre')?.textContent).toBe(`curl -O "${BASE}?v="`) expect(variantTable()).toBeNull() expect(headingBlock('URL builder')).toBeUndefined() expect(normalized(headingBlock('Responses'))).toContain('/media/photo.png?v=thumb') }) }) describe('I4. No variants, not an admin', () => { it.each([['member'], [null]])( 'sends no request and shows plain information for role %s', async (role) => { await mountPage({ role }) await focusWindow() expect(requestedPaths()).toEqual([]) const text = normalized(images()) expect(text).toContain('No image variants yet') expect(text).toContain( 'Ask an owner or admin to add one. Until you do, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.', ) expect(button('Go to image variants')).toBeUndefined() expect(images().querySelectorAll('a')).toHaveLength(0) expect(headingBlock('URL builder')).toBeUndefined() expect(requestSnippet().querySelector('pre')?.textContent).toContain('') }, ) it('shows the same state when an owner gets a 403', async () => { await mountPage({ list: () => Promise.reject(new ApiError(403)) }) expect(normalized(images())).toContain( 'Ask an owner or admin to add one. Until you do, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.', ) expect(images().querySelectorAll('a')).toHaveLength(0) expect(headingBlock('URL builder')).toBeUndefined() }) }) describe('I5. Variant removed', () => { function assertRemoved() { expect(variantTrigger().textContent?.trim()).toBe('hero (removed)') expect(urlSnippet().querySelector('pre')?.textContent).toBe(`${BASE}?v=hero`) const warning = alerts().find((item) => item.textContent?.includes('no longer exists'))! expect(warning.textContent).toContain('The variant "hero" no longer exists') expect(normalized(warning)).toContain( `There is no fallback to the original. Requests with ?v=hero return status 400 with the body ${BODY_HERO}. Remove the parameter to get the original image, or pick another variant.`, ) const box = preview() expect(box.getAttribute('aria-label')).toBe('No preview: unknown variant') expect(box.textContent?.trim()).toBe('No preview: unknown variant') expect(box.getAttribute('style')).toContain('4 / 3') expect(box.className).toContain('opacity-50') } it('shows the warning after a window focus reload drops the selected variant', async () => { await mountPage() await pickVariant('hero') expect(listCalls()).toHaveLength(1) listHandler = listing(boardVariants().filter((item) => item.name !== 'hero')) await focusWindow() expect(listCalls()).toHaveLength(2) assertRemoved() }) it('shows the warning after Try again when the first load failed and the list lacks the selection', async () => { await mountPage({ list: () => Promise.reject(new ApiError(500)) }) listHandler = listing(boardVariants()) button('Try again')!.click() await flushPromises() await pickVariant('hero') listHandler = listing(boardVariants().filter((item) => item.name !== 'hero')) await focusWindow() assertRemoved() }) }) describe('I6. Loading', () => { it('draws three skeletons in a busy region and nothing else of the section', async () => { const pending = deferred() await mountPage({ list: () => pending.promise }) const busy = images().querySelector('[aria-busy="true"]')! expect(busy.children).toHaveLength(3) expect(images().querySelector('h2')?.textContent?.trim()).toBe('Images and variants') expect(images().querySelector('h3')).toBeNull() expect(variantTable()).toBeNull() expect(images().querySelector('pre')).toBeNull() pending.resolve({ items: [] }) }) }) describe('I7. Error loading', () => { it.each([ ['status 500', () => Promise.reject(new ApiError(500))], ['a network failure', () => Promise.reject(new NetworkError())], ])('shows the alert, the placeholder snippet and retries for %s', async (_name, failing) => { await mountPage({ list: failing }) const [alert] = alerts() expect(alert!.textContent).toContain("Couldn't load image variants") expect(alert!.textContent).toContain( 'The snippets below still work. The variant list and the URL builder need a connection.', ) expect(requestSnippet().querySelector('pre')?.textContent).toContain('') expect(variantTable()).toBeNull() expect(headingBlock('URL builder')).toBeUndefined() expect(headingBlock('Responses')).toBeUndefined() listHandler = listing(boardVariants()) button('Try again')!.click() await flushPromises() expect(listCalls()).toHaveLength(2) expect(alerts()).toHaveLength(0) expect(variantRows()).toHaveLength(12) }) }) describe('I8. Snippet copied', () => { it('copies the active tab, reads Copied politely for 2 s and leaves the builder alone', async () => { const writeText = stubClipboard() await mountPage() vi.useFakeTimers() copyButton(requestSnippet()).click() await flushPromises() expect(writeText).toHaveBeenCalledWith(HTML) const live = requestSnippet().querySelector('[aria-live="polite"]')! expect(live.textContent).toBe('Copied') expect(copyButton(urlSnippet()).textContent?.trim()).toBe('Copy') await vi.advanceTimersByTimeAsync(1999) expect(live.textContent).toBe('Copied') await vi.advanceTimersByTimeAsync(1) expect(live.textContent).toBe('Copy') }) }) describe('The URL builder', () => { it('builds the URL and the placeholder preview for the picked variant', async () => { await mountPage() await pickVariant('square') expect(urlSnippet().querySelector('pre')?.textContent).toBe(`${BASE}?v=square`) const box = preview() expect(box.getAttribute('role')).toBe('img') expect(box.getAttribute('aria-label')).toBe('square · 600×600 · JPEG') expect(box.textContent?.trim()).toBe('square · 600×600 · JPEG') expect(box.getAttribute('style')).toContain('600 / 600') expect(box.className).toContain('max-h-60') expect(images().querySelectorAll('img')).toHaveLength(0) expect(requestedPaths()).toEqual([LIST_PATH]) }) }) describe('The preview ratio', () => { it('falls back to 4 / 3 when a dimension is null', async () => { await mountPage() await pickVariant('card') expect(preview().getAttribute('style')).toContain('4 / 3') expect(preview().getAttribute('aria-label')).toBe('card · 800 × auto · WebP') }) }) describe('I9. URL builder copied', () => { it('copies the exact URL and leaves the request snippet alone', async () => { const writeText = stubClipboard() await mountPage() vi.useFakeTimers() copyButton(urlSnippet()).click() await flushPromises() expect(writeText).toHaveBeenCalledWith(`${BASE}?v=thumb`) expect(urlSnippet().querySelector('[aria-live="polite"]')?.textContent).toBe('Copied') expect(copyButton(requestSnippet()).textContent?.trim()).toBe('Copy') await vi.advanceTimersByTimeAsync(2000) expect(urlSnippet().querySelector('[aria-live="polite"]')?.textContent).toBe('Copy') }) }) describe('Reload triggers', () => { it('sends one call on mount, one per focus and one per Try again', async () => { await mountPage() expect(listCalls()).toHaveLength(1) await focusWindow() await focusWindow() expect(listCalls()).toHaveLength(3) expect(requestedPaths()).toEqual([LIST_PATH, LIST_PATH, LIST_PATH]) }) it('keeps the active tab and the selection when the variant is still enabled', async () => { await mountPage() tabNamed(requestSnippet(), 'JavaScript').click() await flushPromises() await pickVariant('square') await focusWindow() const request = requestSnippet() expect( tabs(request) .find((tab) => tab.getAttribute('aria-selected') === 'true') ?.textContent?.trim(), ).toBe('JavaScript') expect(variantTrigger().textContent?.trim()).toBe('square') expect(urlSnippet().querySelector('pre')?.textContent).toBe(`${BASE}?v=square`) }) it('stops listening to focus once the page is gone', async () => { await mountPage() wrapper!.unmount() wrapper = null await focusWindow() expect(listCalls()).toHaveLength(1) }) }) describe('The Responses table', () => { it('lists the six rows with the exact bodies and no 409 or 422', async () => { await mountPage() const headers = Array.from( images().querySelectorAll('[aria-label="Responses"] [role="columnheader"]'), ).map((item) => item.textContent?.trim()) expect(headers).toEqual(['Request', 'Status', 'Response']) const rows = responseRows().map((row) => Array.from(row.children) .map((cell) => normalized(cell)) .join(' '), ) expect(rows).toEqual([ '/media/photo.png?v=thumb 200 The variant image. Generated on first request.', `/media/photo.png?v=unknown 400 Unknown variant ${BODY_UNKNOWN}`, `/media/photo.png?width=400 400 Unsupported parameter ${BODY_UNSUPPORTED}`, '/media/missing.png?v=thumb 404 Not found 404 page not found', '/media/huge.png?v=thumb 413 Source image too large The source image is too large to generate this variant.', '/media/photo.png?v=thumb 500 Unable to read the file Unable to read the file', ]) const statuses = responseRows().map((row) => row.querySelectorAll('span')[1]!.textContent) for (const absent of ['409', '422']) { expect(statuses).not.toContain(absent) } expect(images().querySelector('[aria-label="Responses"]')!.className).toContain('min-w-140') expect(variantTable()!.className).toContain('min-w-130') }) }) describe('The error sample label', () => { it('keeps "Error response · text/plain" and labels nothing in the images section text/plain', async () => { await mountPage() const errors = document.body.querySelector('[data-anchor="errors"] [role="tablist"]')! expect(errors.textContent?.trim()).toBe('Error response · text/plain') expect(normalized(images())).not.toContain('text/plain') }) }) describe('Calls made by the page', () => { it('sends only the list call for an owner and for an admin', async () => { await mountPage({ role: 'admin' }) expect(requestedPaths()).toEqual([LIST_PATH]) }) it('sends nothing for any other role', async () => { await mountPage({ role: 'member' }) expect(requestedPaths()).toEqual([]) }) })