import type { Page } from 'playwright' export interface ApiMock { status: number body?: unknown headers?: Record hang?: boolean } export interface DiffException { maxPercent: number reason: string approvedBy: string } export interface VisualState { exception?: DiffException skip?: 'clock-sensitive' 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: { is_verified: true, username: 'marta' }, } 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) } 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 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 integrationOrigin = 'https://cms.renew.example' async function clickBaseUrlCopy(page: Page) { await page.getByRole('button', { name: 'Copy', exact: true }).first().click() } export const visualStates: Record> = { 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: in progress': { path: '/article/12/edit', now: editorNow, api: editorApi(), steps: async (page) => { await markDirty(page) await openImageUploader(page) await freezeUploadProgress(page, mib(2.4), mib(3.9)) await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9)) await page.getByText('north-wall.jpg 62%').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'), }, }, }