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 hang?: boolean } export interface DiffException { maxPercent: number reason: string approvedBy: string } export interface VisualState { exception?: DiffException skip?: 'clock-sensitive' | 'pending' path: string origin?: string now?: string paused?: boolean api?: Record steps?: (page: Page) => Promise } 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 { 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 { 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 editorNow = '2026-09-26T11:02:00Z' const bathroomBody = "

Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and write down what stays.

" + '

What goes into the price

' + '
  • Stripping out and making good
  • Plumbing moves, if any
  • Tiles, fittings and a day for silicone
' + '

We don’t add a margin on materials you choose yourself.

' 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, created_at: '2026-03-12T09:14:00Z', updated_at: editorNow, } function editorApi(overrides: Record = {}): Record { return { 'GET /v1/auth/me': verifiedSession, 'GET /v1/article/12': { status: 200, body: editorArticle }, ...overrides, } } 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') } async function typeExactBodyLength(page: Page, totalLength: number) { const editable = page.locator('[contenteditable="true"]') await editable.click() await page.keyboard.press('Control+a') await page.keyboard.press('Delete') const inner = 'a'.repeat(Math.max(0, totalLength - 7)) await page.keyboard.insertText(inner) } 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((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 = `${pathname}` 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 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('/image')) { 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' const newArticleBody = 'Small rooms show every grout line, so we start with the floor.' function newArticleHomeApi(overrides: Record = {}): Record { return { 'GET /v1/auth/me': verifiedSession, 'GET /v1/article/': { status: 200, body: sixArticlesFixture }, ...overrides, } } async function openNewArticleModal(page: Page) { await page.getByRole('button', { name: 'New article' }).click() await page.getByRole('dialog').waitFor() } async function fillNewArticleValid(page: Page) { await page.getByPlaceholder(`e.g. ${newArticleTitle}`).fill(newArticleTitle) await page .getByPlaceholder("Start writing. You'll continue in the editor.") .fill(newArticleBody) } 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: [], 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: [], 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: [], 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: [], 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: [], 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', }, ] } 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 = {}): Record { return { 'GET /v1/auth/me': verifiedSession, 'GET /v1/article/': { status: 200, body: articlesFixture() }, ...overrides, } } 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 { 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 { 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 = {}): Record { 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', exact: true }).first().click() } const acceptInvitePath = '/invite/accept?c=abc123' const acceptInviteEmail = 'lea.martin@renew.example' function acceptInviteWith(response: ApiMock): Omit { 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, } 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 = {}, ): Record { return { 'GET /v1/auth/me': profileSession(displayName), ...overrides, } } function profileState( displayName = '', overrides: Record = {}, steps?: (page: Page) => Promise, ): 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('[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.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 } const mediaSamples: MediaSample[] = [ { id: 1, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, } } 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, next_cursor: null, counts } } function mediaApi(overrides: Record = {}): Record { 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 = {}) { 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, steps?: (page: Page) => Promise, 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 = {}, ) { 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 = {}, ) { const duplicates = mediaList(mediaItems(mediaDuplicateListIds)) return mediaListApi( { first: duplicates, later: later ? mediaList(mediaItems(later.ids), later.counts) : duplicates, }, overrides, ) } function mediaBulkApi( deleteResponses: Record, 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 } 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) { await page.evaluate( ({ behaviors, ratio, hungLabel }) => { class StubRequest extends EventTarget { upload = new EventTarget() status = 0 responseText = '' withCredentials = false responseHeaders: Record = {} 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, 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 = { '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 } })), Offline: mediaState(mediaApi(), goOffline), } export const visualStates: Record> = { profile: { 'Account menu': { path: '/home', now: boardNow, api: { 'GET /v1/auth/me': verifiedSession, 'GET /v1/article/': { status: 200, body: sixArticlesFixture }, }, 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: { Default: { path: '/settings', api: { 'GET /v1/auth/me': verifiedSession, 'GET /v1/settings/': { status: 200, body: { version: 'v2.2.0-beta' } }, }, }, }, integration: { Default: { path: '/integration', origin: integrationOrigin, api: { 'GET /v1/auth/me': verifiedSession }, }, Copied: { path: '/integration', origin: integrationOrigin, api: { 'GET /v1/auth/me': verifiedSession }, steps: clickBaseUrlCopy, }, }, articles: { 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/': { status: 200, body: 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/': { status: 200, body: [] } }), 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/': { status: 200, body: 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/': { status: 200, body: 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/': { status: 200, body: 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/': { status: 200, body: 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/': { status: 200, body: 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/article/': { status: 200, body: sixArticlesFixture }, }, }, Loading: { path: '/home', now: boardNow, api: { 'GET /v1/auth/me': verifiedSession, 'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true }, }, }, 'Empty, first run': { path: '/home', now: boardNow, api: { 'GET /v1/auth/me': verifiedSession, 'GET /v1/article/': { status: 200, body: [] }, }, }, Error: { path: '/home', now: boardNow, api: { 'GET /v1/auth/me': verifiedSession, 'GET /v1/article/': { status: 500 }, }, }, }, '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: { 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) }, }, 'Near the limit': { path: '/article/12/edit', now: editorNow, api: editorApi(), steps: async (page) => { await typeExactBodyLength(page, 9240) }, }, 'Too long': { path: '/article/12/edit', now: editorNow, api: editorApi(), steps: async (page) => { await typeExactBodyLength(page, 10214) }, }, 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.getByRole('button', { name: 'Back to Articles' }).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 }, }, }, '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/article/12/image': { status: 201, body: { location: '/static/uploadedImages/north-wall.jpg' }, }, }), steps: async (page) => { await mockUploadedImage(page) await page.getByText('add a margin on materials').click() await page.keyboard.press('Home') await openImageUploader(page) await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9)) await page.getByText('Save to keep it in the article.').first().waitFor() await page.waitForTimeout(650) }, }, }, 'new-article': { Default: { path: '/home', now: boardNow, api: newArticleHomeApi(), steps: openNewArticleModal, }, Validation: { path: '/home', now: boardNow, 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.getByPlaceholder("Start writing. You'll continue in the editor.").click() await page.getByPlaceholder("Start writing. You'll continue in the editor.").blur() }, }, Submitting: { path: '/home', now: boardNow, api: newArticleHomeApi({ 'POST /v1/article/': { status: 201, hang: true } }), steps: submitNewArticle(), }, 'Title rejected (400)': { path: '/home', now: boardNow, api: newArticleHomeApi({ 'POST /v1/article/': { status: 400 } }), steps: submitNewArticle(), }, Error: { path: '/home', now: boardNow, 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'), }, }, media: mediaStates, }