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

647 lines
22 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, 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<typeof import('@/composables/useApi')>()
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 = [
'<img',
` src="${BASE}?v=card"`,
` srcset="${BASE}?v=thumb 400w,`,
` ${BASE}?v=card 800w,`,
` ${BASE}?v=hero 1200w"`,
' sizes="(min-width: 800px) 800px, 100vw"',
' alt="North wall">',
].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<unknown>
interface MountOptions {
role?: string | null
list?: ListHandler
desktop?: boolean
}
let wrapper: VueWrapper | null = null
let requestMock: ReturnType<typeof vi.fn>
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<HTMLElement>('[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<HTMLElement>('[role="tab"]'))
}
function tabNamed(root: ParentNode, label: string) {
return tabs(root).find((tab) => tab.textContent?.trim() === label)!
}
function copyButton(root: ParentNode) {
return root.querySelector<HTMLElement>('button[aria-label="Copy code"]')!
}
function requestSnippet() {
return headingBlock('Request an image')!
}
function builder() {
return headingBlock('URL builder')!
}
function urlSnippet() {
return builder().querySelector<HTMLElement>('pre')!.parentElement!
}
function variantTable() {
return images().querySelector<HTMLElement>('[role="table"][aria-label="Variants"]')
}
function variantRows() {
const body = variantTable()?.querySelectorAll('[role="rowgroup"]')[1]
return Array.from(body?.querySelectorAll<HTMLElement>('[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<HTMLElement>('[role="rowgroup"]')[1]!.children ?? [])
}
function alerts() {
return Array.from(images().querySelectorAll<HTMLElement>('[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<HTMLElement>('div[role="img"]')!
}
function variantTrigger() {
return builder().querySelector<HTMLElement>('button[aria-label="Variant"]')!
}
async function pickVariant(name: string) {
variantTrigger().click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[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=<variant-name>"`)
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('<variant-name>')
},
)
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<unknown>()
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('<variant-name>')
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([])
})
})