feat(integration): add image variants section and standardize code snippets

This commit is contained in:
Florian Sylvain committed 2026-10-12 23:27:41 +02:00
1 parent 6f9ae6207a
commit e6746f2be7
28 files changed
+2256 -422

No files matched your search

+22 -76
View File
@@ -2,13 +2,13 @@
import { t, translate } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import CodeBlock from '@/components/CodeBlock.vue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import CodeText from '@/components/CodeText.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import EndpointCard from '@/components/EndpointCard.vue'
import IntegrationImages from '@/components/IntegrationImages.vue'
import SectionIndex from '@/components/SectionIndex.vue'
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import {
API_GROUPS,
ERROR_ROWS,
@@ -17,29 +17,26 @@ import {
SESSION_GROUPS,
authExamples,
} from '@/data/apiCatalogue'
import { SECTION_TITLE } from '@/lib/apiReference'
const SPY_OFFSET_PX = 120
const origin = computed(() => window.location.origin)
const { copied, copy } = useCopyToClipboard()
const copyButtonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyButtonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
function copyBaseUrl() {
copy(origin.value)
}
const root = ref<HTMLElement | null>(null)
const activeSection = ref('auth')
const authTab = ref('curl')
const baseUrlTabs = computed(() => [{ k: 'base', label: t('Base URL'), code: origin.value }])
const errorSampleTabs = computed(() => [
{ k: 'error', label: t('Error response · text/plain'), code: ERROR_SAMPLE },
])
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
const sections = computed(() => [
{ id: 'auth', label: t('Authentication') },
...API_GROUPS.map((group) => ({ id: group.key, label: translate(group.title) })),
{ id: 'images', label: t('Images and variants') },
{ id: 'session', label: t('Session only') },
{ id: 'errors', label: t('Errors') },
{ id: 'notes', label: t('Notes') },
@@ -48,14 +45,8 @@ const sections = computed(() => [
const authItems = computed(() => {
const examples = authExamples(origin.value)
return [
{ label: 'curl', value: 'curl', code: examples.curl, lang: 'shell', title: 'Shell' },
{
label: 'fetch',
value: 'fetch',
code: examples.fetch,
lang: 'javascript',
title: 'JavaScript',
},
{ k: 'curl', label: 'curl', code: examples.curl },
{ k: 'fetch', label: 'fetch', code: examples.fetch },
]
})
@@ -141,10 +132,12 @@ function updateActive() {
}
const limit = parent.getBoundingClientRect().top + SPY_OFFSET_PX
let current = sections.value[0]!.id
let currentTop = Number.NEGATIVE_INFINITY
for (const section of sections.value) {
const target = sectionElement(section.id)
if (target && target.getBoundingClientRect().top <= limit) {
const top = sectionElement(section.id)?.getBoundingClientRect().top
if (top !== undefined && top <= limit && top > currentTop) {
current = section.id
currentTop = top
}
}
activeSection.value = current
@@ -159,15 +152,14 @@ onMounted(() => {
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
const SECTION_TITLE = 'text-lg font-semibold text-highlighted'
const SECTION_CLASS = 'flex scroll-mt-16 flex-col gap-2.5 lg:scroll-mt-8'
const NOTE_ICON = 'mt-0.75 size-4 shrink-0 text-info'
const AUTH_CARD = 'flex flex-col gap-2.5 border border-default bg-default p-4'
const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
</script>
<template>
<DashboardShell :mobile-title="t('Integration')">
<DashboardShell compact-padding :mobile-title="t('Integration')">
<div ref="root" class="flex flex-col lg:flex-row lg:items-start lg:gap-10">
<div class="flex min-w-0 flex-1 flex-col gap-7 lg:gap-8">
<div class="flex flex-col gap-1.5">
@@ -186,7 +178,7 @@ const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
list: 'gap-1',
}"
/>
<h1 class="text-xl font-semibold text-highlighted lg:text-2xl">
<h1 class="text-xl font-semibold text-highlighted">
{{ t('API reference') }}
</h1>
<p class="text-base text-muted">
@@ -204,24 +196,7 @@ const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Base URL') }}</span>
<div class="flex border border-accented bg-default">
<span
class="h-10 min-w-0 flex-1 truncate px-3 font-mono text-sm leading-10 text-default"
>{{ origin }}</span
>
<UButton
:icon="copyButtonIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-10 gap-1.5 rounded-none border-l border-accented px-3 font-medium"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copyBaseUrl"
>
{{ copyButtonLabel }}
</UButton>
</div>
<CodeSnippet :tabs="baseUrlTabs" />
</div>
<section
@@ -272,27 +247,7 @@ const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
)
"
/></span>
<UTabs
v-model="authTab"
:items="authItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0 border border-default',
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
trigger:
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
:label="(item as (typeof authItems)[number]).title"
:lang="(item as (typeof authItems)[number]).lang"
:code="(item as (typeof authItems)[number]).code"
/>
</template>
</UTabs>
<CodeSnippet v-model:active="authTab" :tabs="authItems" />
<span class="text-pretty text-muted"
><CodeText
:text="
@@ -343,6 +298,8 @@ const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
</div>
</section>
<IntegrationImages :origin="origin" />
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
<div class="flex flex-wrap items-baseline gap-2.5">
<h2 id="h-session" :class="SECTION_TITLE">{{ t('Session only') }}</h2>
@@ -399,18 +356,7 @@ const CARD_TITLE = 'text-lg leading-5.5 font-semibold text-highlighted'
</div>
</template>
</UTable>
<div class="min-w-0 border border-default bg-elevated">
<div
class="flex h-8 items-center border-b border-default px-3 font-mono text-xs font-medium text-muted"
>
{{ t('Error response · text/plain') }}
</div>
<pre
tabindex="0"
class="overflow-x-auto p-3 font-mono text-code whitespace-pre text-default"
>{{ ERROR_SAMPLE }}</pre
>
</div>
<CodeSnippet :tabs="errorSampleTabs" />
</section>
<section data-anchor="notes" aria-labelledby="h-notes" :class="SECTION_CLASS">
+18 -13
View File
@@ -690,7 +690,9 @@ describe('Create key: success reveals the key once', () => {
const warning = modal.querySelector('[role="alert"]')!
expect(warning.textContent).toContain("You won't be able to see this key again.")
expect(modal.textContent).toContain(FULL_KEY)
expect(modal.querySelector('.font-mono')?.textContent?.trim()).toBe(FULL_KEY)
expect(modal.querySelector('pre')?.textContent?.trim()).toBe(FULL_KEY)
expect(modal.querySelector('[role="tablist"]')?.textContent?.trim()).toBe('API key')
expect(modal.querySelectorAll('[role="tab"]')).toHaveLength(0)
expect(modal.textContent).toContain(
'Closing this window discards the key from the screen. Copy it first.',
)
@@ -759,7 +761,7 @@ describe('One-time reveal: copied', () => {
expect(copied).toBeTruthy()
expect(copied.className).toContain('text-success')
expect(iconOf(copied)).toBe('i-lucide-check')
expect(copied.getAttribute('aria-live')).toBe('polite')
expect(copied.getAttribute('aria-label')).toBe('Copy code')
expect(formModal()!.querySelector('[aria-live="polite"]')?.textContent?.trim()).toBe(
'Copied',
)
@@ -942,18 +944,21 @@ describe('Mobile Create key button, not drawn on board', () => {
{ name: 'list', list: () => Promise.resolve(ownerFixture()) },
{ name: 'loading', list: () => deferred<ApiKey[]>().promise },
{ name: 'error', list: () => Promise.reject(new ApiError(500)) },
])('is pinned in a sticky footer, full width and 48 px in $name with no header button', async ({ list }) => {
await mountPage({ list }, { desktop: false })
])(
'is pinned in a sticky footer, full width and 48 px in $name with no header button',
async ({ list }) => {
await mountPage({ list }, { desktop: false })
const buttons = buttonsIn(document.body).filter(
(button) => button.textContent?.trim() === 'Create key',
)
expect(buttons).toHaveLength(1)
expect(buttons[0]!.parentElement!.className).toContain('sticky')
expect(buttons[0]!.className).toContain('h-12')
expect(buttons[0]!.className).toContain('w-full')
expect(buttons[0]!.parentElement!.style.display).not.toBe('none')
})
const buttons = buttonsIn(document.body).filter(
(button) => button.textContent?.trim() === 'Create key',
)
expect(buttons).toHaveLength(1)
expect(buttons[0]!.parentElement!.className).toContain('sticky')
expect(buttons[0]!.className).toContain('h-12')
expect(buttons[0]!.className).toContain('w-full')
expect(buttons[0]!.parentElement!.style.display).not.toBe('none')
},
)
it('is not drawn in the Empty state', async () => {
await mountPage({ list: () => Promise.resolve([]) }, { desktop: false })
@@ -0,0 +1,630 @@
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'
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 = ['settings', 'images', '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',
role: 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, 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)
}
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('Session only') - 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([])
})
})
+88 -20
View File
@@ -22,6 +22,7 @@ const SECTION_LABELS = [
'Media',
'Users and invites',
'Settings',
'Images and variants',
'Session only',
'Errors',
'Notes',
@@ -97,10 +98,7 @@ async function activateTab(container: HTMLElement, label: string) {
const button = Array.from(container.querySelectorAll('[role="tab"]')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
button?.click()
await flushPromises()
}
@@ -182,7 +180,7 @@ describe('Default, every card collapsed', () => {
expect(section(wrapper, 'settings').text()).toContain('1 route')
})
it('makes no /v1 request while rendering or expanding cards', async () => {
it('makes no /v1 request for a session without the owner or admin role', async () => {
const wrapper = await mountIntegration()
await expand(wrapper, 'POST /v1/article/')
await expand(wrapper, 'GET /v1/media/')
@@ -210,12 +208,12 @@ describe('Group scrolled, one card expanded', () => {
const tabs = created.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['fetch', 'curl', 'Response'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(text).toContain('JavaScript')
expect(
created
.findAll('button')
.some((button) => button.attributes('aria-label') === 'Copy JavaScript example'),
).toBe(true)
expect(created.findAll('[role="tablist"]')).toHaveLength(1)
expect(created.get('pre').text()).toBe(
endpointExamples(created.props('endpoint'), STUB_ORIGIN).fetch,
)
expect(text).not.toContain('Shell')
expect(created.findAll('button[aria-label="Copy code"]')).toHaveLength(1)
expect(text).toContain('Errors')
const statuses = created
@@ -273,17 +271,19 @@ describe('Copied, the Base URL copy button', () => {
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
expect(findButton(wrapper, 'Copied')).toBeTruthy()
expect(wrapper.find('[aria-live="polite"]').text()).toContain('Copied')
expect(wrapper.find('[aria-live="polite"]').text()).toBe('Copied')
await vi.advanceTimersByTimeAsync(2000)
await vi.advanceTimersByTimeAsync(1999)
expect(findButton(wrapper, 'Copied')).toBeTruthy()
await vi.advanceTimersByTimeAsync(1)
expect(findButton(wrapper, 'Copy')).toBeTruthy()
expect(wrapper.find('[aria-live="polite"]').text()).toBe('Copy')
expect(findButton(wrapper, 'Copied')).toBeFalsy()
})
})
describe("Copied, a CodeBlock's Copy button is independent of the Base URL's", () => {
it("copies only that card's exact code and leaves the other buttons unaffected", async () => {
describe("Copied, a CodeSnippet's Copy button is independent of the Base URL's", () => {
it("copies only that card's active tab and leaves the other buttons unaffected", async () => {
const writeText = stubClipboard()
vi.useFakeTimers()
const wrapper = await mountIntegration()
@@ -291,7 +291,7 @@ describe("Copied, a CodeBlock's Copy button is independent of the Base URL's", (
const button = created
.findAll('button')
.find((item) => item.attributes('aria-label') === 'Copy JavaScript example')!
.find((item) => item.attributes('aria-label') === 'Copy code')!
await button.trigger('click')
await flushPromises()
@@ -299,10 +299,13 @@ describe("Copied, a CodeBlock's Copy button is independent of the Base URL's", (
expect(writeText).toHaveBeenCalledWith(expected)
const copied = wrapper.findAll('button').filter((item) => item.text().trim() === 'Copied')
expect(copied).toHaveLength(1)
expect(copied[0]!.attributes('aria-label')).toBe('Copy JavaScript example')
expect(findButton(wrapper, 'Copy')).toBeTruthy()
expect(copied[0]!.attributes('aria-label')).toBe('Copy code')
expect(copied[0]!.element).toBe(button.element)
expect(findButton(wrapper, 'Copy')!.attributes('aria-label')).toBe('Copy code')
await vi.advanceTimersByTimeAsync(2000)
await vi.advanceTimersByTimeAsync(1999)
expect(findButton(wrapper, 'Copied')).toBeTruthy()
await vi.advanceTimersByTimeAsync(1)
expect(findButton(wrapper, 'Copied')).toBeFalsy()
})
})
@@ -591,3 +594,68 @@ describe('Notes', () => {
)
})
})
describe('CodeSnippet migration', () => {
it('draws the Base URL as a CodeSnippet with the single tab "Base URL"', async () => {
const wrapper = await mountIntegration()
const label = wrapper.findAll('span').find((item) => item.text() === 'Base URL')!
const snippet = label.element.parentElement!
expect(snippet.querySelectorAll('[role="tab"]')).toHaveLength(0)
expect(snippet.querySelector('[role="tablist"]')?.textContent?.trim()).toBe('Base URL')
expect(snippet.querySelector('pre')?.textContent).toBe(STUB_ORIGIN)
})
it('draws the Authentication example with curl and fetch tabs and no Shell or JavaScript label', async () => {
const wrapper = await mountIntegration()
const auth = section(wrapper, 'auth')
expect(auth.findAll('[role="tablist"]')).toHaveLength(1)
expect(auth.findAll('[role="tab"]').map((tab) => tab.text())).toEqual(['curl', 'fetch'])
expect(auth.get('[role="tab"][aria-selected="true"]').text()).toBe('curl')
expect(auth.get('pre').text()).toBe(authExamples(STUB_ORIGIN).curl)
expect(auth.text()).not.toContain('Shell')
expect(auth.text()).not.toContain('JavaScript')
})
it('draws the Errors sample as a CodeSnippet named "Error response · text/plain"', async () => {
const wrapper = await mountIntegration()
const errors = section(wrapper, 'errors')
expect(errors.get('[role="tablist"]').text()).toBe('Error response · text/plain')
expect(errors.findAll('button[aria-label="Copy code"]')).toHaveLength(1)
expect(errors.get('pre').text().split('\n')).toEqual([
'HTTP/1.1 403 Forbidden',
'Content-Type: text/plain',
'',
'This action needs a real session.',
])
})
it('keeps independent Copy buttons across the page', async () => {
const writeText = stubClipboard()
const wrapper = await mountIntegration()
await findButton(wrapper, 'Copy')!.trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(STUB_ORIGIN)
expect(wrapper.findAll('button').filter((item) => item.text() === 'Copied')).toHaveLength(1)
})
it('removes CodeBlock and every import of it, and keeps CodeText', () => {
const sources = import.meta.glob<string>('/src/**/*.{vue,ts}', {
query: '?raw',
import: 'default',
eager: true,
})
const paths = Object.keys(sources)
expect(paths).not.toContain('/src/components/CodeBlock.vue')
expect(paths).toContain('/src/components/CodeText.vue')
const importers = paths.filter(
(path) => !path.endsWith('.spec.ts') && sources[path]!.includes('CodeBlock'),
)
expect(importers).toEqual([])
})
})