import { vi } from 'vitest' import { defineComponent, h } from 'vue' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import UApp from '@nuxt/ui/components/App.vue' import router from '@/router' import { ApiError, useApi } from '@/composables/useApi' import type { MediaItem } from '@/composables/useMedia' import { useSessionStore } from '@/stores/session' import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import MediaView from '../MediaView.vue' type Row = [ number, string, number, number | null, number | null, string, number, string, string, boolean, string, number, number, ] const ROWS: Row[] = [ [ 1, 'north-wall.jpg', 4089446, 4032, 3024, 'image/jpeg', 2, 'Marta Alves', '2026-09-12T12:00:00Z', false, '0a', 4, 266240, ], [ 2, 'kitchen-after.webp', 1258291, 2400, 1600, 'image/webp', 3, 'Inès Duarte', '2026-09-08T12:00:00Z', false, '1b', 5, 419840, ], [ 3, 'kitchen-before.webp', 1153434, 2400, 1600, 'image/webp', 1, 'Inès Duarte', '2026-09-08T11:00:00Z', false, '2c', 5, 404480, ], [ 4, 'logo-renew.svg', 8192, null, null, 'image/svg+xml', 5, 'jonas', '2026-09-05T12:00:00Z', false, '3d', 0, 0, ], [ 5, 'floorboards-copy.jpg', 2516582, 3600, 2400, 'image/jpeg', 3, 'Marta Alves', '2026-09-02T12:00:00Z', true, '0e', 3, 245760, ], [ 6, 'IMG_2208.jpg', 2516582, 3600, 2400, 'image/jpeg', 2, 'jonas', '2026-08-30T12:00:00Z', true, '0e', 0, 0, ], [ 7, 'IMG_4471.jpg', 3250585, 3600, 2400, 'image/jpeg', 2, 'Marta Alves', '2026-08-29T12:00:00Z', true, '2f', 2, 153600, ], [ 8, 'window-frame.jpg', 1887437, 3000, 2000, 'image/jpeg', 0, 'Inès Duarte', '2026-08-28T12:00:00Z', false, '3a', 0, 0, ], [ 9, 'stone-wall-detail.jpg', 3250585, 3600, 2400, 'image/jpeg', 1, 'jonas', '2026-08-21T12:00:00Z', true, '2f', 1, 61440, ], [ 10, 'bathroom-tiles.png', 880640, 1800, 1200, 'image/png', 0, 'Marta Alves', '2026-08-04T12:00:00Z', false, '1b', 3, 194560, ], [ 11, 'lime-plaster.jpg', 2202009, 3200, 2133, 'image/jpeg', 2, 'Inès Duarte', '2026-07-19T12:00:00Z', false, '1c', 4, 286720, ], [ 12, 'floorboards.jpg', 2516582, 3600, 2400, 'image/jpeg', 4, 'Inès Duarte', '2026-06-14T12:00:00Z', true, '0e', 5, 440320, ], ] export const FULL_COUNTS = { all: 12, unused: 2, duplicates: 2 } export const MEGABYTE = 1024 * 1024 export function fixture(ids?: number[]): MediaItem[] { return ROWS.filter(([id]) => !ids || ids.includes(id)).map( ([ id, filename, size, width, height, content_type, used_in, uploaded_by, created_at, duplicate, sha256, variant_count, variant_size, ]) => ({ id, filename, url: `/media/${filename}`, size, content_type, width, height, sha256, uploaded_by, created_at, used_in, duplicate, variant_count, variant_size, }), ) } export function listBody( items: MediaItem[], counts = FULL_COUNTS, paging: { total?: number; page?: number; size?: number } = {}, ) { return { items, total: paging.total ?? items.length, page: paging.page ?? 1, size: paging.size ?? 25, counts, } } export function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void const promise = new Promise((res, rej) => { resolve = res reject = rej }) return { promise, resolve, reject } } export interface Handlers { list?: (path: string) => unknown usage?: (id: number) => unknown hash?: (path: string) => unknown remove?: (id: number) => unknown merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown preview?: () => unknown purge?: (body: { fingerprint: string }) => unknown variants?: (id: number) => unknown generateAll?: (id: number) => unknown generate?: (id: number, name: string) => unknown purgeVariant?: (id: number, name: string) => unknown } export function libraryHandlers( outcome: (id: number) => unknown = () => undefined, ids?: number[], ): Handlers { const gone = new Set() return { list: () => listBody(fixture(ids).filter((item) => !gone.has(item.id))), remove: async (id) => { try { const result = await outcome(id) gone.add(id) return result } catch (error) { if (error instanceof ApiError && error.status === 404) { gone.add(id) } throw error } }, } } export const kit = { requestMock: vi.fn(), wrapper: null as VueWrapper | null, } function dispatcher(handlers: Handlers) { return (path: string, options?: RequestInit) => { const method = options?.method ?? 'GET' if (method === 'GET' && path.startsWith('/v1/media/?hash=')) { return (handlers.hash ?? (() => listBody([], FULL_COUNTS)))(path) } if (method === 'GET' && path.startsWith('/v1/media/?')) { return (handlers.list ?? (() => listBody(fixture())))(path) } if (method === 'GET' && path === '/v1/media/purge-preview') { return (handlers.preview ?? (() => undefined))() } if (method === 'POST' && path === '/v1/media/purge') { return (handlers.purge ?? (() => undefined))(JSON.parse(String(options?.body))) } const variantsList = /^\/v1\/media\/(\d+)\/variants$/.exec(path) if (variantsList && method === 'GET') { return (handlers.variants ?? (() => ({ unsupported: null, items: [] })))( Number(variantsList[1]), ) } if (variantsList && method === 'POST') { return (handlers.generateAll ?? (() => undefined))(Number(variantsList[1])) } const variantNamed = /^\/v1\/media\/(\d+)\/variants\/([\w-]+)$/.exec(path) if (variantNamed && method === 'POST') { return (handlers.generate ?? (() => undefined))( Number(variantNamed[1]), variantNamed[2]!, ) } if (variantNamed && method === 'DELETE') { return (handlers.purgeVariant ?? (() => undefined))( Number(variantNamed[1]), variantNamed[2]!, ) } const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path) if (method === 'GET' && usage) { return (handlers.usage ?? (() => []))(Number(usage[1])) } if (method === 'POST' && path === '/v1/media/merge') { return (handlers.merge ?? (() => ({ articles_updated: 0, merged: 0 })))( JSON.parse(String(options?.body)), ) } const single = /^\/v1\/media\/(\d+)$/.exec(path) if (method === 'DELETE' && single) { return (handlers.remove ?? (() => undefined))(Number(single[1])) } throw new Error(`Unexpected request: ${method} ${path}`) } } function fetchFrom(dispatch: ReturnType) { return vi.fn(async (path: string, init?: RequestInit) => { const reply = (status: number, body?: unknown) => ({ ok: status >= 200 && status < 300, status, headers: new Headers(), json: async () => body, text: async () => '', }) try { const body = await dispatch(path, init) return reply(body === undefined ? 204 : 200, body) } catch (error) { if (error instanceof ApiError) { return reply(error.status) } throw error } }) } export async function mountPage( handlers: Handlers = {}, url = '/media', width = DESKTOP_WIDTH, options: { realApi?: boolean; role?: string; app?: boolean } = {}, ) { setViewportWidth(width) setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: (options.role ?? 'owner') as never, } session.status = 'ready' const dispatch = dispatcher(handlers) if (options.realApi) { kit.requestMock = fetchFrom(dispatch) vi.stubGlobal('fetch', kit.requestMock) } else { kit.requestMock = vi.fn(async (path: string, init?: RequestInit) => dispatch(path, init)) vi.mocked(useApi).mockReturnValue({ getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, }), request: kit.requestMock, }) } await router.push(url) const root = options.app ? defineComponent({ render: () => h(UApp, null, { default: () => h(MediaView) }), }) : MediaView kit.wrapper = mount(root, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return kit.wrapper } export async function unmountPage() { kit.wrapper?.unmount() kit.wrapper = null vi.useRealTimers() vi.unstubAllGlobals() vi.restoreAllMocks() await router.push('/login') document.body.innerHTML = '' } export function textOf(element: Element | null | undefined) { if (!element) { return '' } const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) const parts: string[] = [] while (walker.nextNode()) { const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim() if (text) { parts.push(text) } } return parts.join(' ') } export function cards() { return Array.from(document.body.querySelectorAll('[data-media-card]')) } export function cardFor(filename: string) { return cards().find((card) => textOf(card).includes(filename))! } export function tabs() { return Array.from(document.body.querySelectorAll('[role="tab"]')) } export function tab(label: string) { return tabs().find((element) => textOf(element).startsWith(label))! } export function findButton(label: string, root: ParentNode = document.body) { return Array.from(root.querySelectorAll('button')).find( (button) => textOf(button) === label, ) } export function panel() { return document.body.querySelector('[role="dialog"]') } export function dialog() { return document.body.querySelector('[role="alertdialog"]') } export function listCalls() { return kit.requestMock.mock.calls.filter( ([path]) => typeof path === 'string' && path.startsWith('/v1/media/?') && !path.includes('hash='), ) } export function lastListPath() { return listCalls().at(-1)![0] as string } export function callsTo(method: string, pattern: RegExp) { return kit.requestMock.mock.calls.filter( ([path, options]) => (options?.method ?? 'GET') === method && pattern.test(path), ) } export function press(element: Element, key: string) { element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) } export function pressOnWindow(key: string) { document.body.dispatchEvent( new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }), ) } export async function settle() { await flushPromises() await new Promise((resolve) => setTimeout(resolve, 10)) await flushPromises() await new Promise((resolve) => setTimeout(resolve, 10)) await flushPromises() } export function announcer() { return textOf(document.body.querySelector('[data-testid="media-announcer"]')) } export function checkboxFor(filename: string) { return document.body.querySelector( `[role="checkbox"][aria-label="Select ${filename}"]`, )! } export function selectAllCheckbox() { return document.body .querySelector('[data-testid="image-count"]')! .parentElement!.querySelector('[role="checkbox"]')! } export function tableHeaderCheckbox() { return document.body.querySelector('[role="checkbox"][aria-label="Select all"]')! } export function bulkBar() { return document.body.querySelector('[data-testid="media-bulk-bar"]') } export function fileWith(name: string, type: string, size?: number, content = name) { const file = new File([content], name, { type }) if (size !== undefined) { Object.defineProperty(file, 'size', { value: size }) } return file } export function hexOf(content: string) { return Array.from(new TextEncoder().encode(content), (byte) => byte.toString(16).padStart(2, '0'), ) .join('') .padEnd(64, '0') .slice(0, 64) } export function stubDigest() { const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => { const hex = Array.from(new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0')) .join('') .padEnd(64, '0') .slice(0, 64) return new Uint8Array(hex.match(/../g)!.map((pair) => Number.parseInt(pair, 16))).buffer }) vi.stubGlobal('crypto', { subtle: { digest } }) return digest } export class FakeXhr extends EventTarget { static instances: FakeXhr[] = [] upload = new EventTarget() method = '' url = '' status = 0 responseText = '' withCredentials = false body: FormData | null = null aborted = false finished = false headers: Record = {} open(method: string, url: string) { this.method = method this.url = url } send(body: FormData) { this.body = body FakeXhr.instances.push(this) } abort() { this.aborted = true this.finished = true this.dispatchEvent(new Event('abort')) } getResponseHeader(name: string) { return this.headers[name.toLowerCase()] ?? null } progress(loaded: number, total: number) { this.upload.dispatchEvent(Object.assign(new Event('progress'), { loaded, total })) } respond(status: number, headers: Record = {}, text = '') { this.status = status this.headers = Object.fromEntries( Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), ) this.responseText = text this.finished = true this.dispatchEvent(new Event('load')) } fail() { this.finished = true this.dispatchEvent(new Event('error')) } get fileName() { return (this.body?.get('file') as File).name } } export function stubXhr() { FakeXhr.instances = [] vi.stubGlobal('XMLHttpRequest', FakeXhr) } export function inFlight() { return FakeXhr.instances.filter((xhr) => !xhr.finished) } export function xhrFor(name: string) { return FakeXhr.instances.find((xhr) => xhr.fileName === name)! } export function fileInput() { return document.body.querySelector('input[type="file"]')! } export async function chooseFiles(files: File[]) { const input = fileInput() Object.defineProperty(input, 'files', { value: files, configurable: true }) input.dispatchEvent(new Event('change', { bubbles: true })) await settle() } export function dragEvent(type: string, types: string[], files: File[] = []) { const event = new Event(type, { bubbles: true, cancelable: true }) Object.defineProperty(event, 'dataTransfer', { value: { types, files } }) document.body.dispatchEvent(event) return event } export function uploadPanel() { return document.body.querySelector('[data-testid="media-upload-panel"]') } export function uploadRows() { return Array.from(document.body.querySelectorAll('[data-testid="upload-row"]')) } export function rowTexts() { return uploadRows().map(textOf) } export const STORED_NAME = '1.jpg' export const NORTH_WALL_BYTES = 4089446 const VARIANT_SPECS: [ string, number | null, number | null, string, string, number, number, number, ][] = [ ['card', 800, null, 'inside', 'webp', 800, 600, 65536], ['hero', 1200, 630, 'cover', 'webp', 1200, 630, 98304], ['og', 1200, 630, 'cover', 'jpeg', 1200, 630, 145408], ['square', 600, 600, 'cover', 'jpeg', 600, 600, 83968], ['thumb', 400, 300, 'cover', 'webp', 400, 300, 18432], ] export function variantItems(generated: string[] = VARIANT_SPECS.map(([name]) => name)) { return VARIANT_SPECS.map( ([name, width, height, fit, format, outputWidth, outputHeight, size]) => ({ name, width, height, fit, format, output_width: outputWidth, output_height: outputHeight, output_format: format, generated: generated.includes(name), size: generated.includes(name) ? size : null, url: `/static/uploadedImages/${STORED_NAME}?v=${name}`, }), ) } export function variantList(generated?: string[]) { return { unsupported: null, items: variantItems(generated) } }