Files
RenewCMS/web/visual/states.ts
T

352 lines
9.4 KiB
TypeScript

import type { Page } from 'playwright'
export interface ApiMock {
status: number
body?: unknown
headers?: Record<string, string>
hang?: boolean
}
export interface DiffException {
maxPercent: number
reason: string
approvedBy: string
}
export interface VisualState {
exception?: DiffException
path: 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: { is_verified: true, username: 'marta' },
}
export const visualStates: Record<string, Record<string, VisualState>> = {
home: {
Default: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
},
},
Loading: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
},
},
'Empty, first run': {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: [] },
},
},
Error: {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 500 },
},
},
},
'log-in': {
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
'Fresh install': {
path: '/login',
api: { 'GET /v1/auth/registration': registrationOpen },
exception: {
maxPercent: 1.1,
reason: 'The board draws a design-time "API pending" chip in the footer; the API exists, so the app omits it and the theme select fits on the first row on mobile',
approvedBy: 'Florian Sylvain, 2026-09-26',
},
},
'Validation errors': {
...loginWith({ status: 500 }),
steps: submitLogin('ma', ''),
},
Submitting: {
...loginWith({ status: 200, hang: true }),
steps: submitLogin('marta', 'limewash2026'),
},
'Wrong credentials (403)': {
...loginWith({ status: 403, body: 'forbidden' }),
steps: submitLogin('marta', 'limewash2026'),
},
'Too many attempts (429)': {
...loginWith({ status: 429, headers: { 'Retry-After': '42' } }),
steps: submitLogin('marta', 'limewash'),
},
'Session expired': {
...loginWith({ status: 500 }, '/login?redirect=/article/12/edit&reason=expired'),
steps: async (page) => {
await page.getByPlaceholder('Your username').fill('marta')
await page.locator('input[autocomplete="current-password"]').focus()
},
},
'Password changed': {
path: '/login?reason=password-changed',
api: { 'GET /v1/auth/registration': registrationClosed },
},
'Server or network error': {
...loginWith({ status: 500 }),
steps: submitLogin('marta', 'limewash2026'),
},
},
register: {
Default: { path: '/register' },
'Validation errors': {
path: '/register',
steps: async (page) => {
await fillRegister(page, 'ma', 'marta@renew', 'lime')
},
},
'Filled, valid': {
path: '/register',
steps: async (page) => {
await fillRegister(page, 'marta', 'marta@renew.example', 'limewash2026')
await page.getByRole('button', { name: 'Show password' }).click()
},
},
Submitting: {
...registerWith({ status: 200, hang: true }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
'Registration closed (403)': {
...registerWith({ status: 403 }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
'Server error': {
...registerWith({ status: 500 }),
steps: submitRegister('marta', 'marta@renew.example', 'limewash2026'),
},
},
pending: {
Default: {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
},
},
'Email unknown': {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
},
'Confirm cancel': {
path: '/register/pending',
api: { 'GET /v1/auth/me': { status: 200, body: { is_verified: false } } },
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
},
},
Cancelling: {
path: '/register/pending',
api: {
'GET /v1/auth/me': { status: 200, body: { is_verified: false } },
'DELETE /v1/auth/register': { status: 200, hang: true },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
},
},
Cancelled: {
path: '/register/pending',
api: {
'GET /v1/auth/me': { status: 200, body: { is_verified: false } },
'DELETE /v1/auth/register': { status: 200, body: { message: 'ok' } },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
await page.waitForURL('**/register')
},
},
'Already verified (403)': {
path: '/register/pending',
paused: true,
api: {
'DELETE /v1/auth/register': { status: 403 },
'GET /v1/auth/me': { status: 200, body: { is_verified: true } },
},
steps: async (page) => {
await setPendingEmail(page, 'marta@renew.example')
await openCancelDialog(page)
await confirmCancelDialog(page)
await page.getByText("You're verified").waitFor()
},
},
},
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',
},
},
}