2916 lines
85 KiB
TypeScript
2916 lines
85 KiB
TypeScript
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,
|
||
...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 updateCheckHangs: ApiMock = { status: 200, hang: true }
|
||
|
||
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: 'pending' },
|
||
'Update available, notes open': { path: '/settings', skip: 'pending' },
|
||
Downloading: { path: '/settings', skip: 'pending' },
|
||
Verifying: { path: '/settings', skip: 'pending' },
|
||
'Ready to install': { path: '/settings', skip: 'pending' },
|
||
'Restart: confirm': { path: '/settings', skip: 'pending' },
|
||
'Restart: request failed': { path: '/settings', skip: 'pending' },
|
||
'Restarting to install': { path: '/settings', skip: 'pending' },
|
||
'Restarting, taking longer': { path: '/settings', skip: 'pending' },
|
||
'Update done': { path: '/settings', skip: 'pending' },
|
||
'Update failed, rolled back': { path: '/settings', skip: 'pending' },
|
||
'Download failed': { path: '/settings', skip: 'pending' },
|
||
'Verification failed': { path: '/settings', skip: 'pending' },
|
||
'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', skip: 'pending' },
|
||
'Plain restart: restarting': { path: '/settings', skip: 'pending' },
|
||
'Plain restart: done': { path: '/settings', skip: 'pending' },
|
||
'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: 'pending' },
|
||
},
|
||
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', skip: 'pending' },
|
||
},
|
||
'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,
|
||
}
|