Files
RenewCMS/web/visual/states.ts
T

3047 lines
88 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from 'node:crypto'
import type { Page } from 'playwright'
export interface ApiMock {
status: number
body?: unknown
bodyFor?: (url: URL, call: number) => unknown
headers?: Record<string, string>
hang?: boolean
}
export interface DiffException {
maxPercent: number
reason: string
approvedBy: string
}
export interface VisualState {
exception?: DiffException
skip?: 'clock-sensitive' | 'pending'
path: string
origin?: string
now?: string
paused?: boolean
api?: Record<string, ApiMock>
steps?: (page: Page) => Promise<void>
}
const registrationClosed: ApiMock = { status: 200, body: { open: false } }
const registrationOpen: ApiMock = { status: 200, body: { open: true } }
async function fillLogin(page: Page, username: string, password: string) {
await page.getByPlaceholder('Your username').fill(username)
await page.locator('input[autocomplete="current-password"]').fill(password)
}
function submitLogin(username: string, password: string) {
return async (page: Page) => {
await fillLogin(page, username, password)
await page.getByRole('button', { name: 'Log in', exact: true }).click()
}
}
function loginWith(response: ApiMock, path = '/login'): Omit<VisualState, 'steps'> {
return {
path,
api: {
'GET /v1/auth/registration': registrationClosed,
'POST /v1/auth/login': response,
},
}
}
async function fillRegister(page: Page, username: string, email: string, password: string) {
await page.getByPlaceholder('e.g. marta').fill(username)
await page.getByPlaceholder('e.g. marta').blur()
await page.getByPlaceholder('you@example.com').fill(email)
await page.getByPlaceholder('you@example.com').blur()
await page.locator('input[autocomplete="new-password"]').fill(password)
await page.locator('input[autocomplete="new-password"]').blur()
}
function registerWith(response: ApiMock): Omit<VisualState, 'steps'> {
return {
path: '/register',
api: {
'POST /v1/auth/register': response,
},
}
}
function submitRegister(username: string, email: string, password: string) {
return async (page: Page) => {
await fillRegister(page, username, email, password)
await page.getByRole('button', { name: 'Create account', exact: true }).click()
}
}
async function setPendingEmail(page: Page, email: string) {
await page.evaluate((value) => sessionStorage.setItem('renewcms:pendingEmail', value), email)
await page.reload()
await page.waitForLoadState('networkidle')
}
async function openCancelDialog(page: Page) {
await page.getByRole('button', { name: 'Cancel registration', exact: true }).click()
}
async function confirmCancelDialog(page: Page) {
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Cancel registration', exact: true })
.click()
}
const boardNow = '2026-09-10T09:00:00Z'
const sixArticlesFixture = [
{
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
is_online: true,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-09-10T07:00:00Z',
},
{
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
is_online: false,
created_at: '2026-09-09T08:00:00Z',
updated_at: '2026-09-09T09:00:00Z',
},
{
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: '2026-09-07T09:00:00Z',
},
{
id: 4,
title: 'Winter checklist for timber windows',
body: '',
images: [],
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: '2026-09-05T09:00:00Z',
},
{
id: 5,
title: 'Why we reuse original floorboards',
body: '',
images: [],
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
},
{
id: 6,
title: 'Our first passive-house retrofit',
body: '',
images: [],
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
},
]
const verifiedSession: ApiMock = {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: '',
role: 'owner',
},
}
const updateRunningVersion = 'v2.2.0-beta'
const updateLatestTag = 'v2.3.0'
const updateReleaseUrl = 'https://git.floriansylvain.fr/fsylvain/RenewCMS/releases/tag/v2.3.0'
const updateReleasedAt = '2026-09-24T08:00:00Z'
const adminSession: ApiMock = {
status: 200,
body: { ...(verifiedSession.body as object), role: 'admin' },
}
function updateStatusBody(overrides: Record<string, unknown> = {}) {
return {
current: updateRunningVersion,
latest: updateRunningVersion,
available: false,
release_url: updateReleaseUrl,
notes: { version: updateRunningVersion, released_at: updateReleasedAt, items: [] },
checked_at: new Date().toISOString(),
can_apply: false,
state: 'idle',
progress: null,
error: null,
started_at: null,
finished_at: null,
...overrides,
}
}
function updateNeverChecked() {
return updateStatusBody({
latest: '',
release_url: null,
notes: null,
checked_at: null,
})
}
function updateAvailable() {
return updateStatusBody({
latest: updateLatestTag,
available: true,
notes: { version: updateLatestTag, released_at: updateReleasedAt, items: [] },
})
}
const updateBoardNotes = {
version: updateLatestTag,
released_at: updateReleasedAt,
items: [
'Images upload faster, and large images no longer stall.',
'Fixed a problem where a draft could show an old title after saving.',
'Small improvements to the Users page.',
],
}
const updateBoardTotalBytes = 27262976
const updateBoardDoneBytes = 11534336
const updateJobStartedAt = '2026-09-30T09:51:00Z'
function updateStartable(overrides: Record<string, unknown> = {}) {
return updateStatusBody({
latest: updateLatestTag,
available: true,
notes: updateBoardNotes,
can_apply: true,
...overrides,
})
}
function updateJob(state: string, overrides: Record<string, unknown> = {}) {
return updateStartable({ state, started_at: updateJobStartedAt, ...overrides })
}
function updateDownloading() {
return updateJob('downloading', {
progress: {
percent: 42,
downloaded_bytes: updateBoardDoneBytes,
total_bytes: updateBoardTotalBytes,
},
})
}
const updateCheckHangs: ApiMock = { status: 200, hang: true }
const restartStartedAt = '2026-09-30T09:51:00'
const restartFinishedAt = '2026-09-30T09:52:00'
const restartMemoryKey = 'renewcms:update-restart'
function restartApi(status: unknown, restart: ApiMock): Record<string, ApiMock> {
return { ...updateApi('owner', status), 'POST /v1/system/update/restart': restart }
}
function restartAccepted(status: Record<string, unknown>): ApiMock {
return { status: 202, body: { ...status, state: 'restarting', started_at: restartStartedAt } }
}
async function openRestartDialog(page: Page) {
await page.getByRole('button', { name: 'Restart', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
}
function confirmRestart(label: string) {
return async (page: Page) => {
await openRestartDialog(page)
await page.getByRole('button', { name: label, exact: true }).click()
}
}
async function rememberRestartAndReload(page: Page) {
await page.evaluate(
([key, startedAt]) =>
sessionStorage.setItem(key!, JSON.stringify({ kind: 'update', started_at: startedAt })),
[restartMemoryKey, restartStartedAt],
)
await page.reload()
await page.getByText('Your site is back on v2.2.0-beta.').first().waitFor()
await page.mouse.move(0, 0)
}
function updateApi(
role: 'owner' | 'admin',
status: unknown,
check: ApiMock = { status: 200, body: updateStatusBody() },
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': role === 'owner' ? verifiedSession : adminSession,
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
'GET /v1/system/update': { status: 200, body: status },
'POST /v1/system/update/check': check,
}
}
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,
}
const emptyLibrary = {
items: [],
next_cursor: null,
counts: { all: 0, unused: 0, duplicates: 0 },
}
function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/12': { status: 200, body: editorArticle },
'GET /v1/media/': { status: 200, body: emptyLibrary },
...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('/v1/media')) {
setTimeout(() => {
const event = new Event('progress')
Object.assign(event, { loaded, total, lengthComputable: true })
this.upload.dispatchEvent(event)
}, 50)
return
}
return originalSend.apply(this, args)
}
},
{ loaded, total },
)
}
const newArticleTitle = 'Choosing tiles for a small bathroom'
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',
display_name: 'Paul Huber',
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
},
{
kind: 'user',
id: 3,
username: 'ines',
display_name: 'Inès Duarte',
email: 'ines.duarte@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
},
{
kind: 'user',
id: 2,
username: 'jonas',
display_name: '',
email: 'jonas@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
},
{
kind: 'user',
id: 1,
username: 'marta',
display_name: '',
email: 'marta@renew.example',
role: 'owner',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
},
]
function usersApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/user/': { status: 200, body: usersFixture },
...overrides,
}
}
async function openInviteModal(page: Page) {
await page.getByRole('button', { name: 'Invite user' }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
function rowActionButton(page: Page, email: string, name: string) {
return page
.locator('[data-testid="user-row"], [data-testid="user-card"]')
.filter({ hasText: email })
.getByRole('button', { name, exact: true })
}
async function openCancelInviteDialog(page: Page) {
await rowActionButton(page, 'sam.oliveira@renew.example', 'Cancel invite').click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
async function confirmCancelInvite(page: Page) {
await openCancelInviteDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: /^Cancel(ling…| invite)$/ })
.click()
await page.mouse.move(0, 0)
}
async function renewPaulHuberInvite(page: Page) {
await rowActionButton(page, 'p.huber@gmail.com', 'Renew invite').click()
await page.mouse.move(0, 0)
}
function inviteWith(email: string, response?: ApiMock) {
return async (page: Page) => {
await openInviteModal(page)
await page.getByPlaceholder('name@example.com').fill(email)
if (response) {
await page.getByRole('button', { name: 'Send invite', exact: true }).click()
await page.mouse.move(0, 0)
} else {
await page.getByPlaceholder('name@example.com').blur()
await page.mouse.move(0, 0)
}
}
}
const integrationOrigin = 'https://cms.renew.example'
async function clickBaseUrlCopy(page: Page) {
await page.getByRole('button', { name: 'Copy', 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,
role: 'owner',
},
}
}
function profileApi(
displayName = '',
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': profileSession(displayName),
...overrides,
}
}
function profileState(
displayName = '',
overrides: Record<string, ApiMock> = {},
steps?: (page: Page) => Promise<void>,
): VisualState {
return { path: '/profile', api: profileApi(displayName, overrides), steps }
}
async function typeDisplayName(page: Page, value: string) {
await page.getByPlaceholder(profileFieldPlaceholder).fill(value)
}
async function scrollToTop(page: Page) {
await page.evaluate(() => {
window.scrollTo(0, 0)
document.querySelectorAll<HTMLElement>('[data-slot="body"]').forEach((element) => {
element.scrollTop = 0
})
})
}
async function pressSave(page: Page) {
await page.getByRole('button', { name: 'Save', exact: true }).click()
}
async function saveDisplayName(page: Page, value: string) {
await typeDisplayName(page, value)
await pressSave(page)
await page.mouse.move(0, 0)
}
async function pressSendResetLink(page: Page) {
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
await page.mouse.move(0, 0)
await scrollToTop(page)
}
function resetState(response: ApiMock): VisualState {
return profileState(
profileDisplayName,
{ 'POST /v1/auth/password-reset': response },
pressSendResetLink,
)
}
async function openAccountMenu(page: Page) {
const isMobile = (page.viewportSize()?.width ?? 0) < 1024
if (isMobile) {
await page.locator('[data-slot="toggle"]').click()
await page.locator('[role="dialog"] button[aria-haspopup]').click()
await page.locator('[role="menu"]').waitFor()
} else {
await page.locator('[data-slot="footer"] button[aria-haspopup]').click()
await page.locator('[role="menu"]').waitFor()
}
await page.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<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': mediaSession,
'GET /v1/media/': {
status: 200,
bodyFor: (url) =>
url.searchParams.has('hash')
? mediaList(mediaItems(mediaDuplicateIds))
: mediaList(mediaItems()),
},
'GET /v1/media/1/usage': { status: 200, body: mediaNorthWallUsage },
'GET /v1/media/12/usage': { status: 200, body: mediaFloorboardsUsage },
...overrides,
}
}
function mediaApiAfterDelete(id: number, overrides: Record<string, ApiMock> = {}) {
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (_url, call) =>
call === 1
? mediaList(mediaItems())
: mediaList(mediaItems(undefined, [id]), { all: 11, unused: 1, duplicates: 2 }),
},
...overrides,
})
}
function mediaState(
api: Record<string, ApiMock>,
steps?: (page: Page) => Promise<void>,
path = '/media',
): VisualState {
return { path, origin: mediaOrigin, api, steps }
}
const mediaDuplicateListIds = [12, 5, 6, 7, 9]
const mediaMergedDuplicateIds = [7, 9]
const mediaMergedCounts = { all: 10, unused: 2, duplicates: 1 }
const mediaMegabyte = 1024 * 1024
const mediaHungHashLabel = 'attic'
const mediaProgressRatio = 0.423
const mediaRetryAfter = '42'
const mediaDuplicatesPath = '/media?filter=duplicates'
function mediaListApi(
lists: { first: unknown; later?: unknown },
overrides: Record<string, ApiMock> = {},
) {
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (_url, call) => (call === 1 ? lists.first : (lists.later ?? lists.first)),
},
...overrides,
})
}
function mediaDuplicatesApi(
later?: { ids: number[]; counts: typeof mediaFullCounts },
overrides: Record<string, ApiMock> = {},
) {
const duplicates = mediaList(mediaItems(mediaDuplicateListIds))
return mediaListApi(
{
first: duplicates,
later: later ? mediaList(mediaItems(later.ids), later.counts) : duplicates,
},
overrides,
)
}
function mediaBulkApi(
deleteResponses: Record<string, ApiMock>,
removed: number[],
counts: typeof mediaFullCounts,
) {
return mediaListApi(
{
first: mediaList(mediaItems()),
later: mediaList(mediaItems(undefined, removed), counts),
},
deleteResponses,
)
}
function mediaCheckbox(page: Page, filename: string) {
return page.getByRole('checkbox', { name: `Select ${filename}`, exact: true })
}
async function selectMediaCards(page: Page) {
for (const filename of ['north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg']) {
await mediaCheckbox(page, filename).click()
}
await page.getByTestId('media-bulk-bar').waitFor()
await scrollToTop(page)
await page.mouse.move(0, 0)
}
async function openBulkDeleteDialog(page: Page) {
await selectMediaCards(page)
await page.getByTestId('bulk-delete').click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function confirmBulkDelete(wait?: string) {
return async (page: Page) => {
await openBulkDeleteDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete 3 images', exact: true })
.click()
if (wait) {
await page.getByText(wait, { exact: true }).first().waitFor()
await scrollToTop(page)
}
await page.mouse.move(0, 0)
}
}
interface MediaUploadFile {
name: string
mimeType: string
size: number
label?: string
}
type MediaUploadBehavior =
| { kind: 'hold' }
| { kind: 'respond'; status: number; headers?: Record<string, string> }
function mediaUploadFile(name: string, mimeType: string, size: number, label?: string) {
return { name, mimeType, size: Math.round(size), label } satisfies MediaUploadFile
}
const mediaUploadFiles = {
kitchenRoof: mediaUploadFile('kitchen-roof.jpg', 'image/jpeg', 2.2 * mediaMegabyte),
porchSteps: mediaUploadFile('porch-steps.jpg', 'image/jpeg', 2.6 * mediaMegabyte),
floorboards: mediaUploadFile('floorboards-2.jpg', 'image/jpeg', 2.4 * mediaMegabyte),
scan: mediaUploadFile('scan.tif', 'image/tiff', 14.2 * mediaMegabyte),
roof: mediaUploadFile('roof.png', 'image/png', 1.4 * mediaMegabyte),
attic: mediaUploadFile('attic.jpg', 'image/jpeg', 1.9 * mediaMegabyte, mediaHungHashLabel),
notes: mediaUploadFile('notes.pdf', 'application/pdf', 210 * 1024),
broken: mediaUploadFile('broken.png', 'image/png', 3 * mediaMegabyte),
}
const uploadCreated: MediaUploadBehavior = { kind: 'respond', status: 201 }
const uploadServerError: MediaUploadBehavior = { kind: 'respond', status: 500 }
const uploadUnreadable: MediaUploadBehavior = { kind: 'respond', status: 400 }
function mediaUploadPayload(file: MediaUploadFile) {
const buffer = Buffer.alloc(file.size)
buffer.write(file.label ?? file.name)
return { name: file.name, mimeType: file.mimeType, buffer }
}
function mediaUploadApi() {
const duplicateHash = createHash('sha256')
.update(mediaUploadPayload(mediaUploadFiles.floorboards).buffer)
.digest('hex')
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: (url) => {
const hash = url.searchParams.get('hash')
if (hash === null) {
return mediaList(mediaItems())
}
return hash === duplicateHash ? mediaList(mediaItems([12, 5, 6])) : mediaList([])
},
},
})
}
async function stubMediaUploads(page: Page, behaviors: Record<string, MediaUploadBehavior>) {
await page.evaluate(
({ behaviors, ratio, hungLabel }) => {
class StubRequest extends EventTarget {
upload = new EventTarget()
status = 0
responseText = ''
withCredentials = false
responseHeaders: Record<string, string> = {}
open() {}
send(form: FormData) {
const file = form.get('file') as File
const behavior = behaviors[file.name]
Promise.resolve().then(() => {
this.upload.dispatchEvent(
Object.assign(new Event('progress'), {
loaded: Math.round(
file.size * (behavior?.kind === 'hold' ? ratio : 1),
),
total: file.size,
}),
)
if (behavior?.kind === 'respond') {
this.status = behavior.status
this.responseHeaders = behavior.headers ?? {}
this.dispatchEvent(new Event('load'))
}
})
}
abort() {
this.dispatchEvent(new Event('abort'))
}
getResponseHeader(name: string) {
return this.responseHeaders[name] ?? null
}
}
;(window as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest = StubRequest
const subtle = crypto.subtle
const digest = subtle.digest.bind(subtle)
subtle.digest = (algorithm, data) => {
const head = new TextDecoder().decode(
(data as ArrayBuffer).slice(0, hungLabel.length),
)
return head === hungLabel ? new Promise(() => {}) : digest(algorithm, data)
}
},
{ behaviors, ratio: mediaProgressRatio, hungLabel: mediaHungHashLabel },
)
}
async function chooseMediaFiles(page: Page, files: MediaUploadFile[]) {
await page.locator('input[type="file"]').setInputFiles(files.map(mediaUploadPayload))
}
function mediaUploadState(
files: MediaUploadFile[],
behaviors: Record<string, MediaUploadBehavior>,
settled: string,
): VisualState {
return mediaState(mediaUploadApi(), async (page) => {
await stubMediaUploads(page, behaviors)
await chooseMediaFiles(page, files)
await page.getByText(settled, { exact: true }).first().waitFor()
await page.mouse.move(0, 0)
})
}
async function dragFilesOverPage(page: Page) {
await page.evaluate(() => {
const event = new Event('dragenter', { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: { types: ['Files'], files: [] } })
document.body.dispatchEvent(event)
})
await page.getByTestId('media-drop-area').waitFor()
}
async function rateLimitAnUpload(page: Page) {
const frozen = new Date('2026-09-30T12:00:00Z')
await page.clock.install({ time: frozen })
await page.clock.pauseAt(new Date(frozen.getTime() + 1000))
await stubMediaUploads(page, {
[mediaUploadFiles.porchSteps.name]: {
kind: 'respond',
status: 429,
headers: { 'Retry-After': mediaRetryAfter },
},
})
await chooseMediaFiles(page, [mediaUploadFiles.porchSteps])
await page.getByText('Too many uploads', { exact: true }).first().waitFor()
await page.getByRole('button', { name: 'Cancel all', exact: true }).click()
await page.mouse.move(0, 0)
}
async function openMergeDialog(page: Page) {
await page.getByRole('button', { name: 'Merge into this one' }).first().click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function confirmMerge(wait?: string) {
return async (page: Page) => {
await openMergeDialog(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Merge 2 copies', exact: true })
.click()
if (wait) {
await page.getByText(wait, { exact: true }).first().waitFor()
}
await page.mouse.move(0, 0)
}
}
function mediaCard(page: Page, id: number) {
return page.locator(`[data-media-card][data-media-id="${id}"]`)
}
async function openMediaPanel(page: Page, id: number) {
await mediaCard(page, id).dispatchEvent('click')
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
async function openMediaDialog(page: Page, id: number, action: 'Rename' | 'Delete') {
await openMediaPanel(page, id)
await page.getByRole('dialog').getByRole('button', { name: action, exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
function mediaRenameWith(value: string, submit: boolean) {
return async (page: Page) => {
await openMediaDialog(page, 1, 'Rename')
const field = page.getByRole('alertdialog').locator('input')
await field.fill(value)
if (submit) {
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Rename', exact: true })
.click()
} else {
await field.focus()
}
await page.mouse.move(0, 0)
}
}
function mediaConfirmDelete(id: number) {
return async (page: Page) => {
await openMediaDialog(page, id, 'Delete')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete image', exact: true })
.click()
await page.mouse.move(0, 0)
}
}
async function goOffline(page: Page) {
await page.evaluate(() => {
Object.defineProperty(navigator, 'onLine', { get: () => false, configurable: true })
window.dispatchEvent(new Event('offline'))
})
await page.getByText("You're offline.").first().waitFor()
}
const mediaStates: Record<string, VisualState> = {
'Grid, many images': mediaState(mediaApi()),
'Grid, few images': mediaState(
mediaApi({
'GET /v1/media/': {
status: 200,
body: mediaList(mediaItems([1, 4, 8]), { all: 3, unused: 1, duplicates: 0 }),
},
}),
),
'List view': mediaState(mediaApi(), undefined, '/media?view=list'),
Loading: mediaState(mediaApi({ 'GET /v1/media/': { status: 200, hang: true } })),
'Empty library, first run': mediaState(
mediaApi({
'GET /v1/media/': {
status: 200,
body: mediaList([], { all: 0, unused: 0, duplicates: 0 }),
},
}),
),
'No results': mediaState(
mediaApi({ 'GET /v1/media/': { status: 200, body: mediaList([]) } }),
undefined,
'/media?q=wall&filter=unused',
),
'Hover, focus and keyboard': mediaState(mediaApi(), async (page) => {
await mediaCard(page, 3).focus()
await mediaCard(page, 2).hover()
}),
'Selection and bulk bar': mediaState(mediaApi(), selectMediaCards),
'Bulk delete: confirm': mediaState(mediaApi(), openBulkDeleteDialog),
'Bulk delete: in progress': mediaState(
mediaApi({
'DELETE /v1/media/1': { status: 204, hang: true },
'DELETE /v1/media/2': { status: 204, hang: true },
'DELETE /v1/media/8': { status: 204, hang: true },
}),
confirmBulkDelete(),
),
'Bulk delete: partly failed': mediaState(
mediaBulkApi(
{
'DELETE /v1/media/1': { status: 204 },
'DELETE /v1/media/2': { status: 204 },
'DELETE /v1/media/8': { status: 500 },
},
[1, 2],
{ all: 10, unused: 2, duplicates: 2 },
),
confirmBulkDelete("Couldn't delete 1 image"),
),
'Bulk delete: done': mediaState(
mediaBulkApi(
{
'DELETE /v1/media/1': { status: 204 },
'DELETE /v1/media/2': { status: 204 },
'DELETE /v1/media/8': { status: 204 },
},
[1, 2, 8],
{ all: 9, unused: 1, duplicates: 2 },
),
confirmBulkDelete('3 images deleted'),
),
'Detail panel: in use': mediaState(mediaApi(), (page) => openMediaPanel(page, 1)),
'Detail panel: unused': mediaState(mediaApi(), (page) => openMediaPanel(page, 8)),
'Detail panel: duplicate': mediaState(mediaApi(), (page) => openMediaPanel(page, 12)),
'Copy URL': mediaState(mediaApi(), async (page) => {
await openMediaPanel(page, 1)
await page
.getByRole('dialog')
.getByRole('button', { name: 'Copy URL', exact: true })
.click()
await page.getByText('URL copied', { exact: true }).first().waitFor()
await page.mouse.move(0, 0)
}),
'Rename: dialog': mediaState(mediaApi(), mediaRenameWith('north-wall', false)),
'Rename: saving': mediaState(
mediaApi({ 'PATCH /v1/media/1': { status: 200, hang: true } }),
mediaRenameWith('north-wall-01', true),
),
'Rename: validation': mediaState(mediaApi(), async (page) => {
await mediaRenameWith('!!!', false)(page)
await page.getByRole('alertdialog').locator('input').blur()
await page.mouse.move(0, 0)
}),
'Rename: failed': mediaState(
mediaApi({ 'PATCH /v1/media/1': { status: 500 } }),
mediaRenameWith('north-wall-01', true),
),
'Rename: done': mediaState(
mediaApi({
'PATCH /v1/media/1': {
status: 200,
body: mediaItem(mediaSamples[0]!, 'north-wall-01.jpg'),
},
}),
async (page) => {
await mediaRenameWith('north-wall-01', true)(page)
await page.getByText('Image renamed', { exact: true }).first().waitFor()
await page.mouse.move(0, 0)
},
),
'Delete: unused image': mediaState(mediaApi(), (page) => openMediaDialog(page, 8, 'Delete')),
'Delete: image in use': mediaState(mediaApi(), (page) => openMediaDialog(page, 1, 'Delete')),
'Delete: in progress': mediaState(
mediaApi({ 'DELETE /v1/media/1': { status: 204, hang: true } }),
mediaConfirmDelete(1),
),
'Delete: failed': mediaState(
mediaApi({ 'DELETE /v1/media/8': { status: 500 } }),
mediaConfirmDelete(8),
),
'Delete: done': mediaState(
mediaApiAfterDelete(8, { 'DELETE /v1/media/8': { status: 204 } }),
async (page) => {
await mediaConfirmDelete(8)(page)
await page.getByText('Image deleted', { exact: true }).first().waitFor()
await scrollToTop(page)
await page.mouse.move(0, 0)
},
),
'Image already gone (404)': mediaState(
mediaApiAfterDelete(8, { 'DELETE /v1/media/8': { status: 404 } }),
async (page) => {
await mediaConfirmDelete(8)(page)
await page.getByText('That image no longer exists', { exact: true }).first().waitFor()
await scrollToTop(page)
await page.mouse.move(0, 0)
},
),
'Upload: drag over the page': mediaState(mediaApi(), dragFilesOverPage),
'Upload: several files, mixed': mediaUploadState(
[
mediaUploadFiles.kitchenRoof,
mediaUploadFiles.porchSteps,
mediaUploadFiles.floorboards,
mediaUploadFiles.scan,
mediaUploadFiles.roof,
mediaUploadFiles.attic,
],
{
[mediaUploadFiles.kitchenRoof.name]: uploadCreated,
[mediaUploadFiles.porchSteps.name]: { kind: 'hold' },
[mediaUploadFiles.roof.name]: uploadServerError,
},
'Waiting',
),
'Upload: summary': mediaUploadState(
[
mediaUploadFiles.kitchenRoof,
mediaUploadFiles.porchSteps,
mediaUploadFiles.floorboards,
mediaUploadFiles.scan,
mediaUploadFiles.roof,
],
{
[mediaUploadFiles.kitchenRoof.name]: uploadCreated,
[mediaUploadFiles.porchSteps.name]: uploadCreated,
[mediaUploadFiles.roof.name]: uploadServerError,
},
'Upload finished',
),
'Upload: rejected files (413, 400)': mediaUploadState(
[mediaUploadFiles.scan, mediaUploadFiles.notes, mediaUploadFiles.broken],
{ [mediaUploadFiles.broken.name]: uploadUnreadable },
'Nothing was uploaded',
),
'Upload: too many requests (429)': mediaState(mediaUploadApi(), rateLimitAnUpload),
'Duplicates: groups': mediaState(mediaDuplicatesApi(), undefined, mediaDuplicatesPath),
'Duplicates: empty': mediaState(
mediaApi({
'GET /v1/media/': {
status: 200,
body: mediaList([], { all: 12, unused: 2, duplicates: 0 }),
},
}),
undefined,
mediaDuplicatesPath,
),
'Merge: confirm': mediaState(mediaDuplicatesApi(), openMergeDialog, mediaDuplicatesPath),
'Merge: in progress': mediaState(
mediaDuplicatesApi(undefined, { 'POST /v1/media/merge': { status: 200, hang: true } }),
confirmMerge(),
mediaDuplicatesPath,
),
'Merge: failed': mediaState(
mediaDuplicatesApi(undefined, { 'POST /v1/media/merge': { status: 500 } }),
confirmMerge("Couldn't merge the copies. Nothing was changed. Try again in a moment."),
mediaDuplicatesPath,
),
'Merge: conflict (409)': mediaState(
mediaDuplicatesApi(undefined, { 'POST /v1/media/merge': { status: 409 } }),
confirmMerge(
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.',
),
mediaDuplicatesPath,
),
'Merge: done': mediaState(
mediaDuplicatesApi(
{ ids: mediaMergedDuplicateIds, counts: mediaMergedCounts },
{ 'POST /v1/media/merge': { status: 200, body: { articles_updated: 5, merged: 2 } } },
),
confirmMerge('Copies merged'),
mediaDuplicatesPath,
),
'List failed to load': mediaState(mediaApi({ 'GET /v1/media/': { status: 500 } })),
Offline: mediaState(mediaApi(), goOffline),
}
const pickerIntro =
'A bathroom is priced by what has to happen behind the walls before anything gets tiled.'
const pickerOutro = 'We don’t add a margin on materials you choose yourself.'
const invisibleImage = '/static/uploadedImages/blank.svg'
const invisibleImageSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'
const boardImageSvgFor = (width: number, height: number) =>
[
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 672 378" preserveAspectRatio="xMidYMid slice">`,
'<style>.tone{fill:#e8dfd1}.ink{color:#7d6d56}@media (prefers-color-scheme:dark){.tone{fill:#d4c5b0}}</style>',
'<defs><pattern id="hatch" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">',
'<rect width="1" height="12" fill="rgb(128 128 128 / 0.12)"/></pattern></defs>',
'<rect class="tone" width="672" height="378"/>',
'<rect fill="url(#hatch)" width="672" height="378"/>',
'<g class="ink" opacity="0.55" transform="translate(316 160) scale(1.6667)" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">',
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></g>',
'</svg>',
].join('')
const boardImageMaxWidth = 672
const boardImageSideSpace = 32
function boardImageFor(page: Page) {
const viewport = page.viewportSize()
const width = Math.min(
boardImageMaxWidth,
(viewport?.width ?? boardImageMaxWidth) - boardImageSideSpace,
)
return boardImageSvgFor(width, Math.round((width * 9) / 16))
}
const boardImage = '/static/uploadedImages/board-image.svg'
function pickerBody(image: string | null, ids: number[] = []) {
const nodes = ids.map(
(id) => `<img src="${image ?? invisibleImage}" alt="" data-image-id="${id}">`,
)
return `<p>${pickerIntro}</p>${nodes.join('')}<p>${pickerOutro}</p>`
}
const pickerCounts = { all: 48, unused: 2, duplicates: 2 }
const pickerFirstPageCursor = 'c24'
const pickerSearchTerm = 'wall'
function pickerList(url: URL) {
if (url.searchParams.has('hash')) {
return mediaList(mediaItems([1]), pickerCounts)
}
if (url.searchParams.has('q')) {
return { items: [], next_cursor: null, counts: pickerCounts }
}
return { items: mediaItems(), next_cursor: pickerFirstPageCursor, counts: pickerCounts }
}
function pickerApi(
overrides: Record<string, ApiMock> = {},
body = pickerBody(null, [1, 4]),
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': mediaSession,
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
'GET /v1/media/': { status: 200, bodyFor: pickerList },
...overrides,
}
}
function pickerState(
steps?: (page: Page) => Promise<void>,
api: Record<string, ApiMock> = pickerApi(),
): VisualState {
return {
path: '/article/12/edit',
api,
steps: async (page) => {
await stubEditorImages(page)
await steps?.(page)
},
}
}
async function openPickerTab(page: Page, name: 'Upload' | 'Library') {
await openImageUploader(page)
await page.getByRole('tab', { name, exact: true }).click()
}
async function waitForPickerTiles(page: Page) {
await page.locator('[data-picker-tile]').first().waitFor()
}
async function selectPickerTile(page: Page, filename: string) {
await waitForPickerTiles(page)
await page.locator(`[data-picker-tile][aria-label^="${filename},"]`).click()
await page.getByTestId('picker-panel').waitFor()
}
async function insertFromLibrary(page: Page, filename: string) {
await openImageUploader(page)
await selectPickerTile(page, filename)
await page.getByRole('button', { name: 'Insert image', exact: true }).click()
await page.getByText('Save to keep it in the article.').first().waitFor()
}
async function selectEditorImage(page: Page) {
await page.locator('[data-testid="article-editor-image"] img').first().click()
await page.getByRole('button', { name: 'Remove image', exact: true }).waitFor()
}
async function stubEditorImages(page: Page) {
await page.route('**/static/uploadedImages/blank.svg', (route) =>
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: invisibleImageSvg }),
)
await page.route('**/static/uploadedImages/board-image.svg', (route) =>
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: boardImageFor(page) }),
)
await page.reload()
await page.waitForFunction(
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
)
}
async function stubBoardImage(page: Page) {
await page.route('**/media/north-wall.jpg', (route) =>
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: boardImageFor(page) }),
)
}
async function browseLibraryThenClose(page: Page) {
await openImageUploader(page)
await waitForPickerTiles(page)
await page.keyboard.press('Escape')
await page.getByRole('dialog').waitFor({ state: 'hidden' })
}
async function requestRemoveFromEditor(page: Page) {
await browseLibraryThenClose(page)
await selectEditorImage(page)
await page.getByRole('button', { name: 'Remove image', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
}
const simplifiedEditorReason =
'The board draws a simplified editor (centred 680 px column, no toolbar, no details panel, fixed image placeholder) while the real editor is left-aligned with a toolbar, a taller header and a details panel, so the image block and page chrome sit elsewhere'
const simplifiedEditorMaxPercent = 40
function withSimplifiedEditorException(state: VisualState): VisualState {
return {
...state,
exception: {
maxPercent: simplifiedEditorMaxPercent,
reason: simplifiedEditorReason,
approvedBy: 'user',
},
}
}
const pickerStates: Record<string, VisualState> = {
'Upload tab': pickerState(async (page) => {
await openPickerTab(page, 'Upload')
}),
'Upload tab: already in your library': {
...pickerState(async (page) => {
await openPickerTab(page, 'Upload')
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
await page.getByText('Already in your library').waitFor()
}),
exception: {
maxPercent: 20,
reason: 'The board buttons collapse to about 22 px on mobile (flex:1 in a column) while the feature requires 44 px full-width stacked buttons, which makes the modal 31 px taller',
approvedBy: 'user',
},
},
'Library: loading': pickerState(
async (page) => {
await openImageUploader(page)
await page.getByTestId('picker-skeleton-tile').first().waitFor()
},
pickerApi({ 'GET /v1/media/': { status: 200, hang: true } }),
),
'Library: nothing selected': pickerState(async (page) => {
await openImageUploader(page)
await waitForPickerTiles(page)
}),
'Library: selected': pickerState(async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'kitchen-after.webp')
}),
'Library: alt text entered': pickerState(async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'kitchen-after.webp')
await page
.getByPlaceholder('e.g. North wall of the kitchen before plastering')
.fill('A kitchen with new oak worktops and white tiles')
}),
'Library: loading more': pickerState(async (page) => {
await openImageUploader(page)
await waitForPickerTiles(page)
await page.route(/\/v1\/media\/\?.*cursor=/, () => undefined)
await page.getByRole('button', { name: 'Load more', exact: true }).click()
await page.getByTestId('picker-skeleton-tile').first().waitFor()
}),
'Library: empty': pickerState(
async (page) => {
await openPickerTab(page, 'Library')
await page.getByText('Your library is empty').waitFor()
},
pickerApi({ 'GET /v1/media/': { status: 200, body: emptyLibrary } }),
),
'Library: no results': pickerState(async (page) => {
await openImageUploader(page)
await waitForPickerTiles(page)
await page.getByLabel('Search by filename').fill(pickerSearchTerm)
await page.getByText(`No images match "${pickerSearchTerm}"`).waitFor()
}),
'Library: failed to load': pickerState(
async (page) => {
await openImageUploader(page)
await page.getByText("Couldn't load your library").waitFor()
},
pickerApi({ 'GET /v1/media/': { status: 500 } }),
),
Inserted: withSimplifiedEditorException(
pickerState(
async (page) => {
await stubBoardImage(page)
await page.getByText('behind the walls').click()
await page.keyboard.press('End')
await insertFromLibrary(page, 'north-wall.jpg')
await page.waitForTimeout(400)
await selectEditorImage(page)
},
pickerApi({}, pickerBody(null)),
),
),
'Remove image: confirm': withSimplifiedEditorException(
pickerState(requestRemoveFromEditor, pickerApi({}, pickerBody(boardImage, [1]))),
),
Removed: pickerState(
async (page) => {
await requestRemoveFromEditor(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Remove image', exact: true })
.click()
await page.getByText("It's still in your library.").first().waitFor()
},
pickerApi({}, pickerBody(boardImage, [1])),
),
}
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 },
},
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: {
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
Checking: {
path: '/settings',
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
},
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
'Update available': { path: '/settings', skip: 'clock-sensitive' },
'Update available, notes open': { path: '/settings', skip: 'clock-sensitive' },
Downloading: { path: '/settings', api: updateApi('owner', updateDownloading()) },
Verifying: { path: '/settings', api: updateApi('owner', updateJob('verifying')) },
'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) },
'Restart: confirm': {
path: '/settings',
api: restartApi(updateJob('ready'), { status: 500 }),
steps: openRestartDialog,
},
'Restart: request failed': {
path: '/settings',
api: restartApi(updateJob('ready'), { status: 500 }),
steps: async (page) => {
await confirmRestart('Restart now')(page)
await page.getByText("Couldn't start the restart").waitFor()
await page.mouse.move(0, 0)
},
},
'Restarting to install': {
path: '/settings',
api: restartApi(updateJob('ready'), restartAccepted(updateJob('ready'))),
steps: async (page) => {
await confirmRestart('Restart now')(page)
await page.getByText('Installing version 2.3.0').waitFor()
},
},
'Restarting, taking longer': { path: '/settings', skip: 'clock-sensitive' },
'Update done': { path: '/settings', skip: 'clock-sensitive' },
'Update failed, rolled back': {
path: '/settings',
api: updateApi(
'owner',
updateJob('failed', {
error: 'rolled_back',
started_at: restartStartedAt,
finished_at: restartFinishedAt,
}),
),
steps: rememberRestartAndReload,
},
'Download failed': {
path: '/settings',
api: updateApi('owner', updateJob('failed', { error: 'download' })),
},
'Verification failed': {
path: '/settings',
api: updateApi('owner', updateJob('failed', { error: 'verification' })),
},
'Too many requests (429)': {
path: '/settings',
api: updateApi('owner', updateNeverChecked(), {
status: 429,
headers: { 'Retry-After': '42' },
}),
},
'Not prepared': {
path: '/settings',
api: updateApi('owner', updateAvailable()),
},
'Not prepared: command copied': {
path: '/settings',
origin: integrationOrigin,
api: updateApi('owner', updateAvailable()),
steps: async (page) => {
await page.getByRole('button', { name: 'Copy', exact: true }).click()
await page.mouse.move(0, 0)
},
},
'Plain restart: confirm': {
path: '/settings',
api: restartApi(updateStatusBody({ can_apply: true }), { status: 500 }),
steps: openRestartDialog,
},
'Plain restart: restarting': {
path: '/settings',
api: restartApi(
updateStatusBody({ can_apply: true }),
restartAccepted(updateStatusBody({ can_apply: true })),
),
steps: async (page) => {
await confirmRestart('Restart site')(page)
await page.getByText('Restarting your site').waitFor()
},
},
'Plain restart: done': { path: '/settings', skip: 'clock-sensitive' },
'Admin view': {
path: '/settings',
api: updateApi('admin', updateAvailable()),
},
'Role changed (403)': {
path: '/settings',
api: {
...updateApi('owner', updateNeverChecked(), { status: 403 }),
'GET /v1/auth/me': {
status: 200,
bodyFor: (_url, call) => ({
...(verifiedSession.body as object),
role: call === 1 ? 'owner' : 'admin',
}),
},
'GET /v1/system/update': {
status: 200,
bodyFor: (_url, call) =>
call === 1 ? updateNeverChecked() : updateAvailable(),
},
},
},
'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' },
},
integration: {
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/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
},
},
Loading: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
},
},
'Empty, first run': {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: [] },
},
},
Error: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 500 },
},
},
'Update available (owner)': {
path: '/home',
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateAvailable() },
},
},
'Update ready to install (owner)': {
path: '/home',
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateJob('ready') },
},
},
},
'log-in': {
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
'Fresh install': {
path: '/login',
api: { 'GET /v1/auth/registration': registrationOpen },
exception: {
maxPercent: 1.1,
reason: 'The board draws a design-time "API pending" chip in the footer; the API exists, so the app omits it and the theme select fits on the first row on mobile',
approvedBy: 'Florian Sylvain, 2026-09-26',
},
},
'Validation errors': {
...loginWith({ status: 500 }),
steps: submitLogin('ma', ''),
},
Submitting: {
...loginWith({ status: 200, hang: true }),
steps: submitLogin('marta', 'limewash2026'),
},
'Wrong credentials (403)': {
...loginWith({ status: 403, body: 'forbidden' }),
steps: submitLogin('marta', 'limewash2026'),
},
'Too many attempts (429)': {
...loginWith({ status: 429, headers: { 'Retry-After': '42' } }),
steps: submitLogin('marta', 'limewash'),
},
'Session expired': {
...loginWith({ status: 500 }, '/login?redirect=/article/12/edit&reason=expired'),
steps: async (page) => {
await page.getByPlaceholder('Your username').fill('marta')
await page.locator('input[autocomplete="current-password"]').focus()
},
},
'Password changed': {
path: '/login?reason=password-changed',
api: { 'GET /v1/auth/registration': registrationClosed },
},
'Server or network error': {
...loginWith({ status: 500 }),
steps: submitLogin('marta', 'limewash2026'),
},
},
register: {
Default: { path: '/register' },
'Validation errors': {
path: '/register',
steps: async (page) => {
await fillRegister(page, 'ma', 'marta@renew', 'lime')
},
},
'Filled, valid': {
path: '/register',
steps: async (page) => {
await fillRegister(page, 'marta', 'marta@renew.example', 'limewash2026')
await page.getByRole('button', { name: 'Show password' }).click()
},
},
Submitting: {
...registerWith({ status: 200, hang: true }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
'Registration closed (403)': {
...registerWith({ status: 403 }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
'Server error': {
...registerWith({ status: 500 }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
},
pending: {
Default: {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
},
},
'Email unknown': {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
},
'Confirm cancel': {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
},
},
Cancelling: {
path: '/register/pending',
api: {
'GET /v1/auth/me': { status: 200, body: { is_verified: false } },
'DELETE /v1/auth/register': { status: 200, hang: true },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
},
},
Cancelled: {
path: '/register/pending',
api: {
'GET /v1/auth/me': { status: 200, body: { is_verified: false } },
'DELETE /v1/auth/register': { status: 200, body: { message: 'ok' } },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
await page.waitForURL('**/register')
},
},
'Already verified (403)': {
path: '/register/pending',
paused: true,
api: {
'DELETE /v1/auth/register': { status: 403 },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
await page.getByText("You're verified").waitFor()
},
},
},
editor: {
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/media/': {
status: 201,
body: {
id: 13,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/north-wall.jpg',
size: mib(3.9),
content_type: 'image/jpeg',
width: 4032,
height: 3024,
sha256: 'a'.repeat(64),
uploaded_by: 'Marta Alves',
created_at: editorNow,
used_in: 0,
duplicate: false,
},
},
}),
steps: async (page) => {
await mockUploadedImage(page)
await page.getByText('add a margin on materials').click()
await page.keyboard.press('Home')
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
await page.getByRole('dialog').getByRole('button', { name: 'Upload image' }).click()
await page.getByText('Save to keep it in the article.').first().waitFor()
await page.waitForTimeout(650)
},
},
},
'new-article': {
Default: {
path: '/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,
'media-picker': pickerStates,
}