feat: media page T1
This commit is contained in:
1 parent
67d456baf6
commit
008b0dfbb9
46 files changed
+5742
-204
No files matched your search
+369
-19
@@ -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),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user