Files
RenewCMS/web/visual/states.ts
T

7466 lines
214 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 { createHash } from 'node:crypto'
import type { Page } from 'playwright'
export interface ApiMock {
status: number
body?: unknown
bodyFor?: (url: URL, call: number) => unknown
headers?: Record<string, string>
hang?: boolean
}
export interface DiffException {
maxPercent: number | { desktop: number; mobile: number }
reason: string
approvedBy: string
}
export interface VisualState {
exception?: DiffException
skip?: 'clock-sensitive' | 'pending'
path: string
origin?: string
now?: string
paused?: boolean
api?: Record<string, ApiMock>
steps?: (page: Page) => Promise<void>
}
const registrationClosed: ApiMock = { status: 200, body: { open: false } }
const registrationOpen: ApiMock = { status: 200, body: { open: true } }
async function fillLogin(page: Page, username: string, password: string) {
await page.getByPlaceholder('Your username').fill(username)
await page.locator('input[autocomplete="current-password"]').fill(password)
}
function submitLogin(username: string, password: string) {
return async (page: Page) => {
await fillLogin(page, username, password)
await page.getByRole('button', { name: 'Log in', exact: true }).click()
}
}
function loginWith(response: ApiMock, path = '/login'): Omit<VisualState, 'steps'> {
return {
path,
api: {
'GET /v1/auth/registration': registrationClosed,
'POST /v1/auth/login': response,
},
}
}
async function fillRegister(page: Page, username: string, email: string, password: string) {
await page.getByPlaceholder('e.g. marta').fill(username)
await page.getByPlaceholder('e.g. marta').blur()
await page.getByPlaceholder('you@example.com').fill(email)
await page.getByPlaceholder('you@example.com').blur()
await page.locator('input[autocomplete="new-password"]').fill(password)
await page.locator('input[autocomplete="new-password"]').blur()
}
function registerWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: '/register',
api: {
'POST /v1/auth/register': response,
},
}
}
function submitRegister(username: string, email: string, password: string) {
return async (page: Page) => {
await fillRegister(page, username, email, password)
await page.getByRole('button', { name: 'Create account', exact: true }).click()
}
}
async function setPendingEmail(page: Page, email: string) {
await page.evaluate((value) => sessionStorage.setItem('renewcms:pendingEmail', value), email)
await page.reload()
await page.waitForLoadState('networkidle')
}
async function openCancelDialog(page: Page) {
await page.getByRole('button', { name: 'Cancel registration', exact: true }).click()
}
async function confirmCancelDialog(page: Page) {
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Cancel registration', exact: true })
.click()
}
const boardNow = '2026-09-10T09:00:00Z'
const sixArticlesFixture = [
{
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
is_online: true,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-09-10T07:00:00Z',
},
{
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
is_online: false,
created_at: '2026-09-09T08:00:00Z',
updated_at: '2026-09-09T09:00:00Z',
},
{
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: '2026-09-07T09:00:00Z',
},
{
id: 4,
title: 'Winter checklist for timber windows',
body: '',
images: [],
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: '2026-09-05T09:00:00Z',
},
{
id: 5,
title: 'Why we reuse original floorboards',
body: '',
images: [],
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
},
{
id: 6,
title: 'Our first passive-house retrofit',
body: '',
images: [],
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
},
]
const verifiedSession: ApiMock = {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: '',
role: 'owner',
},
}
const updateRunningVersion = 'v2.2.0-beta'
const workspaceSettingsBody = {
version: updateRunningVersion,
activity_retention_days: 90,
activity_retention_max_gb: 0,
activity_log_size_bytes: 0,
activity_last_cleanup_at: '2026-10-06T03:00:00Z',
activity_last_cleanup_removed: 142,
activity_next_cleanup_at: '2026-10-07T03:00:00Z',
}
const updateLatestTag = 'v2.3.0'
const updateReleaseUrl = 'https://git.floriansylvain.fr/fsylvain/RenewCMS/releases/tag/v2.3.0'
const updateReleasedAt = '2026-09-24T08:00:00Z'
const adminSession: ApiMock = {
status: 200,
body: { ...(verifiedSession.body as object), role: 'admin' },
}
function updateStatusBody(overrides: Record<string, unknown> = {}) {
return {
current: updateRunningVersion,
latest: updateRunningVersion,
available: false,
release_url: updateReleaseUrl,
notes: { version: updateRunningVersion, released_at: updateReleasedAt, items: [] },
checked_at: new Date().toISOString(),
can_apply: false,
state: 'idle',
progress: null,
error: null,
started_at: null,
finished_at: null,
...overrides,
}
}
function updateNeverChecked() {
return updateStatusBody({
latest: '',
release_url: null,
notes: null,
checked_at: null,
})
}
function updateAvailable() {
return updateStatusBody({
latest: updateLatestTag,
available: true,
notes: { version: updateLatestTag, released_at: updateReleasedAt, items: [] },
})
}
const updateBoardNotes = {
version: updateLatestTag,
released_at: updateReleasedAt,
items: [
'Images upload faster, and large images no longer stall.',
'Fixed a problem where a draft could show an old title after saving.',
'Small improvements to the Users page.',
],
}
const updateBoardTotalBytes = 27262976
const updateBoardDoneBytes = 11534336
const updateJobStartedAt = '2026-09-30T09:51:00Z'
function updateStartable(overrides: Record<string, unknown> = {}) {
return updateStatusBody({
latest: updateLatestTag,
available: true,
notes: updateBoardNotes,
can_apply: true,
...overrides,
})
}
function updateJob(state: string, overrides: Record<string, unknown> = {}) {
return updateStartable({ state, started_at: updateJobStartedAt, ...overrides })
}
function updateDownloading() {
return updateJob('downloading', {
progress: {
percent: 42,
downloaded_bytes: updateBoardDoneBytes,
total_bytes: updateBoardTotalBytes,
},
})
}
const updateCheckHangs: ApiMock = { status: 200, hang: true }
const restartStartedAt = '2026-09-30T09:51:00'
const restartFinishedAt = '2026-09-30T09:52:00'
const restartMemoryKey = 'renewcms:update-restart'
function restartApi(status: unknown, restart: ApiMock): Record<string, ApiMock> {
return { ...updateApi('owner', status), 'POST /v1/system/update/restart': restart }
}
function restartAccepted(status: Record<string, unknown>): ApiMock {
return { status: 202, body: { ...status, state: 'restarting', started_at: restartStartedAt } }
}
async function openRestartDialog(page: Page) {
await page.getByRole('button', { name: 'Restart', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
}
function confirmRestart(label: string) {
return async (page: Page) => {
await openRestartDialog(page)
await page.getByRole('button', { name: label, exact: true }).click()
}
}
async function rememberRestartAndReload(page: Page) {
await page.evaluate(
([key, startedAt]) =>
sessionStorage.setItem(key!, JSON.stringify({ kind: 'update', started_at: startedAt })),
[restartMemoryKey, restartStartedAt],
)
await page.reload()
await page.getByText('Your site is back on v2.2.0-beta.').first().waitFor()
await page.mouse.move(0, 0)
}
function updateApi(
role: 'owner' | 'admin',
status: unknown,
check: ApiMock = { status: 200, body: updateStatusBody() },
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': role === 'owner' ? verifiedSession : adminSession,
'GET /v1/settings/': { status: 200, body: workspaceSettingsBody },
[purgeSettingsPath]: settingsBody(purgeSettingsBody()),
'GET /v1/system/update': { status: 200, body: status },
'POST /v1/system/update/check': check,
}
}
const editorNow = '2026-09-26T11:02:00Z'
const seoReady = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
const bathroomBody =
"<p>Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>" +
'<h2>What goes into the price</h2>' +
'<ul><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>' +
'<p>We don’t add a margin on materials you choose yourself.</p>'
const editorArticle = {
id: 12,
title: 'How we price a bathroom renovation',
body: bathroomBody,
images: [
{ id: 1, url: '/images/1.png' },
{ id: 2, url: '/images/2.png' },
{ id: 3, url: '/images/3.png' },
],
is_online: false,
site_id: 1,
authors: [{ id: 1, name: 'Inès Duarte', slug: 'ines-duarte' }],
created_at: '2026-03-12T09:14:00Z',
updated_at: editorNow,
}
const emptyLibrary = {
items: [],
next_cursor: null,
counts: { all: 0, unused: 0, duplicates: 0 },
}
function boardSiteList(count = 1): ApiMock {
return { status: 200, bodyFor: () => directorySites.slice(0, count) }
}
function boardAuthorList(): ApiMock {
return {
status: 200,
bodyFor: (url, call) => authorListMock(directoryAuthors()).bodyFor!(url, call),
}
}
const boardBylineTable: Record<string, { id: number; slug: string }> = {
'Inès Duarte': { id: 1, slug: 'ines-duarte' },
'Jonas Pereira': { id: 2, slug: 'jonas-pereira' },
'Camille Roche': { id: 3, slug: 'camille-roche' },
'Paul Huber': { id: 4, slug: 'paul-huber' },
'Léa Fontaine': { id: 5, slug: 'lea-fontaine' },
'Dr. Alexandra Konstantinopoulou-Whitfield': {
id: 6,
slug: 'dr-alexandra-konstantinopoulou-whitfield',
},
}
function boardBylines(...names: string[]) {
return names.map((name) => ({ name, ...boardBylineTable[name]! }))
}
function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/12': { status: 200, body: editorArticle },
'GET /v1/media/': { status: 200, body: emptyLibrary },
'GET /v1/site/': boardSiteList(),
'GET /v1/author/': boardAuthorList(),
'POST /v1/article/12/seo/check': { status: 200, body: seoReady },
...overrides,
}
}
const metaToggle = '[data-testid="meta-toggle"]:visible'
const metaBody = '[data-testid="meta-body"]:visible'
const metaTrigger = `${metaBody} button[aria-haspopup="listbox"]`
const marta = { id: 7, name: 'Marta Alves', slug: 'marta-alves' }
function metaSession(role = 'owner', withAuthor = false): ApiMock {
return {
status: 200,
body: {
...(verifiedSession.body as object),
display_name: 'Marta Alves',
role,
...(withAuthor ? { author: marta } : {}),
},
}
}
interface MetaOptions {
authors?: string[]
siteId?: number
sites?: number
role?: string
withAuthor?: boolean
overrides?: Record<string, ApiMock>
}
function metaApi(options: MetaOptions = {}) {
return editorApi({
'GET /v1/auth/me': metaSession(options.role, options.withAuthor),
'GET /v1/article/12': {
status: 200,
body: {
...editorArticle,
site_id: options.siteId ?? 1,
authors: boardBylines(...(options.authors ?? ['Inès Duarte'])),
},
},
'GET /v1/site/': boardSiteList(options.sites ?? 1),
...(options.overrides ?? {}),
})
}
async function showMetaSection(page: Page) {
if (isNarrow(page)) {
await page.getByRole('button', { name: 'Details', exact: true }).click()
await page.getByRole('dialog').waitFor()
}
const toggle = page.locator(metaToggle)
await toggle.waitFor()
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click()
}
await page.locator(metaBody).waitFor()
await page.mouse.move(0, 0)
}
async function openSiteSelect(page: Page) {
await page.locator(metaTrigger).first().click()
await page.getByRole('option').first().waitFor()
await page.mouse.move(0, 0)
}
async function openAuthorCombobox(page: Page) {
await page.locator(metaTrigger, { hasText: 'Add an author' }).click()
await page.getByPlaceholder('Search authors').waitFor()
}
async function searchAuthors(page: Page, term: string) {
await page.getByPlaceholder('Search authors').fill(term)
await page.waitForTimeout(450)
}
async function openCreateAuthorDialog(page: Page, term: string) {
await openAuthorCombobox(page)
await searchAuthors(page, term)
await page.getByTestId('create-author-row').click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function metaState(options: MetaOptions, steps: (page: Page) => Promise<void>): VisualState {
return {
path: '/article/12/edit',
now: editorNow,
api: metaApi(options),
steps: async (page) => {
await showMetaSection(page)
await steps(page)
},
}
}
async function grabAuthor(page: Page, name: string) {
await page.getByRole('button', { name: `Reorder ${name}`, exact: true }).focus()
await page.keyboard.press('Space')
await page.mouse.move(0, 0)
}
async function liftAuthor(page: Page, name: string) {
const handle = await page
.getByRole('button', { name: `Reorder ${name}`, exact: true })
.boundingBox()
const first = await page.locator(`${metaBody} [data-testid="author-row"]`).first().boundingBox()
const x = handle!.x + handle!.width / 2
await page.mouse.move(x, handle!.y + handle!.height / 2)
await page.mouse.down()
await page.mouse.move(x, first!.y + 2, { steps: 6 })
}
const newAuthorSlugTaken: ApiMock = { status: 409, body: 'author_slug_taken' }
async function focusBoldSample(page: Page) {
await page.getByText('write down what stays').click()
}
async function markDirty(page: Page) {
await page.getByText('choose yourself').click()
await page.keyboard.press('End')
await page.keyboard.type('x')
await page.keyboard.press('Backspace')
}
const PARAGRAPH_TAGS_LENGTH = 7
const veryLongBodyLength = 120000
const veryLongBodyWords = 20000
const EDITOR_LIST_ITEM_PARAGRAPH_TAGS = 21
function wordsIn(html: string) {
return html
.replace(/<\/(?:p|h[1-6]|li)>/g, ' ')
.replace(/<[^>]+>/g, '')
.split(/\s+/)
.filter(Boolean).length
}
function paragraphOfWords(totalLength: number, wordCount: number) {
const letters = totalLength - PARAGRAPH_TAGS_LENGTH - (wordCount - 1)
const shortWords = wordCount * 5 - letters
const words = Array.from({ length: wordCount }, (_, index) =>
index < shortWords ? 'abcd' : 'abcde',
)
return `<p>${words.join(' ')}</p>`
}
const veryLongArticle = {
...editorArticle,
body:
bathroomBody +
paragraphOfWords(
veryLongBodyLength - bathroomBody.length - EDITOR_LIST_ITEM_PARAGRAPH_TAGS,
veryLongBodyWords - wordsIn(bathroomBody),
),
}
function veryLongApi(overrides: Record<string, ApiMock> = {}) {
return editorApi({ 'GET /v1/article/12': { status: 200, body: veryLongArticle }, ...overrides })
}
async function markVeryLongDirty(page: Page) {
await markDirty(page)
await focusBoldSample(page)
}
const linkLongUrl =
'https://www.example.org/planning/guidance/listed-buildings/windows-and-doors/…/2026-update.pdf'
const linkSentence =
'<p>We also check the <a href="/articles/winter-checklist" target="_blank" rel="noopener noreferrer">timber windows</a> before any tiling starts.</p>'
const linkedBody = bathroomBody.replace('</p><h2>', `</p>${linkSentence}<h2>`)
const linkImageSrc = '/static/uploadedImages/blank.svg'
const linkedImageBody = `${linkedBody}<img src="${linkImageSrc}" alt="" data-image-id="1">`
const MOBILE_MAX_WIDTH = 640
const TOOLTIP_SETTLE_MS = 700
function linkedApi(body = linkedBody) {
return editorApi({
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
})
}
function isNarrow(page: Page) {
return (page.viewportSize()?.width ?? MOBILE_MAX_WIDTH) < MOBILE_MAX_WIDTH
}
function linkButton(page: Page) {
return page.getByRole('button', { name: 'Insert link', exact: true }).first()
}
function linkDialog(page: Page) {
return page.locator('[data-presentation]')
}
async function typeIntoLinkField(page: Page, label: string, value: string) {
await linkDialog(page).getByLabel(label, { exact: true }).fill(value)
}
async function openLinkDialogOnWord(page: Page) {
await markDirty(page)
await page.getByText('choose yourself').dblclick()
await page.keyboard.press('Control+k')
await linkDialog(page).waitFor()
}
async function openLinkDialogOnLink(page: Page) {
await markDirty(page)
await page.locator('.article-content a').click()
await page.getByRole('button', { name: 'Edit', exact: true }).click()
await linkDialog(page).waitFor()
}
async function dirtyWithLinks(page: Page) {
await focusBoldSample(page)
await markDirty(page)
await focusBoldSample(page)
}
async function useTouchPointer(page: Page) {
const session = await page.context().newCDPSession(page)
await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 })
}
function mib(value: number) {
return Math.round(value * 1024 * 1024)
}
function kb(value: number) {
return Math.round(value * 1024)
}
async function openImageUploader(page: Page) {
await page.getByRole('button', { name: 'Image', exact: true }).first().click()
await page.getByRole('dialog').getByText('Add an image').waitFor()
}
async function chooseImageFile(page: Page, filename: string, mimeType: string, size: number) {
await page.locator('input[type="file"]').setInputFiles({
name: filename,
mimeType,
buffer: Buffer.alloc(size),
})
}
async function chooseRasterImageFile(
page: Page,
filename: string,
mimeType: string,
naturalWidth: number,
naturalHeight: number,
sizeBytes: number,
) {
await page.evaluate(
async ({ filename, mimeType, naturalWidth, naturalHeight, sizeBytes }) => {
const canvas = document.createElement('canvas')
canvas.width = naturalWidth
canvas.height = naturalHeight
const ctx = canvas.getContext('2d')!
ctx.fillStyle = '#d8d0c3'
ctx.fillRect(0, 0, naturalWidth, naturalHeight)
ctx.fillStyle = '#8f8373'
ctx.fillRect(0, naturalHeight * 0.68, naturalWidth, naturalHeight * 0.32)
ctx.fillStyle = '#f1ece3'
ctx.strokeStyle = '#c4b9a8'
ctx.lineWidth = naturalWidth * 0.015
ctx.fillRect(
naturalWidth * 0.56,
naturalHeight * 0.14,
naturalWidth * 0.22,
naturalHeight * 0.42,
)
ctx.strokeRect(
naturalWidth * 0.56,
naturalHeight * 0.14,
naturalWidth * 0.22,
naturalHeight * 0.42,
)
const blob = await new Promise<Blob>((resolve) =>
canvas.toBlob((b) => resolve(b!), mimeType, 0.85),
)
const file = new File([blob], filename, { type: mimeType })
Object.defineProperty(file, 'size', { value: sizeBytes, configurable: true })
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
input.files = dataTransfer.files
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('input', { bubbles: true }))
},
{ filename, mimeType, naturalWidth, naturalHeight, sizeBytes },
)
}
async function openCropStep(
page: Page,
filename: string,
mimeType: string,
naturalWidth: number,
naturalHeight: number,
sizeBytes: number,
) {
await openImageUploader(page)
await chooseRasterImageFile(page, filename, mimeType, naturalWidth, naturalHeight, sizeBytes)
await page.getByRole('dialog').getByText('Crop and resize').waitFor()
}
async function selectAspectRatio(page: Page, ratio: string) {
await page
.getByRole('radiogroup', { name: 'Aspect ratio' })
.getByRole('radio', { name: ratio, exact: true })
.click()
}
async function resizeCropFrameTo(
page: Page,
naturalWidth: number,
targetWidthPx: number,
targetHeightPx: number,
) {
const frame = page.locator('[data-testid="crop-frame"]')
const handle = page.locator('[data-testid="crop-handle"]').nth(3)
async function currentSize() {
const box = (await frame.boundingBox())!
const stageWidth = await frame.evaluate(
(el) => el.parentElement!.getBoundingClientRect().width,
)
const scale = stageWidth / naturalWidth
return {
width: Math.round(box.width / scale),
height: Math.round(box.height / scale),
scale,
}
}
for (let attempt = 0; attempt < 6; attempt += 1) {
const frameBox = (await frame.boundingBox())!
const handleBox = (await handle.boundingBox())!
const { width: currentWidth, height: currentHeight, scale } = await currentSize()
if (
Math.abs(currentWidth - targetWidthPx) <= 30 &&
Math.abs(currentHeight - targetHeightPx) <= 30
) {
break
}
const startX = handleBox.x + handleBox.width / 2
const startY = handleBox.y + handleBox.height / 2
const targetX = frameBox.x + targetWidthPx * scale
const targetY = frameBox.y + targetHeightPx * scale
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(targetX, targetY, { steps: 1 })
await page.mouse.up()
await page.waitForTimeout(50)
}
for (let guard = 0; guard < 60; guard += 1) {
const { width: currentWidth, height: currentHeight } = await currentSize()
if (currentWidth === targetWidthPx && currentHeight === targetHeightPx) {
return
}
if (currentWidth !== targetWidthPx) {
await frame.press(currentWidth > targetWidthPx ? 'Shift+ArrowLeft' : 'Shift+ArrowRight')
await page.waitForTimeout(120)
continue
}
await frame.press(currentHeight > targetHeightPx ? 'Shift+ArrowUp' : 'Shift+ArrowDown')
await page.waitForTimeout(120)
}
}
async function mockUploadedImage(page: Page) {
const isDark = await page.evaluate(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
)
const background = isDark ? 'rgb(26,26,26)' : 'rgb(244,244,245)'
const border = isDark ? 'rgb(64,64,64)' : 'rgb(212,212,216)'
await page.route('**/static/uploadedImages/**', async (route) => {
const pathname = new URL(route.request().url()).pathname
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="450"><rect width="100%" height="100%" fill="${background}" stroke="${border}" stroke-width="2" /><text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="16" fill="${border}">${pathname}</text></svg>`
await route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg })
})
}
async function freezeUploadProgress(page: Page, loaded: number, total: number) {
await page.evaluate(
({ loaded, total }) => {
const proto = XMLHttpRequest.prototype as unknown as Record<string, unknown>
const originalOpen = proto.open as (...args: unknown[]) => void
proto.open = function (this: XMLHttpRequest, ...args: unknown[]) {
;(this as unknown as { __imageUrl?: string }).__imageUrl = String(args[1])
return originalOpen.apply(this, args)
}
const originalSend = proto.send as (...args: unknown[]) => void
proto.send = function (this: XMLHttpRequest, ...args: unknown[]) {
const url = (this as unknown as { __imageUrl?: string }).__imageUrl ?? ''
if (url.includes('/v1/media')) {
setTimeout(() => {
const event = new Event('progress')
Object.assign(event, { loaded, total, lengthComputable: true })
this.upload.dispatchEvent(event)
}, 50)
return
}
return originalSend.apply(this, args)
}
},
{ loaded, total },
)
}
const newArticleTitle = 'Choosing tiles for a small bathroom'
function newArticleHomeApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return articlesApi(overrides)
}
async function openNewArticleModal(page: Page) {
await page.getByRole('button', { name: 'New article' }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
async function fillNewArticleValid(page: Page) {
await page.getByPlaceholder(`e.g. ${newArticleTitle}`).fill(newArticleTitle)
}
function submitNewArticle() {
return async (page: Page) => {
await openNewArticleModal(page)
await fillNewArticleValid(page)
await page.getByRole('button', { name: 'Create draft', exact: true }).click()
await page.mouse.move(0, 0)
}
}
const articlesNow = '2026-09-15T12:00:00Z'
const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS
function articlesFixtureIso(offsetMs: number) {
return new Date(new Date(articlesNow).getTime() - offsetMs).toISOString()
}
function articlesFixture() {
return [
{
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
site_id: 2,
authors: boardBylines('Inès Duarte'),
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: articlesFixtureIso(2 * HOUR_MS),
},
{
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
site_id: 1,
authors: boardBylines('Inès Duarte', 'Camille Roche'),
is_online: false,
created_at: '2026-09-09T09:00:00Z',
updated_at: articlesFixtureIso(1 * DAY_MS),
},
{
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
site_id: 3,
authors: boardBylines('Jonas Pereira'),
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: articlesFixtureIso(3 * DAY_MS),
},
{
id: 4,
title: 'Winter checklist for timber windows',
body: '',
images: [],
site_id: 2,
authors: boardBylines(
'Inès Duarte',
'Jonas Pereira',
'Camille Roche',
'Paul Huber',
'Léa Fontaine',
),
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: articlesFixtureIso(5 * DAY_MS),
},
{
id: 5,
title: 'Why we reuse original floorboards',
body: '',
images: [],
site_id: 1,
authors: boardBylines('Paul Huber'),
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
},
{
id: 6,
title: 'Our first passive-house retrofit',
body: '',
images: [],
site_id: 2,
authors: boardBylines(),
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
},
]
}
interface ListedArticle {
title: string
is_online: boolean
site_id?: number
}
function foldTitle(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
}
function articleEnvelopeFor(all: ListedArticle[], url: URL) {
const page = Number(url.searchParams.get('page') ?? 1)
const size = Number(url.searchParams.get('size') ?? 25)
const q = url.searchParams.get('q') ?? ''
const status = url.searchParams.get('status') ?? ''
const siteKey = url.searchParams.get('site') ?? ''
const siteId = directorySites.find((site) => site.key === siteKey)?.id
const matching = all
.filter((article) => !siteKey || article.site_id === siteId)
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || foldTitle(article.title).includes(foldTitle(q)))
const online = all.filter((article) => article.is_online).length
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
counts: { all: all.length, online, draft: all.length - online },
}
}
function articleListMock(all: ListedArticle[]): ApiMock {
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url) }
}
function articlesFixtureWithImages() {
return articlesFixture().map((article) =>
article.id === 4
? {
...article,
images: [
{ id: 10, url: '/images/10.png' },
{ id: 11, url: '/images/11.png' },
{ id: 12, url: '/images/12.png' },
],
}
: article,
)
}
function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': articleListMock(articlesFixture()),
'GET /v1/site/': boardSiteList(),
...overrides,
}
}
function multiSiteArticlesApi(overrides: Record<string, ApiMock> = {}) {
return articlesApi({ 'GET /v1/site/': boardSiteList(4), ...overrides })
}
async function openSiteFilter(page: Page) {
await page.locator('button[aria-label="Site"]:visible').click()
await page.getByRole('option').first().waitFor()
await page.mouse.move(0, 0)
}
const clockSensitiveArticles = (path: string): VisualState => ({
path,
skip: 'clock-sensitive',
})
const pagination: Record<string, VisualState> = {
'First page': clockSensitiveArticles('/article'),
'Middle page': clockSensitiveArticles('/article?page=7'),
'Last page': clockSensitiveArticles('/article?page=13'),
'Single page': clockSensitiveArticles('/article'),
'Loading next page': clockSensitiveArticles('/article?page=6'),
'Error loading a page': clockSensitiveArticles('/article?page=6'),
'Page out of range': clockSensitiveArticles('/article?page=99'),
'Search resets to page 1': clockSensitiveArticles('/article?page=7'),
'Empty after search': {
path: '/article',
api: articlesApi(),
steps: async (page) => {
await page.getByPlaceholder('Search by title').fill('tiles')
await page.getByText('No articles match "tiles"').waitFor()
},
},
'Page size menu': clockSensitiveArticles('/article?page=2'),
'Focus-visible': clockSensitiveArticles('/article?page=7'),
}
async function openArticleRowMenu(page: Page, title: string) {
await page.getByRole('button', { name: `Actions for ${title}` }).click()
}
async function chooseArticleRowMenuItem(page: Page, label: string) {
await page.getByRole('menuitem', { name: label, exact: true }).click()
}
async function fillResetRequestEmail(page: Page, email: string) {
await page.getByPlaceholder('you@example.com').fill(email)
}
function submitResetRequest(email: string) {
return async (page: Page) => {
await fillResetRequestEmail(page, email)
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
await page.mouse.move(0, 0)
}
}
function resetRequestWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: '/register/reset/request',
api: {
'POST /v1/auth/password-reset': response,
},
}
}
const resetConfirmPath = '/register/reset/validate?c=abc123&email=marta%40renew.example'
async function fillResetConfirm(page: Page, password: string, confirmPassword: string) {
await page.locator('#reset-new-password').fill(password)
await page.locator('#reset-confirm-password').fill(confirmPassword)
}
function submitResetConfirm(password: string, confirmPassword: string) {
return async (page: Page) => {
await fillResetConfirm(page, password, confirmPassword)
await page.getByRole('button', { name: 'Change password', exact: true }).click()
await page.mouse.move(0, 0)
}
}
function resetConfirmWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: resetConfirmPath,
api: {
'POST /v1/auth/password-reset/confirm': response,
},
}
}
const usersFixture = [
{
kind: 'invite',
id: 2,
email: 'sam.oliveira@renew.example',
status: 'pending',
created_at: '2026-09-27T09:00:00Z',
},
{
kind: 'invite',
id: 1,
email: 'p.huber@gmail.com',
display_name: 'Paul Huber',
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
},
{
kind: 'user',
id: 3,
username: 'ines',
display_name: 'Inès Duarte',
email: 'ines.duarte@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
},
{
kind: 'user',
id: 2,
username: 'jonas',
display_name: '',
email: 'jonas@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
},
{
kind: 'user',
id: 1,
username: 'marta',
display_name: '',
email: 'marta@renew.example',
role: 'owner',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
},
]
function usersApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/user/': { status: 200, body: usersFixture },
...overrides,
}
}
async function openInviteModal(page: Page) {
await page.getByRole('button', { name: 'Invite user' }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
function rowActionButton(page: Page, email: string, name: string) {
return page
.locator('[data-testid="user-row"], [data-testid="user-card"]')
.filter({ hasText: email })
.getByRole('button', { name, exact: true })
}
async function openCancelInviteDialog(page: Page) {
await rowActionButton(page, 'sam.oliveira@renew.example', 'Cancel invite').click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
async function confirmCancelInvite(page: Page) {
await openCancelInviteDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: /^Cancel(ling…| invite)$/ })
.click()
await page.mouse.move(0, 0)
}
async function renewPaulHuberInvite(page: Page) {
await rowActionButton(page, 'p.huber@gmail.com', 'Renew invite').click()
await page.mouse.move(0, 0)
}
function inviteWith(email: string, response?: ApiMock) {
return async (page: Page) => {
await openInviteModal(page)
await page.getByPlaceholder('name@example.com').fill(email)
if (response) {
await page.getByRole('button', { name: 'Send invite', exact: true }).click()
await page.mouse.move(0, 0)
} else {
await page.getByPlaceholder('name@example.com').blur()
await page.mouse.move(0, 0)
}
}
}
const integrationOrigin = 'https://cms.renew.example'
async function clickBaseUrlCopy(page: Page) {
await page.getByRole('button', { name: 'Copy code', exact: true }).first().click()
}
const integrationImagesOffsets = { desktop: -9, mobile: -40 }
type ImageVariantRow = [string, number, number | null, string]
const integrationVariantRows: ImageVariantRow[] = [
['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 integrationVariant(row: ImageVariantRow, enabled = true) {
const [name, width, height, format] = row
return {
name,
width,
height,
fit: 'cover',
format,
quality: 82,
generate_on_upload: false,
enabled,
generated_files: 0,
cache_size: 0,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-03-12T09:00:00Z',
}
}
function integrationVariantsList(rows: ImageVariantRow[], withDisabled = false): ApiMock {
const items = rows.map((row) => integrationVariant(row))
if (withDisabled) {
items.push(integrationVariant(['print', 0, null, 'png'], false))
}
return { status: 200, body: { items } }
}
const integrationRemovedRows = integrationVariantRows.filter((row) => row[0] !== 'hero').slice(0, 5)
const integrationManyVariants = integrationVariantsList(integrationVariantRows, true)
function afterImagesLoaded(steps: VisualState['steps']): VisualState['steps'] {
if (!steps) {
return undefined
}
return async (page) => {
await page.locator('[data-anchor="images"] h3').first().waitFor()
await steps(page)
}
}
function integrationState(overrides: Partial<VisualState> = {}): VisualState {
return {
path: '/integration',
origin: integrationOrigin,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/image-variants': integrationManyVariants,
},
...overrides,
steps: afterImagesLoaded(overrides.steps),
}
}
function integrationImagesState(
list: ApiMock | null,
overrides: Partial<VisualState> & { session?: ApiMock } = {},
): VisualState {
const { session = adminSession, ...rest } = overrides
return {
path: '/integration',
origin: integrationOrigin,
api: {
'GET /v1/auth/me': session,
...(list ? { 'GET /v1/image-variants': list } : {}),
},
steps: (page) => scrollToAnchor(page, 'images', integrationImagesOffsets),
...rest,
}
}
const memberSession: ApiMock = {
status: 200,
body: { ...(verifiedSession.body as object), role: 'member' },
}
async function pickBuilderVariant(page: Page, name: string) {
await page.locator('button[aria-label="Variant"]').click()
await page.getByRole('option', { name, exact: true }).click()
}
async function copyUnder(page: Page, heading: string) {
await scrollToAnchor(page, 'images', integrationImagesOffsets)
await page
.locator('[data-anchor="images"] h3', { hasText: heading })
.locator('xpath=..')
.getByRole('button', { name: 'Copy code' })
.click()
await page.mouse.move(0, 0)
}
async function scrollToAnchor(
page: Page,
anchor: string,
offsets?: { desktop?: number; mobile?: number },
) {
const mobile = (page.viewportSize()?.width ?? 0) < 1024
const offset = mobile ? offsets?.mobile : offsets?.desktop
await page.evaluate(
({ id, offset }) => {
const target = document.querySelector(`[data-anchor="${id}"]`)
if (!target) return
if (offset === undefined) {
target.scrollIntoView({ block: 'start' })
return
}
let scroller = target.parentElement
while (scroller && !['auto', 'scroll'].includes(getComputedStyle(scroller).overflowY)) {
scroller = scroller.parentElement
}
if (!scroller) return
scroller.scrollTop +=
target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - offset
},
{ id: anchor, offset },
)
await page.waitForTimeout(150)
}
const acceptInvitePath = '/invite/accept?c=abc123'
const acceptInviteEmail = 'lea.martin@renew.example'
function acceptInviteWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: acceptInvitePath,
api: {
'GET /v1/auth/invite': { status: 200, body: { email: acceptInviteEmail } },
'POST /v1/auth/invite/accept': response,
},
}
}
async function fillAcceptInvite(page: Page, username: string, password: string) {
await page.getByPlaceholder('e.g. lea').fill(username)
await page.getByPlaceholder('e.g. lea').blur()
await page.locator('input[autocomplete="new-password"]').fill(password)
await page.locator('input[autocomplete="new-password"]').blur()
}
function submitAcceptInvite(username: string, password: string) {
return async (page: Page) => {
await fillAcceptInvite(page, username, password)
await page.locator('input[autocomplete="new-password"]').press('Enter')
}
}
const profileDisplayName = 'Marta Alves'
const profileFieldPlaceholder = 'e.g. Marta Alves'
const profileTooLongName = 'Marta Alves, Head of Renovations, Brussels and Wallonia office'
const profileSavedBody = {
id: 1,
username: 'marta',
display_name: profileDisplayName,
}
const profileAuthorRecord = {
id: 7,
name: 'Marta Alves',
slug: 'marta-alves',
bio: 'Project coordinator. Writes about permits and planning.',
avatar_id: null,
website: null,
social_links: [],
user_id: 1,
is_linked: true,
article_count: 0,
user_email: 'marta@renew.example',
}
function profileAuthorSession(role = 'owner', withAuthor = false): ApiMock {
return {
status: 200,
body: {
...(profileSession(profileDisplayName).body as object),
role,
...(withAuthor ? { author: { id: 7, name: 'Marta Alves', slug: 'marta-alves' } } : {}),
},
}
}
function authorProfileState(
overrides: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
session: ApiMock = profileAuthorSession(),
): VisualState {
return {
path: '/profile',
api: { 'GET /v1/auth/me': session, ...overrides },
steps,
}
}
async function createAuthorProfile(page: Page) {
await page.getByRole('button', { name: 'Create my author profile', exact: true }).click()
}
function profileSession(displayName = ''): ApiMock {
return {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: displayName,
role: 'owner',
},
}
}
function profileApi(
displayName = '',
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': profileSession(displayName),
...overrides,
}
}
function profileState(
displayName = '',
overrides: Record<string, ApiMock> = {},
steps?: (page: Page) => Promise<void>,
): VisualState {
return { path: '/profile', api: profileApi(displayName, overrides), steps }
}
async function typeDisplayName(page: Page, value: string) {
await page.getByPlaceholder(profileFieldPlaceholder).fill(value)
}
async function scrollToTop(page: Page) {
await page.evaluate(() => {
window.scrollTo(0, 0)
document.querySelectorAll<HTMLElement>('[data-slot="body"]').forEach((element) => {
element.scrollTop = 0
})
})
}
async function pressSave(page: Page) {
await page.getByRole('button', { name: 'Save', exact: true }).click()
}
async function saveDisplayName(page: Page, value: string) {
await typeDisplayName(page, value)
await pressSave(page)
await page.mouse.move(0, 0)
}
async function pressSendResetLink(page: Page) {
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
await page.mouse.move(0, 0)
await scrollToTop(page)
}
function resetState(response: ApiMock): VisualState {
return profileState(
profileDisplayName,
{ 'POST /v1/auth/password-reset': response },
pressSendResetLink,
)
}
async function openAccountMenu(page: Page) {
const isMobile = (page.viewportSize()?.width ?? 0) < 1024
if (isMobile) {
await page.locator('[data-slot="toggle"]').click()
await page.locator('[role="dialog"] button[aria-haspopup]').click()
await page.locator('[role="menu"]').waitFor()
} else {
await page.locator('[data-slot="footer"] button[aria-haspopup]').click()
await page.locator('[role="menu"]').waitFor()
}
await page.getByRole('menuitemradio', { name: 'Light', exact: true }).click()
await page.evaluate(() => {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.classList.toggle('dark', dark)
document.documentElement.classList.toggle('light', !dark)
})
await page.mouse.move(0, 0)
}
const usersWithDisplayNames = usersFixture.map((item) =>
item.kind === 'user' && item.username === 'marta'
? { ...item, display_name: profileDisplayName }
: item,
)
interface MediaSample {
id: number
filename: string
size: number
width: number | null
height: number | null
contentType: string
usedIn: number
uploadedBy: string
createdAt: string
duplicate: boolean
tone: number
variantCount: number
variantSize: number
}
const mediaSamples: MediaSample[] = [
{
id: 1,
variantCount: 4,
variantSize: 266240,
filename: 'north-wall.jpg',
size: 4089446,
width: 4032,
height: 3024,
contentType: 'image/jpeg',
usedIn: 2,
uploadedBy: 'Marta Alves',
createdAt: '2026-09-12T12:00:00Z',
duplicate: false,
tone: 0,
},
{
id: 2,
variantCount: 5,
variantSize: 419840,
filename: 'kitchen-after.webp',
size: 1258291,
width: 2400,
height: 1600,
contentType: 'image/webp',
usedIn: 3,
uploadedBy: 'Inès Duarte',
createdAt: '2026-09-08T12:00:00Z',
duplicate: false,
tone: 1,
},
{
id: 3,
variantCount: 5,
variantSize: 404480,
filename: 'kitchen-before.webp',
size: 1153434,
width: 2400,
height: 1600,
contentType: 'image/webp',
usedIn: 1,
uploadedBy: 'Inès Duarte',
createdAt: '2026-09-08T11:00:00Z',
duplicate: false,
tone: 2,
},
{
id: 4,
variantCount: 0,
variantSize: 0,
filename: 'logo-renew.svg',
size: 8192,
width: null,
height: null,
contentType: 'image/svg+xml',
usedIn: 5,
uploadedBy: 'jonas',
createdAt: '2026-09-05T12:00:00Z',
duplicate: false,
tone: 3,
},
{
id: 5,
variantCount: 3,
variantSize: 245760,
filename: 'floorboards-copy.jpg',
size: 2516582,
width: 3600,
height: 2400,
contentType: 'image/jpeg',
usedIn: 3,
uploadedBy: 'Marta Alves',
createdAt: '2026-09-02T12:00:00Z',
duplicate: true,
tone: 0,
},
{
id: 6,
variantCount: 0,
variantSize: 0,
filename: 'IMG_2208.jpg',
size: 2516582,
width: 3600,
height: 2400,
contentType: 'image/jpeg',
usedIn: 2,
uploadedBy: 'jonas',
createdAt: '2026-08-30T12:00:00Z',
duplicate: true,
tone: 0,
},
{
id: 7,
variantCount: 2,
variantSize: 153600,
filename: 'IMG_4471.jpg',
size: 3250585,
width: 3600,
height: 2400,
contentType: 'image/jpeg',
usedIn: 2,
uploadedBy: 'Marta Alves',
createdAt: '2026-08-29T12:00:00Z',
duplicate: true,
tone: 2,
},
{
id: 8,
variantCount: 0,
variantSize: 0,
filename: 'window-frame.jpg',
size: 1887437,
width: 3000,
height: 2000,
contentType: 'image/jpeg',
usedIn: 0,
uploadedBy: 'Inès Duarte',
createdAt: '2026-08-28T12:00:00Z',
duplicate: false,
tone: 3,
},
{
id: 9,
variantCount: 1,
variantSize: 61440,
filename: 'stone-wall-detail.jpg',
size: 3250585,
width: 3600,
height: 2400,
contentType: 'image/jpeg',
usedIn: 1,
uploadedBy: 'jonas',
createdAt: '2026-08-21T12:00:00Z',
duplicate: true,
tone: 2,
},
{
id: 10,
variantCount: 3,
variantSize: 194560,
filename: 'bathroom-tiles.png',
size: 880640,
width: 1800,
height: 1200,
contentType: 'image/png',
usedIn: 0,
uploadedBy: 'Marta Alves',
createdAt: '2026-08-04T12:00:00Z',
duplicate: false,
tone: 1,
},
{
id: 11,
variantCount: 4,
variantSize: 286720,
filename: 'lime-plaster.jpg',
size: 2202009,
width: 3200,
height: 2133,
contentType: 'image/jpeg',
usedIn: 2,
uploadedBy: 'Inès Duarte',
createdAt: '2026-07-19T12:00:00Z',
duplicate: false,
tone: 1,
},
{
id: 12,
variantCount: 5,
variantSize: 440320,
filename: 'floorboards.jpg',
size: 2516582,
width: 3600,
height: 2400,
contentType: 'image/jpeg',
usedIn: 4,
uploadedBy: 'Inès Duarte',
createdAt: '2026-06-14T12:00:00Z',
duplicate: true,
tone: 0,
},
]
const mediaOrigin = 'https://cms.renew.example'
const mediaSession: ApiMock = {
status: 200,
body: { ...(verifiedSession.body as object), display_name: 'Marta Alves' },
}
const mediaFullCounts = { all: 12, unused: 2, duplicates: 2 }
const mediaNorthWallUsage = [
{ id: 10, title: 'How we price a bathroom renovation', published: true },
{ id: 11, title: 'Winter checklist for timber windows', published: false },
]
const mediaFloorboardsUsage = [
{ id: 10, title: 'How we price a bathroom renovation', published: true },
{ id: 11, title: 'Winter checklist for timber windows', published: false },
{ id: 12, title: 'Before and after: the Rue Verte kitchen', published: true },
{ id: 13, title: 'Why we reuse original floorboards', published: true },
]
const mediaDuplicateIds = [12, 5, 6]
const mediaDuplicateGroups = [mediaDuplicateIds, [7, 9]]
function mediaHashFor(sample: MediaSample) {
const group = mediaDuplicateGroups.find((ids) => ids.includes(sample.id))
const key = group ? group[0]! : sample.id
return `${sample.tone}${String(key).padStart(2, '0').repeat(31)}a`
}
function mediaItem(sample: MediaSample, filename = sample.filename) {
return {
id: sample.id,
filename,
url: `/media/${filename}`,
size: sample.size,
content_type: sample.contentType,
width: sample.width,
height: sample.height,
sha256: mediaHashFor(sample),
uploaded_by: sample.uploadedBy,
created_at: sample.createdAt,
used_in: sample.usedIn,
duplicate: sample.duplicate,
variant_count: sample.variantCount,
variant_size: sample.variantSize,
}
}
function mediaItems(ids?: number[], without: number[] = []) {
return mediaSamples
.filter((sample) => (!ids || ids.includes(sample.id)) && !without.includes(sample.id))
.map((sample) => mediaItem(sample))
}
function mediaList(items: unknown[], counts = mediaFullCounts) {
return { items, total: items.length, page: 1, size: 25, counts }
}
const mediaPagedTotal = 312
const mediaPagedCounts = { all: mediaPagedTotal, unused: 2, duplicates: 2 }
const mediaPastLastPage = 99
const mediaFailingPage = '7'
function mediaPagedApi() {
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (url) => {
const page = Number(url.searchParams.get('page'))
return {
items: page === mediaPastLastPage ? [] : mediaItems(),
total: mediaPagedTotal,
page,
size: Number(url.searchParams.get('size')),
counts: mediaPagedCounts,
}
},
},
})
}
function isMediaPage(url: URL, wanted: string) {
return url.pathname === '/v1/media/' && url.searchParams.get('page') === wanted
}
async function scrollEverythingToTop(page: Page) {
await page.evaluate(() => {
for (const element of document.querySelectorAll('*')) {
element.scrollTop = 0
}
})
}
async function pressNextPage(page: Page) {
await page.getByRole('button', { name: 'Next page' }).click()
}
function mediaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': mediaSession,
'GET /v1/media/': {
status: 200,
bodyFor: (url) =>
url.searchParams.has('hash')
? mediaList(mediaItems(mediaDuplicateIds))
: mediaList(mediaItems()),
},
'GET /v1/media/1/usage': { status: 200, body: mediaNorthWallUsage },
'GET /v1/media/12/usage': { status: 200, body: mediaFloorboardsUsage },
...overrides,
}
}
function mediaApiAfterDelete(id: number, overrides: Record<string, ApiMock> = {}) {
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (_url, call) =>
call === 1
? mediaList(mediaItems())
: mediaList(mediaItems(undefined, [id]), { all: 11, unused: 1, duplicates: 2 }),
},
...overrides,
})
}
function mediaState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
path = '/media',
): VisualState {
return { path, origin: mediaOrigin, api, steps }
}
const mediaDuplicateListIds = [12, 5, 6, 7, 9]
const mediaMergedDuplicateIds = [7, 9]
const mediaMergedCounts = { all: 10, unused: 2, duplicates: 1 }
const mediaMegabyte = 1024 * 1024
const mediaHungHashLabel = 'attic'
const mediaProgressRatio = 0.423
const mediaRetryAfter = '42'
const mediaDuplicatesPath = '/media?filter=duplicates'
function mediaListApi(
lists: { first: unknown; later?: unknown },
overrides: Record<string, ApiMock> = {},
) {
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (_url, call) => (call === 1 ? lists.first : (lists.later ?? lists.first)),
},
...overrides,
})
}
function mediaDuplicatesApi(
later?: { ids: number[]; counts: typeof mediaFullCounts },
overrides: Record<string, ApiMock> = {},
) {
const duplicates = mediaList(mediaItems(mediaDuplicateListIds))
return mediaListApi(
{
first: duplicates,
later: later ? mediaList(mediaItems(later.ids), later.counts) : duplicates,
},
overrides,
)
}
function mediaBulkApi(
deleteResponses: Record<string, ApiMock>,
removed: number[],
counts: typeof mediaFullCounts,
) {
return mediaListApi(
{
first: mediaList(mediaItems()),
later: mediaList(mediaItems(undefined, removed), counts),
},
deleteResponses,
)
}
function mediaCheckbox(page: Page, filename: string) {
return page.getByRole('checkbox', { name: `Select ${filename}`, exact: true })
}
async function selectMediaCards(page: Page) {
for (const filename of ['north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg']) {
await mediaCheckbox(page, filename).click()
}
await page.getByTestId('media-bulk-bar').waitFor()
await scrollToTop(page)
await page.mouse.move(0, 0)
}
async function openBulkDeleteDialog(page: Page) {
await selectMediaCards(page)
await page.getByTestId('bulk-delete').click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function confirmBulkDelete(wait?: string) {
return async (page: Page) => {
await openBulkDeleteDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete 3 images', exact: true })
.click()
if (wait) {
await page.getByText(wait, { exact: true }).first().waitFor()
await scrollToTop(page)
}
await page.mouse.move(0, 0)
}
}
interface MediaUploadFile {
name: string
mimeType: string
size: number
label?: string
}
type MediaUploadBehavior =
| { kind: 'hold' }
| { kind: 'respond'; status: number; headers?: Record<string, string> }
function mediaUploadFile(name: string, mimeType: string, size: number, label?: string) {
return { name, mimeType, size: Math.round(size), label } satisfies MediaUploadFile
}
const mediaUploadFiles = {
kitchenRoof: mediaUploadFile('kitchen-roof.jpg', 'image/jpeg', 2.2 * mediaMegabyte),
porchSteps: mediaUploadFile('porch-steps.jpg', 'image/jpeg', 2.6 * mediaMegabyte),
floorboards: mediaUploadFile('floorboards-2.jpg', 'image/jpeg', 2.4 * mediaMegabyte),
scan: mediaUploadFile('scan.tif', 'image/tiff', 14.2 * mediaMegabyte),
roof: mediaUploadFile('roof.png', 'image/png', 1.4 * mediaMegabyte),
attic: mediaUploadFile('attic.jpg', 'image/jpeg', 1.9 * mediaMegabyte, mediaHungHashLabel),
notes: mediaUploadFile('notes.pdf', 'application/pdf', 210 * 1024),
broken: mediaUploadFile('broken.png', 'image/png', 3 * mediaMegabyte),
}
const uploadCreated: MediaUploadBehavior = { kind: 'respond', status: 201 }
const uploadServerError: MediaUploadBehavior = { kind: 'respond', status: 500 }
const uploadUnreadable: MediaUploadBehavior = { kind: 'respond', status: 400 }
function mediaUploadPayload(file: MediaUploadFile) {
const buffer = Buffer.alloc(file.size)
buffer.write(file.label ?? file.name)
return { name: file.name, mimeType: file.mimeType, buffer }
}
function mediaUploadApi() {
const duplicateHash = createHash('sha256')
.update(mediaUploadPayload(mediaUploadFiles.floorboards).buffer)
.digest('hex')
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (url) => {
const hash = url.searchParams.get('hash')
if (hash === null) {
return mediaList(mediaItems())
}
return hash === duplicateHash ? mediaList(mediaItems([12, 5, 6])) : mediaList([])
},
},
})
}
async function stubMediaUploads(page: Page, behaviors: Record<string, MediaUploadBehavior>) {
await page.evaluate(
({ behaviors, ratio, hungLabel }) => {
class StubRequest extends EventTarget {
upload = new EventTarget()
status = 0
responseText = ''
withCredentials = false
responseHeaders: Record<string, string> = {}
open() {}
send(form: FormData) {
const file = form.get('file') as File
const behavior = behaviors[file.name]
Promise.resolve().then(() => {
this.upload.dispatchEvent(
Object.assign(new Event('progress'), {
loaded: Math.round(
file.size * (behavior?.kind === 'hold' ? ratio : 1),
),
total: file.size,
}),
)
if (behavior?.kind === 'respond') {
this.status = behavior.status
this.responseHeaders = behavior.headers ?? {}
this.dispatchEvent(new Event('load'))
}
})
}
abort() {
this.dispatchEvent(new Event('abort'))
}
getResponseHeader(name: string) {
return this.responseHeaders[name] ?? null
}
}
;(window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = StubRequest
const subtle = crypto.subtle
const digest = subtle.digest.bind(subtle)
subtle.digest = (algorithm, data) => {
const head = new TextDecoder().decode(
(data as ArrayBuffer).slice(0, hungLabel.length),
)
return head === hungLabel ? new Promise(() => {}) : digest(algorithm, data)
}
},
{ behaviors, ratio: mediaProgressRatio, hungLabel: mediaHungHashLabel },
)
}
async function chooseMediaFiles(page: Page, files: MediaUploadFile[]) {
await page.locator('input[type="file"]').setInputFiles(files.map(mediaUploadPayload))
}
function mediaUploadState(
files: MediaUploadFile[],
behaviors: Record<string, MediaUploadBehavior>,
settled: string,
): VisualState {
return mediaState(mediaUploadApi(), async (page) => {
await stubMediaUploads(page, behaviors)
await chooseMediaFiles(page, files)
await page.getByText(settled, { exact: true }).first().waitFor()
await page.mouse.move(0, 0)
})
}
async function dragFilesOverPage(page: Page) {
await page.evaluate(() => {
const event = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: { types: ['Files'], files: [] } })
document.body.dispatchEvent(event)
})
await page.getByTestId('media-drop-area').waitFor()
}
async function rateLimitAnUpload(page: Page) {
const frozen = new Date('2026-09-30T12:00:00Z')
await page.clock.install({ time: frozen })
await page.clock.pauseAt(new Date(frozen.getTime() + 1000))
await stubMediaUploads(page, {
[mediaUploadFiles.porchSteps.name]: {
kind: 'respond',
status: 429,
headers: { 'Retry-After': mediaRetryAfter },
},
})
await chooseMediaFiles(page, [mediaUploadFiles.porchSteps])
await page.getByText('Too many uploads', { exact: true }).first().waitFor()
await page.getByRole('button', { name: 'Cancel all', exact: true }).click()
await page.mouse.move(0, 0)
}
async function openMergeDialog(page: Page) {
await page.getByRole('button', { name: 'Merge into this one' }).first().click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function confirmMerge(wait?: string) {
return async (page: Page) => {
await openMergeDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Merge 2 copies', exact: true })
.click()
if (wait) {
await page.getByText(wait, { exact: true }).first().waitFor()
}
await page.mouse.move(0, 0)
}
}
function mediaCard(page: Page, id: number) {
return page.locator(`[data-media-card][data-media-id="${id}"]`)
}
async function openMediaPanel(page: Page, id: number) {
await mediaCard(page, id).dispatchEvent('click')
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
async function openMediaDialog(page: Page, id: number, action: 'Rename' | 'Delete') {
await openMediaPanel(page, id)
await page.getByRole('dialog').getByRole('button', { name: action, exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function mediaRenameWith(value: string, submit: boolean) {
return async (page: Page) => {
await openMediaDialog(page, 1, 'Rename')
const field = page.getByRole('alertdialog').locator('input')
await field.fill(value)
if (submit) {
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Rename', exact: true })
.click()
} else {
await field.focus()
}
await page.mouse.move(0, 0)
}
}
function mediaConfirmDelete(id: number) {
return async (page: Page) => {
await openMediaDialog(page, id, 'Delete')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete image', exact: true })
.click()
await page.mouse.move(0, 0)
}
}
async function goOffline(page: Page) {
await page.evaluate(() => {
Object.defineProperty(navigator, 'onLine', { get: () => false, configurable: true })
window.dispatchEvent(new Event('offline'))
})
await page.getByText("You're offline.").first().waitFor()
}
const mediaStates: Record<string, VisualState> = {
'Grid, many images': mediaState(mediaApi()),
'Grid, few images': mediaState(
mediaApi({
'GET /v1/media/': {
status: 200,
body: mediaList(mediaItems([1, 4, 8]), { all: 3, unused: 1, duplicates: 0 }),
},
}),
),
'List view': mediaState(mediaApi(), undefined, '/media?view=list'),
Loading: mediaState(mediaApi({ 'GET /v1/media/': { status: 200, hang: true } })),
'Empty library, first run': mediaState(
mediaApi({
'GET /v1/media/': {
status: 200,
body: mediaList([], { all: 0, unused: 0, duplicates: 0 }),
},
}),
),
'No results': mediaState(
mediaApi({ 'GET /v1/media/': { status: 200, body: mediaList([]) } }),
undefined,
'/media?q=wall&filter=unused',
),
'Hover, focus and keyboard': mediaState(mediaApi(), async (page) => {
await mediaCard(page, 3).focus()
await mediaCard(page, 2).hover()
}),
'Selection and bulk bar': mediaState(mediaApi(), selectMediaCards),
'Bulk delete: confirm': mediaState(mediaApi(), openBulkDeleteDialog),
'Bulk delete: in progress': mediaState(
mediaApi({
'DELETE /v1/media/1': { status: 204, hang: true },
'DELETE /v1/media/2': { status: 204, hang: true },
'DELETE /v1/media/8': { status: 204, hang: true },
}),
confirmBulkDelete(),
),
'Bulk delete: partly failed': mediaState(
mediaBulkApi(
{
'DELETE /v1/media/1': { status: 204 },
'DELETE /v1/media/2': { status: 204 },
'DELETE /v1/media/8': { status: 500 },
},
[1, 2],
{ all: 10, unused: 2, duplicates: 2 },
),
confirmBulkDelete("Couldn't delete 1 image"),
),
'Bulk delete: done': mediaState(
mediaBulkApi(
{
'DELETE /v1/media/1': { status: 204 },
'DELETE /v1/media/2': { status: 204 },
'DELETE /v1/media/8': { status: 204 },
},
[1, 2, 8],
{ all: 9, unused: 1, duplicates: 2 },
),
confirmBulkDelete('3 images deleted'),
),
'Detail panel: in use': mediaState(mediaApi(), (page) => openMediaPanel(page, 1)),
'Detail panel: unused': mediaState(mediaApi(), (page) => openMediaPanel(page, 8)),
'Detail panel: duplicate': mediaState(mediaApi(), (page) => openMediaPanel(page, 12)),
'Copy URL': mediaState(mediaApi(), async (page) => {
await openMediaPanel(page, 1)
await page
.getByRole('dialog')
.getByRole('button', { name: 'Copy URL', exact: true })
.click()
await page.getByText('URL copied', { exact: true }).first().waitFor()
await page.mouse.move(0, 0)
}),
'Rename: dialog': mediaState(mediaApi(), mediaRenameWith('north-wall', false)),
'Rename: saving': mediaState(
mediaApi({ 'PATCH /v1/media/1': { status: 200, hang: true } }),
mediaRenameWith('north-wall-01', true),
),
'Rename: validation': mediaState(mediaApi(), async (page) => {
await mediaRenameWith('!!!', false)(page)
await page.getByRole('alertdialog').locator('input').blur()
await page.mouse.move(0, 0)
}),
'Rename: failed': mediaState(
mediaApi({ 'PATCH /v1/media/1': { status: 500 } }),
mediaRenameWith('north-wall-01', true),
),
'Rename: done': mediaState(
mediaApi({
'PATCH /v1/media/1': {
status: 200,
body: mediaItem(mediaSamples[0]!, 'north-wall-01.jpg'),
},
}),
async (page) => {
await mediaRenameWith('north-wall-01', true)(page)
await page.getByText('Image renamed', { exact: true }).first().waitFor()
await page.mouse.move(0, 0)
},
),
'Delete: unused image': mediaState(mediaApi(), (page) => openMediaDialog(page, 8, 'Delete')),
'Delete: image in use': mediaState(mediaApi(), (page) => openMediaDialog(page, 1, 'Delete')),
'Delete: in progress': mediaState(
mediaApi({ 'DELETE /v1/media/1': { status: 204, hang: true } }),
mediaConfirmDelete(1),
),
'Delete: failed': mediaState(
mediaApi({ 'DELETE /v1/media/8': { status: 500 } }),
mediaConfirmDelete(8),
),
'Delete: done': mediaState(
mediaApiAfterDelete(8, { 'DELETE /v1/media/8': { status: 204 } }),
async (page) => {
await mediaConfirmDelete(8)(page)
await page.getByText('Image deleted', { exact: true }).first().waitFor()
await scrollToTop(page)
await page.mouse.move(0, 0)
},
),
'Image already gone (404)': mediaState(
mediaApiAfterDelete(8, { 'DELETE /v1/media/8': { status: 404 } }),
async (page) => {
await mediaConfirmDelete(8)(page)
await page.getByText('That image no longer exists', { exact: true }).first().waitFor()
await scrollToTop(page)
await page.mouse.move(0, 0)
},
),
'Upload: drag over the page': mediaState(mediaApi(), dragFilesOverPage),
'Upload: several files, mixed': mediaUploadState(
[
mediaUploadFiles.kitchenRoof,
mediaUploadFiles.porchSteps,
mediaUploadFiles.floorboards,
mediaUploadFiles.scan,
mediaUploadFiles.roof,
mediaUploadFiles.attic,
],
{
[mediaUploadFiles.kitchenRoof.name]: uploadCreated,
[mediaUploadFiles.porchSteps.name]: { kind: 'hold' },
[mediaUploadFiles.roof.name]: uploadServerError,
},
'Waiting',
),
'Upload: summary': mediaUploadState(
[
mediaUploadFiles.kitchenRoof,
mediaUploadFiles.porchSteps,
mediaUploadFiles.floorboards,
mediaUploadFiles.scan,
mediaUploadFiles.roof,
],
{
[mediaUploadFiles.kitchenRoof.name]: uploadCreated,
[mediaUploadFiles.porchSteps.name]: uploadCreated,
[mediaUploadFiles.roof.name]: uploadServerError,
},
'Upload finished',
),
'Upload: rejected files (413, 400)': mediaUploadState(
[mediaUploadFiles.scan, mediaUploadFiles.notes, mediaUploadFiles.broken],
{ [mediaUploadFiles.broken.name]: uploadUnreadable },
'Nothing was uploaded',
),
'Upload: too many requests (429)': mediaState(mediaUploadApi(), rateLimitAnUpload),
'Duplicates: groups': mediaState(mediaDuplicatesApi(), undefined, mediaDuplicatesPath),
'Duplicates: empty': mediaState(
mediaApi({
'GET /v1/media/': {
status: 200,
body: mediaList([], { all: 12, unused: 2, duplicates: 0 }),
},
}),
undefined,
mediaDuplicatesPath,
),
'Merge: confirm': mediaState(mediaDuplicatesApi(), openMergeDialog, mediaDuplicatesPath),
'Merge: in progress': mediaState(
mediaDuplicatesApi(undefined, { 'POST /v1/media/merge': { status: 200, hang: true } }),
confirmMerge(),
mediaDuplicatesPath,
),
'Merge: failed': mediaState(
mediaDuplicatesApi(undefined, { 'POST /v1/media/merge': { status: 500 } }),
confirmMerge("Couldn't merge the copies. Nothing was changed. Try again in a moment."),
mediaDuplicatesPath,
),
'Merge: conflict (409)': mediaState(
mediaDuplicatesApi(undefined, { 'POST /v1/media/merge': { status: 409 } }),
confirmMerge(
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.',
),
mediaDuplicatesPath,
),
'Merge: done': mediaState(
mediaDuplicatesApi(
{ ids: mediaMergedDuplicateIds, counts: mediaMergedCounts },
{ 'POST /v1/media/merge': { status: 200, body: { articles_updated: 5, merged: 2 } } },
),
confirmMerge('Copies merged'),
mediaDuplicatesPath,
),
'List failed to load': mediaState(mediaApi({ 'GET /v1/media/': { status: 500 } })),
'Pagination: first page': mediaState(mediaPagedApi()),
'Pagination: middle page': mediaState(mediaPagedApi(), undefined, '/media?page=7'),
'Pagination: last page': mediaState(mediaPagedApi(), undefined, '/media?page=13'),
'Pagination: list view': mediaState(mediaPagedApi(), undefined, '/media?view=list&page=2'),
'Pagination: loading next page': mediaState(
mediaPagedApi(),
async (page) => {
await page.route(
(url) => isMediaPage(url, mediaFailingPage),
() => undefined,
)
await pressNextPage(page)
await page.getByTestId('media-skeleton').waitFor()
await scrollEverythingToTop(page)
},
'/media?page=6',
),
'Pagination: error loading a page': mediaState(
mediaPagedApi(),
async (page) => {
await page.route(
(url) => isMediaPage(url, mediaFailingPage),
(route) => route.fulfill({ status: 500 }),
)
await pressNextPage(page)
await page.getByText("Couldn't load page 7. Showing page 6.").waitFor()
await scrollEverythingToTop(page)
},
'/media?page=6',
),
'Pagination: page out of range': mediaState(
mediaPagedApi(),
async (page) => {
await page.getByText("That page doesn't exist. Showing the last page.").waitFor()
await scrollEverythingToTop(page)
},
`/media?page=${mediaPastLastPage}`,
),
'Pagination: page size menu': mediaState(mediaPagedApi(), async (page) => {
await page.locator('[aria-label="Rows per page"]').click()
await page.getByRole('listbox').waitFor()
await scrollEverythingToTop(page)
}),
'Pagination: focus-visible': mediaState(
mediaPagedApi(),
async (page) => {
await page.getByRole('button', { name: 'Next page' }).focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
await scrollEverythingToTop(page)
},
'/media?page=7',
),
Offline: mediaState(mediaApi(), goOffline),
}
const pickerIntro =
'A bathroom is priced by what has to happen behind the walls before anything gets tiled.'
const pickerOutro = 'We don’t add a margin on materials you choose yourself.'
const invisibleImage = '/static/uploadedImages/blank.svg'
const invisibleImageSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'
const boardImageSvgFor = (width: number, height: number) =>
[
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 672 378" preserveAspectRatio="xMidYMid slice">`,
'<style>.tone{fill:#e8dfd1}.ink{color:#7d6d56}@media (prefers-color-scheme:dark){.tone{fill:#d4c5b0}}</style>',
'<defs><pattern id="hatch" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">',
'<rect width="1" height="12" fill="rgb(128 128 128 / 0.12)"/></pattern></defs>',
'<rect class="tone" width="672" height="378"/>',
'<rect fill="url(#hatch)" width="672" height="378"/>',
'<g class="ink" opacity="0.55" transform="translate(316 160) scale(1.6667)" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">',
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></g>',
'</svg>',
].join('')
const boardImageMaxWidth = 672
const boardImageSideSpace = 32
function boardImageFor(page: Page) {
const viewport = page.viewportSize()
const width = Math.min(
boardImageMaxWidth,
(viewport?.width ?? boardImageMaxWidth) - boardImageSideSpace,
)
return boardImageSvgFor(width, Math.round((width * 9) / 16))
}
const boardImage = '/static/uploadedImages/board-image.svg'
function pickerBody(image: string | null, ids: number[] = []) {
const nodes = ids.map(
(id) => `<img src="${image ?? invisibleImage}" alt="" data-image-id="${id}">`,
)
return `<p>${pickerIntro}</p>${nodes.join('')}<p>${pickerOutro}</p>`
}
const pickerCounts = { all: 48, unused: 2, duplicates: 2 }
const pickerFirstPageCursor = 'c24'
const pickerSearchTerm = 'wall'
function pickerList(url: URL) {
if (url.searchParams.has('hash')) {
return mediaList(mediaItems([1]), pickerCounts)
}
if (url.searchParams.has('q')) {
return { items: [], next_cursor: null, counts: pickerCounts }
}
return { items: mediaItems(), next_cursor: pickerFirstPageCursor, counts: pickerCounts }
}
function pickerApi(
overrides: Record<string, ApiMock> = {},
body = pickerBody(null, [1, 4]),
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': mediaSession,
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
'GET /v1/site/': boardSiteList(),
'GET /v1/media/': { status: 200, bodyFor: pickerList },
'GET /v1/image-variants': { status: 403 },
...overrides,
}
}
function pickerState(
steps?: (page: Page) => Promise<void>,
api: Record<string, ApiMock> = pickerApi(),
): VisualState {
return {
path: '/article/12/edit',
api,
steps: async (page) => {
await stubEditorImages(page)
await steps?.(page)
},
}
}
async function openPickerTab(page: Page, name: 'Upload' | 'Library') {
await openImageUploader(page)
await page.getByRole('tab', { name, exact: true }).click()
}
async function waitForPickerTiles(page: Page) {
await page.locator('[data-picker-tile]').first().waitFor()
}
async function selectPickerTile(page: Page, filename: string) {
await waitForPickerTiles(page)
await page.locator(`[data-picker-tile][aria-label^="${filename},"]`).click()
await page.getByTestId('picker-panel').waitFor()
}
async function insertFromLibrary(page: Page, filename: string) {
await openImageUploader(page)
await selectPickerTile(page, filename)
await page.getByRole('button', { name: 'Insert image', exact: true }).click()
await page.getByText('Save to keep it in the article.').first().waitFor()
}
async function selectEditorImage(page: Page) {
await page.locator('[data-testid="article-editor-image"] img').first().click()
await page.getByRole('button', { name: 'Remove image', exact: true }).waitFor()
}
async function stubEditorImages(page: Page) {
await page.route('**/static/uploadedImages/blank.svg', (route) =>
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: invisibleImageSvg }),
)
await page.route('**/static/uploadedImages/board-image.svg', (route) =>
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: boardImageFor(page) }),
)
await page.reload()
await page.waitForFunction(
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
)
}
async function stubBoardImage(page: Page) {
await page.route('**/media/north-wall.jpg', (route) =>
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: boardImageFor(page) }),
)
}
async function browseLibraryThenClose(page: Page) {
await openImageUploader(page)
await waitForPickerTiles(page)
await page.keyboard.press('Escape')
await page.getByRole('dialog').waitFor({ state: 'hidden' })
}
async function requestRemoveFromEditor(page: Page) {
await browseLibraryThenClose(page)
await selectEditorImage(page)
await page.getByRole('button', { name: 'Remove image', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
}
const simplifiedEditorReason =
'The board draws a simplified editor (centred 680 px column, no toolbar, no details panel, fixed image placeholder) while the real editor is left-aligned with a toolbar, a taller header and a details panel, so the image block and page chrome sit elsewhere'
const simplifiedEditorMaxPercent = 40
function withSimplifiedEditorException(state: VisualState): VisualState {
return {
...state,
exception: {
maxPercent: simplifiedEditorMaxPercent,
reason: simplifiedEditorReason,
approvedBy: 'user',
},
}
}
const pickerStates: Record<string, VisualState> = {
'Upload tab': pickerState(async (page) => {
await openPickerTab(page, 'Upload')
}),
'Upload tab: already in your library': {
...pickerState(async (page) => {
await openPickerTab(page, 'Upload')
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
await page.getByText('Already in your library').waitFor()
}),
exception: {
maxPercent: 20,
reason: 'The board buttons collapse to about 22 px on mobile (flex:1 in a column) while the feature requires 44 px full-width stacked buttons, which makes the modal 31 px taller',
approvedBy: 'user',
},
},
'Library: loading': pickerState(
async (page) => {
await openImageUploader(page)
await page.getByTestId('picker-skeleton-tile').first().waitFor()
},
pickerApi({ 'GET /v1/media/': { status: 200, hang: true } }),
),
'Library: nothing selected': pickerState(async (page) => {
await openImageUploader(page)
await waitForPickerTiles(page)
}),
'Library: selected': pickerState(async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'kitchen-after.webp')
}),
'Library: alt text entered': pickerState(async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'kitchen-after.webp')
await page
.getByPlaceholder('e.g. North wall of the kitchen before plastering')
.fill('A kitchen with new oak worktops and white tiles')
}),
'Library: loading more': pickerState(async (page) => {
await openImageUploader(page)
await waitForPickerTiles(page)
await page.route(/\/v1\/media\/\?.*cursor=/, () => undefined)
await page.getByRole('button', { name: 'Load more', exact: true }).click()
await page.getByTestId('picker-skeleton-tile').first().waitFor()
}),
'Library: empty': pickerState(
async (page) => {
await openPickerTab(page, 'Library')
await page.getByText('Your library is empty').waitFor()
},
pickerApi({ 'GET /v1/media/': { status: 200, body: emptyLibrary } }),
),
'Library: no results': pickerState(async (page) => {
await openImageUploader(page)
await waitForPickerTiles(page)
await page.getByLabel('Search by filename').fill(pickerSearchTerm)
await page.getByText(`No images match "${pickerSearchTerm}"`).waitFor()
}),
'Library: failed to load': pickerState(
async (page) => {
await openImageUploader(page)
await page.getByText("Couldn't load your library").waitFor()
},
pickerApi({ 'GET /v1/media/': { status: 500 } }),
),
Inserted: withSimplifiedEditorException(
pickerState(
async (page) => {
await stubBoardImage(page)
await page.getByText('behind the walls').click()
await page.keyboard.press('End')
await insertFromLibrary(page, 'north-wall.jpg')
await page.waitForTimeout(400)
await selectEditorImage(page)
},
pickerApi({}, pickerBody(null)),
),
),
'Remove image: confirm': withSimplifiedEditorException(
pickerState(requestRemoveFromEditor, pickerApi({}, pickerBody(boardImage, [1]))),
),
Removed: pickerState(
async (page) => {
await requestRemoveFromEditor(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Remove image', exact: true })
.click()
await page.getByText("It's still in your library.").first().waitFor()
},
pickerApi({}, pickerBody(boardImage, [1])),
),
}
const apiKeysFullKey = 'rcms_Kf3x9QwL0aBcT7nV2uYdE5sHjR8mPz4WqXg6C1oIyNe'
const apiKeysTooLongName =
'Nightly import of the new media library from the Brussels office file server'
const apiKeysOwnerList = [
{
id: 4,
user_id: 1,
name: 'Website build',
prefix: 'rcms_Kf3x9Qw',
status: 'active',
created_at: '2026-03-12T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: null,
revoked_at: null,
created_by_name: 'Marta Alves',
},
{
id: 2,
user_id: 2,
name: 'Nightly media import',
prefix: 'rcms_Mq7bT2d',
status: 'active',
created_at: '2026-04-02T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: '2026-12-31T12:00:00Z',
revoked_at: null,
created_by_name: 'Jonas',
},
{
id: 3,
user_id: 1,
name: 'Staging preview',
prefix: 'rcms_Zt2dL8a',
status: 'expired',
created_at: '2026-06-14T09:00:00Z',
last_used_at: '2026-08-20T09:00:00Z',
expires_at: '2026-08-30T12:00:00Z',
revoked_at: null,
created_by_name: 'Marta Alves',
},
{
id: 1,
user_id: 3,
name: 'Old migration script',
prefix: 'rcms_Hn5wR4c',
status: 'revoked',
created_at: '2026-04-03T09:00:00Z',
last_used_at: '2026-05-09T09:00:00Z',
expires_at: null,
revoked_at: '2026-05-10T09:00:00Z',
created_by_name: 'Inès Duarte',
},
]
const apiKeysAdminList = [
{
id: 2,
user_id: 2,
name: 'Nightly media import',
prefix: 'rcms_Mq7bT2d',
status: 'active',
created_at: '2026-04-02T09:00:00Z',
last_used_at: '2026-10-03T09:00:00Z',
expires_at: '2026-12-31T12:00:00Z',
revoked_at: null,
},
{
id: 1,
user_id: 2,
name: 'Old CI token',
prefix: 'rcms_Lp9cE1x',
status: 'revoked',
created_at: '2026-01-18T09:00:00Z',
last_used_at: '2026-02-02T09:00:00Z',
expires_at: null,
revoked_at: '2026-02-03T09:00:00Z',
},
]
function apiKeysApi(
list: ApiMock,
overrides: Record<string, ApiMock> = {},
session: ApiMock = verifiedSession,
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': session,
'GET /v1/api-key': list,
...overrides,
}
}
const apiKeysOwnerApi = (overrides: Record<string, ApiMock> = {}) =>
apiKeysApi({ status: 200, body: apiKeysOwnerList }, overrides)
const apiKeysCreated: ApiMock = {
status: 201,
body: {
id: 5,
name: 'Nightly media import',
prefix: apiKeysFullKey.slice(0, 12),
key: apiKeysFullKey,
created_at: '2026-10-04T09:00:00Z',
expires_at: null,
},
}
async function openApiKeyForm(page: Page) {
await page.getByRole('button', { name: 'Create key', exact: true }).click()
await page.getByRole('dialog').waitFor()
}
async function fillApiKeyForm(page: Page, name: string, expiry: string) {
await page.locator('input[name="name"]').fill(name)
if (expiry) {
await page.locator('input[name="expires_at"]').fill(expiry)
}
}
async function submitApiKeyForm(page: Page) {
await page.getByRole('dialog').getByRole('button', { name: 'Create key', exact: true }).click()
}
async function settleApiKeyDialog(page: Page) {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.mouse.move(0, 0)
}
async function revealApiKey(page: Page) {
await openApiKeyForm(page)
await fillApiKeyForm(page, 'Nightly media import', '')
await submitApiKeyForm(page)
await page.getByText('Your new API key').waitFor()
await page.mouse.move(0, 0)
}
function nightlyRevokeButton(page: Page) {
return page
.locator('tbody tr, li')
.filter({ hasText: 'Nightly media import' })
.getByRole('button', { name: 'Revoke', exact: true })
}
async function openApiKeyRevoke(page: Page) {
await nightlyRevokeButton(page).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
async function confirmApiKeyRevoke(page: Page) {
await openApiKeyRevoke(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Revoke key', exact: true })
.click()
await page.getByText('Key revoked').waitFor()
await page.mouse.move(0, 0)
}
const apiKeysStates: Record<string, VisualState> = {
'Owner: list': { path: '/integration/api-keys', api: apiKeysOwnerApi() },
'Admin: own keys only': {
path: '/integration/api-keys',
api: apiKeysApi({ status: 200, body: apiKeysAdminList }, {}, adminSession),
},
Loading: {
path: '/integration/api-keys',
api: apiKeysApi({ status: 200, hang: true }),
},
Empty: { path: '/integration/api-keys', api: apiKeysApi({ status: 200, body: [] }) },
Error: { path: '/integration/api-keys', api: apiKeysApi({ status: 500 }) },
'Create key': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: async (page) => {
await openApiKeyForm(page)
await fillApiKeyForm(page, 'Nightly media import', '2026-12-31')
await settleApiKeyDialog(page)
},
},
'Create key: validation': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: async (page) => {
await openApiKeyForm(page)
await fillApiKeyForm(page, '', '2026-03-12')
await submitApiKeyForm(page)
await settleApiKeyDialog(page)
},
},
'Create key: name too long': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: async (page) => {
await openApiKeyForm(page)
await fillApiKeyForm(page, apiKeysTooLongName, '')
await settleApiKeyDialog(page)
},
},
'One-time reveal': {
path: '/integration/api-keys',
api: apiKeysOwnerApi({ 'POST /v1/api-key': apiKeysCreated }),
steps: revealApiKey,
},
'One-time reveal: copied': {
path: '/integration/api-keys',
origin: integrationOrigin,
api: apiKeysOwnerApi({ 'POST /v1/api-key': apiKeysCreated }),
steps: async (page) => {
await revealApiKey(page)
await page
.getByRole('dialog')
.getByRole('button', { name: 'Copy code', exact: true })
.click()
await page.getByRole('dialog').getByText('Copied', { exact: true }).waitFor()
await page.mouse.move(0, 0)
},
},
'Revoke: confirm': {
path: '/integration/api-keys',
api: apiKeysOwnerApi(),
steps: openApiKeyRevoke,
},
Revoked: {
path: '/integration/api-keys',
api: apiKeysOwnerApi({ 'DELETE /v1/api-key/2': { status: 204 } }),
steps: confirmApiKeyRevoke,
},
}
const purgeSettingsPath = 'GET /v1/media/purge-settings'
const purgePatchPath = 'PATCH /v1/media/purge-settings'
const purgePreviewPath = 'GET /v1/media/purge-preview'
const purgeRunPath = 'POST /v1/media/purge'
const purgeStaleCopy =
'The library changed, so nothing was purged. Check the new summary and try again.'
function purgeSettingsBody(overrides: Record<string, unknown> = {}) {
return {
auto_purge_enabled: false,
auto_purge_interval_days: 30,
last_purge_at: null,
last_purge_merged: 0,
last_purge_articles_updated: 0,
enabled_at: null,
next_purge_at: null,
...overrides,
}
}
const purgeLastRun = {
last_purge_at: '2026-06-14T09:00:00Z',
last_purge_merged: 3,
last_purge_articles_updated: 5,
}
const purgeOnBody = purgeSettingsBody({
...purgeLastRun,
auto_purge_enabled: true,
enabled_at: '2026-06-14T09:00:00Z',
next_purge_at: '2026-07-14T09:00:00Z',
})
const purgeDoneLast = {
last_purge_at: '2026-10-05T09:00:00Z',
last_purge_merged: 9,
last_purge_articles_updated: 12,
}
const purgeGroups = [
{
keep_id: 12,
filename: 'floorboards.jpg',
url: '/media/floorboards.jpg',
used_in: 4,
merged: 2,
},
{ keep_id: 7, filename: 'IMG_4471.jpg', url: '/media/IMG_4471.jpg', used_in: 2, merged: 1 },
{
keep_id: 2,
filename: 'kitchen-after.webp',
url: '/media/kitchen-after.webp',
used_in: 3,
merged: 3,
},
{ keep_id: 4, filename: 'logo-renew.svg', url: '/media/logo-renew.svg', used_in: 5, merged: 3 },
{
keep_id: 11,
filename: 'lime-plaster.jpg',
url: '/media/lime-plaster.jpg',
used_in: 2,
merged: 1,
},
]
function purgePreviewBody(overrides: Record<string, unknown> = {}) {
return {
groups: 4,
copies: 9,
articles: 12,
list: purgeGroups.slice(0, 4),
more: 0,
fingerprint: 'ab12',
...overrides,
}
}
const purgePreviewMany = purgePreviewBody({
groups: 8,
copies: 15,
articles: 24,
list: purgeGroups,
more: 3,
})
const purgePreviewOne = purgePreviewBody({
groups: 1,
copies: 1,
articles: 2,
list: [purgeGroups[1]],
fingerprint: 'one',
})
const purgePreviewThree = purgePreviewBody({
groups: 3,
copies: 7,
articles: 10,
list: [purgeGroups[0], purgeGroups[2], purgeGroups[3]],
fingerprint: 'cd34',
})
const purgePreviewNone = purgePreviewBody({
groups: 0,
copies: 0,
articles: 0,
list: [],
fingerprint: 'none',
})
const purgeRunPartial = {
groups_total: 4,
groups_purged: 3,
merged: 8,
articles_updated: 10,
failed_groups: 1,
}
const purgeRunDone = {
groups_total: 4,
groups_purged: 4,
merged: 9,
articles_updated: 12,
failed_groups: 0,
}
function sessionWithRole(role: string): ApiMock {
return { status: 200, body: { ...(verifiedSession.body as object), role } }
}
function cleanupApi(
settings: ApiMock,
overrides: Record<string, ApiMock> = {},
role = 'owner',
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': sessionWithRole(role),
'GET /v1/settings/': { status: 200, body: workspaceSettingsBody },
'GET /v1/system/update': {
status: 200,
body: updateStatusBody({ checked_at: '2026-09-10T09:41:00Z', can_apply: true }),
},
[purgeSettingsPath]: settings,
...overrides,
}
}
function settingsBody(body: unknown): ApiMock {
return { status: 200, body }
}
async function scrollSettingsToCleanup(page: Page, desktopTop = 300, mobileTop = 480) {
const top = (page.viewportSize()?.width ?? 1440) < 768 ? mobileTop : desktopTop
await page.evaluate((top) => {
const card = document.querySelector<HTMLElement>('[data-testid="settings-card"]')
if (card?.parentElement && !card.parentElement.querySelector('[data-scroll-room]')) {
const room = document.createElement('div')
room.setAttribute('data-scroll-room', '')
room.style.cssText = 'flex-shrink:0;height:1200px'
card.parentElement.append(room)
}
let node: HTMLElement | null = card
while (node) {
if (
node.scrollHeight > node.clientHeight &&
/auto|scroll/.test(getComputedStyle(node).overflowY)
) {
node.scrollTop = top
return
}
node = node.parentElement
}
window.scrollTo(0, top)
}, top)
}
function cleanupState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
exception?: DiffException,
): VisualState {
return {
path: '/settings',
now: boardNow,
api,
exception,
steps: async (page) => {
await scrollSettingsToCleanup(page)
await steps?.(page)
await scrollSettingsToCleanup(page)
},
}
}
async function openCleanupDialog(page: Page) {
await page.getByRole('button', { name: 'Purge duplicates', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function confirmCleanupDialog(label: string, wait?: string) {
return async (page: Page) => {
await openCleanupDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: label, exact: true })
.click()
if (wait) {
await page.getByText(wait, { exact: true }).first().waitFor()
}
await page.mouse.move(0, 0)
}
}
async function typeDays(page: Page, value: string) {
const field = page.locator('#auto-purge-days')
await field.fill(value)
await field.press('Enter')
await page.mouse.move(0, 0)
}
function afterFirst(first: unknown, later: unknown): ApiMock {
return { status: 200, bodyFor: (_url, call) => (call === 1 ? first : later) }
}
function cleanupDialogApi(overrides: Record<string, ApiMock> = {}) {
return cleanupApi(settingsBody(purgeSettingsBody()), {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
...overrides,
})
}
async function holdToast(page: Page) {
await page.getByText('Duplicates purged', { exact: true }).hover()
}
function successState(settings: ApiMock) {
return cleanupState(
cleanupApi(settings, {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
[purgeRunPath]: settingsBody(purgeRunDone),
}),
async (page) => {
await confirmCleanupDialog('Purge 4 groups', 'Duplicates purged')(page)
await holdToast(page)
},
)
}
const retentionPatchPath = 'PATCH /v1/settings/'
const retentionGetPath = 'GET /v1/settings/'
const retentionFiveGb = { activity_retention_max_gb: 5, activity_log_size_bytes: 3435973837 }
function retentionApi(
settings: Record<string, unknown>,
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return cleanupApi(settingsBody(purgeSettingsBody()), {
[retentionGetPath]: settingsBody({ ...workspaceSettingsBody, ...settings }),
...overrides,
})
}
function retentionState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
exception?: DiffException,
): VisualState {
const scroll = (page: Page) => scrollSettingsToCleanup(page, 560, 936)
return {
path: '/settings',
now: boardNow,
api,
exception,
steps: async (page) => {
await scroll(page)
await steps?.(page)
await page.mouse.move(0, 0)
await scroll(page)
},
}
}
function chooseRetention(control: string, option: string) {
return async (page: Page) => {
await page.locator(`[aria-labelledby="${control}"]`).click()
await page.getByRole('option', { name: option, exact: true }).click()
}
}
const retentionStates: Record<string, VisualState> = {
'Default, 90 days or 5 GB': retentionState(
retentionApi(
{ activity_retention_max_gb: 1, activity_log_size_bytes: 3435973837 },
{
[retentionPatchPath]: settingsBody({
...workspaceSettingsBody,
...retentionFiveGb,
}),
},
),
async (page) => {
await chooseRetention('retention-size-label', '5 GB')(page)
await page.getByText('Saved', { exact: true }).first().waitFor()
},
),
'Age only (no size limit)': retentionState(retentionApi({})),
'Size only (forever)': retentionState(
retentionApi({ ...retentionFiveGb, activity_retention_days: 0 }),
),
'Shortening the period': retentionState(
retentionApi(retentionFiveGb),
chooseRetention('retention-label', '30 days'),
),
Forever: retentionState(
retentionApi({
activity_retention_days: 0,
activity_last_cleanup_at: null,
activity_next_cleanup_at: null,
}),
),
'Save error': retentionState(
retentionApi({}, { [retentionPatchPath]: { status: 500 } }),
async (page) => {
await chooseRetention('retention-label', '365 days')(page)
await page.getByText("Couldn't save the retention period").waitFor()
},
{
maxPercent: 13,
reason: 'Board draws 28 px buttons on mobile; foundations.md requires 44 px touch targets',
approvedBy: 'user',
},
),
}
const cleanupStates: Record<string, VisualState> = {
'Default: off, never run': cleanupState(cleanupApi(settingsBody(purgeSettingsBody()))),
'On: 30 days, last run': cleanupState(cleanupApi(settingsBody(purgeOnBody))),
Saving: cleanupState(
cleanupApi(settingsBody(purgeSettingsBody(purgeLastRun)), {
[purgePatchPath]: { status: 200, hang: true },
}),
async (page) => {
await page.getByRole('switch').click()
await page.locator('#auto-purge-days').focus()
await page.mouse.move(0, 0)
},
),
Saved: cleanupState(
cleanupApi(settingsBody(purgeSettingsBody(purgeLastRun)), {
[purgePatchPath]: settingsBody(purgeOnBody),
}),
async (page) => {
await page.getByRole('switch').click()
await page.getByText('Saved', { exact: true }).waitFor()
await page.mouse.move(0, 0)
},
),
'Save failed': cleanupState(
cleanupApi(settingsBody(purgeOnBody), { [purgePatchPath]: { status: 500 } }),
async (page) => {
await typeDays(page, '45')
await page.getByText("Couldn't save. Try again in a moment.").waitFor()
},
),
'Validation error': cleanupState(cleanupApi(settingsBody(purgeOnBody)), async (page) => {
await typeDays(page, '400')
await page.getByText('Enter a whole number of days between 1 and 365.').waitFor()
}),
'Read-only (non-admin)': cleanupState(cleanupApi(settingsBody(purgeOnBody), {}, 'member')),
'Loading skeleton': cleanupState(cleanupApi({ status: 200, hang: true })),
Offline: cleanupState(cleanupApi(settingsBody(purgeOnBody)), goOffline),
'Loading preview': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, hang: true } }),
openCleanupDialog,
),
Confirm: cleanupState(cleanupDialogApi(), openCleanupDialog),
'Confirm: many groups': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, body: purgePreviewMany } }),
openCleanupDialog,
),
'In progress': cleanupState(
cleanupDialogApi({ [purgeRunPath]: { status: 200, hang: true } }),
confirmCleanupDialog('Purge 4 groups'),
),
'Nothing to purge': cleanupState(
cleanupDialogApi({ [purgePreviewPath]: { status: 200, body: purgePreviewNone } }),
openCleanupDialog,
),
Error: cleanupState(
cleanupDialogApi({ [purgeRunPath]: { status: 500 } }),
confirmCleanupDialog(
'Purge 4 groups',
"Couldn't purge the duplicates. Nothing was changed. Try again in a moment.",
),
),
'Partial failure': cleanupState(
cleanupDialogApi({
[purgePreviewPath]: afterFirst(purgePreviewBody(), purgePreviewOne),
[purgeRunPath]: settingsBody(purgeRunPartial),
}),
confirmCleanupDialog(
'Purge 4 groups',
"Some groups couldn't be merged. 3 of 4 groups were purged. Try again to finish.",
),
),
'Conflict (409)': cleanupState(
cleanupDialogApi({
[purgePreviewPath]: afterFirst(purgePreviewBody(), purgePreviewThree),
[purgeRunPath]: { status: 409, body: purgeStaleCopy },
}),
confirmCleanupDialog('Purge 4 groups', purgeStaleCopy),
),
Success: successState(afterFirst(purgeSettingsBody(), purgeSettingsBody(purgeDoneLast))),
'Success, automatic purge on': successState(
afterFirst(
purgeOnBody,
purgeSettingsBody({
...purgeDoneLast,
auto_purge_enabled: true,
next_purge_at: '2026-11-04T09:00:00Z',
}),
),
),
}
async function openMediaPurgeDialog(page: Page) {
await page.getByRole('button', { name: 'Purge duplicates', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
const mediaPurgeStates: Record<string, VisualState> = {
'Duplicates: purge all': mediaState(
mediaDuplicatesApi(undefined, {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
}),
openMediaPurgeDialog,
mediaDuplicatesPath,
),
'Purge duplicates: confirm': mediaState(
mediaDuplicatesApi(undefined, {
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
}),
openMediaPurgeDialog,
mediaDuplicatesPath,
),
'Duplicates: no purge button (member)': mediaState(
mediaDuplicatesApi(undefined, {
'GET /v1/auth/me': {
status: 200,
body: { ...(mediaSession.body as object), role: 'member' },
},
}),
undefined,
mediaDuplicatesPath,
),
'Duplicates tab with the button': mediaState(
mediaDuplicatesApi(),
undefined,
mediaDuplicatesPath,
),
'Duplicates tab: offline': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
}
const activityOwnerSession: ApiMock = {
status: 200,
body: {
id: 2,
is_verified: true,
username: 'ines',
email: 'ines.duarte@renew.example',
display_name: 'Inès Duarte',
role: 'owner',
},
}
const activityEditorSession: ApiMock = {
status: 200,
body: {
id: 3,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'editor',
},
}
const activitySummaryDefault = {
total: 1284,
previous_total: 1146,
active_people: { users: 7, api_keys: 2 },
failed: 6,
security: 11,
series: {
total: [4, 6, 5, 8, 7, 9, 6],
active: [5, 6, 6, 7, 7, 8, 9],
failed: [0, 1, 0, 2, 1, 0, 2],
security: [1, 0, 2, 1, 3, 2, 2],
},
}
const activitySummaryNone = {
total: 0,
previous_total: 0,
active_people: { users: 0, api_keys: 0 },
failed: 0,
security: 0,
series: {
total: [0, 0, 0, 0, 0, 0, 0],
active: [0, 0, 0, 0, 0, 0, 0],
failed: [0, 0, 0, 0, 0, 0, 0],
security: [0, 0, 0, 0, 0, 0, 0],
},
}
const activityLongTitle =
'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
function activityEvent(
id: number,
createdAt: string,
action: string,
category: string,
actor: { type: string; id: number | null; name: string },
extra: Record<string, unknown> = {},
) {
return {
id,
created_at: createdAt,
action,
category,
actor,
target: null,
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: '203.0.113.24',
user_agent: 'Firefox 131 on macOS',
params: {},
changes: [],
...extra,
}
}
const activityTarget = (type: string, id: string, name: string, exists = true) => ({
type,
id,
name,
exists,
})
const activityBoardEvents = [
activityEvent(
1,
'2026-10-06T09:42:18Z',
'article.publish',
'articles',
{ type: 'user', id: 3, name: 'Marta Alves' },
{
target: activityTarget('article', '8', 'Spring release'),
changes: [{ field: 'online', kind: 'text', before: 'false', after: 'true' }],
},
),
activityEvent(
2,
'2026-10-06T09:15:04Z',
'article.edit',
'articles',
{ type: 'user', id: 3, name: 'Marta Alves' },
{
target: activityTarget('article', '5', 'Why we reuse original floorboards'),
changes: [
{
field: 'title',
kind: 'text',
before: 'Why we reuse floorboards',
after: 'Why we reuse original floorboards',
},
{ field: 'body', kind: 'body', delta: 312 },
],
},
),
activityEvent(
3,
'2026-10-06T08:31:47Z',
'auth.login_failed',
'auth',
{ type: 'anonymous', id: null, name: 'alex@example.com' },
{
outcome: 'failed',
security: true,
failure_reason: 'The password did not match. 3 attempts in 10 minutes.',
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
},
),
activityEvent(
4,
'2026-10-06T07:50:09Z',
'api_key.revoke',
'api_keys',
{ type: 'user', id: 2, name: 'Jonas Weber' },
{
target: activityTarget('api_key', '71', 'Mobile app'),
security: true,
ip: '203.0.113.90',
user_agent: 'Safari 18 on iOS',
},
),
activityEvent(
5,
'2026-10-06T02:00:02Z',
'system.media_purge',
'system',
{ type: 'system', id: null, name: 'System' },
{
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
},
),
activityEvent(
6,
'2026-10-05T17:20:36Z',
'invite.accept',
'users',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{ target: activityTarget('invite', '2', 'jonas@renew.example'), security: true },
),
activityEvent(
7,
'2026-10-05T15:05:51Z',
'media.upload',
'media',
{ type: 'api_key', id: 7, name: 'Website sync' },
{
target: activityTarget('image', '4', 'north-wall.jpg'),
source: 'api_key',
ip: '192.0.2.18',
user_agent: 'node-fetch 3.3',
},
),
activityEvent(
8,
'2026-10-05T13:40:13Z',
'media.delete',
'media',
{ type: 'user', id: 3, name: 'Marta Alves' },
{ target: activityTarget('image', '9', 'old-banner.png', false) },
),
activityEvent(
9,
'2026-10-05T10:12:27Z',
'article.publish',
'articles',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('article', '6', activityLongTitle),
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
},
),
activityEvent(
10,
'2026-10-05T02:00:01Z',
'system.activity_cleanup',
'system',
{ type: 'system', id: null, name: 'System' },
{ source: 'system', ip: null, user_agent: null, params: { removed: 142 } },
),
activityEvent(
11,
'2026-10-03T15:48:22Z',
'article.edit',
'articles',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('article', '2', 'Choosing lime plaster for old stone walls'),
changes: [
{
field: 'title',
kind: 'text',
before: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes.',
after: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes, plus a seasonal maintenance checklist.',
},
],
},
),
activityEvent(
12,
'2026-10-03T14:02:40Z',
'api_key.create',
'api_keys',
{ type: 'user', id: 2, name: 'Jonas Weber' },
{
target: activityTarget('api_key', '71', 'Mobile app', false),
security: true,
ip: '203.0.113.90',
},
),
activityEvent(
13,
'2026-10-03T13:20:05Z',
'user.update_profile',
'users',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('user', '8', 'Sam Rivera'),
changes: [
{
field: 'display_name',
kind: 'text',
before: 'Sam Rivera',
after: 'Samuel Rivera',
},
],
},
),
activityEvent(
14,
'2026-10-03T10:36:58Z',
'media.upload',
'media',
{ type: 'user', id: 3, name: 'Marta Alves' },
{ target: activityTarget('image', '5', 'floorboards-copy.jpg') },
),
activityEvent(
15,
'2026-10-03T08:05:12Z',
'auth.password_reset_completed',
'auth',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{ target: activityTarget('user', '8', 'Sam Rivera'), security: true },
),
]
const homeActivityTimes = [
'2026-09-10T08:58:00Z',
'2026-09-10T08:31:00Z',
'2026-09-10T07:55:00Z',
'2026-09-10T06:55:00Z',
'2026-09-10T02:00:00Z',
]
const homeActivityMock: ApiMock = {
status: 200,
body: {
items: activityBoardEvents
.slice(0, 5)
.map((event, index) => ({ ...event, created_at: homeActivityTimes[index] })),
total: 5,
page: 1,
size: 5,
},
}
function activityPage(items: unknown[], total: number) {
return { items, total, page: 1, size: 25 }
}
function activityApi(
overrides: Record<string, ApiMock> = {},
session: ApiMock = activityOwnerSession,
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': session,
'GET /v1/activity': { status: 200, body: activityPage(activityBoardEvents, 1284) },
'GET /v1/activity/summary': { status: 200, body: activitySummaryDefault },
...overrides,
}
}
const activityClockSensitive: VisualState = { path: '/activity', skip: 'clock-sensitive' }
async function filterActivityToNothing(page: Page) {
const mobile = (page.viewportSize()?.width ?? 0) < 768
const search = page.getByPlaceholder('Search activity')
await search.fill('sitemap')
await search.press('Enter')
if (mobile) {
await page.getByRole('button', { name: /^Filters/ }).click()
}
await page.getByRole('button', { name: 'Category', exact: true }).click()
await page.getByRole('option', { name: 'Media', exact: true }).click()
await page
.getByRole('group', { name: 'Outcome' })
.getByRole('button', { name: 'Failed', exact: true })
.click()
if (mobile) {
await page.getByRole('button', { name: /^Show \d+ results?$/ }).click()
}
await page.getByText('No activity matches these filters').waitFor()
await page.mouse.move(0, 0)
}
const activityStates: Record<string, VisualState> = {
'Default, no filter': activityClockSensitive,
'Card hover': activityClockSensitive,
'Card focus-visible': activityClockSensitive,
'Failed card active': activityClockSensitive,
'Active card hover': activityClockSensitive,
'Security card active': activityClockSensitive,
'Chip × hover': activityClockSensitive,
'Clear all hover': activityClockSensitive,
'Clear all focus-visible': activityClockSensitive,
'Card plus other filters': activityClockSensitive,
'Menu open': activityClockSensitive,
'Menu, item hover': activityClockSensitive,
'Menu, filtered view': activityClockSensitive,
'Menu, no permission': activityClockSensitive,
'Export button focus-visible': activityClockSensitive,
'Dialog: configure, CSV only': activityClockSensitive,
'Dialog: preparing download': activityClockSensitive,
'Dialog: download started': activityClockSensitive,
'Dialog: failed': activityClockSensitive,
'Dialog: nothing to export': activityClockSensitive,
'Dialog: not allowed (403)': activityClockSensitive,
'Toast: current view': activityClockSensitive,
'Toast: export all': activityClockSensitive,
'Dialog: configure, CSV or JSON (proposed)': { path: '/activity', skip: 'pending' },
'Menu, nothing to export': {
path: '/activity',
api: activityApi({
'GET /v1/activity': {
status: 200,
bodyFor: (url) =>
url.searchParams.has('q') ||
url.searchParams.has('category') ||
url.searchParams.has('outcome')
? activityPage([], 0)
: activityPage(activityBoardEvents, 1284),
},
}),
steps: async (page) => {
await filterActivityToNothing(page)
await page.getByRole('button', { name: 'Export', exact: true }).click()
await page.getByRole('menuitem').first().waitFor()
await page.mouse.move(0, 0)
await page.waitForTimeout(300)
},
},
'Default, large dataset': activityClockSensitive,
Loading: {
path: '/activity',
api: activityApi({
'GET /v1/activity': { status: 200, body: activityPage([], 0), hang: true },
'GET /v1/activity/summary': { status: 200, body: activitySummaryDefault, hang: true },
}),
steps: async (page) => {
await page.waitForTimeout(400)
},
},
'Zero-value summary strip': activityClockSensitive,
'Filtered, card applied': activityClockSensitive,
'Mobile filters sheet': activityClockSensitive,
'Empty, no activity yet': {
path: '/activity',
api: activityApi({
'GET /v1/activity': { status: 200, body: activityPage([], 0) },
'GET /v1/activity/summary': { status: 200, body: activitySummaryNone },
}),
},
'Empty, filters match nothing': {
path: '/activity',
api: activityApi({
'GET /v1/activity': {
status: 200,
bodyFor: (url) =>
url.searchParams.has('q') ||
url.searchParams.has('category') ||
url.searchParams.has('outcome')
? activityPage([], 0)
: activityPage(activityBoardEvents, 1284),
},
}),
steps: filterActivityToNothing,
},
'Error with retry': {
path: '/activity',
api: activityApi({ 'GET /v1/activity': { status: 500 } }),
},
'Forbidden (403)': {
path: '/activity',
api: activityApi({ 'GET /v1/activity': { status: 403 } }, activityEditorSession),
},
'Edit with a diff': activityClockSensitive,
'Settings diff, long text': activityClockSensitive,
'Failed action, very long target title': activityClockSensitive,
'Deleted target, no diff': activityClockSensitive,
'System-initiated, no human actor': activityClockSensitive,
'Security event, failed login': activityClockSensitive,
Default: activityClockSensitive,
Empty: {
path: '/activity',
api: activityApi(
{ 'GET /v1/activity': { status: 200, body: activityPage([], 0) } },
activityEditorSession,
),
},
Details: activityClockSensitive,
}
const variantsPath = '/settings/media/variants'
const variantsMegabyte = 1048576
const variantsSession: ApiMock = {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'owner',
},
}
function boardVariant(
name: string,
width: number | null,
height: number | null,
fit: string,
format: string,
quality: number | null,
files: number,
megabytes: number,
enabled = true,
) {
return {
name,
width,
height,
fit,
format,
quality,
generate_on_upload: false,
enabled,
generated_files: files,
cache_size: megabytes * variantsMegabyte,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-03-12T09:00:00Z',
}
}
const variantsBoardList = [
boardVariant('thumb', 400, 300, 'cover', 'webp', 80, 412, 21),
boardVariant('card', 800, null, 'inside', 'webp', 80, 318, 58),
boardVariant('hero', 1200, 630, 'cover', 'webp', 82, 244, 84),
boardVariant('square', 600, 600, 'cover', 'jpeg', 85, 140, 19),
boardVariant('og', 1200, 630, 'cover', 'jpeg', 80, 96, 9),
boardVariant('print', null, 2400, 'inside', 'png', null, 74, 21, false),
]
function variantsApi(
items: unknown[] = variantsBoardList,
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': variantsSession,
'GET /v1/image-variants': { status: 200, body: { items } },
...overrides,
}
}
function variantsListState(
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
items: unknown[] = variantsBoardList,
): VisualState {
return { path: variantsPath, api: variantsApi(items, overrides), steps }
}
async function openVariantMenu(page: Page, name: string) {
await page.getByRole('button', { name: `Actions for ${name}`, exact: true }).click()
await page.getByRole('menu').waitFor()
}
async function chooseVariantMenuItem(page: Page, name: string, item: string) {
await openVariantMenu(page, name)
await page.getByRole('menuitem', { name: item, exact: true }).click()
}
async function scrollVariantsPage(page: Page, offset: number) {
await page.evaluate((value) => {
const body = document
.querySelector<HTMLElement>('#dashboard-content')
?.closest<HTMLElement>('[data-slot="body"]')
if (body) body.scrollTop = value
}, offset)
await page.waitForTimeout(150)
}
async function scrollVariantsForm(page: Page, offset: number) {
await page.evaluate((value) => {
const body = document.querySelector<HTMLElement>('[role="dialog"] [data-slot="body"]')
if (body) body.scrollTop = value
}, offset)
await page.waitForTimeout(150)
}
function isMobileViewport(page: Page) {
return (page.viewportSize()?.width ?? 0) < 640
}
async function openVariantForm(page: Page) {
await page.getByRole('button', { name: 'Add variant', exact: true }).first().click()
await page.getByRole('dialog').waitFor()
}
function variantField(page: Page, name: string) {
return page.locator(`[data-field="${name}"] input:not([aria-hidden="true"])`)
}
async function pickVariantFormat(page: Page, label: string) {
await page.getByRole('dialog').getByRole('combobox').click()
await page.getByRole('option', { name: label, exact: true }).click()
}
interface VariantFormFill {
name: string
width: string
height: string
format: string
quality?: string
generate?: boolean
}
async function fillVariantForm(page: Page, values: VariantFormFill) {
await variantField(page, 'name').fill(values.name)
await variantField(page, 'width').fill(values.width)
await variantField(page, 'height').fill(values.height)
await pickVariantFormat(page, values.format)
if (values.quality !== undefined) {
await variantField(page, 'quality').fill(values.quality)
}
if (values.generate) {
await page.getByRole('switch', { name: 'Generate on upload', exact: true }).click()
}
}
const heroForm: VariantFormFill = {
name: 'hero',
width: '1200',
height: '630',
format: 'WebP',
quality: '82',
generate: true,
}
async function blurFocus(page: Page) {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.waitForTimeout(100)
}
async function pressVariantFormButton(page: Page, label: string) {
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
}
async function openEditHero(page: Page) {
await chooseVariantMenuItem(page, 'hero', 'Edit')
await page.getByRole('dialog').waitFor()
}
const variantsStates: Record<string, VisualState> = {
Loaded: variantsListState(),
Loading: {
path: variantsPath,
api: variantsApi(variantsBoardList, {
'GET /v1/image-variants': { status: 200, hang: true },
}),
},
Empty: variantsListState({}, undefined, []),
'Error loading': variantsListState({ 'GET /v1/image-variants': { status: 500 } }),
Forbidden: {
path: variantsPath,
api: {
'GET /v1/auth/me': {
status: 200,
body: { ...(variantsSession.body as object), role: 'user' },
},
},
},
'Row menu': variantsListState({}, (page) => openVariantMenu(page, 'hero')),
'Row action in progress': variantsListState(
{ 'POST /v1/image-variants/hero/purge': { status: 200, hang: true } },
async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Purge cache')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Purge 244 files' })
.click()
await page.mouse.move(0, 0)
},
),
'Purge confirm': variantsListState({}, async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Purge cache')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'Delete confirm': variantsListState({}, async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Delete')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'Delete in progress': variantsListState(
{ 'DELETE /v1/image-variants/hero': { status: 204, hang: true } },
async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Delete')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete variant' })
.click()
await page.mouse.move(0, 0)
},
),
'Request reference': {
...variantsListState({}, async (page) => {
if (isMobileViewport(page)) {
await scrollVariantsPage(page, 900)
}
}),
exception: {
maxPercent: 21,
reason: 'board draws /media/photo.png?v=thumb (2 lines) and the mandated /static/uploadedImages/photo.png?v=thumb wraps to 3; board scroll 900 is unreachable (page max ~861)',
approvedBy: 'user',
},
},
'Create: pristine': variantsListState({}, openVariantForm),
'Create: dirty': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await page.locator('input[type="range"]').focus()
if (isMobileViewport(page)) {
await scrollVariantsForm(page, 560)
}
}),
'Field errors': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, {
name: 'Hero banner',
width: '9000',
height: '630',
format: 'WebP',
quality: '140',
})
await variantField(page, 'name').blur()
await variantField(page, 'width').blur()
await variantField(page, 'quality').blur()
await blurFocus(page)
}),
'No dimension': variantsListState({}, async (page) => {
await openVariantForm(page)
await variantField(page, 'name').fill('banner')
await pickVariantFormat(page, 'WebP')
await pressVariantFormButton(page, 'Add variant')
await blurFocus(page)
}),
'Name already taken': variantsListState(
{
'POST /v1/image-variants': {
status: 409,
body: 'A variant named “thumb” already exists. Choose another name.',
},
},
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, {
name: 'thumb',
width: '400',
height: '300',
format: 'WebP',
quality: '80',
generate: true,
})
await pressVariantFormButton(page, 'Add variant')
await page.getByText('already exists').waitFor()
await blurFocus(page)
},
),
Saving: variantsListState(
{ 'POST /v1/image-variants': { status: 201, hang: true } },
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await pressVariantFormButton(page, 'Add variant')
await page.mouse.move(0, 0)
},
),
'Save error': variantsListState(
{ 'POST /v1/image-variants': { status: 500 } },
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await pressVariantFormButton(page, 'Add variant')
await page.getByText("Couldn't save the variant").waitFor()
await page.mouse.move(0, 0)
},
),
'Edit: pristine': variantsListState({}, openEditHero),
'Edit: dirty': variantsListState({}, async (page) => {
await openEditHero(page)
await variantField(page, 'quality').fill('70')
await blurFocus(page)
}),
'Unsaved changes': variantsListState({}, async (page) => {
await openEditHero(page)
await variantField(page, 'quality').fill('70')
await pressVariantFormButton(page, 'Cancel')
await page.getByRole('alertdialog').waitFor()
}),
'PNG: no quality': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, { ...heroForm, quality: undefined })
await pickVariantFormat(page, 'PNG')
await blurFocus(page)
}),
}
const cachePath = '/settings/media/cache'
const cacheBoardImages: [string, number, number][] = [
['north-wall.jpg', 6, 6082765],
['lime-plaster.jpg', 6, 5138022],
['kitchen-after.webp', 5, 4613734],
['kitchen-before.webp', 5, 4089446],
['floorboards.jpg', 5, 3774873],
['IMG_4471.jpg', 4, 3250585],
['stone-wall-detail.jpg', 4, 2936012],
['bathroom-tiles.png', 4, 2726297],
['roof-lantern.jpg', 3, 2411724],
['chimney-stack.jpg', 3, 2202009],
]
const cacheBoardStats = {
generated_files: 1284,
disk_used: 222298112,
variants_defined: 6,
variants_enabled: 5,
variants_disabled: 1,
rejected_requests: 37,
rejected_window_days: 7,
unused: { files: 312, bytes: 42991616, days: 30 },
by_variant: [
{ name: 'hero', enabled: true, files: 244, bytes: 88080384 },
{ name: 'card', enabled: true, files: 318, bytes: 60817408 },
{ name: 'thumb', enabled: true, files: 412, bytes: 22020096 },
{ name: 'print', enabled: false, files: 74, bytes: 22020096 },
{ name: 'square', enabled: true, files: 140, bytes: 19922944 },
{ name: 'og', enabled: true, files: 96, bytes: 9437184 },
],
top_images: cacheBoardImages.map(([filename, files, bytes], index) => ({
id: index + 1,
filename,
url: `/static/uploadedImages/${filename}`,
files,
bytes,
})),
}
const cacheEmptyStats = {
...cacheBoardStats,
generated_files: 0,
disk_used: 0,
unused: { files: 0, bytes: 0, days: 30 },
by_variant: [],
top_images: [],
}
const cacheStatsKey = 'GET /v1/image-variants/cache'
const cachePurgeKey = 'POST /v1/image-variants/cache/purge'
const cachePurgeAllResult = {
scope: 'all',
purged_files: 1284,
purged_bytes: 222298112,
failed_files: 0,
}
function cacheState(
statsMock: ApiMock = { status: 200, body: cacheBoardStats },
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
): VisualState {
return {
path: cachePath,
api: { 'GET /v1/auth/me': variantsSession, [cacheStatsKey]: statsMock, ...overrides },
steps,
}
}
async function confirmPurgeAll(page: Page) {
await page.getByRole('button', { name: 'Purge all variants', exact: true }).first().click()
await page.getByRole('alertdialog').getByRole('textbox').fill('purge')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Purge all variants', exact: true })
.click()
await page.mouse.move(0, 0)
}
const cacheStates: Record<string, VisualState> = {
'V5a · Loaded': cacheState(),
'V5b · Loading': cacheState({ status: 200, hang: true }),
'V5c · Empty cache': cacheState({ status: 200, body: cacheEmptyStats }),
'V5d · Purging': cacheState(
{ status: 200, body: cacheBoardStats },
{ [cachePurgeKey]: { status: 200, hang: true } },
confirmPurgeAll,
),
'V5e · Purge done': cacheState(
{
status: 200,
bodyFor: (_url, call) => (call === 1 ? cacheBoardStats : cacheEmptyStats),
},
{ [cachePurgeKey]: { status: 200, body: cachePurgeAllResult } },
confirmPurgeAll,
),
'V5f · Purge failed': cacheState(
{ status: 200, body: cacheBoardStats },
{
[cachePurgeKey]: {
status: 200,
body: {
...cachePurgeAllResult,
purged_files: 640,
purged_bytes: 111149056,
failed_files: 644,
},
},
},
confirmPurgeAll,
),
'V5g · Error loading': cacheState({ status: 500 }),
'V5h · Purge all: confirm': cacheState(undefined, {}, async (page) => {
await page.getByRole('button', { name: 'Purge all variants', exact: true }).first().click()
await page.getByRole('alertdialog').waitFor()
await page.getByRole('alertdialog').getByRole('textbox').fill('pur')
await page.mouse.move(0, 0)
}),
'V5i · Purge all: confirm, typed': cacheState(undefined, {}, async (page) => {
await page.getByRole('button', { name: 'Purge all variants', exact: true }).first().click()
await page.getByRole('alertdialog').waitFor()
await page.getByRole('alertdialog').getByRole('textbox').fill('purge')
await page.mouse.move(0, 0)
}),
'V5j · Purge unused: confirm': cacheState(undefined, {}, async (page) => {
await page.getByRole('button', { name: 'Purge unused', exact: true }).first().click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
}
const variantBoardSizes: Record<string, number> = {
thumb: 18432,
card: 65536,
hero: 98304,
square: 83968,
og: 145408,
}
const variantBoardOutputs: Record<string, [number, number, string]> = {
thumb: [400, 300, 'webp'],
card: [800, 600, 'webp'],
hero: [1200, 630, 'webp'],
square: [600, 600, 'jpeg'],
og: [1200, 630, 'jpeg'],
}
const variantBoardNames = ['thumb', 'card', 'hero', 'square', 'og']
const variantLibraryTotals: Record<number, [number, number]> = {
1: [4, 266240],
2: [5, 419840],
3: [5, 404480],
5: [3, 245760],
7: [2, 153600],
9: [1, 61440],
10: [3, 194560],
11: [4, 286720],
12: [5, 440320],
}
const variantsMediaPath = '/media'
const variantsPanelId = 1
const variantsSvgId = 4
function variantLibraryItems(ids?: number[]) {
const order = (id: number) => (ids ? ids.indexOf(id) : id)
return mediaSamples
.filter((sample) => !ids || ids.includes(sample.id))
.sort((left, right) => order(left.id) - order(right.id))
.map((sample) => {
const [count, size] = variantLibraryTotals[sample.id] ?? [0, 0]
return {
...mediaItem({ ...sample, duplicate: false }),
variant_count: count,
variant_size: size,
}
})
}
function boardVariantItems(generated: string[], storedName = '1.jpg') {
return variantBoardNames.map((name) => {
const [width, height, format] = variantBoardOutputs[name]!
const isGenerated = generated.includes(name)
return {
name,
width,
height,
fit: 'cover',
format,
output_width: width,
output_height: height,
output_format: format,
generated: isGenerated,
size: isGenerated ? variantBoardSizes[name]! : null,
url: `/static/uploadedImages/${storedName}?v=${name}`,
}
})
}
function variantsMediaApi(
ids?: number[],
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': variantsSession,
'GET /v1/media/': { status: 200, body: mediaList(variantLibraryItems(ids)) },
'GET /v1/media/1/usage': { status: 200, body: mediaNorthWallUsage },
...overrides,
}
}
function variantsMediaState(
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
path = variantsMediaPath,
ids?: number[],
): VisualState {
return { path, api: variantsMediaApi(ids, overrides), steps }
}
async function openSortMenuOf(page: Page) {
await page.getByRole('button', { name: 'Sort', exact: true }).click()
await page.getByRole('menu').waitFor()
}
async function openImagePanel(page: Page, id = variantsPanelId) {
await page.locator(`[data-media-card][data-media-id="${id}"]`).click()
await page.getByRole('dialog').waitFor()
}
async function openVariantsTabOf(page: Page, id = variantsPanelId) {
await openImagePanel(page, id)
await page.getByRole('tab', { name: 'Variants', exact: true }).click()
await page.getByTestId('variants-tab').locator('[data-testid="variants-original"]').waitFor()
await page.mouse.move(0, 0)
}
function variantRow(page: Page, name: string) {
return page.locator(`[data-testid="variant-row"][data-variant="${name}"]`)
}
function variantsTabApi(
generated: string[],
overrides: Record<string, ApiMock> = {},
id = variantsPanelId,
) {
return variantsMediaApi(undefined, {
[`GET /v1/media/${id}/variants`]: {
status: 200,
body: { unsupported: null, items: boardVariantItems(generated) },
},
...overrides,
})
}
function variantsTabState(
generated: string[],
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
): VisualState {
return {
path: variantsMediaPath,
api: variantsTabApi(generated, overrides),
steps: async (page) => {
await openVariantsTabOf(page)
await steps?.(page)
},
}
}
const variantsPartlyGenerated = ['thumb', 'card', 'hero', 'square']
const variantsFailedGeneration: ApiMock = {
status: 200,
body: {
generated: 2,
failed: 2,
failures: [
{ name: 'card', cause: 'unsupported_format' },
{ name: 'hero', cause: 'too_large' },
],
items: boardVariantItems(['thumb', 'square', 'og']),
},
}
const variantsPickerPath = '/article/12/edit'
function variantsBoardArticleBody(src: string | null) {
const image = src ? `<img src="${src}" alt="" data-image-id="1">` : ''
return `<p>${pickerIntro}</p>${image}<p>${pickerOutro}</p>`
}
function variantsPickerApi(catalog: unknown[]) {
return pickerApi(
{
'GET /v1/auth/me': variantsSession,
'GET /v1/media/': {
status: 200,
body: {
items: variantLibraryItems([1, 2, 3, 5]),
next_cursor: null,
counts: mediaFullCounts,
},
},
'GET /v1/image-variants': { status: 200, body: { items: catalog } },
},
variantsBoardArticleBody(null),
)
}
function variantsPickerState(
catalog: unknown[],
steps?: (page: Page) => Promise<void>,
): VisualState {
return {
path: variantsPickerPath,
api: variantsPickerApi(catalog),
steps: async (page) => {
await stubEditorImages(page)
await steps?.(page)
},
}
}
async function chooseBoardVariant(page: Page, label: string) {
await page.getByTestId('variant-select').click()
await page.getByRole('option', { name: label }).first().click()
await page.mouse.move(0, 0)
}
function variantsBlockState(catalog: unknown[]): VisualState {
return {
path: variantsPickerPath,
api: pickerApi(
{
'GET /v1/auth/me': variantsSession,
'GET /v1/image-variants': { status: 200, body: { items: catalog } },
},
variantsBoardArticleBody('/media/north-wall.jpg?v=hero'),
),
steps: async (page) => {
await page.getByTestId('article-editor-variant-warning').waitFor()
await page.mouse.move(0, 0)
},
}
}
const variantsWithoutHero = variantsBoardList.filter((entry) => entry.name !== 'hero')
const variantsHeroDisabled = variantsBoardList.map((entry) =>
entry.name === 'hero' ? { ...entry, enabled: false } : entry,
)
const settingsNavPath = '/settings'
function settingsNavState(path: string, drawer = false): VisualState {
const base =
path === cachePath
? cacheState()
: path === variantsPath
? variantsListState()
: {
path,
api: {
...updateApi('owner', updateNeverChecked()),
'GET /v1/auth/me': variantsSession,
},
}
if (!drawer) {
return base
}
return {
...base,
steps: async (page) => {
if (isMobileViewport(page)) {
await page.locator('[data-slot="toggle"]').first().click()
await page.mouse.move(0, 0)
await page.waitForTimeout(250)
}
},
}
}
const settingsNavStates: Record<string, VisualState> = {
'N1 · Parent selected': settingsNavState(settingsNavPath),
'N2 · Child selected: Variants': settingsNavState(variantsPath),
'N3 · Child selected: Cache': settingsNavState(cachePath),
'N4 · Mobile drawer, parent selected': {
...settingsNavState(settingsNavPath, true),
exception: {
maxPercent: { desktop: 18.2, mobile: 10 },
reason: 'board inconsistency: desktop frame of a mobile-only state',
approvedBy: 'Florian Sylvain',
},
},
'N5 · Mobile drawer, child selected': settingsNavState(variantsPath, true),
}
const variantsMediaStates: Record<string, VisualState> = {
'Entry: sidebar': {
path: '/media',
origin: mediaOrigin,
exception: {
maxPercent: { desktop: 27, mobile: 10 },
reason: 'board Media page behind the sidebar is a simplified mock (no tabs, Select all, view toggle, Duplicate badges)',
approvedBy: 'user',
},
api: mediaApi({ 'GET /v1/auth/me': variantsSession }),
steps: async (page) => {
if (isMobileViewport(page)) {
await page.locator('[data-slot="toggle"]').first().click()
}
await page
.locator(
'nav[aria-label="Main"]:visible a[href="/settings"] [data-slot="linkTrailing"]',
)
.click()
await page.mouse.move(0, 0)
await page.waitForTimeout(250)
},
},
'V3a · Grid with badges': variantsMediaState(),
'V3b · Sort menu': variantsMediaState(
{},
async (page) => {
await openSortMenuOf(page)
await page.mouse.move(0, 0)
},
'/media?sort=total_size',
),
'V3c · Filter: With variants, sorted by total size': variantsMediaState(
{},
undefined,
'/media?variants=with&sort=total_size',
[1, 7, 9, 12, 5, 11, 2, 3, 10],
),
'V3d · Filter: Without variants': variantsMediaState(
{},
undefined,
'/media?variants=without',
[4, 6, 8],
),
'V3e · Filter: nothing to show': variantsMediaState({}, undefined, '/media?variants=with', []),
'V3f · Delete confirm': variantsMediaState({}, async (page) => {
await openImagePanel(page)
await page.getByRole('dialog').getByRole('button', { name: 'Delete', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'V4a · Partly generated': variantsTabState(variantsPartlyGenerated),
'V4b · Nothing generated yet': variantsTabState([]),
'V4c · Generating': variantsTabState(
['thumb'],
{
'POST /v1/media/1/variants/card': { status: 200, hang: true },
'POST /v1/media/1/variants/hero': { status: 200, hang: true },
},
async (page) => {
await variantRow(page, 'card').getByRole('button', { name: 'Generate now' }).click()
await variantRow(page, 'hero').getByRole('button', { name: 'Generate now' }).click()
await page.mouse.move(0, 0)
},
),
'V4d · Generation failed': variantsTabState(
['thumb'],
{ 'POST /v1/media/1/variants': variantsFailedGeneration },
async (page) => {
await page.getByRole('button', { name: 'Generate all', exact: true }).click()
await variantRow(page, 'card').getByText('Failed').waitFor()
await page.mouse.move(0, 0)
},
),
'V4e · No variants defined': {
path: variantsMediaPath,
api: variantsMediaApi(undefined, {
'GET /v1/media/1/variants': { status: 200, body: { unsupported: null, items: [] } },
}),
steps: async (page) => {
await openVariantsTabOf(page)
},
},
'V4f · Source is an SVG': {
path: variantsMediaPath,
api: variantsMediaApi(undefined, {
'GET /v1/media/4/variants': { status: 200, body: { unsupported: 'svg', items: [] } },
'GET /v1/media/4/usage': { status: 200, body: [] },
}),
steps: async (page) => {
await openVariantsTabOf(page, variantsSvgId)
},
},
'V4h · Preview dialog': variantsTabState(variantsPartlyGenerated, {}, async (page) => {
await variantRow(page, 'thumb').getByRole('button', { name: 'Preview' }).click()
await page.getByRole('dialog').filter({ hasText: 'Preview: thumb' }).waitFor()
await page.mouse.move(0, 0)
}),
}
const variantsEditorStates: Record<string, VisualState> = {
'V6a · Variant select': variantsPickerState(variantsBoardList, async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await chooseBoardVariant(page, 'Hero')
}),
'V6b · Select open': variantsPickerState(variantsBoardList, async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await page.getByTestId('variant-select').click()
await page.getByRole('listbox').waitFor()
await page.mouse.move(0, 0)
}),
'V6c · No variants defined': variantsPickerState([], async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await page.getByTestId('variant-help').waitFor()
await page.mouse.move(0, 0)
}),
'V6d · Variant deleted': variantsBlockState(variantsWithoutHero),
'V6e · Variant disabled': variantsBlockState(variantsHeroDisabled),
}
const boardPredatesPages = 'board predates current Media page and editor'
const variantExceptionCaps: Record<string, { desktop: number; mobile: number }> = {
'3a': { desktop: 28, mobile: 42 },
'3b': { desktop: 28, mobile: 42 },
'3c': { desktop: 28, mobile: 42 },
'3d': { desktop: 10, mobile: 39 },
'3e': { desktop: 10, mobile: 39 },
'3f': { desktop: 78, mobile: 54 },
'4a': { desktop: 15, mobile: 10 },
'4b': { desktop: 15, mobile: 10 },
'4c': { desktop: 15, mobile: 10 },
'4d': { desktop: 15, mobile: 10 },
'4e': { desktop: 15, mobile: 10 },
'4f': { desktop: 15, mobile: 10 },
'6a': { desktop: 29, mobile: 52 },
'6b': { desktop: 29, mobile: 52 },
'6c': { desktop: 29, mobile: 52 },
'6d': { desktop: 29, mobile: 52 },
'6e': { desktop: 29, mobile: 52 },
}
function withVariantExceptions(states: Record<string, VisualState>) {
return Object.fromEntries(
Object.entries(states).map(([name, state]) => {
const cap = variantExceptionCaps[name.slice(1, 3)]
return [
name,
cap
? {
...state,
exception: {
maxPercent: cap,
reason: boardPredatesPages,
approvedBy: 'user',
},
}
: state,
]
}),
)
}
const variantsRowMobileCeilings: Record<string, number> = {
'Grid, many images': 33,
'Grid, few images': 26,
'List view': 19,
Loading: 30,
'Hover, focus and keyboard': 28,
'Selection and bulk bar': 31,
'Bulk delete: confirm': 23,
'Bulk delete: in progress': 23,
'Bulk delete: partly failed': 24,
'Bulk delete: done': 30,
'Detail panel: in use': 21,
'Detail panel: unused': 20,
'Copy URL': 21,
'Rename: dialog': 15,
'Rename: saving': 19,
'Rename: validation': 15,
'Rename: failed': 14,
'Rename: done': 20,
'Delete: image in use': 29,
'Delete: in progress': 29,
'Delete: done': 32,
'Image already gone (404)': 31,
'Upload: several files, mixed': 16,
'Upload: summary': 15,
'Upload: rejected files (413, 400)': 17,
'Upload: too many requests (429)': 29,
'Duplicates: groups': 29,
'Merge: confirm': 13,
'Merge: in progress': 13,
'Merge: done': 29,
Offline: 32,
'Pagination: first page': 33,
'Pagination: middle page': 33,
'Pagination: last page': 33,
'Pagination: list view': 19,
'Pagination: loading next page': 30,
'Pagination: error loading a page': 33,
'Pagination: page out of range': 33,
'Pagination: page size menu': 33,
'Pagination: focus-visible': 33,
'Purge duplicates: confirm': 24,
'Duplicates: no purge button (member)': 30,
'Duplicates tab with the button': 29,
'Duplicates tab: offline': 29,
}
function withVariantsRowException(
states: Record<string, VisualState>,
): Record<string, VisualState> {
return Object.fromEntries(
Object.entries(states).map(([name, state]) => {
const mobile = variantsRowMobileCeilings[name]
if (mobile === undefined) {
return [name, state]
}
return [
name,
{
...state,
exception: {
maxPercent: { desktop: MEDIA_DESKTOP_BUDGET, mobile },
reason: 'variants filter row is not drawn on the Media board (docs/design README: not folded into Media)',
approvedBy: 'user (Florian Sylvain), 2026-10-13',
},
},
]
}),
)
}
const MEDIA_DESKTOP_BUDGET = 10
function withDetailTabsException(states: Record<string, VisualState>): Record<string, VisualState> {
const name = 'Detail panel: duplicate'
const state = states[name]
if (!state) {
return states
}
return {
...states,
[name]: {
...state,
exception: {
maxPercent: { desktop: 11, mobile: 30 },
reason: 'Details | Variants tab row not drawn on the Media board (drawn on the Image detail board)',
approvedBy: 'user (Florian Sylvain), 2026-10-13',
},
},
}
}
function directorySession(role: string, displayName = 'Marta Alves'): ApiMock {
return {
status: 200,
body: { ...(verifiedSession.body as object), display_name: displayName, role },
}
}
const directorySites = [
{
id: 1,
name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
default_social_image_id: 11,
default_robots: 'index',
locale: 'en',
is_default: true,
article_count: 268,
},
{
id: 2,
name: 'Journal',
key: 'journal',
base_url: 'https://journal.renew.example',
default_social_image_id: null,
default_robots: 'index',
locale: null,
is_default: false,
article_count: 38,
},
{
id: 3,
name: 'Projects',
key: 'projects',
base_url: 'https://projects.renew.example',
default_social_image_id: 12,
default_robots: 'noindex',
locale: 'fr',
is_default: false,
article_count: 6,
},
{
id: 4,
name: 'Archive',
key: 'archive',
base_url: 'https://archive.renew.example',
default_social_image_id: null,
default_robots: 'noindex',
locale: null,
is_default: false,
article_count: 0,
},
]
function sitesApi(
overrides: Record<string, ApiMock> = {},
role = 'owner',
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': directorySession(role),
'GET /v1/site/': { status: 200, body: directorySites },
...overrides,
}
}
const listboxTrigger = 'button[aria-haspopup="listbox"]'
function isPhone(page: Page) {
return (page.viewportSize()?.width ?? 0) < 768
}
async function openDirectoryMenu(page: Page, name: string) {
await page.getByRole('button', { name: `Actions for ${name}`, exact: true }).click()
await page.getByRole('menuitem').first().waitFor()
}
async function chooseDirectoryMenuItem(page: Page, label: string) {
await page.getByRole('menuitem', { name: label, exact: true }).click()
await page.mouse.move(0, 0)
}
async function openDirectoryAdd(page: Page, label: string) {
await page.getByRole('button', { name: label, exact: true }).first().click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
async function openSiteEdit(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Edit')
await page.getByRole('dialog').waitFor()
}
async function openSiteDelete(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Delete…')
await page.getByRole('alertdialog').waitFor()
}
function siteFormField(page: Page, placeholder: string) {
return page.getByRole('dialog').getByPlaceholder(placeholder)
}
async function scrollPanelToTop(page: Page) {
await page.evaluate(() => {
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
body.scrollTop = 0
})
})
await page.mouse.move(0, 0)
}
async function submitDirectoryPanel(page: Page, label: string) {
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
await page.mouse.move(0, 0)
}
const sitesStates: Record<string, VisualState> = {
Default: { path: '/settings/sites', api: sitesApi() },
'Only the default site': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: [{ ...directorySites[0], article_count: 312 }],
},
}),
},
Loading: {
path: '/settings/sites',
api: sitesApi({ 'GET /v1/site/': { status: 200, hang: true } }),
},
Error: { path: '/settings/sites', api: sitesApi({ 'GET /v1/site/': { status: 500 } }) },
'Row menu': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Journal')
await page.mouse.move(0, 0)
},
},
'Default site: delete unavailable': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Renew')
if (!isPhone(page)) {
await page.getByRole('menuitem', { name: 'Delete…' }).hover()
await page.waitForTimeout(TOOLTIP_SETTLE_MS)
}
},
},
'Set as default: confirm': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Journal')
await chooseDirectoryMenuItem(page, 'Set as default')
await page.getByRole('alertdialog').waitFor()
},
},
'Hidden entry (admin)': { path: '/settings', skip: 'clock-sensitive' },
'Forbidden (403)': {
path: '/settings/sites',
api: sitesApi({}, 'admin'),
},
'Add: empty': {
path: '/settings/sites',
api: sitesApi(),
steps: (page) => openDirectoryAdd(page, 'Add site'),
},
'Add: key from the name': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await siteFormField(page, 'https://blog.example.com').focus()
},
},
'Add: URL not valid': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await siteFormField(page, 'https://blog.example.com').fill(
'http://journal.renew.example',
)
await siteFormField(page, 'https://blog.example.com').blur()
await page.getByRole('dialog').getByText('Use https. For example').waitFor()
await page.mouse.move(0, 0)
},
},
'Add: duplicate key': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await page.getByRole('button', { name: 'Edit key', exact: true }).click()
await page.getByRole('dialog').getByLabel('Key', { exact: true }).focus()
await siteFormField(page, 'https://blog.example.com').fill(
'https://journal2.renew.example',
)
await page.getByRole('dialog').getByText('already exists').waitFor()
await page.mouse.move(0, 0)
},
},
'Add: save failed': {
path: '/settings/sites',
api: sitesApi({ 'POST /v1/site/': { status: 500 } }),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await siteFormField(page, 'https://blog.example.com').fill(
'https://journal.renew.example',
)
await submitDirectoryPanel(page, 'Add site')
await page.getByRole('dialog').getByText("Couldn't save the site").waitFor()
},
},
'Edit: default site': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteEdit(page, 'Renew')
await page.mouse.move(0, 0)
},
},
'Edit: other site, noindex': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteEdit(page, 'Projects')
await page.mouse.move(0, 0)
},
},
'With articles': {
path: '/settings/sites',
api: sitesApi(),
steps: (page) => openSiteDelete(page, 'Journal'),
},
'Choose destination': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteDelete(page, 'Journal')
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
},
},
'No articles': {
path: '/settings/sites',
api: sitesApi(),
steps: (page) => openSiteDelete(page, 'Archive'),
},
}
const directoryLinks = {
camille: [
{ type: 'mastodon', url: 'https://mastodon.social/@camille' },
{ type: 'linkedin', url: 'linkedin.com/in/camille' },
],
ines: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
}
interface DirectoryAuthor {
id: number
name: string
slug: string
bio: string
avatar_id: number | null
website: string | null
social_links: { type: string; url: string }[]
user_id: number | null
is_linked: boolean
article_count: number
user_email?: string
}
function directoryAuthors(
overrides: Record<string, Partial<DirectoryAuthor>> = {},
owner = true,
): DirectoryAuthor[] {
const base: DirectoryAuthor[] = [
{
id: 1,
name: 'Inès Duarte',
slug: 'ines-duarte',
bio: 'Site manager and restoration carpenter. Writes about timber and joinery.',
avatar_id: 12,
website: 'https://ines-duarte.example',
social_links: directoryLinks.ines,
user_id: 3,
is_linked: true,
article_count: 9,
user_email: 'ines.duarte@renew.example',
},
{
id: 2,
name: 'Jonas Pereira',
slug: 'jonas-pereira',
bio: 'Heat pumps, insulation and the numbers behind them.',
avatar_id: 13,
website: null,
social_links: [],
user_id: 2,
is_linked: true,
article_count: 6,
user_email: 'jonas@renew.example',
},
{
id: 3,
name: 'Camille Roche',
slug: 'camille-roche',
bio: 'Freelance writer on lime and stone.',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 3,
},
{
id: 4,
name: 'Paul Huber',
slug: 'paul-huber',
bio: '',
avatar_id: 14,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 2,
},
{
id: 5,
name: 'Léa Fontaine',
slug: 'lea-fontaine',
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 0,
},
{
id: 6,
name: 'Dr. Alexandra Konstantinopoulou-Whitfield',
slug: 'dr-alexandra-konstantinopoulou-whitfield',
bio: 'Conservation architect.',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 1,
},
]
return base.map((author) => {
const merged = { ...author, ...(overrides[author.slug] ?? {}) }
if (!owner) {
delete merged.user_email
}
return merged
})
}
function authorListMock(authors: DirectoryAuthor[]): ApiMock {
return {
status: 200,
bodyFor: (url) => {
const q = (url.searchParams.get('q') ?? '').toLowerCase()
const matching = authors.filter((author) =>
`${author.name} ${author.slug}`.toLowerCase().includes(q),
)
const size = Number(url.searchParams.get('size') ?? 25)
const page = Number(url.searchParams.get('page') ?? 1)
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
}
},
}
}
const directoryUsers = [
{
kind: 'user',
id: 3,
username: 'ines',
display_name: 'Inès Duarte',
email: 'ines.duarte@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
},
{
kind: 'user',
id: 2,
username: 'jonas',
display_name: 'Jonas Pereira',
email: 'jonas@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
},
{
kind: 'user',
id: 1,
username: 'marta',
display_name: 'Marta Alves',
email: 'marta@renew.example',
role: 'owner',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
},
{
kind: 'user',
id: 4,
username: 'rui',
display_name: 'Rui Santos',
email: 'rui@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-05-02T09:00:00Z',
},
]
function authorsApi(
overrides: Record<string, ApiMock> = {},
options: { role?: string; authors?: DirectoryAuthor[] } = {},
): Record<string, ApiMock> {
const role = options.role ?? 'owner'
return {
'GET /v1/auth/me': directorySession(role),
'GET /v1/author/': authorListMock(options.authors ?? directoryAuthors({}, role !== 'user')),
'GET /v1/user/': { status: 200, body: directoryUsers },
...overrides,
}
}
async function openAuthorEdit(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Edit')
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
async function openAuthorDelete(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Delete…')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
const avatarPickerNames = [
'north-wall.jpg',
'kitchen-after.webp',
'kitchen-before.webp',
'logo-renew.svg',
'window-frame.jpg',
'lime-plaster.jpg',
'floorboards.jpg',
'bathroom-tiles.png',
]
const avatarPickerTones = ['0', '1', '3']
function avatarPickerList() {
const library = mediaItems()
const items = avatarPickerNames
.flatMap((name) => library.filter((item) => item.filename === name))
.map((item, index) => ({
...item,
content_type: 'image/jpeg',
sha256: `${avatarPickerTones[index % avatarPickerTones.length]}${item.sha256.slice(1)}`,
}))
return mediaList(items, pickerCounts)
}
const authorsStates: Record<string, VisualState> = {
Default: { path: '/authors', api: authorsApi() },
Loading: {
path: '/authors',
api: authorsApi({ 'GET /v1/author/': { status: 200, hang: true } }),
},
Error: { path: '/authors', api: authorsApi({ 'GET /v1/author/': { status: 500 } }) },
'Empty, first run': {
path: '/authors',
api: authorsApi({}, { authors: [] }),
},
'No results': { path: '/authors?q=zoe', api: authorsApi() },
'Row menu': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Camille Roche')
await page.mouse.move(0, 0)
},
},
'Read-only list (other roles)': {
path: '/authors',
api: authorsApi({}, { role: 'user' }),
},
'Read-only detail': {
path: '/authors',
api: authorsApi({}, { role: 'user' }),
steps: async (page) => {
await page.getByRole('button', { name: 'View Inès Duarte', exact: true }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
},
},
'Add: empty': {
path: '/authors',
api: authorsApi(),
steps: (page) => openDirectoryAdd(page, 'Add author'),
},
'Slug from the name': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add author')
await page
.getByRole('dialog')
.getByPlaceholder('e.g. Camille Roche')
.fill('Camille Roche')
await page
.getByRole('dialog')
.getByPlaceholder('A sentence or two about the author.')
.fill('Freelance writer on lime and stone.')
},
},
'Slug collision': {
path: '/authors',
api: authorsApi({ 'POST /v1/author/': { status: 409, body: 'author_slug_taken' } }),
steps: async (page) => {
await openDirectoryAdd(page, 'Add author')
await page
.getByRole('dialog')
.getByPlaceholder('e.g. Camille Roche')
.fill('Camille Roche')
await page.getByRole('button', { name: 'Edit slug', exact: true }).click()
await submitDirectoryPanel(page, 'Add author')
await page.getByRole('dialog').getByText('already used').waitFor()
},
},
Validation: {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add author')
await page
.getByRole('dialog')
.getByPlaceholder('A sentence or two about the author.')
.fill('x'.repeat(291))
await page.getByRole('dialog').getByPlaceholder('https://example.com').fill('example')
await submitDirectoryPanel(page, 'Add author')
await page.getByRole('dialog').getByText('Enter a name.').waitFor()
await scrollPanelToTop(page)
},
},
'Social links': {
path: '/authors',
api: authorsApi(
{},
{
authors: directoryAuthors({
'camille-roche': { social_links: directoryLinks.camille },
}),
},
),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page.getByRole('button', { name: 'Add link', exact: true }).click()
await page.getByRole('dialog').getByLabel('Link URL 3').focus()
await scrollPanelToTop(page)
},
},
'Linked user: choose': {
path: '/authors',
api: authorsApi({}, { authors: directoryAuthors({ 'camille-roche': { bio: '' } }) }),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page.getByRole('dialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
await scrollPanelToTop(page)
},
},
'Edit: linked author': {
path: '/authors',
api: authorsApi(),
steps: (page) => openAuthorEdit(page, 'Inès Duarte'),
},
'Avatar: picker': {
path: '/authors',
api: authorsApi(
{
'GET /v1/media/': { status: 200, bodyFor: avatarPickerList },
'GET /v1/image-variants': { status: 403 },
},
{ authors: directoryAuthors({ 'camille-roche': { bio: '' } }) },
),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page.getByRole('button', { name: 'Choose image', exact: true }).click()
await waitForPickerTiles(page)
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
await scrollPanelToTop(page)
await page.mouse.move(0, 0)
},
},
'Save failed': {
path: '/authors',
api: authorsApi(
{ 'PATCH /v1/author/3': { status: 500 } },
{ authors: directoryAuthors({ 'camille-roche': { bio: '' } }) },
),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page
.getByRole('dialog')
.getByPlaceholder('A sentence or two about the author.')
.fill('Freelance writer on lime and stone.')
await submitDirectoryPanel(page, 'Save changes')
await page.getByRole('dialog').getByText("Couldn't save the author").waitFor()
},
},
'Unlink: confirm': {
path: '/authors',
api: authorsApi(
{},
{
authors: directoryAuthors({
'ines-duarte': { social_links: [], bio: '', website: null },
}),
},
),
steps: async (page) => {
await openAuthorEdit(page, 'Inès Duarte')
await page.getByRole('button', { name: 'Unlink', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await scrollPanelToTop(page)
},
},
'Re-link': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Camille Roche')
await chooseDirectoryMenuItem(page, 'Link user')
await page.getByRole('alertdialog').waitFor()
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
},
},
'Reassign (recommended)': {
path: '/authors',
api: authorsApi(),
steps: (page) => openAuthorDelete(page, 'Camille Roche'),
},
'Choose who': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
},
},
Nobody: {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option', { name: /Nobody/ }).click()
await page.mouse.move(0, 0)
},
},
'Failed (shared by site and author delete)': {
path: '/authors',
api: authorsApi({ 'DELETE /v1/author/3': { status: 500 } }),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete and reassign 3 articles', exact: true })
.click()
await page.getByRole('alertdialog').getByText("Couldn't delete the author").waitFor()
await page.mouse.move(0, 0)
},
},
'Server fallback (409)': {
path: '/authors',
api: authorsApi({ 'DELETE /v1/author/3': { status: 409, body: 'author_has_articles' } }),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete and reassign 3 articles', exact: true })
.click()
await page.getByRole('alertdialog').getByText("Can't delete this author").waitFor()
await page.mouse.move(0, 0)
},
},
'No articles': {
path: '/authors',
api: authorsApi(),
steps: (page) => openAuthorDelete(page, 'Léa Fontaine'),
},
}
const seoDescription =
'A bathroom renovation is priced by room size, plumbing changes and finish level.'
const seoLongTitle = 'How we price a bathroom renovation: surveys, plumbing, tiling and finishes'
const seoCoverAlt = 'A tiled bathroom wall during renovation'
const seoFilledTitle = 'What a bathroom renovation costs'
const seoFilledDescription =
'A bathroom renovation is priced by room size, plumbing changes and finish level. Here is how we build a quote.'
const seoFilledExcerpt = 'How we price a bathroom renovation, from the survey to the final invoice.'
const seoBody =
`<p>${seoDescription} We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>` +
'<h2>What goes into the price</h2>' +
'<ul><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>' +
'<p>We don’t add a margin on materials you choose yourself.</p>'
function seoSuggestion(code: string, field: string, params: Record<string, number> = {}) {
return { code, field, params }
}
const seoSuggestions: Record<string, ReturnType<typeof seoSuggestion>> = {
title_long: seoSuggestion('title_long', 'seo_title', { length: 74, max: 60 }),
desc_short: seoSuggestion('desc_short', 'seo_description', { length: 41, min: 70 }),
cover_alt: seoSuggestion('cover_alt', 'cover_image_alt'),
cover_none: seoSuggestion('cover_none', 'cover_image_id'),
no_h: seoSuggestion('no_h', 'body'),
short: seoSuggestion('short', 'body', { length: 120, min: 300 }),
}
function seoCheck(
codes: string[],
status = codes.length ? 'suggestions' : 'ready',
slug: { available: boolean; suggestion: string | null } = { available: true, suggestion: null },
): ApiMock {
return {
status: 200,
body: { status, suggestions: codes.map((code) => seoSuggestions[code]), slug },
}
}
const seoTakenSlug = { available: false, suggestion: 'bathroom-costs-2' }
function seoSite(overrides: Record<string, unknown> = {}) {
return {
...directorySites[0]!,
default_social_image_id: 1,
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: '',
...overrides,
}
}
function seoJournal() {
return {
...directorySites[1]!,
site_name: 'Journal',
title_template: '%title% | %site%',
default_description: '',
}
}
function seoArticle(overrides: Record<string, unknown> = {}, body = seoBody) {
return {
...editorArticle,
body,
slug: 'how-we-price-a-bathroom-renovation',
excerpt: '',
seo_title: '',
seo_description: '',
cover_image_id: null,
cover_image_alt: '',
social_title: '',
social_description: '',
social_image_id: null,
canonical_url: '',
noindex: false,
seo_dismissed: [],
...overrides,
}
}
const seoApiResponse = {
title: 'How we price a bathroom renovation | Renew',
description: seoDescription,
canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation',
robots: 'index, follow',
og: {
type: 'article',
title: 'How we price a bathroom renovation | Renew',
description: seoDescription,
url: 'https://renew.example/how-we-price-a-bathroom-renovation',
site_name: 'Renew',
image: 'https://renew.example/media/north-wall.jpg',
image_alt: seoCoverAlt,
locale: 'en',
},
twitter: {
card: 'summary_large_image',
title: 'How we price a bathroom renovation | Renew',
description: seoDescription,
image: 'https://renew.example/media/north-wall.jpg',
image_alt: seoCoverAlt,
},
image: {
url: 'https://renew.example/media/north-wall.jpg',
alt: seoCoverAlt,
source: 'cover_image',
},
authors: [
{
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
same_as: ['https://www.linkedin.com/in/ines-duarte'],
},
],
published_at: '2026-03-12T09:14:00Z',
modified_at: '2026-10-15T11:02:00Z',
fallbacks: { title: 'title', description: 'body', image: 'cover', canonical: 'site' },
json_ld: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'How we price a bathroom renovation',
description: seoDescription,
image: ['https://renew.example/media/north-wall.jpg'],
author: [
{
'@type': 'Person',
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
sameAs: ['https://www.linkedin.com/in/ines-duarte'],
},
],
datePublished: '2026-03-12T09:14:00Z',
dateModified: '2026-10-15T11:02:00Z',
mainEntityOfPage: 'https://renew.example/how-we-price-a-bathroom-renovation',
},
}
interface SeoOptions {
article?: Record<string, unknown>
body?: string
check?: ApiMock
sites?: unknown[]
overrides?: Record<string, ApiMock>
library?: boolean
}
function seoApi(options: SeoOptions = {}): Record<string, ApiMock> {
return editorApi({
'GET /v1/article/12': {
status: 200,
body: seoArticle(options.article, options.body),
},
'GET /v1/site/': { status: 200, body: options.sites ?? [seoSite()] },
'POST /v1/article/12/seo/check': options.check ?? { status: 200, body: seoReady },
'GET /v1/media/': { status: 200, bodyFor: avatarPickerList },
'GET /v1/image-variants': { status: 403 },
'GET /v1/article/12/seo': { status: 200, body: seoApiResponse },
...(options.overrides ?? {}),
})
}
async function seoOpenSection(page: Page) {
if (isNarrow(page)) {
await page.getByRole('button', { name: 'Details', exact: true }).click()
await page.getByRole('dialog').waitFor()
}
const toggle = page.locator('[data-testid="seo-toggle"]:visible')
await toggle.waitFor()
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click()
}
await page.locator('[data-testid="seo-body"]:visible').waitFor()
await page.mouse.move(0, 0)
}
async function seoOpenPanel(page: Page) {
await seoOpenSection(page)
await page.locator('[data-testid="seo-edit"]:visible').click()
await page.locator('[data-testid="seo-panel-suggestions"]').waitFor()
await page.waitForTimeout(250)
await page.mouse.move(0, 0)
}
async function seoScrollPanel(page: Page, top: number) {
await page.evaluate((offset) => {
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
body.scrollTop = offset
})
}, top)
await page.mouse.move(0, 0)
}
async function seoOpenDisclosure(page: Page, title: string) {
await page.locator('[data-testid="seo-disclosure-toggle"]', { hasText: title }).first().click()
await page.mouse.move(0, 0)
}
function seoSidebarState(options: SeoOptions, open: boolean): VisualState {
return {
path: '/article/12/edit',
now: editorNow,
api: seoApi(options),
steps: async (page) => {
if (open) {
await seoOpenSection(page)
return
}
if (isNarrow(page)) {
await page.getByRole('button', { name: 'Details', exact: true }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
},
}
}
function seoPanelState(
options: SeoOptions,
steps: (page: Page) => Promise<void> = async () => {},
scrollY = 0,
): VisualState {
return {
path: '/article/12/edit',
now: editorNow,
api: seoApi(options),
steps: async (page) => {
await seoOpenPanel(page)
await steps(page)
await seoScrollPanel(page, scrollY)
},
}
}
async function seoTypeInPanel(page: Page, label: string, value: string) {
await page.getByRole('dialog').getByLabel(label).first().fill(value)
}
const seoFilled = {
seo_title: seoFilledTitle,
seo_description: seoFilledDescription,
excerpt: seoFilledExcerpt,
cover_image_id: 1,
cover_image_alt: seoCoverAlt,
}
const seoCoverNoAlt = { cover_image_id: 1, cover_image_alt: '' }
const seoSocialCustom = {
cover_image_id: 1,
cover_image_alt: seoCoverAlt,
social_title: 'Bathroom costs, explained',
social_description: 'A plain guide to what drives the price.',
social_image_id: 2,
}
async function seoOpenApiDialog(page: Page) {
await seoOpenSection(page)
await page.locator('[data-testid="seo-preview"]:visible').click()
await page.locator('[data-testid="api-preview"]').waitFor()
await page.waitForTimeout(250)
await page.mouse.move(0, 0)
}
async function seoSaveFromPanel(page: Page) {
await seoTypeInPanel(page, 'Meta title', seoFilledTitle)
await page
.getByRole('dialog')
.getByRole('button', { name: 'Save changes', exact: true })
.click()
}
const dismissColumnReason =
'The board draws a dismiss column and its footnote in the suggestion list; dismissal is deferred to slice 4'
const boardOmitsMobileDialogReason =
"The board's mobile frame omits the Create author dialog, so only the Details sheet is drawn"
function withMobileException<T extends VisualState>(state: T, mobile: number, reason: string): T {
return {
...state,
exception: {
maxPercent: { desktop: 10, mobile },
reason,
approvedBy: 'fsylvain',
},
}
}
const editorSeoStates: Record<string, VisualState> = {
'Collapsed: ready': seoSidebarState({}, false),
'Collapsed: suggestions': seoSidebarState(
{ check: seoCheck(['title_long', 'cover_alt']) },
false,
),
'Collapsed: hidden': seoSidebarState(
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
false,
),
'Open: ready': seoSidebarState({}, true),
'Open: suggestions': seoSidebarState(
{ check: seoCheck(['title_long', 'cover_alt', 'no_h', 'short']) },
true,
),
'Open: checking': seoSidebarState({ check: { status: 200, hang: true } }, true),
'Open: hidden': seoSidebarState(
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
true,
),
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
'Panel: defaults': withMobileException(
seoPanelState({ check: seoCheck(['cover_none']) }),
26.5,
dismissColumnReason,
),
'Panel: filled in': seoPanelState({ article: seoFilled }),
'Panel: outside the soft ranges': withMobileException(
seoPanelState({
article: {
seo_title: seoLongTitle,
seo_description: 'What a bathroom renovation costs.',
},
check: seoCheck(['title_long', 'desc_short']),
}),
15.5,
dismissColumnReason,
),
'Panel: slug edited': seoPanelState({ article: { slug: 'bathroom-costs' } }),
'Panel: slug taken': seoPanelState(
{
check: {
status: 200,
bodyFor: (_url, call) =>
seoCheck([], 'ready', call >= 3 ? seoTakenSlug : undefined).body,
},
},
async (page) => {
await page.getByRole('dialog').getByRole('button', { name: 'Edit slug' }).click()
const slug = page.getByRole('dialog').getByLabel('URL slug')
await slug.fill('bathroom-costs')
await slug.blur()
await page.getByText('is already used on Renew.').waitFor()
},
),
'Panel: cover without alt': withMobileException(
seoPanelState({
article: seoCoverNoAlt,
check: seoCheck(['cover_alt']),
}),
17,
dismissColumnReason,
),
'Panel: choose cover': withMobileException(
seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
await page
.getByRole('dialog')
.getByRole('button', { name: 'Choose image', exact: true })
.first()
.click()
await waitForPickerTiles(page)
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
await page.mouse.move(0, 0)
}),
29,
dismissColumnReason,
),
'Panel: social sharing open': seoPanelState(
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
(page) => seoOpenDisclosure(page, 'Social sharing'),
560,
),
'Panel: social customised': seoPanelState(
{ article: seoSocialCustom },
(page) => seoOpenDisclosure(page, 'Social sharing'),
560,
),
'Panel: advanced, invalid canonical': seoPanelState(
{},
async (page) => {
await seoOpenDisclosure(page, 'Advanced')
const canonical = page.getByRole('dialog').getByLabel('Canonical URL')
await canonical.fill('renew.example/costs')
await canonical.blur()
await page.getByText('Enter a full address starting with https://').waitFor()
},
640,
),
'Panel: hidden from search': seoPanelState(
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
async () => {},
700,
),
'Panel: mobile search preview': withMobileException(
seoPanelState(
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
async (page) => {
await page
.getByRole('dialog')
.getByRole('tab', { name: 'Mobile', exact: true })
.click()
await page.mouse.move(0, 0)
},
),
17.5,
dismissColumnReason,
),
'Panel: checking': seoPanelState({ check: { status: 200, hang: true } }),
'Panel: saving': seoPanelState(
{ overrides: { 'PATCH /v1/article/12': { status: 200, hang: true } } },
async (page) => {
await seoSaveFromPanel(page)
await page.getByRole('dialog').getByText('Saving…').first().waitFor()
await page.mouse.move(0, 0)
},
),
'Panel: save failed': seoPanelState(
{ overrides: { 'PATCH /v1/article/12': { status: 500 } } },
async (page) => {
await seoSaveFromPanel(page)
await page.getByText("Couldn't save search and sharing.").waitFor()
await page.mouse.move(0, 0)
},
),
'Saved (toast)': {
path: '/article/12/edit',
now: editorNow,
api: seoApi({ overrides: { 'PATCH /v1/article/12': { status: 200, body: seoArticle() } } }),
steps: async (page) => {
await seoOpenPanel(page)
await seoSaveFromPanel(page)
await page.getByText('Search and sharing saved').waitFor()
await page.mouse.move(0, 0)
},
},
Formatted: {
path: '/article/12/edit',
now: editorNow,
api: seoApi({ article: { is_online: true } }),
steps: seoOpenApiDialog,
},
'Raw JSON': {
path: '/article/12/edit',
now: editorNow,
api: seoApi({ article: { is_online: true } }),
steps: async (page) => {
await seoOpenApiDialog(page)
await page.getByRole('tab', { name: 'Raw JSON', exact: true }).click({ force: true })
await page.mouse.move(0, 0)
},
},
Copied: {
path: '/article/12/edit',
origin: integrationOrigin,
now: editorNow,
api: seoApi({ article: { is_online: true } }),
steps: async (page) => {
await page.evaluate(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: async () => undefined },
configurable: true,
})
})
await seoOpenApiDialog(page)
await page.getByRole('tab', { name: 'Raw JSON', exact: true }).click({ force: true })
await page
.getByRole('button', { name: 'Copy JSON', exact: true })
.click({ force: true })
await page.getByRole('button', { name: 'Copied', exact: true }).waitFor()
await page.mouse.move(0, 0)
},
},
'Draft article': {
path: '/article/12/edit',
now: editorNow,
api: seoApi({ article: { is_online: false } }),
steps: seoOpenApiDialog,
},
'Dialog: loading': {
path: '/article/12/edit',
now: editorNow,
api: seoApi({
article: { is_online: true },
overrides: { 'GET /v1/article/12/seo': { status: 200, hang: true } },
}),
steps: async (page) => {
await seoOpenSection(page)
await page.locator('[data-testid="seo-preview"]:visible').click()
await page.locator('[data-testid="api-preview-loading"]').waitFor()
await page.mouse.move(0, 0)
},
},
'Dialog: error': {
path: '/article/12/edit',
now: editorNow,
api: seoApi({
article: { is_online: true },
overrides: { 'GET /v1/article/12/seo': { status: 500 } },
}),
steps: async (page) => {
await seoOpenSection(page)
await page.locator('[data-testid="seo-preview"]:visible').click()
await page.locator('[data-testid="api-preview-error"]').waitFor()
await page.mouse.move(0, 0)
},
},
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
authors: authorsStates,
activity: activityStates,
profile: {
'No author profile': authorProfileState({}),
Error: authorProfileState({ 'POST /v1/author/me': { status: 500 } }, async (page) => {
await createAuthorProfile(page)
await page.getByText("Couldn't create your author profile").waitFor()
await page.mouse.move(0, 0)
}),
Created: authorProfileState(
{ 'POST /v1/author/me': { status: 201, body: profileAuthorRecord } },
async (page) => {
await createAuthorProfile(page)
await page.getByText('Author profile created').first().waitFor()
await page.mouse.move(0, 0)
},
),
'Created, slug adjusted': authorProfileState(
{
'POST /v1/author/me': {
status: 201,
body: { ...profileAuthorRecord, slug: 'marta-alves-2' },
},
},
async (page) => {
await createAuthorProfile(page)
await page.getByText('Your slug is marta-alves-2').waitFor()
await page.mouse.move(0, 0)
},
),
'Edit own author': authorProfileState(
{ 'GET /v1/author/': authorListMock([profileAuthorRecord]) },
async (page) => {
await page.getByRole('button', { name: 'Edit author', exact: true }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
},
profileAuthorSession('user', true),
),
'Account menu': {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': articleListMock(sixArticlesFixture),
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
},
steps: openAccountMenu,
},
Loading: {
path: '/profile',
api: { 'GET /v1/auth/me': { status: 200, hang: true } },
},
'Default, no display name': profileState(),
Editing: profileState('', {}, async (page) => {
await typeDisplayName(page, profileDisplayName)
await page.mouse.move(0, 0)
}),
'Too long': profileState('', {}, async (page) => {
await typeDisplayName(page, profileTooLongName)
await page.mouse.move(0, 0)
}),
Saving: profileState(
'',
{ 'PATCH /v1/user/me': { status: 200, body: profileSavedBody, hang: true } },
async (page) => {
await saveDisplayName(page, profileDisplayName)
},
),
Saved: profileState(
'',
{ 'PATCH /v1/user/me': { status: 200, body: profileSavedBody } },
async (page) => {
await saveDisplayName(page, profileDisplayName)
await page.getByText('Display name saved').first().waitFor()
},
),
'Save failed': profileState('', { 'PATCH /v1/user/me': { status: 500 } }, async (page) => {
await saveDisplayName(page, profileDisplayName)
await page.getByText("Couldn't save your display name").waitFor()
}),
'Reset: sending': resetState({ status: 200, hang: true }),
'Reset: sent': resetState({ status: 200, body: { message: 'ok' } }),
'Reset: too many requests (429)': resetState({
status: 429,
headers: { 'Retry-After': '300' },
}),
'Reset: failed': resetState({ status: 500 }),
'Display names in the Users list': {
path: '/users',
api: {
'GET /v1/auth/me': profileSession(profileDisplayName),
'GET /v1/user/': { status: 200, body: usersWithDisplayNames },
},
exception: {
maxPercent: 10.5,
reason: 'Row actions (Cancel invite, Renew invite) drawn on the board are out of scope for this slice',
approvedBy: 'user',
},
},
},
users: {
Default: {
path: '/users',
api: usersApi(),
},
'Cancel invite: confirm': {
path: '/users',
api: usersApi(),
steps: openCancelInviteDialog,
},
'Cancel invite: sending': {
path: '/users',
api: usersApi({ 'DELETE /v1/user/invite/2': { status: 204, hang: true } }),
steps: confirmCancelInvite,
},
'Cancel invite: failed': {
path: '/users',
api: usersApi({ 'DELETE /v1/user/invite/2': { status: 500 } }),
steps: async (page) => {
await confirmCancelInvite(page)
await page.getByText("Couldn't cancel the invite").waitFor()
await page.mouse.move(0, 0)
},
},
'Cancel invite: done': { path: '/users', skip: 'clock-sensitive' },
'Renew invite: sending': {
path: '/users',
api: usersApi({ 'POST /v1/user/invite/1/renew': { status: 200, hang: true } }),
steps: renewPaulHuberInvite,
},
'Renew invite: done': { path: '/users', skip: 'clock-sensitive' },
'Renew invite: failed': {
path: '/users',
api: usersApi({ 'POST /v1/user/invite/1/renew': { status: 500 } }),
steps: async (page) => {
await renewPaulHuberInvite(page)
await page.getByText("Couldn't renew the invite").waitFor()
await page.mouse.move(0, 0)
},
},
Loading: {
path: '/users',
api: usersApi({ 'GET /v1/user/': { status: 200, body: usersFixture, hang: true } }),
steps: async (page) => {
await page.waitForTimeout(400)
},
},
'Only you': {
path: '/users',
api: usersApi({ 'GET /v1/user/': { status: 200, body: usersFixture.slice(4) } }),
},
Error: { path: '/users', api: usersApi({ 'GET /v1/user/': { status: 500 } }) },
'Invite: default': { path: '/users', api: usersApi(), steps: openInviteModal },
'Invite: validation': {
path: '/users',
api: usersApi(),
steps: inviteWith('sam.oliveira@renew'),
},
'Invite: sending': {
path: '/users',
api: usersApi({ 'POST /v1/user/invite': { status: 201, hang: true } }),
steps: inviteWith('lea.martin@renew.example', { status: 201, hang: true }),
},
'Invite: already a user (409)': {
path: '/users',
api: usersApi({
'POST /v1/user/invite': {
status: 409,
body: 'This address already has an account.',
},
}),
steps: inviteWith('jonas@renew.example', { status: 409 }),
},
'Invite: failed': {
path: '/users',
api: usersApi({ 'POST /v1/user/invite': { status: 500 } }),
steps: inviteWith('lea.martin@renew.example', { status: 500 }),
},
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
},
settings: {
...cleanupStates,
...retentionStates,
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
Checking: {
path: '/settings',
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
},
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
'Update available': { path: '/settings', skip: 'clock-sensitive' },
'Update available, notes open': { path: '/settings', skip: 'clock-sensitive' },
Downloading: {
path: '/settings',
api: updateApi('owner', updateDownloading()),
},
Verifying: {
path: '/settings',
api: updateApi('owner', updateJob('verifying')),
},
'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) },
'Restart: confirm': {
path: '/settings',
api: restartApi(updateJob('ready'), { status: 500 }),
steps: openRestartDialog,
},
'Restart: request failed': {
path: '/settings',
api: restartApi(updateJob('ready'), { status: 500 }),
steps: async (page) => {
await confirmRestart('Restart now')(page)
await page.getByText("Couldn't start the restart").waitFor()
await page.mouse.move(0, 0)
},
},
'Restarting to install': {
path: '/settings',
api: restartApi(updateJob('ready'), restartAccepted(updateJob('ready'))),
steps: async (page) => {
await confirmRestart('Restart now')(page)
await page.getByText('Installing version 2.3.0').waitFor()
},
},
'Restarting, taking longer': { path: '/settings', skip: 'clock-sensitive' },
'Update done': { path: '/settings', skip: 'clock-sensitive' },
'Update failed, rolled back': {
path: '/settings',
api: updateApi(
'owner',
updateJob('failed', {
error: 'rolled_back',
started_at: restartStartedAt,
finished_at: restartFinishedAt,
}),
),
steps: rememberRestartAndReload,
},
'Download failed': {
path: '/settings',
api: updateApi('owner', updateJob('failed', { error: 'download' })),
},
'Verification failed': {
path: '/settings',
api: updateApi('owner', updateJob('failed', { error: 'verification' })),
},
'Too many requests (429)': {
path: '/settings',
api: updateApi('owner', updateNeverChecked(), {
status: 429,
headers: { 'Retry-After': '42' },
}),
},
'Not prepared': {
path: '/settings',
now: boardNow,
api: updateApi('owner', { ...updateAvailable(), checked_at: '2026-09-10T09:41:00Z' }),
},
'Not prepared: command copied': {
path: '/settings',
origin: integrationOrigin,
now: boardNow,
api: updateApi('owner', { ...updateAvailable(), checked_at: '2026-09-10T09:41:00Z' }),
steps: async (page) => {
await page.getByRole('button', { name: 'Copy', exact: true }).click()
await page.mouse.move(0, 0)
},
},
'Plain restart: confirm': {
path: '/settings',
api: restartApi(updateStatusBody({ can_apply: true }), { status: 500 }),
steps: openRestartDialog,
},
'Plain restart: restarting': {
path: '/settings',
api: restartApi(
updateStatusBody({ can_apply: true }),
restartAccepted(updateStatusBody({ can_apply: true })),
),
steps: async (page) => {
await confirmRestart('Restart site')(page)
await page.getByText('Restarting your site').waitFor()
},
},
'Plain restart: done': { path: '/settings', skip: 'clock-sensitive' },
'Admin view': {
path: '/settings',
api: updateApi('admin', updateAvailable()),
},
'Role changed (403)': {
path: '/settings',
api: {
...updateApi('owner', updateNeverChecked(), { status: 403 }),
'GET /v1/auth/me': {
status: 200,
bodyFor: (_url, call) => ({
...(verifiedSession.body as object),
role: call === 1 ? 'owner' : 'admin',
}),
},
'GET /v1/system/update': {
status: 200,
bodyFor: (_url, call) =>
call === 1 ? updateNeverChecked() : updateAvailable(),
},
},
},
'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' },
},
'integration-api-keys': apiKeysStates,
integration: {
'1. Default': integrationState(),
'2. Group scrolled, one card expanded': integrationState({
steps: async (page) => {
await page.getByRole('button', { name: /^POST \/v1\/media\/\. / }).click()
await page
.locator('[data-anchor="ep-med-up"]')
.getByRole('tab', { name: 'curl', exact: true })
.click()
await scrollToAnchor(page, 'ep-med-up', { desktop: 126, mobile: 83 })
},
exception: {
maxPercent: 37.5,
reason: 'The new Query parameters block adds about 70 px above Request body, which shifts every row below the card, and the captions use the real server strings instead of the board samples',
approvedBy: 'Florian Sylvain',
},
}),
'3a. Copied: base URL': integrationState({ steps: clickBaseUrlCopy }),
'3b. Copied: CodeBlock': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'auth', { desktop: 29, mobile: 63 })
await page
.locator('[data-anchor="auth"]')
.getByRole('button', { name: 'Copy code' })
.click()
await page.mouse.move(0, 0)
},
}),
'4. Authentication': integrationState({
steps: (page) => scrollToAnchor(page, 'auth', { desktop: 29, mobile: 63 }),
}),
'5. Session only': integrationState({
steps: (page) => scrollToAnchor(page, 'session', { desktop: -1, mobile: 55 }),
exception: {
maxPercent: 12,
reason: 'The banner names the real routes and wraps differently, a 13th row (DELETE /v1/auth/register) and the login-family captions are new, and a Public auth flow block follows',
approvedBy: 'Florian Sylvain',
},
}),
'6. Errors': integrationState({
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
exception: {
maxPercent: { desktop: 12.5, mobile: 19 },
reason: 'A 409 row is new, the typical-cause text uses the real server messages in place of the longer board prose, and the board changed (17.33 % mobile already at HEAD against the updated board)',
approvedBy: 'Florian Sylvain',
},
}),
'I1 · Many variants': integrationImagesState(integrationManyVariants),
'I2 · One variant': integrationImagesState(
integrationVariantsList(integrationVariantRows.slice(0, 1)),
{
exception: {
maxPercent: { desktop: 16, mobile: 10 },
reason: 'The board draws the striped preview offset by 32 px',
approvedBy: 'Florian Sylvain',
},
},
),
'I3 · No variants, admin': integrationImagesState({ status: 200, body: { items: [] } }),
'I4 · No variants, not an admin': integrationImagesState(null, { session: memberSession }),
'I5 · Variant removed': integrationImagesState(
{
status: 200,
bodyFor: (_url, call) => ({
items: (call === 1
? integrationVariantRows.slice(0, 5)
: integrationRemovedRows
).map((row) => integrationVariant(row)),
}),
},
{
steps: async (page) => {
await scrollToAnchor(page, 'images', integrationImagesOffsets)
await pickBuilderVariant(page, 'hero')
await page.evaluate(() => window.dispatchEvent(new Event('focus')))
await page.getByText('hero (removed)').waitFor()
await scrollToAnchor(page, 'images', integrationImagesOffsets)
},
},
),
'I6 · Loading': integrationImagesState({ status: 200, hang: true }),
'I7 · Error loading': integrationImagesState({ status: 500 }),
'I8 · Snippet copied': integrationImagesState(integrationManyVariants, {
exception: {
maxPercent: { desktop: 12, mobile: 10 },
reason: 'The board draws the striped preview offset by 32 px',
approvedBy: 'Florian Sylvain',
},
steps: async (page) => {
await scrollToAnchor(page, 'images', integrationImagesOffsets)
await page.getByRole('tab', { name: 'JavaScript', exact: true }).click()
await copyUnder(page, 'Request an image')
},
}),
'I9 · URL builder copied': integrationImagesState(integrationManyVariants, {
steps: async (page) => {
await pickBuilderVariant(page, 'card')
await copyUnder(page, 'URL builder')
await scrollToAnchor(page, 'images', integrationImagesOffsets)
},
}),
'7. Navigation': integrationState({
steps: async (page) => {
await scrollToAnchor(page, 'media', { desktop: -5, mobile: -3 })
if ((page.viewportSize()?.width ?? 0) < 1024) {
await page.getByRole('button', { name: 'Jump to section' }).click()
}
},
exception: {
maxPercent: 12.5,
reason: 'The board frame scroll position varies by 18px between captures, so the desktop diff flickers between about 8 % and 12 %',
approvedBy: 'Florian Sylvain',
},
}),
},
articles: {
...pagination,
'Several sites': {
path: '/article',
now: articlesNow,
api: multiSiteArticlesApi(),
},
'Site filter open': {
path: '/article',
now: articlesNow,
api: multiSiteArticlesApi(),
steps: openSiteFilter,
},
'No articles on a site': {
path: '/article?site=archive',
now: articlesNow,
api: multiSiteArticlesApi(),
steps: async (page) => {
await page.getByText('No articles on "Archive"').waitFor()
},
},
Default: {
path: '/article',
now: articlesNow,
api: articlesApi(),
exception: {
maxPercent: 3.7,
reason: 'UTable column boundaries land ~9-12px off the board grid (110/130/140/44px), unresolved by nth-child width overrides on table-fixed',
approvedBy: 'Florian Sylvain',
},
},
'Row menu open': {
path: '/article',
now: articlesNow,
api: articlesApi(),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
},
exception: {
maxPercent: 7.5,
reason: 'Same UTable column-boundary offset as Default, plus the open row menu popover rendering',
approvedBy: 'Florian Sylvain',
},
},
Loading: {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { ...articleListMock(articlesFixture()), hang: true },
}),
exception: {
maxPercent: 10.6,
reason: 'Skeleton placeholder block sizes/positions only approximate the board skeleton shape',
approvedBy: 'Florian Sylvain',
},
},
Empty: {
path: '/article',
now: articlesNow,
api: articlesApi({ 'GET /v1/article/': articleListMock([]) }),
exception: {
maxPercent: 3.6,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout',
approvedBy: 'Florian Sylvain',
},
},
'No results': {
path: '/article',
now: articlesNow,
api: articlesApi(),
steps: async (page) => {
await page.getByPlaceholder('Search by title').fill('tiles')
await page.waitForTimeout(200)
},
exception: {
maxPercent: 3.3,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-results layout',
approvedBy: 'Florian Sylvain',
},
},
'No drafts': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(
articlesFixture()
.slice(0, 4)
.map((article) => ({ ...article, is_online: true })),
),
}),
steps: async (page) => {
await page.getByRole('tab', { name: /^Drafts/ }).focus()
await page.keyboard.press('Enter')
},
exception: {
maxPercent: 2.7,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-drafts layout',
approvedBy: 'Florian Sylvain',
},
},
'Action in progress': {
path: '/article',
now: articlesNow,
api: articlesApi({
'PATCH /v1/article/4/publish': { status: 200, hang: true },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Publish')
},
exception: {
maxPercent: 3.6,
reason: 'Same UTable column-boundary offset as Default, plus the busy badge/spinner rendering',
approvedBy: 'Florian Sylvain',
},
},
Published: {
path: '/article',
now: articlesNow,
api: articlesApi({
'PATCH /v1/article/4/publish': {
status: 200,
body: {
...articlesFixture()[3],
is_online: true,
updated_at: articlesNow,
},
},
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Publish')
await page.getByText('Article published').first().waitFor()
},
exception: {
maxPercent: 4.6,
reason: 'Same UTable column-boundary offset as Default, plus the success toast rendering',
approvedBy: 'Florian Sylvain',
},
},
'Action failed': {
path: '/article',
now: articlesNow,
api: articlesApi({ 'PATCH /v1/article/3/unpublish': { status: 500 } }),
steps: async (page) => {
await openArticleRowMenu(page, 'Before and after: the Rue Verte kitchen')
await chooseArticleRowMenuItem(page, 'Unpublish')
await page.getByText("Couldn't unpublish").first().waitFor()
},
exception: {
maxPercent: 5.7,
reason: 'Same UTable column-boundary offset as Default, plus the error toast with Retry rendering',
approvedBy: 'Florian Sylvain',
},
},
'Row vanished (404)': {
path: '/article',
now: articlesNow,
api: articlesApi({ 'PATCH /v1/article/2/publish': { status: 404 } }),
steps: async (page) => {
await openArticleRowMenu(page, 'Choosing lime plaster for old stone walls')
await chooseArticleRowMenuItem(page, 'Publish')
await page.getByText('That article no longer exists').first().waitFor()
},
exception: {
maxPercent: 4.3,
reason: 'Same UTable column-boundary offset as Default, plus the info toast after row removal',
approvedBy: 'Florian Sylvain',
},
},
'Delete: confirm': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page.getByRole('alertdialog').waitFor()
},
},
'Delete: in progress': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 204, hang: true },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
},
},
'Delete: failed': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 500 },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
await page.getByText("Couldn't delete the article").waitFor()
},
},
Deleted: {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 204 },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
await page.getByText('Article deleted').first().waitFor()
},
},
},
home: {
Default: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': articleListMock(sixArticlesFixture),
},
},
Loading: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': { ...homeActivityMock, hang: true },
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { ...articleListMock(sixArticlesFixture), hang: true },
},
},
'Empty, first run': {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': articleListMock([]),
},
},
Error: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 500 },
},
},
'Update available (owner)': {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': articleListMock(sixArticlesFixture),
'GET /v1/system/update': { status: 200, body: updateAvailable() },
},
},
'Update ready to install (owner)': {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': articleListMock(sixArticlesFixture),
'GET /v1/system/update': { status: 200, body: updateJob('ready') },
},
},
},
'log-in': {
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
'Fresh install': {
path: '/login',
api: { 'GET /v1/auth/registration': registrationOpen },
exception: {
maxPercent: 1.1,
reason: 'The board draws a design-time "API pending" chip in the footer; the API exists, so the app omits it and the theme select fits on the first row on mobile',
approvedBy: 'Florian Sylvain, 2026-09-26',
},
},
'Validation errors': {
...loginWith({ status: 500 }),
steps: submitLogin('ma', ''),
},
Submitting: {
...loginWith({ status: 200, hang: true }),
steps: submitLogin('marta', 'limewash2026'),
},
'Wrong credentials (403)': {
...loginWith({ status: 403, body: 'forbidden' }),
steps: submitLogin('marta', 'limewash2026'),
},
'Too many attempts (429)': {
...loginWith({ status: 429, headers: { 'Retry-After': '42' } }),
steps: submitLogin('marta', 'limewash'),
},
'Session expired': {
...loginWith({ status: 500 }, '/login?redirect=/article/12/edit&reason=expired'),
steps: async (page) => {
await page.getByPlaceholder('Your username').fill('marta')
await page.locator('input[autocomplete="current-password"]').focus()
},
},
'Password changed': {
path: '/login?reason=password-changed',
api: { 'GET /v1/auth/registration': registrationClosed },
},
'Server or network error': {
...loginWith({ status: 500 }),
steps: submitLogin('marta', 'limewash2026'),
},
},
register: {
Default: { path: '/register' },
'Validation errors': {
path: '/register',
steps: async (page) => {
await fillRegister(page, 'ma', 'marta@renew', 'lime')
},
},
'Filled, valid': {
path: '/register',
steps: async (page) => {
await fillRegister(page, 'marta', 'marta@renew.example', 'limewash2026')
await page.getByRole('button', { name: 'Show password' }).click()
},
},
Submitting: {
...registerWith({ status: 200, hang: true }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
'Registration closed (403)': {
...registerWith({ status: 403 }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
'Server error': {
...registerWith({ status: 500 }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
},
pending: {
Default: {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
},
},
'Email unknown': {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
},
'Confirm cancel': {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
},
},
Cancelling: {
path: '/register/pending',
api: {
'GET /v1/auth/me': { status: 200, body: { is_verified: false } },
'DELETE /v1/auth/register': { status: 200, hang: true },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
},
},
Cancelled: {
path: '/register/pending',
api: {
'GET /v1/auth/me': { status: 200, body: { is_verified: false } },
'DELETE /v1/auth/register': { status: 200, body: { message: 'ok' } },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
await page.waitForURL('**/register')
},
},
'Already verified (403)': {
path: '/register/pending',
paused: true,
api: {
'DELETE /v1/auth/register': { status: 403 },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
await page.getByText("You're verified").waitFor()
},
},
},
editor: {
...editorSeoStates,
'Open: one site, one author': metaState({}, async () => {}),
'Open: several sites': metaState({ sites: 4, siteId: 2 }, async () => {}),
'Site select open': metaState({ sites: 4, siteId: 2 }, openSiteSelect),
'No authors, with hint': metaState({ authors: [] }, async () => {}),
'Many authors': metaState(
{
authors: [
'Inès Duarte',
'Jonas Pereira',
'Dr. Alexandra Konstantinopoulou-Whitfield',
'Paul Huber',
],
},
async () => {},
),
'Add author: open': metaState({}, openAuthorCombobox),
'Add author: no match': metaState({}, async (page) => {
await openAuthorCombobox(page)
await searchAuthors(page, 'Zoe Lambert')
await page.getByText('No author matches "Zoe Lambert".').waitFor()
}),
'Add author: open (no create)': metaState({ role: 'user' }, openAuthorCombobox),
'Add author: no match (no create)': metaState({ role: 'user' }, async (page) => {
await openAuthorCombobox(page)
await searchAuthors(page, 'Zoe Lambert')
await page.getByText('No author matches "Zoe Lambert".').waitFor()
}),
'Add author: error': metaState(
{ overrides: { 'GET /v1/author/': { status: 500 } } },
async (page) => {
await openAuthorCombobox(page)
await page.getByText("Couldn't load authors").waitFor()
},
),
'Reorder: keyboard': metaState(
{ authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'] },
(page) => grabAuthor(page, 'Jonas Pereira'),
),
'Reorder: pointer': metaState(
{ authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'] },
(page) => liftAuthor(page, 'Camille Roche'),
),
'Create author: dialog': withMobileException(
metaState({ withAuthor: true }, (page) => openCreateAuthorDialog(page, 'Zoe Lambert')),
55,
boardOmitsMobileDialogReason,
),
'Create author: for me': withMobileException(
metaState({}, async (page) => {
await openCreateAuthorDialog(page, 'Marta Alves')
await page.getByRole('checkbox', { name: /This is me/ }).click()
await page.mouse.move(0, 0)
}),
50,
boardOmitsMobileDialogReason,
),
'Create author: slug taken': withMobileException(
metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } },
async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert')
await page.getByRole('button', { name: 'Create author', exact: true }).click()
await page.getByText('is already used by another author').waitFor()
await page.mouse.move(0, 0)
},
),
40,
boardOmitsMobileDialogReason,
),
'Create author: failed': withMobileException(
metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } },
async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert')
await page.getByRole('button', { name: 'Create author', exact: true }).click()
await page.getByText("Couldn't create the author").waitFor()
await page.mouse.move(0, 0)
},
),
52.5,
boardOmitsMobileDialogReason,
),
Loading: {
path: '/article/12/edit',
now: editorNow,
api: editorApi({
'GET /v1/article/12': { status: 200, body: editorArticle, hang: true },
}),
},
Saved: {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await focusBoldSample(page)
},
},
'Unsaved changes': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await focusBoldSample(page)
},
},
Saving: {
path: '/article/12/edit',
now: editorNow,
api: editorApi({
'PATCH /v1/article/12': { status: 200, body: editorArticle, hang: true },
}),
steps: async (page) => {
await markDirty(page)
await focusBoldSample(page)
await page.getByRole('button', { name: 'Save', exact: true }).click()
},
},
'Save failed': {
path: '/article/12/edit',
now: editorNow,
api: editorApi({ 'PATCH /v1/article/12': { status: 500 } }),
steps: async (page) => {
await markDirty(page)
await page.getByRole('button', { name: 'Save', exact: true }).click()
await page.getByText('Save failed').waitFor()
await focusBoldSample(page)
},
},
'Very long article': {
path: '/article/12/edit',
now: editorNow,
api: veryLongApi(),
steps: markVeryLongDirty,
exception: {
maxPercent: { desktop: 10, mobile: 14.5 },
reason: 'The mobile editor already misses the 10 % budget for every ordinary state at HEAD (11 to 21 %); the very long article states inherit the same layout gap',
approvedBy: 'Florian Sylvain',
},
},
'Very long article: saving': {
path: '/article/12/edit',
now: editorNow,
api: veryLongApi({
'PATCH /v1/article/12': { status: 200, body: veryLongArticle, hang: true },
}),
exception: {
maxPercent: { desktop: 10, mobile: 14.5 },
reason: 'The mobile editor already misses the 10 % budget for every ordinary state at HEAD (11 to 21 %); the very long article states inherit the same layout gap',
approvedBy: 'Florian Sylvain',
},
steps: async (page) => {
await markVeryLongDirty(page)
await page.getByRole('button', { name: 'Save', exact: true }).click()
},
},
'Very long article: saved': { path: '/article/12/edit', skip: 'clock-sensitive' },
'Very long article: save failed': {
path: '/article/12/edit',
now: editorNow,
api: veryLongApi({ 'PATCH /v1/article/12': { status: 500 } }),
exception: {
maxPercent: { desktop: 10, mobile: 14.5 },
reason: 'The mobile editor already misses the 10 % budget for every ordinary state at HEAD (11 to 21 %); the very long article states inherit the same layout gap',
approvedBy: 'Florian Sylvain',
},
steps: async (page) => {
await markVeryLongDirty(page)
await page.getByRole('button', { name: 'Save', exact: true }).click()
await page.getByText('Save failed').waitFor()
await focusBoldSample(page)
},
},
Preview: {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
const preview = page.getByRole('switch')
if (await preview.count()) {
await preview.click()
}
},
},
'Leave with unsaved changes': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
paused: true,
steps: async (page) => {
await markDirty(page)
await focusBoldSample(page)
await page.locator('[aria-label="Back to Articles"]:visible').click()
await page.getByRole('alertdialog').waitFor()
},
},
Offline: {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await focusBoldSample(page)
await page.evaluate(() => {
Object.defineProperty(navigator, 'onLine', {
configurable: true,
get: () => false,
})
window.dispatchEvent(new Event('offline'))
})
await page.getByText('Waiting for connection').waitFor()
},
},
'Not found (404)': {
path: '/article/9999/edit',
now: editorNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/9999': { status: 404 },
'GET /v1/site/': boardSiteList(),
},
},
'Link: create from selection': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await openLinkDialogOnWord(page)
await typeIntoLinkField(page, 'Link text', 'timber windows')
await linkDialog(page).getByLabel('URL', { exact: true }).focus()
},
},
'Link: create with no selection': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await page.keyboard.press('Control+k')
await linkDialog(page).waitFor()
},
},
'Link: edit existing': {
path: '/article/12/edit',
api: linkedApi(),
steps: openLinkDialogOnLink,
},
'Link: invalid URL': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await openLinkDialogOnWord(page)
await typeIntoLinkField(page, 'Link text', 'timber windows')
await typeIntoLinkField(page, 'URL', 'www.example')
await page.keyboard.press('Tab')
await linkDialog(page).getByText('Enter a valid URL').waitFor()
await linkDialog(page).getByText('Links open in the same tab').click()
},
},
'Link: empty text': {
path: '/article/12/edit',
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await page.keyboard.press('Control+k')
await linkDialog(page).waitFor()
await typeIntoLinkField(page, 'URL', '#measuring')
await linkDialog(page).getByRole('button', { name: 'Insert link' }).click()
await linkDialog(page).getByText('Enter the link text.').waitFor()
await linkDialog(page).getByLabel('URL', { exact: true }).focus()
await linkDialog(page).getByText('Links open in the same tab').click()
},
},
'Link: long URL': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await openLinkDialogOnLink(page)
await typeIntoLinkField(page, 'Link text', 'planning guidance')
await typeIntoLinkField(page, 'URL', linkLongUrl)
await linkDialog(page).getByLabel('URL', { exact: true }).focus()
},
},
'Link button: default': {
path: '/article/12/edit',
api: linkedApi(),
steps: dirtyWithLinks,
},
'Link button: hover and tooltip': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await linkButton(page).hover()
await page.waitForTimeout(TOOLTIP_SETTLE_MS)
},
},
'Link button: focus-visible': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await focusBoldSample(page)
await markDirty(page)
await linkButton(page).focus()
},
},
'Link button: active (cursor in a link)': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await page.locator('.article-content a').click()
await page.keyboard.press('Escape')
},
},
'Link button: disabled': {
path: '/article/12/edit',
api: linkedApi(linkedImageBody),
steps: async (page) => {
await dirtyWithLinks(page)
await page.getByTestId('article-editor-image').click({ force: true })
await page.addStyleTag({
content: '[data-testid="article-editor-image"] { display: none; }',
})
},
},
'Link popover and sheet': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
if (isNarrow(page)) {
await useTouchPointer(page)
}
await page.locator('.article-content a').click()
await page.locator('[aria-label="Link"]').waitFor()
},
},
'Rendered link: default': {
path: '/article/12/edit',
api: linkedApi(),
steps: dirtyWithLinks,
},
'Rendered link: hover': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await page.locator('.article-content a').hover()
},
},
'Rendered link: focus': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await markDirty(page)
await page.getByText('before any tiling starts').click()
await page.keyboard.press('Home')
for (let step = 0; step < 21; step += 1) {
await page.keyboard.press('ArrowRight')
}
},
},
'Rendered link: selected': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
await page.locator('.article-content a').selectText()
},
},
'Toolbar overflow (390 px)': {
path: '/article/12/edit',
api: linkedApi(),
steps: async (page) => {
await dirtyWithLinks(page)
if (isNarrow(page)) {
await page
.locator('.article-toolbar')
.evaluate((toolbar) => toolbar.scrollTo({ left: toolbar.scrollWidth }))
}
},
},
'Upload: choose': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openImageUploader(page)
},
},
'Upload: crop and resize': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
},
},
'Upload: crop to a ratio': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
await selectAspectRatio(page, '16:9')
},
},
'Upload: crop narrower than the width': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
await selectAspectRatio(page, '1:1')
await resizeCropFrameTo(page, 4032, 640, 640)
await page.getByText('The crop is 640 px wide').waitFor()
},
},
'Upload: in progress': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
await freezeUploadProgress(page, kb(180), kb(420))
await page.getByRole('dialog').getByRole('button', { name: 'Upload image' }).click()
await page.getByText('north-wall.jpg 43%').waitFor()
},
},
'Upload: rejected': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await openImageUploader(page)
await chooseImageFile(page, 'north-wall.tif', 'image/png', mib(14.2))
await page.getByText(`"north-wall.tif" can't be used`).waitFor()
},
},
'Upload: inserted': {
path: '/article/12/edit',
now: editorNow,
api: editorApi({
'POST /v1/media/': {
status: 201,
body: {
id: 13,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/north-wall.jpg',
size: mib(3.9),
content_type: 'image/jpeg',
width: 4032,
height: 3024,
sha256: 'a'.repeat(64),
uploaded_by: 'Marta Alves',
created_at: editorNow,
used_in: 0,
duplicate: false,
},
},
}),
steps: async (page) => {
await mockUploadedImage(page)
await page.getByText('add a margin on materials').click()
await page.keyboard.press('Home')
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
await page.getByRole('dialog').getByRole('button', { name: 'Upload image' }).click()
await page.getByText('Save to keep it in the article.').first().waitFor()
await page.waitForTimeout(650)
},
},
},
'new-article': {
Default: {
path: '/article',
now: articlesNow,
api: newArticleHomeApi(),
steps: openNewArticleModal,
},
Validation: {
path: '/article',
now: articlesNow,
api: newArticleHomeApi(),
steps: async (page) => {
await openNewArticleModal(page)
await page.getByPlaceholder(`e.g. ${newArticleTitle}`).fill('Ti')
await page.getByPlaceholder(`e.g. ${newArticleTitle}`).blur()
await page.getByRole('button', { name: 'Create draft', exact: true }).click()
await page.mouse.move(0, 0)
},
},
Submitting: {
path: '/article',
now: articlesNow,
api: newArticleHomeApi({ 'POST /v1/article/': { status: 201, hang: true } }),
steps: submitNewArticle(),
},
'Title rejected (400)': {
path: '/article',
now: articlesNow,
api: newArticleHomeApi({ 'POST /v1/article/': { status: 400 } }),
steps: submitNewArticle(),
},
Error: {
path: '/article',
now: articlesNow,
api: newArticleHomeApi({ 'POST /v1/article/': { status: 500 } }),
steps: submitNewArticle(),
},
},
verify: {
Verifying: {
path: '/register/validate?c=abc123',
api: { 'POST /v1/auth/verify': { status: 200, hang: true } },
},
Success: {
path: '/register/validate?c=abc123',
paused: true,
api: {
'POST /v1/auth/verify': { status: 200, body: { message: 'ok' } },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
},
},
'Invalid or expired (403)': {
path: '/register/validate?c=abc123',
api: { 'POST /v1/auth/verify': { status: 403 } },
},
'No session (401)': {
path: '/register/validate?c=abc123',
api: { 'POST /v1/auth/verify': { status: 401 } },
},
'No session, retrying': {
path: '/register/validate?c=abc123',
api: { 'POST /v1/auth/verify': { status: 200, hang: true } },
},
'Missing code': {
path: '/register/validate',
},
},
'forgot-password': {
Default: { path: '/register/reset/request' },
'Validation error': {
path: '/register/reset/request',
steps: submitResetRequest('marta@renew'),
},
Submitting: {
...resetRequestWith({ status: 200, hang: true }),
steps: submitResetRequest('marta@renew.example'),
},
Sent: {
...resetRequestWith({ status: 200, body: { message: 'ok' } }),
steps: submitResetRequest('marta@renew.example'),
},
'Too many attempts (429)': {
...resetRequestWith({ status: 429, headers: { 'Retry-After': '58' } }),
steps: submitResetRequest('marta@renew.example'),
},
'Server error': {
...resetRequestWith({ status: 500 }),
steps: submitResetRequest('marta@renew.example'),
},
},
'reset-password': {
Default: { path: resetConfirmPath },
'Too short': {
path: resetConfirmPath,
steps: submitResetConfirm('lime', 'lime'),
},
Mismatch: {
path: resetConfirmPath,
steps: async (page) => {
await fillResetConfirm(page, 'limewash2026', 'limewash2025')
await page.locator('#reset-confirm-password').blur()
},
},
Submitting: {
...resetConfirmWith({ status: 200, hang: true }),
steps: submitResetConfirm('limewash2026', 'limewash2026'),
},
Success: {
path: resetConfirmPath,
api: {
'POST /v1/auth/password-reset/confirm': { status: 200, body: { message: 'ok' } },
'GET /v1/auth/registration': registrationClosed,
},
steps: async (page) => {
await submitResetConfirm('limewash2026', 'limewash2026')(page)
await page.waitForURL('**/login**')
await page.getByText('Password changed').waitFor()
},
},
'Invalid or expired (403)': {
...resetConfirmWith({ status: 403 }),
steps: submitResetConfirm('limewash2026', 'limewash2026'),
},
'Missing parameters': {
path: '/register/reset/validate',
},
'Server error': {
...resetConfirmWith({ status: 500 }),
steps: submitResetConfirm('limewash2026', 'limewash2026'),
},
},
'accept-invite': {
'Checking the link': {
path: acceptInvitePath,
api: { 'GET /v1/auth/invite': { status: 200, hang: true } },
},
Default: acceptInviteWith({ status: 200 }),
'Validation errors': {
...acceptInviteWith({ status: 200 }),
steps: async (page) => {
await fillAcceptInvite(page, 'ma', 'lime')
await page.locator('input[autocomplete="new-password"]').press('Enter')
},
},
'Filled, valid': {
...acceptInviteWith({ status: 200 }),
steps: async (page) => {
await fillAcceptInvite(page, 'lea', 'limewash2026')
await page.getByRole('button', { name: 'Show password' }).click()
},
},
Submitting: {
...acceptInviteWith({ status: 200, hang: true }),
steps: submitAcceptInvite('lea', 'limewash2026'),
},
'Username taken (409)': {
...acceptInviteWith({ status: 409, body: 'That username is taken. Try another.' }),
steps: submitAcceptInvite('marta', 'limewash2026'),
},
'Invalid or used link (404)': {
path: acceptInvitePath,
api: { 'GET /v1/auth/invite': { status: 404 } },
},
'Expired link (410)': {
path: acceptInvitePath,
api: { 'GET /v1/auth/invite': { status: 410 } },
},
'Server error': {
...acceptInviteWith({ status: 500 }),
steps: submitAcceptInvite('lea', 'limewash2026'),
},
'Failed lookup (500 or network)': {
path: acceptInvitePath,
api: { 'GET /v1/auth/invite': { status: 500 } },
exception: {
maxPercent: 100,
reason: 'State approved by the user with no board frame in docs/design; nothing to diff against',
approvedBy: 'user',
},
},
Success: {
...acceptInviteWith({ status: 200, body: { message: 'ok' } }),
paused: true,
api: {
'GET /v1/auth/invite': { status: 200, body: { email: acceptInviteEmail } },
'POST /v1/auth/invite/accept': { status: 200, body: { message: 'ok' } },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
},
steps: submitAcceptInvite('lea', 'limewash2026'),
},
},
'image-variants': {
...variantsStates,
...cacheStates,
...settingsNavStates,
...withVariantExceptions(variantsMediaStates),
...withVariantExceptions(variantsEditorStates),
},
media: withDetailTabsException(
withVariantsRowException({ ...mediaStates, ...mediaPurgeStates }),
),
'media-picker': pickerStates,
}