feat: media picker and image removal
This commit is contained in:
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()
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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 }))
|
||||
}
|
||||
Reference in new issue
Block a user