feat(SPA): media page T0

This commit is contained in:
fsylvain committed 2026-09-30 19:45:47 +02:00
1 parent 0d99544579
commit 67d456baf6
32 files changed
+4874 -40

No files matched your search

+29 -9
View File
@@ -82,6 +82,12 @@ function parseArgs(argv: string[]) {
return { screen, state: stateWords.join(' ') || undefined, viewports, themes }
}
function tableStates(markdown: string) {
return [...markdown.matchAll(/^\| ([^|]+?) \|/gm)]
.map((match) => match[1]!.trim())
.filter((cell) => cell !== 'State' && !/^-+$/.test(cell))
}
async function readScreen(name: string): Promise<Screen> {
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const reference = /\*\*Visual reference:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown)
@@ -90,7 +96,10 @@ async function readScreen(name: string): Promise<Screen> {
}
const statesStart = markdown.search(/^## States$/m)
const statesMarkdown = statesStart === -1 ? markdown : markdown.slice(statesStart)
const states = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) => match[1]!.trim())
const headingStates = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) =>
match[1]!.trim(),
)
const states = headingStates.length ? headingStates : tableStates(statesMarkdown)
return { name, board: reference[1]!, group: reference[2]!, states }
}
@@ -296,14 +305,14 @@ async function captureDesign(
await frame.screenshot({ path, animations: 'disabled' })
}
function fulfilment(mock: ApiMock) {
const text = typeof mock.body === 'string'
function fulfilment(mock: ApiMock, url: URL, call: number) {
const body = mock.bodyFor ? mock.bodyFor(url, call) : mock.body
const text = typeof body === 'string'
return {
status: mock.status,
headers: mock.headers,
contentType: text ? 'text/plain; charset=utf-8' : 'application/json',
body:
mock.body === undefined ? '' : text ? (mock.body as string) : JSON.stringify(mock.body),
body: body === undefined ? '' : text ? (body as string) : JSON.stringify(body),
}
}
@@ -324,9 +333,13 @@ async function captureApp(
})
await serveIconsLocally(context)
const unmocked = new Set<string>()
const calls = new Map<string, number>()
await context.route('**/v1/**', async (route) => {
const request = route.request()
const key = `${request.method()} ${new URL(request.url()).pathname}`
const url = new URL(request.url())
const key = `${request.method()} ${url.pathname}`
const call = (calls.get(key) ?? 0) + 1
calls.set(key, call)
const mock = setup.api?.[key]
if (!mock) {
unmocked.add(key)
@@ -336,7 +349,7 @@ async function captureApp(
if (mock.hang) {
return
}
await route.fulfill(fulfilment(mock))
await route.fulfill(fulfilment(mock, url, call))
})
if (setup.origin) {
await serveDistLocally(context, setup.origin)
@@ -440,6 +453,7 @@ async function main() {
const lines: string[] = []
let failures = 0
let skipped = 0
let pending = 0
try {
for (const viewport of args.viewports) {
@@ -447,6 +461,11 @@ async function main() {
const board = await openBoard(browser, design.origin, screen, theme)
for (const state of states) {
const skip = setups[state]?.skip
if (skip === 'pending') {
pending++
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
continue
}
if (skip) {
skipped++
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
@@ -508,8 +527,9 @@ async function main() {
design.close()
}
const checked = lines.length - skipped
const skippedNote = skipped ? `, ${skipped} skipped` : ''
const checked = lines.length - skipped - pending
const skippedNote =
(skipped ? `, ${skipped} skipped` : '') + (pending ? `, ${pending} pending` : '')
const verdict = failures
? `FAIL: ${failures} of ${checked} captures failed (budget ${maxDiffPercent}% per capture${skippedNote})`
: `PASS: all ${checked} captures within budget (${maxDiffPercent}% unless an approved exception is listed${skippedNote})`
+430 -1
View File
@@ -3,6 +3,7 @@ import type { Page } from 'playwright'
export interface ApiMock {
status: number
body?: unknown
bodyFor?: (url: URL, call: number) => unknown
headers?: Record<string, string>
hang?: boolean
}
@@ -15,7 +16,7 @@ export interface DiffException {
export interface VisualState {
exception?: DiffException
skip?: 'clock-sensitive'
skip?: 'clock-sensitive' | 'pending'
path: string
origin?: string
now?: string
@@ -805,6 +806,433 @@ const usersWithDisplayNames = usersFixture.map((item) =>
: 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]
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: `${sample.tone}${String(sample.id).padStart(2, '0').repeat(31)}a`,
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 mediaPending: VisualState = { path: '/media', skip: 'pending' }
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': mediaPending,
'Bulk delete: confirm': mediaPending,
'Bulk delete: in progress': mediaPending,
'Bulk delete: partly failed': mediaPending,
'Bulk delete: done': mediaPending,
'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': mediaPending,
'Upload: several files, mixed': mediaPending,
'Upload: summary': mediaPending,
'Upload: rejected files (413, 400)': mediaPending,
'Upload: too many requests (429)': mediaPending,
'Duplicates: groups': mediaPending,
'Duplicates: empty': mediaPending,
'Merge: confirm': mediaPending,
'Merge: in progress': mediaPending,
'Merge: failed': mediaPending,
'Merge: conflict (409)': mediaPending,
'Merge: done': mediaPending,
'List failed to load': mediaState(mediaApi({ 'GET /v1/media/': { status: 500 } })),
Offline: mediaState(mediaApi(), goOffline),
}
export const visualStates: Record<string, Record<string, VisualState>> = {
profile: {
'Account menu': {
@@ -1735,4 +2163,5 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitAcceptInvite('lea', 'limewash2026'),
},
},
media: mediaStates,
}