515 lines
14 KiB
TypeScript
515 lines
14 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' },
|
||
}
|
||
|
||
const editorNow = '2026-09-26T11:02:00Z'
|
||
|
||
const bathroomBody =
|
||
"<p>Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>" +
|
||
'<h2>What goes into the price</h2>' +
|
||
'<ul><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>' +
|
||
'<p>We don’t add a margin on materials you choose yourself.</p>'
|
||
|
||
const editorArticle = {
|
||
id: 12,
|
||
title: 'How we price a bathroom renovation',
|
||
body: bathroomBody,
|
||
images: [
|
||
{ id: 1, url: '/images/1.png' },
|
||
{ id: 2, url: '/images/2.png' },
|
||
{ id: 3, url: '/images/3.png' },
|
||
],
|
||
is_online: false,
|
||
created_at: '2026-03-12T09:14:00Z',
|
||
updated_at: editorNow,
|
||
}
|
||
|
||
function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||
return {
|
||
'GET /v1/auth/me': verifiedSession,
|
||
'GET /v1/article/12': { status: 200, body: editorArticle },
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
async function focusBoldSample(page: Page) {
|
||
await page.getByText('write down what stays').click()
|
||
}
|
||
|
||
async function markDirty(page: Page) {
|
||
await page.getByText('choose yourself').click()
|
||
await page.keyboard.press('End')
|
||
await page.keyboard.type('x')
|
||
await page.keyboard.press('Backspace')
|
||
}
|
||
|
||
async function typeExactBodyLength(page: Page, totalLength: number) {
|
||
const editable = page.locator('[contenteditable="true"]')
|
||
await editable.click()
|
||
await page.keyboard.press('Control+a')
|
||
await page.keyboard.press('Delete')
|
||
const inner = 'a'.repeat(Math.max(0, totalLength - 7))
|
||
await page.keyboard.insertText(inner)
|
||
}
|
||
|
||
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()
|
||
},
|
||
},
|
||
},
|
||
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 },
|
||
},
|
||
},
|
||
},
|
||
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',
|
||
},
|
||
},
|
||
}
|