Files
RenewCMS/web/visual/states.ts
T

1651 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Page } from 'playwright'
export interface ApiMock {
status: number
body?: unknown
headers?: Record<string, string>
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<string, ApiMock>
steps?: (page: Page) => Promise<void>
}
const registrationClosed: ApiMock = { status: 200, body: { open: false } }
const registrationOpen: ApiMock = { status: 200, body: { open: true } }
async function fillLogin(page: Page, username: string, password: string) {
await page.getByPlaceholder('Your username').fill(username)
await page.locator('input[autocomplete="current-password"]').fill(password)
}
function submitLogin(username: string, password: string) {
return async (page: Page) => {
await fillLogin(page, username, password)
await page.getByRole('button', { name: 'Log in', exact: true }).click()
}
}
function loginWith(response: ApiMock, path = '/login'): Omit<VisualState, 'steps'> {
return {
path,
api: {
'GET /v1/auth/registration': registrationClosed,
'POST /v1/auth/login': response,
},
}
}
async function fillRegister(page: Page, username: string, email: string, password: string) {
await page.getByPlaceholder('e.g. marta').fill(username)
await page.getByPlaceholder('e.g. marta').blur()
await page.getByPlaceholder('you@example.com').fill(email)
await page.getByPlaceholder('you@example.com').blur()
await page.locator('input[autocomplete="new-password"]').fill(password)
await page.locator('input[autocomplete="new-password"]').blur()
}
function registerWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: '/register',
api: {
'POST /v1/auth/register': response,
},
}
}
function submitRegister(username: string, email: string, password: string) {
return async (page: Page) => {
await fillRegister(page, username, email, password)
await page.getByRole('button', { name: 'Create account', exact: true }).click()
}
}
async function setPendingEmail(page: Page, email: string) {
await page.evaluate((value) => sessionStorage.setItem('renewcms:pendingEmail', value), email)
await page.reload()
await page.waitForLoadState('networkidle')
}
async function openCancelDialog(page: Page) {
await page.getByRole('button', { name: 'Cancel registration', exact: true }).click()
}
async function confirmCancelDialog(page: Page) {
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Cancel registration', exact: true })
.click()
}
const boardNow = '2026-09-10T09:00:00Z'
const sixArticlesFixture = [
{
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
is_online: true,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-09-10T07:00:00Z',
},
{
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
is_online: false,
created_at: '2026-09-09T08:00:00Z',
updated_at: '2026-09-09T09:00:00Z',
},
{
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: '2026-09-07T09:00:00Z',
},
{
id: 4,
title: 'Winter checklist for timber windows',
body: '',
images: [],
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: '2026-09-05T09:00:00Z',
},
{
id: 5,
title: 'Why we reuse original floorboards',
body: '',
images: [],
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
},
{
id: 6,
title: 'Our first passive-house retrofit',
body: '',
images: [],
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
},
]
const verifiedSession: ApiMock = {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: '',
},
}
const editorNow = '2026-09-26T11:02:00Z'
const bathroomBody =
"<p>Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>" +
'<h2>What goes into the price</h2>' +
'<ul><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>' +
'<p>We don’t add a margin on materials you choose yourself.</p>'
const editorArticle = {
id: 12,
title: 'How we price a bathroom renovation',
body: bathroomBody,
images: [
{ id: 1, url: '/images/1.png' },
{ id: 2, url: '/images/2.png' },
{ id: 3, url: '/images/3.png' },
],
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: editorNow,
}
function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
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<Blob>((resolve) =>
canvas.toBlob((b) => resolve(b!), mimeType, 0.85),
)
const file = new File([blob], filename, { type: mimeType })
Object.defineProperty(file, 'size', { value: sizeBytes, configurable: true })
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
input.files = dataTransfer.files
input.dispatchEvent(new Event('change', { bubbles: true }))
input.dispatchEvent(new Event('input', { bubbles: true }))
},
{ filename, mimeType, naturalWidth, naturalHeight, sizeBytes },
)
}
async function openCropStep(
page: Page,
filename: string,
mimeType: string,
naturalWidth: number,
naturalHeight: number,
sizeBytes: number,
) {
await openImageUploader(page)
await chooseRasterImageFile(page, filename, mimeType, naturalWidth, naturalHeight, sizeBytes)
await page.getByRole('dialog').getByText('Crop and resize').waitFor()
}
async function selectAspectRatio(page: Page, ratio: string) {
await page
.getByRole('radiogroup', { name: 'Aspect ratio' })
.getByRole('radio', { name: ratio, exact: true })
.click()
}
async function resizeCropFrameTo(
page: Page,
naturalWidth: number,
targetWidthPx: number,
targetHeightPx: number,
) {
const frame = page.locator('[data-testid="crop-frame"]')
const handle = page.locator('[data-testid="crop-handle"]').nth(3)
async function currentSize() {
const box = (await frame.boundingBox())!
const stageWidth = await frame.evaluate(
(el) => el.parentElement!.getBoundingClientRect().width,
)
const scale = stageWidth / naturalWidth
return {
width: Math.round(box.width / scale),
height: Math.round(box.height / scale),
scale,
}
}
for (let attempt = 0; attempt < 6; attempt += 1) {
const frameBox = (await frame.boundingBox())!
const handleBox = (await handle.boundingBox())!
const { width: currentWidth, height: currentHeight, scale } = await currentSize()
if (
Math.abs(currentWidth - targetWidthPx) <= 30 &&
Math.abs(currentHeight - targetHeightPx) <= 30
) {
break
}
const startX = handleBox.x + handleBox.width / 2
const startY = handleBox.y + handleBox.height / 2
const targetX = frameBox.x + targetWidthPx * scale
const targetY = frameBox.y + targetHeightPx * scale
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(targetX, targetY, { steps: 1 })
await page.mouse.up()
await page.waitForTimeout(50)
}
for (let guard = 0; guard < 60; guard += 1) {
const { width: currentWidth, height: currentHeight } = await currentSize()
if (currentWidth === targetWidthPx && currentHeight === targetHeightPx) {
return
}
if (currentWidth !== targetWidthPx) {
await frame.press(currentWidth > targetWidthPx ? 'Shift+ArrowLeft' : 'Shift+ArrowRight')
await page.waitForTimeout(120)
continue
}
await frame.press(currentHeight > targetHeightPx ? 'Shift+ArrowUp' : 'Shift+ArrowDown')
await page.waitForTimeout(120)
}
}
async function mockUploadedImage(page: Page) {
const isDark = await page.evaluate(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
)
const background = isDark ? 'rgb(26,26,26)' : 'rgb(244,244,245)'
const border = isDark ? 'rgb(64,64,64)' : 'rgb(212,212,216)'
await page.route('**/static/uploadedImages/**', async (route) => {
const pathname = new URL(route.request().url()).pathname
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="450"><rect width="100%" height="100%" fill="${background}" stroke="${border}" stroke-width="2" /><text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="16" fill="${border}">${pathname}</text></svg>`
await route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg })
})
}
async function freezeUploadProgress(page: Page, loaded: number, total: number) {
await page.evaluate(
({ loaded, total }) => {
const proto = XMLHttpRequest.prototype as unknown as Record<string, unknown>
const originalOpen = proto.open as (...args: unknown[]) => void
proto.open = function (this: XMLHttpRequest, ...args: unknown[]) {
;(this as unknown as { __imageUrl?: string }).__imageUrl = String(args[1])
return originalOpen.apply(this, args)
}
const originalSend = proto.send as (...args: unknown[]) => void
proto.send = function (this: XMLHttpRequest, ...args: unknown[]) {
const url = (this as unknown as { __imageUrl?: string }).__imageUrl ?? ''
if (url.includes('/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<string, ApiMock> = {}): Record<string, ApiMock> {
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<string, ApiMock> = {}): Record<string, ApiMock> {
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<VisualState, 'steps'> {
return {
path: '/register/reset/request',
api: {
'POST /v1/auth/password-reset': response,
},
}
}
const resetConfirmPath = '/register/reset/validate?c=abc123&email=marta%40renew.example'
async function fillResetConfirm(page: Page, password: string, confirmPassword: string) {
await page.locator('#reset-new-password').fill(password)
await page.locator('#reset-confirm-password').fill(confirmPassword)
}
function submitResetConfirm(password: string, confirmPassword: string) {
return async (page: Page) => {
await fillResetConfirm(page, password, confirmPassword)
await page.getByRole('button', { name: 'Change password', exact: true }).click()
await page.mouse.move(0, 0)
}
}
function resetConfirmWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: resetConfirmPath,
api: {
'POST /v1/auth/password-reset/confirm': response,
},
}
}
const usersFixture = [
{
kind: 'invite',
id: 2,
email: 'sam.oliveira@renew.example',
status: 'pending',
created_at: '2026-09-27T09:00:00Z',
},
{
kind: 'invite',
id: 1,
email: 'p.huber@gmail.com',
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
},
{
kind: 'user',
id: 3,
username: 'ines',
display_name: 'Inès Duarte',
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
},
{
kind: 'user',
id: 2,
username: 'jonas',
display_name: '',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
},
{
kind: 'user',
id: 1,
username: 'marta',
display_name: '',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
},
]
function usersApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/user/': { status: 200, body: usersFixture },
...overrides,
}
}
async function openInviteModal(page: Page) {
await page.getByRole('button', { name: 'Invite user' }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
function 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<VisualState, 'steps'> {
return {
path: acceptInvitePath,
api: {
'GET /v1/auth/invite': { status: 200, body: { email: acceptInviteEmail } },
'POST /v1/auth/invite/accept': response,
},
}
}
async function fillAcceptInvite(page: Page, username: string, password: string) {
await page.getByPlaceholder('e.g. lea').fill(username)
await page.getByPlaceholder('e.g. lea').blur()
await page.locator('input[autocomplete="new-password"]').fill(password)
await page.locator('input[autocomplete="new-password"]').blur()
}
function submitAcceptInvite(username: string, password: string) {
return async (page: Page) => {
await fillAcceptInvite(page, username, password)
await page.locator('input[autocomplete="new-password"]').press('Enter')
}
}
const profileDisplayName = 'Marta Alves'
const profileFieldPlaceholder = 'e.g. Marta Alves'
const profileTooLongName = 'Marta Alves, Head of Renovations, Brussels and Wallonia office'
const profileSavedBody = {
id: 1,
username: 'marta',
display_name: profileDisplayName,
}
function profileSession(displayName = ''): ApiMock {
return {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: displayName,
},
}
}
function profileApi(
displayName = '',
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': profileSession(displayName),
...overrides,
}
}
function profileState(
displayName = '',
overrides: Record<string, ApiMock> = {},
steps?: (page: Page) => Promise<void>,
): VisualState {
return { path: '/profile', api: profileApi(displayName, overrides), steps }
}
async function typeDisplayName(page: Page, value: string) {
await page.getByPlaceholder(profileFieldPlaceholder).fill(value)
}
async function scrollToTop(page: Page) {
await page.evaluate(() => {
window.scrollTo(0, 0)
document.querySelectorAll<HTMLElement>('[data-slot="body"]').forEach((element) => {
element.scrollTop = 0
})
})
}
async function pressSave(page: Page) {
await page.getByRole('button', { name: 'Save', exact: true }).click()
}
async function saveDisplayName(page: Page, value: string) {
await typeDisplayName(page, value)
await pressSave(page)
await page.mouse.move(0, 0)
}
async function pressSendResetLink(page: Page) {
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
await page.mouse.move(0, 0)
await scrollToTop(page)
}
function resetState(response: ApiMock): VisualState {
return profileState(
profileDisplayName,
{ 'POST /v1/auth/password-reset': response },
pressSendResetLink,
)
}
async function openAccountMenu(page: Page) {
const isMobile = (page.viewportSize()?.width ?? 0) < 1024
if (isMobile) {
await page.locator('[data-slot="toggle"]').click()
await page.locator('[role="dialog"] button[aria-haspopup]').click()
await page.locator('[role="menu"]').waitFor()
} else {
await page.locator('[data-slot="footer"] button[aria-haspopup]').click()
await page.locator('[role="menu"]').waitFor()
}
await page.mouse.move(0, 0)
}
const usersWithDisplayNames = usersFixture.map((item) =>
item.kind === 'user' && item.username === 'marta'
? { ...item, display_name: profileDisplayName }
: item,
)
export const visualStates: Record<string, Record<string, VisualState>> = {
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 },
},
},
},
users: {
Default: { path: '/users', api: usersApi() },
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' },
},
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'),
},
},
}