feat: media picker and image removal

This commit is contained in:
fsylvain committed 2026-10-01 05:04:58 +02:00
1 parent 008b0dfbb9
commit b3793d5dbb
54 files changed
+3769 -1412

No files matched your search

@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ArticleDetails from '../ArticleDetails.vue'
import type { Article } from '@/composables/useArticles'
afterEach(() => {
document.body.innerHTML = ''
})
function mountDetails(article: Omit<Article, 'images'>) {
return mount(ArticleDetails, {
props: { article: article as Article },
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
const baseArticle = {
id: 3,
title: 'Kitchen',
body: '<p>a</p>',
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-09-26T11:02:00Z',
}
describe('An article from the API carries no images field', () => {
it('renders the details with an images count read from the body', () => {
const wrapper = mountDetails({
...baseArticle,
body: '<p>a</p><img src="/a.png"><img src="/b.png">',
})
expect(wrapper.text()).toContain('Images')
expect(wrapper.text()).toContain('2')
wrapper.unmount()
})
it('counts no image for a body without any', () => {
const wrapper = mountDetails(baseArticle)
expect(wrapper.text()).toContain('Images')
wrapper.unmount()
})
})
@@ -34,7 +34,6 @@ async function mountArticleEditor(body = '<p>Hello</p>') {
id: 12,
title: 'T',
body,
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
@@ -106,3 +105,17 @@ describe('removeImage removes only the node whose data-image-id matches', () =>
expect(editor.emitted('update:modelValue')).toBeFalsy()
})
})
describe('imageSrc reads the src of the node whose data-image-id matches', () => {
it('returns the src of the matching image and null for an unknown id', async () => {
const wrapper = await mountArticleEditor(
'<p>Hello</p><img src="/static/uploadedImages/a.png" alt="" data-image-id="10">',
)
const vm = wrapper.findComponent(ArticleEditor).vm as unknown as {
imageSrc: (id: number) => string | null
}
expect(vm.imageSrc(10)).toBe('/static/uploadedImages/a.png')
expect(vm.imageSrc(999)).toBeNull()
})
})
@@ -74,14 +74,9 @@ describe('Cancelling — the action is in flight and the dialog cannot be dismis
await clickButton('Cancel registration')
expect(action).toHaveBeenCalledTimes(1)
expect(
(findButtonElement('Keep my account') as HTMLButtonElement)
.disabled,
).toBe(true)
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(true)
expect(findButtonElement('Cancelling…')).toBeTruthy()
expect(
(findButtonElement('Cancelling…') as HTMLButtonElement).disabled,
).toBe(true)
expect((findButtonElement('Cancelling…') as HTMLButtonElement).disabled).toBe(true)
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
resolveAction?.()
@@ -163,26 +158,60 @@ describe('dismissResolvesTo="confirm" makes Esc/overlay resolve as confirmed', (
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
it('re-enables both buttons and shows the rejection message inline', async () => {
const action = vi.fn(() =>
Promise.reject(new Error("Couldn't cancel registration.")),
)
const action = vi.fn(() => Promise.reject(new Error("Couldn't cancel registration.")))
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toBeFalsy()
expect(document.body.textContent).toContain(
"Couldn't cancel registration.",
)
expect(
(findButtonElement('Keep my account') as HTMLButtonElement)
.disabled,
).toBe(false)
expect(
(findButtonElement('Cancel registration') as HTMLButtonElement)
.disabled,
).toBe(false)
expect(document.body.textContent).toContain("Couldn't cancel registration.")
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(false)
expect((findButtonElement('Cancel registration') as HTMLButtonElement).disabled).toBe(false)
wrapper.unmount()
})
})
describe('A neutral confirm with an image preview', () => {
function mountPreview(preview: { src: string; filename?: string; size?: string }) {
return mount(ConfirmDialog, {
attachTo: document.body,
props: {
title: 'Remove this image from the article?',
description: 'It stays in your library.',
confirmLabel: 'Remove image',
cancelLabel: 'Cancel',
pendingLabel: 'Removing…',
tone: 'neutral',
preview,
action: async () => {},
},
global: { plugins: [uiPlugin] },
})
}
it('shows the thumbnail, the filename and the size, and a primary confirm', async () => {
const wrapper = mountPreview({
src: '/media/north-wall.jpg',
filename: 'north-wall.jpg',
size: '3.9 MB',
})
await flushPromises()
const row = document.body.querySelector('[data-testid="confirm-preview"]')!
expect(row.textContent).toContain('north-wall.jpg')
expect(row.textContent).toContain('Stays in the library · 3.9 MB')
expect(row.querySelector('img')?.getAttribute('src')).toBe('/media/north-wall.jpg')
expect(findButtonElement('Remove image')?.className).not.toContain('bg-error')
wrapper.unmount()
})
it('shows the thumbnail and "Stays in the library" for an image the session has not seen', async () => {
const wrapper = mountPreview({ src: '/media/seven.jpg' })
await flushPromises()
const row = document.body.querySelector('[data-testid="confirm-preview"]')!
expect(row.textContent?.replace(/\s+/g, ' ').trim()).toBe('Stays in the library')
wrapper.unmount()
})
})
+368 -164
View File
@@ -1,16 +1,44 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ImageUploader from '../ImageUploader.vue'
import { nextTick } from 'vue'
import * as fileHash from '@/lib/fileHash'
import * as imageUpload from '@/lib/imageUpload'
import { ApiError, NetworkError, redirectToLogin, redirectUnverified } from '@/composables/useApi'
import { fixture } from '@/views/__tests__/mediaTestKit'
import {
altField,
apiKit,
click,
dialogText,
findButton,
hashCalls,
installApi,
installEmptyLibrary,
listCalls,
modalOpen,
mountUploader,
openUploader,
pageBody,
selectedTab,
settle,
tabButtons,
textOf,
} from './pickerKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
redirectToLogin: vi.fn(),
redirectUnverified: vi.fn(),
}
})
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn() }))
vi.mock('@/lib/imageUpload', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageUpload')>()
return {
...actual,
validateImageFile: vi.fn(actual.validateImageFile),
}
return { ...actual, validateImageFile: vi.fn(actual.validateImageFile) }
})
vi.mock('@/lib/imageCrop', async (importOriginal) => {
@@ -22,6 +50,8 @@ vi.mock('@/lib/imageCrop', async (importOriginal) => {
}
})
const NORTH_WALL_HASH = 'a'.repeat(64)
class FakeXMLHttpRequest {
static instances: FakeXMLHttpRequest[] = []
@@ -88,7 +118,7 @@ class FakeXMLHttpRequest {
}
function latestXHR() {
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]
return FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]!
}
function mib(value: number) {
@@ -101,38 +131,6 @@ function fileFixture(name: string, type: string, sizeMB: number) {
return file
}
const Host = defineComponent({
components: { ImageUploader },
props: {
articleId: { type: Number, required: true },
initialFile: { type: Object as () => File | null, default: null },
},
emits: ['uploaded', 'close'],
setup(_props, { emit }) {
const open = ref(false)
return {
open,
onUploaded: (location: string, imageId: number) => emit('uploaded', location, imageId),
onClose: () => emit('close'),
}
},
template: `<ImageUploader v-model:open="open" :article-id="articleId" :initial-file="initialFile" @uploaded="onUploaded" @close="onClose" />`,
})
function mountUploader(props: { articleId?: number; initialFile?: File | null } = {}) {
return mount(Host, {
props: { articleId: 12, initialFile: null, ...props },
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
async function openModal(wrapper: ReturnType<typeof mountUploader>) {
;(wrapper.vm as unknown as { open: boolean }).open = true
await nextTick()
await nextTick()
}
function fileInput() {
return document.body.querySelector<HTMLInputElement>('[data-testid="image-file-input"]')!
}
@@ -149,21 +147,11 @@ async function chooseFile(file: File) {
})
input.dispatchEvent(new Event('change'))
await nextTick()
await nextTick()
await flushPromises()
}
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
await settle()
}
async function clickButton(text: string) {
findButton(text)?.click()
await nextTick()
await nextTick()
await flushPromises()
await click(findButton(text))
}
async function chooseRasterFileAndConfirmUpload(file: File) {
@@ -171,47 +159,78 @@ async function chooseRasterFileAndConfirmUpload(file: File) {
await clickButton('Upload image')
}
function mediaItem(overrides: Record<string, unknown> = {}) {
return { ...fixture([1])[0]!, ...overrides }
}
let wrapper: ReturnType<typeof mountUploader> | null = null
async function openOnUpload(routes: Parameters<typeof installEmptyLibrary>[0] = {}) {
installEmptyLibrary(routes)
wrapper = mountUploader()
await openUploader(wrapper)
}
const RealXMLHttpRequest = global.XMLHttpRequest
beforeEach(() => {
FakeXMLHttpRequest.instances = []
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
vi.mocked(fileHash.hashFile).mockResolvedValue(NORTH_WALL_HASH)
vi.mocked(redirectToLogin).mockClear()
vi.mocked(redirectUnverified).mockClear()
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
document.body.innerHTML = ''
global.XMLHttpRequest = RealXMLHttpRequest
})
describe('Opening the uploader from the toolbar image button shows the idle state', () => {
it('shows the idle copy and sends no request', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
describe("The toolbar's image button opens on Upload when the library is empty", () => {
it('shows both tabs, the drop zone copy and a footer with Cancel only', async () => {
await openOnUpload()
expect(document.body.textContent).toContain('Add an image')
expect(document.body.textContent).toContain('Drop an image here')
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
expect(findButton('Choose file')).toBeTruthy()
expect(dialogText()).toContain('Add an image')
expect(tabButtons().map(textOf)).toEqual(['Upload', 'Library'])
expect(textOf(selectedTab())).toBe('Upload')
expect(dialogText()).toContain('Drop an image here, or choose a file')
expect(dialogText()).toContain('PNG, JPEG, WEBP, SVG · max 10 MB')
expect(findButton('Choose file')).toBeUndefined()
expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Insert image')).toBeUndefined()
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
wrapper.unmount()
})
})
describe('Dragging a file over the drop zone shows the dragging-over state', () => {
it('shows "Drop to upload"', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await openOnUpload()
dropZone().dispatchEvent(new Event('dragover', { bubbles: true, cancelable: true }))
await nextTick()
expect(document.body.textContent).toContain('Drop to upload')
wrapper.unmount()
expect(dialogText()).toContain('Drop to upload')
})
})
describe('Choosing a file the browser can reject never sends a request', () => {
describe('Dropping a file opens Upload with the file', () => {
it('looks the hash up, sends no list request and opens the crop step', async () => {
installApi()
wrapper = mountUploader({ initialFile: fileFixture('before.png', 'image/png', 3.9) })
await openUploader(wrapper)
expect(hashCalls()).toEqual([NORTH_WALL_HASH])
expect(listCalls()).toHaveLength(0)
expect(dialogText()).toContain('Crop and resize')
expect(dialogText()).toContain('before.png')
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
})
})
describe('Choosing a file the browser can reject sends nothing', () => {
it.each([
{
filename: 'plan.pdf',
@@ -234,183 +253,368 @@ describe('Choosing a file the browser can reject never sends a request', () => {
])(
'rejects $filename with the exact copy',
async ({ filename, contentType, sizeMB, description }) => {
const wrapper = mountUploader()
await openModal(wrapper)
await openOnUpload()
await chooseFile(fileFixture(filename, contentType, sizeMB))
expect(hashCalls()).toHaveLength(0)
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(document.body.textContent).toContain(`"${filename}" can't be used`)
expect(document.body.textContent).toContain(description)
expect(dialogText()).toContain(`"${filename}" can't be used`)
expect(dialogText()).toContain(description)
expect(findButton('Choose another file')).toBeTruthy()
expect(findButton('Cancel')).toBeTruthy()
wrapper.unmount()
expect(tabButtons()).toHaveLength(0)
},
)
})
describe('"Choose another file" from the rejected state returns to idle', () => {
it('shows the idle state again', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
it('shows the idle state again, with the tabs', async () => {
await openOnUpload()
await chooseFile(fileFixture('plan.pdf', 'application/pdf', 1.2))
await clickButton('Choose another file')
expect(document.body.textContent).toContain('Drop an image here')
wrapper.unmount()
expect(dialogText()).toContain('Drop an image here, or choose a file')
expect(tabButtons().map(textOf)).toEqual(['Upload', 'Library'])
})
})
describe('A file whose content exists shows the duplicate card and sends nothing', () => {
it('shows the card with the oldest match and its two actions', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const card = textOf(document.body.querySelector('[data-testid="duplicate-card"]'))
expect(card).toContain('Already in your library')
expect(card).toContain(
'north-wall.jpg matches an image uploaded on 12 Sep 2026 by Marta Alves.',
)
expect(findButton('Use existing')).toBeTruthy()
expect(findButton('Upload anyway')).toBeTruthy()
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(dialogText()).not.toContain('Crop and resize')
expect(tabButtons()).toHaveLength(2)
})
it('names the oldest of several matches', async () => {
await openOnUpload({
hash: () =>
pageBody(
[
mediaItem({
id: 9,
created_at: '2026-09-20T12:00:00Z',
uploaded_by: 'jonas',
}),
mediaItem({
id: 3,
created_at: '2026-09-01T12:00:00Z',
uploaded_by: 'Inès Duarte',
}),
],
null,
),
})
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(dialogText()).toContain(
'north-wall.jpg matches an image uploaded on 1 Sep 2026 by Inès Duarte.',
)
})
it('drops the "by" part when the uploader is unknown', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem({ uploaded_by: '' })], null) })
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(dialogText()).toContain('north-wall.jpg matches an image uploaded on 12 Sep 2026.')
})
it('goes to the Library with the existing image selected when "Use existing" is activated', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await clickButton('Use existing')
expect(textOf(selectedTab())).toBe('Library')
expect(textOf(document.body.querySelector('[data-testid="picker-panel"]'))).toContain(
'north-wall.jpg',
)
expect(altField()!.value).toBe('')
expect(findButton('Insert image')?.disabled).toBe(false)
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
})
it('continues to crop and upload without a second lookup when "Upload anyway" is activated', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
await clickButton('Upload anyway')
expect(dialogText()).toContain('Crop and resize')
expect(hashCalls()).toHaveLength(1)
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
})
it('uploads an SVG at once when "Upload anyway" is activated', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem()], null) })
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
await clickButton('Upload anyway')
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/media/')
})
})
describe('The duplicate check is skipped when it cannot run', () => {
it.each([
{
name: 'crypto.subtle is undefined',
arrange: () => vi.mocked(fileHash.hashFile).mockResolvedValue(null),
routes: {},
},
{
name: 'the lookup resolves with a 500 response',
arrange: () => undefined,
routes: { hash: () => Promise.reject(new ApiError(500)) },
},
{
name: 'the lookup never reaches the server',
arrange: () => undefined,
routes: { hash: () => Promise.reject(new NetworkError()) },
},
])('continues to the crop step with no card when $name', async ({ arrange, routes }) => {
arrange()
await openOnUpload(routes)
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(dialogText()).toContain('Crop and resize')
expect(document.body.querySelector('[data-testid="duplicate-card"]')).toBeNull()
})
it('uploads an SVG when the lookup fails', async () => {
await openOnUpload({ hash: () => Promise.reject(new NetworkError()) })
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
})
})
describe('A valid upload shows progress and its cancel action reads "Cancel upload"', () => {
it('sends the resized file and reports live progress', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
it('sends the resized file to /v1/media/ and reports live progress', async () => {
await openOnUpload()
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
const xhr = latestXHR()
expect(xhr.url).toBe('/v1/article/12/image')
expect(xhr.method).toBe('POST')
expect(xhr.url).toBe('/v1/media/')
expect(xhr.withCredentials).toBe(true)
expect(xhr.sentBody?.get('file')).toBeInstanceOf(File)
expect(document.body.textContent).toContain('north-wall.jpg 0%')
expect(document.body.textContent).toContain('Uploading 0.0 of 420 KB')
expect(dialogText()).toContain('north-wall.jpg 0%')
expect(dialogText()).toContain('Uploading 0.0 of 420 KB')
expect(findButton('Cancel upload')).toBeTruthy()
expect(tabButtons()).toHaveLength(0)
xhr.fireProgress(180 * 1024, 420 * 1024)
await nextTick()
expect(document.body.textContent).toContain('north-wall.jpg 43%')
expect(document.body.textContent).toContain('Uploading 180 of 420 KB')
wrapper.unmount()
expect(dialogText()).toContain('north-wall.jpg 43%')
expect(dialogText()).toContain('Uploading 180 of 420 KB')
})
it('reads megabytes for a larger file', async () => {
await openOnUpload()
await chooseFile(fileFixture('logo.svg', 'image/svg+xml', 3.9))
latestXHR().fireProgress(mib(2.4), mib(3.9))
await nextTick()
expect(dialogText()).toContain('logo.svg 62%')
expect(dialogText()).toContain('Uploading 2.4 of 3.9 MB')
})
})
describe('Closing the dialog during an upload cancels it', () => {
it('aborts the in-flight XMLHttpRequest and closes the modal', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
await openOnUpload()
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
await clickButton('Cancel upload')
expect(xhr.aborted).toBe(true)
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
expect(modalOpen()).toBe(false)
expect(wrapper!.emitted('uploaded')).toBeUndefined()
})
})
describe('A successful upload emits the location and auto-closes 600 ms later', () => {
describe('A successful upload emits the library item and auto-closes 600 ms later', () => {
it('shows "Image added" then closes', async () => {
vi.useFakeTimers()
const wrapper = mountUploader()
await openModal(wrapper)
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
installEmptyLibrary()
wrapper = mountUploader()
;(wrapper.vm as unknown as { open: boolean }).open = true
await vi.advanceTimersByTimeAsync(0)
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
const item = mediaItem({ id: 13, url: '/static/uploadedImages/13.jpg' })
xhr.resolve(
201,
JSON.stringify({ id: 7, location: '/static/uploadedImages/north-wall.jpg' }),
)
latestXHR().resolve(201, JSON.stringify(item))
await vi.advanceTimersByTimeAsync(0)
expect(wrapper.emitted('uploaded')).toEqual([['/static/uploadedImages/north-wall.jpg', 7]])
expect(document.body.textContent).toContain('Image added')
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toEqual([[item]])
expect(dialogText()).toContain('Image added')
expect(modalOpen()).toBe(true)
await vi.advanceTimersByTimeAsync(590)
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
expect(modalOpen()).toBe(true)
await vi.advanceTimersByTimeAsync(10)
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
vi.useRealTimers()
expect(modalOpen()).toBe(false)
})
})
describe('A server-side upload failure keeps the article unchanged and offers a retry', () => {
describe('A server answer for a file that passed the browser checks', () => {
it.each([
{ name: 'a 500 response', trigger: (xhr: FakeXMLHttpRequest) => xhr.resolve(500, '') },
{ name: 'a network failure', trigger: (xhr: FakeXMLHttpRequest) => xhr.fail() },
])('shows the failed state for $name', async ({ trigger }) => {
const wrapper = mountUploader()
await openModal(wrapper)
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
const xhr = latestXHR()
{
file: 'notes.pdf',
type: 'image/png',
sizeMB: 0.2,
status: 415,
title: '"notes.pdf" can\'t be used',
text: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
confirm: 'Choose another file',
},
{
file: 'north-wall.tif',
type: 'image/png',
sizeMB: 14.2,
status: 413,
title: '"north-wall.tif" can\'t be used',
text: 'north-wall.tif is 14.2 MB. Images can be up to 10 MB.',
confirm: 'Choose another file',
},
{
file: 'broken.png',
type: 'image/png',
sizeMB: 3,
status: 400,
title: '"broken.png" can\'t be used',
text: 'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
confirm: 'Choose another file',
},
{
file: 'north-wall.jpg',
type: 'image/jpeg',
sizeMB: 3.9,
status: 500,
title: 'Upload failed',
text: "The server didn't accept the file. Your article hasn't changed.",
confirm: 'Try again',
},
{
file: 'north-wall.jpg',
type: 'image/jpeg',
sizeMB: 3.9,
status: 429,
title: 'Upload failed',
text: "The server didn't accept the file. Your article hasn't changed.",
confirm: 'Try again',
},
{
file: 'north-wall.jpg',
type: 'image/jpeg',
sizeMB: 3.9,
status: 0,
title: 'Upload failed',
text: "The server didn't accept the file. Your article hasn't changed.",
confirm: 'Try again',
},
])(
'answers $status with $title',
async ({ file, type, sizeMB, status, title, text, confirm }) => {
await openOnUpload()
const chosen = fileFixture(file, type, sizeMB)
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
await chooseFile(chosen)
if (findButton('Upload image')) {
await clickButton('Upload image')
}
const xhr = latestXHR()
trigger(xhr)
await flushPromises()
if (status === 0) {
xhr.fail()
} else {
xhr.resolve(status, '')
}
await settle()
expect(document.body.textContent).toContain('Upload failed')
expect(document.body.textContent).toContain(
"The server didn't accept the file. Your article hasn't changed.",
)
expect(findButton('Try again')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toBeFalsy()
wrapper.unmount()
})
expect(dialogText()).toContain(title)
expect(dialogText()).toContain(text)
expect(findButton(confirm)).toBeTruthy()
expect(wrapper!.emitted('uploaded')).toBeUndefined()
expect(tabButtons()).toHaveLength(0)
},
)
})
describe('"Try again" re-attempts the upload with the same file', () => {
it('resends the same file under field "file"', async () => {
const wrapper = mountUploader()
await openModal(wrapper)
it('resends the same file under field "file" without a new lookup', async () => {
await openOnUpload()
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
latestXHR().resolve(500, '')
await flushPromises()
await settle()
await clickButton('Try again')
expect(FakeXMLHttpRequest.instances).toHaveLength(2)
const xhr = latestXHR()
expect(xhr.url).toBe('/v1/article/12/image')
expect(xhr.url).toBe('/v1/media/')
expect((xhr.sentBody?.get('file') as File).name).toBe('north-wall.jpg')
expect(document.body.textContent).toContain('north-wall.jpg 0%')
wrapper.unmount()
expect(hashCalls()).toHaveLength(1)
expect(dialogText()).toContain('north-wall.jpg 0%')
})
})
describe('A server 400 for a type the client should have caught shows the same rejected copy', () => {
it('shows the type-rejection copy', async () => {
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
const wrapper = mountUploader()
await openModal(wrapper)
await chooseRasterFileAndConfirmUpload(fileFixture('notes.pdf', 'image/png', 1.2))
const xhr = latestXHR()
describe('Session errors are global', () => {
it('hands a 401 on the upload to the login redirect with no local copy', async () => {
await openOnUpload()
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
xhr.resolve(400, 'the file must be a PNG, JPEG, WEBP, or SVG image')
await flushPromises()
latestXHR().resolve(401, '')
await settle()
expect(document.body.textContent).toContain(`"notes.pdf" can't be used`)
expect(document.body.textContent).toContain(
'Use a PNG, JPEG, WEBP or SVG image, up to 10 MB.',
)
expect(findButton('Choose another file')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toBeFalsy()
wrapper.unmount()
expect(redirectToLogin).toHaveBeenCalledTimes(1)
expect(dialogText()).not.toContain('Upload failed')
})
})
describe('A server 400 for a size the client should have caught shows the size-named copy', () => {
it('shows the size-rejection copy computed from the file', async () => {
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
const wrapper = mountUploader()
await openModal(wrapper)
await chooseRasterFileAndConfirmUpload(fileFixture('north-wall.tif', 'image/png', 14.2))
const xhr = latestXHR()
it('hands a 403 on the upload to the unverified redirect with no local copy', async () => {
await openOnUpload()
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
xhr.resolve(400, 'the file exceeds the maximum upload size')
await flushPromises()
latestXHR().resolve(403, '')
await settle()
expect(document.body.textContent).toContain(`"north-wall.tif" can't be used`)
expect(document.body.textContent).toContain(
'north-wall.tif is 14.2 MB. Images can be up to 10 MB.',
)
expect(findButton('Choose another file')).toBeTruthy()
expect(wrapper.emitted('uploaded')).toBeFalsy()
wrapper.unmount()
expect(redirectUnverified).toHaveBeenCalledTimes(1)
expect(dialogText()).not.toContain('Upload failed')
})
it('stops on a 401 from the hash lookup with no local copy', async () => {
await openOnUpload({ hash: () => Promise.reject(new ApiError(401)) })
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
expect(FakeXMLHttpRequest.instances).toHaveLength(0)
expect(dialogText()).not.toContain('Crop and resize')
expect(apiKit.request).toHaveBeenCalled()
})
})
@@ -1,11 +1,18 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
import ImageUploader from '../ImageUploader.vue'
import { installEmptyLibrary, mountUploader, openUploader } from './pickerKit'
import { renderCroppedImage } from '@/lib/imageCrop'
import type { CropRect } from '@/lib/imageCrop'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@/lib/fileHash', () => ({ hashFile: vi.fn(async () => null) }))
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
@@ -95,36 +102,7 @@ function fileFixture(name: string, type: string, sizeMB: number) {
return file
}
const Host = defineComponent({
components: { ImageUploader },
props: {
articleId: { type: Number, required: true },
},
emits: ['uploaded', 'close'],
setup(_props, { emit }) {
const open = ref(false)
return {
open,
onUploaded: (location: string) => emit('uploaded', location),
onClose: () => emit('close'),
}
},
template: `<ImageUploader v-model:open="open" :article-id="articleId" @uploaded="onUploaded" @close="onClose" />`,
})
function mountUploader() {
return mount(Host, {
props: { articleId: 12 },
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
async function openModal(wrapper: ReturnType<typeof mountUploader>) {
;(wrapper.vm as unknown as { open: boolean }).open = true
await nextTick()
await nextTick()
}
const openModal = openUploader
function fileInput() {
return document.body.querySelector<HTMLInputElement>('[data-testid="image-file-input"]')!
@@ -138,8 +116,7 @@ async function chooseFile(file: File) {
})
input.dispatchEvent(new Event('change'))
await nextTick()
await nextTick()
await nextTick()
await flushPromises()
}
function findButton(text: string) {
@@ -151,8 +128,7 @@ function findButton(text: string) {
async function clickButton(text: string) {
findButton(text)?.click()
await nextTick()
await nextTick()
await nextTick()
await flushPromises()
}
function radioPill(groupLabel: string, text: string) {
@@ -187,6 +163,7 @@ function north_wall() {
const RealXMLHttpRequest = global.XMLHttpRequest
beforeEach(() => {
installEmptyLibrary()
FakeXMLHttpRequest.instances = []
global.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest
})
@@ -223,7 +200,7 @@ describe('An SVG skips the crop step and uploads immediately', () => {
await chooseFile(fileFixture('diagram.svg', 'image/svg+xml', 0.04))
expect(FakeXMLHttpRequest.instances).toHaveLength(1)
expect(latestXHR().url).toBe('/v1/article/12/image')
expect(latestXHR().url).toBe('/v1/media/')
expect(document.body.textContent).not.toContain('Crop and resize')
wrapper.unmount()
})
@@ -0,0 +1,522 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ApiError, NetworkError } from '@/composables/useApi'
import { MOBILE_WIDTH } from './viewport'
import { fixture } from '@/views/__tests__/mediaTestKit'
import {
altField,
apiKit,
click,
clickTab,
dialogText,
findButton,
installApi,
listCalls,
modalOpen,
mountUploader,
openUploader,
pageBody,
press,
searchField,
secondPage,
selectedTab,
selectTile,
settle,
skeletonTiles,
textOf,
tile,
tiles,
typeInto,
type PickerRoutes,
} from './pickerKit'
vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
}
})
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
let wrapper: ReturnType<typeof mountUploader> | null = null
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
async function openLibrary(routes: PickerRoutes = {}) {
installApi(routes)
wrapper = mountUploader()
await openUploader(wrapper)
}
async function openWithFakeTimers(routes: PickerRoutes = {}, width?: number) {
vi.useFakeTimers()
installApi(routes)
wrapper = mountUploader({}, width)
;(wrapper.vm as unknown as { open: boolean }).open = true
await vi.advanceTimersByTimeAsync(0)
}
function summary() {
return textOf(document.body.querySelector('[data-testid="picker-summary"]'))
}
async function searchFor(value: string) {
await typeInto(searchField(), value)
await new Promise((resolve) => setTimeout(resolve, 150))
await settle()
}
describe('The library has images, so Library opens', () => {
it('selects Library with a disabled "Insert image" and draws skeletons only after 150 ms', async () => {
const pending = deferred<unknown>()
await openWithFakeTimers({ list: () => pending.promise })
expect(textOf(selectedTab())).toBe('Library')
expect(findButton('Cancel')?.disabled).toBe(false)
expect(findButton('Insert image')?.disabled).toBe(true)
expect(skeletonTiles()).toHaveLength(0)
expect(tiles()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(8)
pending.resolve(pageBody())
await vi.advanceTimersByTimeAsync(0)
expect(skeletonTiles()).toHaveLength(0)
expect(tiles()).toHaveLength(12)
})
it('draws 9 skeleton tiles on mobile', async () => {
await openWithFakeTimers({ list: () => new Promise(() => undefined) }, MOBILE_WIDTH)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(9)
})
it('asks for 24 images and nothing else', async () => {
await openLibrary()
expect(listCalls()).toHaveLength(1)
expect(listCalls()[0]!.toString()).toBe('limit=24')
})
})
describe('The library is empty, so Upload opens', () => {
it('opens the Upload tab idle, and Library then shows the empty panel', async () => {
await openLibrary({ list: () => pageBody([], null) })
expect(textOf(selectedTab())).toBe('Upload')
expect(dialogText()).toContain('Drop an image here, or choose a file')
await clickTab('Library')
expect(dialogText()).toContain('Your library is empty')
expect(dialogText()).toContain(
'Images you upload appear here, ready to use in any article.',
)
expect(findButton('Insert image')?.disabled).toBe(true)
})
it('switches to Upload from "Upload an image"', async () => {
await openLibrary({ list: () => pageBody([], null) })
await clickTab('Library')
await click(findButton('Upload an image'))
expect(textOf(selectedTab())).toBe('Upload')
})
})
describe('A dropped file opens Upload and sends no list request', () => {
it('stays quiet until the Library tab is selected', async () => {
installApi({ hash: () => new Promise(() => undefined) })
wrapper = mountUploader({
initialFile: new File(['x'], 'before.png', { type: 'image/png' }),
})
await openUploader(wrapper)
expect(textOf(selectedTab())).toBe('Upload')
expect(listCalls()).toHaveLength(0)
await clickTab('Library')
expect(listCalls()).toHaveLength(1)
})
})
describe('Library, nothing selected', () => {
it('shows the line, the 12 tiles in order and the tags on the images in the article', async () => {
await openLibrary()
expect(summary()).toBe('Newest first · 48 images')
expect(tiles().map((element) => element.getAttribute('data-image-id'))).toEqual(
fixture().map((item) => String(item.id)),
)
const tagged = tiles()
.filter((element) => textOf(element).includes('In this article'))
.map((element) => element.getAttribute('data-image-id'))
expect(tagged).toEqual(['1', '4'])
expect(textOf(tile('north-wall.jpg'))).toContain('north-wall.jpg')
expect(findButton('Load more')?.getAttribute('aria-disabled')).toBeNull()
expect(findButton('Insert image')?.disabled).toBe(true)
})
})
describe('A search is active, so the count is not shown', () => {
it('reads "Newest first"', async () => {
await openLibrary({
list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()),
})
await searchFor('wall')
expect(summary()).toBe('Newest first')
})
})
describe('Load more is hidden on the last page', () => {
it('does not show "Load more" without a cursor', async () => {
await openLibrary({ list: () => pageBody(fixture(), null) })
expect(findButton('Load more')).toBeUndefined()
})
})
describe("A tile's accessible name", () => {
it.each([
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles'],
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article'],
['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused'],
])('names %s', async (filename, name) => {
await openLibrary()
expect(tile(filename).getAttribute('aria-label')).toBe(name)
})
})
describe('A tile is selected by pointer or keyboard', () => {
it.each([
['clicks', async () => click(tile('kitchen-after.webp'))],
[
'presses Enter',
async () => {
press(tile('kitchen-after.webp'), 'Enter')
await settle()
},
],
[
'presses Space',
async () => {
press(tile('kitchen-after.webp'), ' ')
await settle()
},
],
])('selects the tile when the user %s on it', async (_name, act) => {
await openLibrary()
await act()
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
expect(
tile('kitchen-after.webp').querySelector('[data-testid="picker-check"]'),
).not.toBeNull()
expect(findButton('Insert image')?.disabled).toBe(false)
})
})
describe('The selection panel', () => {
it('shows the thumbnail, the details and the alt text field', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
const panel = textOf(document.body.querySelector('[data-testid="picker-panel"]'))
expect(panel).toContain('kitchen-after.webp')
expect(panel).toContain('2400 × 1600 · 1.2 MB')
expect(panel).toContain('Alt text (optional)')
expect(panel).toContain(
"Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration.",
)
expect(altField()?.value).toBe('')
expect(findButton('Load more')).toBeUndefined()
})
it('reads "Vector · 8 KB" for an SVG', async () => {
await openLibrary()
await selectTile('logo-renew.svg')
expect(textOf(document.body.querySelector('[data-testid="picker-panel"]'))).toContain(
'Vector · 8 KB',
)
})
it('allows an image already in the article to be selected', async () => {
await openLibrary()
await selectTile('north-wall.jpg')
expect(findButton('Insert image')?.disabled).toBe(false)
})
})
describe('Selection is single', () => {
it('selects only the last tile and empties the alt text', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen')
await selectTile('window-frame.jpg')
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('false')
expect(tile('window-frame.jpg').getAttribute('aria-pressed')).toBe('true')
expect(altField()!.value).toBe('')
})
it('keeps the tile selected, and its alt text, when it is activated again', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen')
await selectTile('kitchen-after.webp')
expect(tile('kitchen-after.webp').getAttribute('aria-pressed')).toBe('true')
expect(altField()!.value).toBe('A kitchen')
})
})
describe('Insert', () => {
function kitchenAfterAtStaticUrl() {
return pageBody(
fixture().map((item) =>
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
),
)
}
it('emits the item and the alt text, then closes at once with no write request', async () => {
await openLibrary({ list: kitchenAfterAtStaticUrl })
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen with new oak worktops and white tiles')
await click(findButton('Insert image'))
const [item, alt] = wrapper!.emitted('inserted')![0] as [
{ id: number; url: string },
string,
]
expect(item.id).toBe(2)
expect(item.url).toBe('/static/uploadedImages/2.webp')
expect(alt).toBe('A kitchen with new oak worktops and white tiles')
expect(modalOpen()).toBe(false)
expect(apiKit.request.mock.calls.every(([, options]) => !options?.method)).toBe(true)
})
it('inserts an empty alt for a decorative image', async () => {
await openLibrary()
await selectTile('kitchen-after.webp')
await click(findButton('Insert image'))
expect(wrapper!.emitted('inserted')![0]![1]).toBe('')
})
it.each([
['activates "Cancel"', async () => click(findButton('Cancel'))],
[
'activates the x',
async () =>
click(document.body.querySelector('[role="dialog"] button[aria-label="Close"]')),
],
[
'presses Escape',
async () => {
document.body
.querySelector('[role="dialog"]')!
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await settle()
},
],
])('changes nothing when the user %s', async (_name, act) => {
await openLibrary()
await selectTile('kitchen-after.webp')
await typeInto(altField()!, 'A kitchen')
await act()
expect(modalOpen()).toBe(false)
expect(wrapper!.emitted('inserted')).toBeUndefined()
expect(wrapper!.emitted('uploaded')).toBeUndefined()
})
})
describe('Search', () => {
it('searches after a 120 ms debounce, with no cursor, and clears the selection', async () => {
await openWithFakeTimers()
tile('kitchen-after.webp').click()
await vi.advanceTimersByTimeAsync(0)
expect(findButton('Insert image')?.disabled).toBe(false)
await typeInto(searchField(), 'wall')
await vi.advanceTimersByTimeAsync(119)
expect(listCalls()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
expect(listCalls()).toHaveLength(2)
expect(listCalls()[1]!.get('q')).toBe('wall')
expect(listCalls()[1]!.get('limit')).toBe('24')
expect(listCalls()[1]!.has('cursor')).toBe(false)
expect(findButton('Insert image')?.disabled).toBe(true)
})
it('draws skeleton tiles while a search loads, after 150 ms', async () => {
await openWithFakeTimers({
list: (query) => (query.get('q') ? new Promise(() => undefined) : pageBody()),
})
await typeInto(searchField(), 'wall')
await vi.advanceTimersByTimeAsync(120)
expect(skeletonTiles()).toHaveLength(0)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(8)
})
it('clears the search from the x in the field', async () => {
await openLibrary({
list: (query) => (query.get('q') ? pageBody(fixture([1, 12]), null) : pageBody()),
})
await searchFor('wall')
await click(document.body.querySelector('button[aria-label="Clear search"]'))
expect(searchField().value).toBe('')
const last = listCalls().at(-1)!
expect(last.has('q')).toBe(false)
expect(last.get('limit')).toBe('24')
})
it('shows the no results panel and "Clear search" empties the field', async () => {
await openLibrary({
list: (query) => (query.get('q') ? pageBody([], null) : pageBody()),
})
await searchFor('wall')
expect(dialogText()).toContain('No images match "wall"')
expect(dialogText()).toContain(
'Check the spelling, or search for a shorter part of the filename.',
)
expect(searchField().value).toBe('wall')
expect(findButton('Insert image')?.disabled).toBe(true)
await click(findButton('Clear search'))
expect(searchField().value).toBe('')
expect(listCalls().at(-1)!.has('q')).toBe(false)
expect(tiles()).toHaveLength(12)
})
})
describe('Load more', () => {
it('shows progress, appends the new tiles and moves focus to the first one', async () => {
const more = deferred<unknown>()
await openWithFakeTimers({
list: (query) => (query.get('cursor') ? more.promise : pageBody()),
})
const button = findButton('Load more')!
button.focus()
button.click()
await vi.advanceTimersByTimeAsync(0)
const last = listCalls().at(-1)!
expect(last.get('cursor')).toBe('c24')
expect(last.get('limit')).toBe('24')
const loading = findButton('Loading…')!
expect(loading.getAttribute('aria-disabled')).toBe('true')
expect(document.activeElement).toBe(loading)
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(4)
more.resolve(pageBody(secondPage(), null))
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(tiles()).toHaveLength(24)
expect(skeletonTiles()).toHaveLength(0)
expect(document.activeElement).toBe(tiles()[12])
})
it('draws a row of 3 skeleton tiles on mobile', async () => {
await openWithFakeTimers(
{ list: (query) => (query.get('cursor') ? new Promise(() => undefined) : pageBody()) },
MOBILE_WIDTH,
)
findButton('Load more')!.click()
await vi.advanceTimersByTimeAsync(150)
expect(skeletonTiles()).toHaveLength(3)
})
it('combines the cursor with the search', async () => {
await openLibrary({
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 'c24'),
})
await searchFor('wall')
await click(findButton('Load more'))
const last = listCalls().at(-1)!
expect(last.get('q')).toBe('wall')
expect(last.get('limit')).toBe('24')
expect(last.get('cursor')).toBe('c24')
})
})
describe('The library fails to load', () => {
it.each([
['a 500 response', () => Promise.reject(new ApiError(500))],
['a request that never reaches the server', () => Promise.reject(new NetworkError())],
])('shows the alert for %s and retries', async (_name, failure) => {
let failing = true
await openLibrary({ list: () => (failing ? failure() : pageBody()) })
expect(dialogText()).toContain("Couldn't load your library")
expect(dialogText()).toContain('Check your connection and try again.')
expect(findButton('Insert image')?.disabled).toBe(true)
failing = false
await click(findButton('Try again'))
expect(listCalls()).toHaveLength(2)
expect(tiles()).toHaveLength(12)
expect(dialogText()).not.toContain("Couldn't load your library")
})
})
+190
View File
@@ -0,0 +1,190 @@
import { vi } from 'vitest'
import { defineComponent, nextTick, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import ImageUploader from '../ImageUploader.vue'
import { useApi } from '@/composables/useApi'
import type { MediaItem } from '@/composables/useMedia'
import { DESKTOP_WIDTH, setViewportWidth } from './viewport'
import { fixture, textOf } from '@/views/__tests__/mediaTestKit'
export { textOf }
export const FIRST_PAGE_CURSOR = 'c24'
export const PICKER_COUNTS = { all: 48, unused: 2, duplicates: 2 }
export function pageBody(
items: MediaItem[] = fixture(),
next_cursor: string | null = FIRST_PAGE_CURSOR,
) {
return { items, next_cursor, counts: PICKER_COUNTS }
}
export function secondPage() {
return fixture().map((item) => ({
...item,
id: item.id + 12,
filename: `more-${item.filename}`,
url: `/media/more-${item.filename}`,
}))
}
export interface PickerRoutes {
list?: (query: URLSearchParams) => unknown
hash?: (hash: string) => unknown
}
export const apiKit = { request: vi.fn() }
export function installApi(routes: PickerRoutes = {}) {
apiKit.request = vi.fn(async (path: string) => {
const url = new URL(path, 'http://localhost')
const hash = url.searchParams.get('hash')
if (hash !== null) {
return (routes.hash ?? (() => pageBody([], null)))(hash)
}
return (routes.list ?? (() => pageBody()))(url.searchParams)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request: apiKit.request,
})
}
export function installEmptyLibrary(routes: PickerRoutes = {}) {
installApi({ list: () => pageBody([], null), ...routes })
}
export function listCalls() {
return apiKit.request.mock.calls
.map(([path]) => new URL(path as string, 'http://localhost'))
.filter((url) => url.pathname === '/v1/media/' && !url.searchParams.has('hash'))
.map((url) => url.searchParams)
}
export function hashCalls() {
return apiKit.request.mock.calls
.map(([path]) => new URL(path as string, 'http://localhost'))
.filter((url) => url.searchParams.has('hash'))
.map((url) => url.searchParams.get('hash'))
}
export const Host = defineComponent({
components: { ImageUploader },
props: {
initialFile: { type: Object as () => File | null, default: null },
inArticleIds: { type: Array as () => number[], default: () => [1, 4] },
},
emits: ['uploaded', 'inserted', 'close'],
setup(_props, { emit }) {
const open = ref(false)
return {
open,
onUploaded: (item: MediaItem) => emit('uploaded', item),
onInserted: (item: MediaItem, alt: string) => emit('inserted', item, alt),
onClose: () => emit('close'),
}
},
template: `<ImageUploader v-model:open="open" :initial-file="initialFile" :in-article-ids="inArticleIds" @uploaded="onUploaded" @inserted="onInserted" @close="onClose" />`,
})
export function mountUploader(
props: { initialFile?: File | null; inArticleIds?: number[] } = {},
width = DESKTOP_WIDTH,
) {
setViewportWidth(width)
setActivePinia(createPinia())
return mount(Host, {
props,
global: { plugins: [uiPlugin] },
attachTo: document.body,
})
}
export async function openUploader(wrapper: ReturnType<typeof mountUploader>) {
;(wrapper.vm as unknown as { open: boolean }).open = true
await nextTick()
await nextTick()
await flushPromises()
}
export async function settle() {
await flushPromises()
await nextTick()
await flushPromises()
}
export function tabButtons() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
}
export function tabButton(label: string) {
return tabButtons().find((element) => textOf(element) === label)
}
export function selectedTab() {
return tabButtons().find((element) => element.getAttribute('aria-selected') === 'true')
}
export function tiles() {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('[data-picker-tile]'))
}
export function tile(filename: string) {
return tiles().find((element) =>
element.getAttribute('aria-label')?.startsWith(`${filename},`),
)!
}
export function skeletonTiles() {
return document.body.querySelectorAll('[data-testid="picker-skeleton-tile"]')
}
export function findButton(label: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === label,
)
}
export async function click(element: Element | null | undefined) {
;(element as HTMLElement).click()
await settle()
}
export async function clickTab(label: string) {
tabButton(label)!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
await settle()
}
export async function selectTile(filename: string) {
await click(tile(filename))
}
export function altField() {
return document.body.querySelector<HTMLInputElement>(
'input[placeholder="e.g. North wall of the kitchen before plastering"]',
)
}
export async function typeInto(input: HTMLInputElement, value: string) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}
export function searchField() {
return document.body.querySelector<HTMLInputElement>('input[aria-label="Search by filename"]')!
}
export function dialogText() {
return textOf(document.body.querySelector('[role="dialog"]'))
}
export function modalOpen() {
return document.body.querySelector('[role="dialog"]') !== null
}
export function press(element: Element, key: string) {
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
}