feat: media page T1

This commit is contained in:
fsylvain committed 2026-10-01 01:36:35 +02:00
1 parent 67d456baf6
commit 008b0dfbb9
46 files changed
+5742 -204

No files matched your search

+369 -19
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto'
import type { Page } from 'playwright'
export interface ApiMock {
@@ -996,6 +997,13 @@ const mediaFloorboardsUsage = [
{ 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 {
@@ -1006,7 +1014,7 @@ function mediaItem(sample: MediaSample, filename = sample.filename) {
content_type: sample.contentType,
width: sample.width,
height: sample.height,
sha256: `${sample.tone}${String(sample.id).padStart(2, '0').repeat(31)}a`,
sha256: mediaHashFor(sample),
uploaded_by: sample.uploadedBy,
created_at: sample.createdAt,
used_in: sample.usedIn,
@@ -1061,7 +1069,257 @@ function mediaState(
return { path, origin: mediaOrigin, api, steps }
}
const mediaPending: VisualState = { path: '/media', skip: 'pending' }
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}"]`)
@@ -1145,11 +1403,40 @@ const mediaStates: Record<string, VisualState> = {
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,
'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)),
@@ -1217,18 +1504,81 @@ const mediaStates: Record<string, VisualState> = {
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,
'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),
}