1651 lines
47 KiB
TypeScript
1651 lines
47 KiB
TypeScript
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'),
|
||
},
|
||
},
|
||
}
|