// @vitest-environment jsdom import { flushSync, mount, unmount } from 'svelte' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { loadEditor, WRITING_AIDS } from '$lib/editor.js' import MarkdownEditor from './MarkdownEditor.svelte' // The contracts a replacement editor breaks, held one at a time. // // A textarea gets all of these from the browser for nothing: the value a form reads, a reset after // submit, a disabled state, Escape leaving the field, and the caret staying where the reader put it. // A contenteditable gets none of them, so each one is wired by hand in `MarkdownEditor.svelte` and // each one is asserted here — mounted for real, in a DOM, with CodeMirror actually running. const upload = vi.hoisted(() => vi.fn()) vi.mock('$lib/write.js', () => ({ uploadImage: upload })) // One test needs the CodeMirror chunk to NEVER arrive, so that the textarea half — the editor on a // network that dropped the chunk — is the thing under test rather than a stage it passes through. const chunk = vi.hoisted(() => ({ held: false })) vi.mock('$lib/editor.js', async (importOriginal) => { const real = await importOriginal() return { ...real, loadEditor: () => (chunk.held ? new Promise(() => {}) : real.loadEditor()), } }) const IMAGE_URI = 'at://did:plc:abc/com.disnetdev.radial.image/3mrv' /** * Uploads that do not come back until the test says so, in order, one in flight at a time. The * returned handle lands whichever one is currently waiting — which is what makes "the reader keeps * editing while the blob is on the wire" something a test can hold at all. */ function pending(...images: { markdown: string; alt: string }[]): () => void { let next = 0 let land = (): void => {} upload.mockImplementation( async () => new Promise((resolve) => { const image = images[Math.min(next, images.length - 1)] ?? { markdown: '', alt: '' } next += 1 land = () => { resolve({ uri: IMAGE_URI, ...image }) } }), ) return () => { land() } } let host: HTMLDivElement let component: Record | undefined /** Mount the editor and wait for the CodeMirror chunk, so every test below runs on the real thing. */ async function open(props: Record = {}): Promise<{ state: { value: string; disabled: boolean; uploading: boolean } view: import('@codemirror/view').EditorView content: HTMLElement }> { const cm = await loadEditor() const state = $state({ value: '', disabled: false, uploading: false, ...props }) component = mount(MarkdownEditor, { target: host, props: state }) as Record // The editor is created inside a promise chain; two turns of the microtask queue is what it takes // for the already-resolved module cache to come back and for the effect to settle. await Promise.resolve() await Promise.resolve() flushSync() const content = host.querySelector('.cm-content') as HTMLElement return { state, view: cm.EditorView.findFromDOM(content) as never, content } } beforeEach(() => { upload.mockReset() chunk.held = false host = document.createElement('div') document.body.append(host) }) afterEach(() => { if (component) unmount(component) component = undefined host.remove() }) describe('the markdown editor', () => { it('starts as a textarea and carries the draft into CodeMirror when it lands', async () => { // The chunk can take longer to arrive than a person takes to start typing, so the fallback is a // working field and the swap must not cost them their first sentence. const state = $state({ value: 'typed before the chunk arrived' }) component = mount(MarkdownEditor, { target: host, props: state }) as Record const plain = host.querySelector('textarea') as HTMLTextAreaElement expect(plain.value).toBe('typed before the chunk arrived') expect(plain.hidden).toBe(false) plain.focus() plain.setSelectionRange(6, 12) const cm = await loadEditor() await Promise.resolve() await Promise.resolve() flushSync() expect(host.querySelector('.cm-content')?.textContent).toBe('typed before the chunk arrived') expect((host.querySelector('textarea') as HTMLTextAreaElement).hidden).toBe(true) const content = host.querySelector('.cm-content') as HTMLElement const view = cm.EditorView.findFromDOM(content) expect(view?.state.selection.main).toMatchObject({ from: 6, to: 12 }) }) it('keeps the browser writing aids on in both halves, and through a view update', async () => { const state = $state({ value: '', disabled: false }) component = mount(MarkdownEditor, { target: host, props: state }) as Record const plain = host.querySelector('textarea') as HTMLTextAreaElement for (const [name, value] of Object.entries(WRITING_AIDS)) expect(plain.getAttribute(name)).toBe(value) await loadEditor() await Promise.resolve() await Promise.resolve() flushSync() const content = host.querySelector('.cm-content') as HTMLElement // Not merely at mount. CodeMirror recomputes the whole content attribute set on every view // update and writes back anything that differs from what it last computed, so an attribute set // on `contentDOM` by hand survives only for as long as its own default happens to compare equal. // One reconfigure — the same one the disabled test uses — is enough to run that path. state.disabled = true flushSync() state.disabled = false flushSync() for (const [name, value] of Object.entries(WRITING_AIDS)) expect(content.getAttribute(name)).toBe(value) }) it('sends what is typed back out as a plain string', async () => { const { state, view } = await open({ value: 'one' }) view.dispatch({ changes: { from: 3, insert: ' two' } }) flushSync() // Still a string, and still the same string a `--body` argument takes: nothing above this had // to learn anything about the editor. expect(state.value).toBe('one two') }) it('takes a value the parent changed, including the clear after a successful post', async () => { const { state, view } = await open({ value: 'a message' }) state.value = '' flushSync() expect(view.state.doc.toString()).toBe('') // …and again, so a card re-opened on a different draft shows that draft. state.value = 'a different message' flushSync() expect(view.state.doc.toString()).toBe('a different message') }) it('does not echo its own change back into itself', async () => { const { state, view } = await open({ value: 'hello world' }) view.dispatch({ changes: { from: 5, insert: ',' }, selection: { anchor: 2 } }) flushSync() // A round trip that re-dispatched would replace the whole document and take the caret to the end // of it on every keystroke — the classic symptom, and the reason the parent→editor effect // compares before it dispatches. expect(state.value).toBe('hello, world') expect(view.state.selection.main.head).toBe(2) }) it('stops taking input while the parent is busy, without losing what is in it', async () => { const { state, view } = await open({ value: 'half written', disabled: true }) expect(view.contentDOM.getAttribute('contenteditable')).toBe('false') state.disabled = false flushSync() expect(view.contentDOM.getAttribute('contenteditable')).toBe('true') expect(view.state.doc.toString()).toBe('half written') }) it('leaves the field on Escape and submits on ⌘↵', async () => { const submitted = vi.fn() const { view, content } = await open({ value: 'ready', onSubmit: submitted }) view.focus() // Ctrl rather than ⌘ because `Mod` is whatever the platform's is, and jsdom is not a Mac. content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true })) expect(submitted).toHaveBeenCalledTimes(1) content.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) // Escape gives the page back; it never discards the draft (`keys.ts`). expect(document.activeElement).not.toBe(content) expect(view.state.doc.toString()).toBe('ready') }) it('destroys the editor on unmount', async () => { const { content } = await open({ value: 'x' }) expect(content.isConnected).toBe(true) unmount(component as Record) component = undefined expect(host.querySelector('.cm-content')).toBeNull() }) it('inserts an uploaded image at the caret, replacing the selection and selecting the alt', async () => { upload.mockResolvedValue({ uri: IMAGE_URI, markdown: `![the ledger](radial-image:${IMAGE_URI})`, alt: 'the ledger', }) const { state, view, content } = await open({ value: 'before SELECTED after', images: true }) view.dispatch({ selection: { anchor: 7, head: 15 } }) content.dispatchEvent( Object.assign(new Event('paste', { bubbles: true, cancelable: true }), { clipboardData: { files: [pngFile('ledger.png')] }, }), ) await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(1)) flushSync() expect(state.value).toBe(`before ![the ledger](radial-image:${IMAGE_URI}) after`) // The alt came off a filename, which is a guess — so it is what the caret is holding, and typing // over it needs no click. const { from, to } = view.state.selection.main expect(view.state.doc.sliceString(from, to)).toBe('the ledger') expect(state.uploading).toBe(false) }) it('puts a second image after the first rather than inside it', async () => { // The alt of the image that just landed is selected, and a selection is not an insertion point: // inserting the next one there would replace that alt and nest the second image inside the // first's markdown. One ordinary multi-file drop is all it takes, so it is held here. const second = 'at://did:plc:abc/com.disnetdev.radial.image/3mrw' upload .mockResolvedValueOnce({ uri: IMAGE_URI, markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one', }) .mockResolvedValueOnce({ uri: second, markdown: `![two](radial-image:${second})`, alt: 'two', }) const { state, view, content } = await open({ value: 'before after', images: true }) view.dispatch({ selection: { anchor: 7 } }) content.dispatchEvent( Object.assign(new Event('drop', { bubbles: true, cancelable: true }), { dataTransfer: { files: [pngFile('one.png'), pngFile('two.png')], getData: () => '' }, }), ) await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(2)) await vi.waitFor(() => { flushSync() expect(state.uploading).toBe(false) }) expect(state.value).toBe( `before ![one](radial-image:${IMAGE_URI})![two](radial-image:${second}) after`, ) // Two whole images, neither one wrapped around the other, and the caret holding the LAST alt — // the one still worth correcting. const { from, to } = view.state.selection.main expect(view.state.doc.sliceString(from, to)).toBe('two') }) it('uses a selected range only once in a multi-image batch', async () => { const second = 'at://did:plc:abc/com.disnetdev.radial.image/3mr-selected' upload .mockResolvedValueOnce({ uri: IMAGE_URI, markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one', }) .mockResolvedValueOnce({ uri: second, markdown: `![two](radial-image:${second})`, alt: 'two', }) const { state, view, content } = await open({ value: 'before SELECTED after', images: true }) view.dispatch({ selection: { anchor: 7, head: 15 } }) content.dispatchEvent( Object.assign(new Event('paste', { bubbles: true, cancelable: true }), { clipboardData: { files: [pngFile('one.png'), pngFile('two.png')] }, }), ) await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(2)) await vi.waitFor(() => { flushSync() expect(state.uploading).toBe(false) }) expect(state.value).toBe( `before ![one](radial-image:${IMAGE_URI})![two](radial-image:${second}) after`, ) }) it('does not let a selected-range upload erase a racing drop inside that range', async () => { const uriA = 'at://did:plc:abc/com.disnetdev.radial.image/3mr-race-a' const uriB = 'at://did:plc:abc/com.disnetdev.radial.image/3mr-race-b' const land = new Map void>() upload.mockImplementation( async (file: File) => new Promise((resolve) => { const a = file.name === 'a.png' const uri = a ? uriA : uriB land.set(file.name, () => resolve({ uri, markdown: `![${a ? 'A' : 'B'}](radial-image:${uri})`, alt: a ? 'A' : 'B', }), ) }), ) const { state, view, content } = await open({ value: 'before SELECTED after', images: true }) view.dispatch({ selection: { anchor: 7, head: 15 } }) // B owns a replace-range. A separate pointer drop then lands inside it and returns first. content.dispatchEvent( Object.assign(new Event('paste', { bubbles: true, cancelable: true }), { clipboardData: { files: [pngFile('b.png')] }, }), ) await vi.waitFor(() => expect(land.has('b.png')).toBe(true)) vi.spyOn(view, 'posAtCoords').mockReturnValue(10) content.dispatchEvent( Object.assign(new Event('drop', { bubbles: true, cancelable: true }), { clientX: 50, clientY: 20, dataTransfer: { files: [pngFile('a.png')], getData: () => '' }, }), ) await vi.waitFor(() => expect(land.has('a.png')).toBe(true)) land.get('a.png')?.() await vi.waitFor(() => expect(state.value).toContain(`radial-image:${uriA}`)) land.get('b.png')?.() await vi.waitFor(() => { flushSync() expect(state.uploading).toBe(false) }) expect(state.value).toBe( `before ![B](radial-image:${uriB})SEL![A](radial-image:${uriA})ECTED after`, ) }) it('puts a second image after the first when the reader edited its selected alt', async () => { const second = 'at://did:plc:abc/com.disnetdev.radial.image/3mrx' upload .mockResolvedValueOnce({ uri: IMAGE_URI, markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one', }) .mockResolvedValueOnce({ uri: second, markdown: `![two](radial-image:${second})`, alt: 'two', }) const { state, view, content } = await open({ value: '', images: true }) const paste = (name: string): void => { content.dispatchEvent( Object.assign(new Event('paste', { bubbles: true, cancelable: true }), { clipboardData: { files: [pngFile(name)] }, }), ) } paste('one.png') await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(1)) const selected = view.state.selection.main view.dispatch({ changes: { from: selected.from, to: selected.to, insert: 'screenshot of the ledger' }, selection: { anchor: selected.from + 'screenshot of the ledger'.length }, }) paste('two.png') await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(2)) flushSync() expect(state.value).toBe( `![screenshot of the ledger](radial-image:${IMAGE_URI})![two](radial-image:${second})`, ) }) it('inserts a dropped image where the drop cursor points', async () => { upload.mockResolvedValue({ uri: IMAGE_URI, markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one', }) const { state, view, content } = await open({ value: 'before after', images: true }) vi.spyOn(view, 'posAtCoords').mockReturnValue(7) view.dispatch({ selection: { anchor: 0 } }) content.dispatchEvent( Object.assign(new Event('drop', { bubbles: true, cancelable: true }), { clientX: 50, clientY: 20, dataTransfer: { files: [pngFile('one.png')], getData: () => '' }, }), ) await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(1)) flushSync() expect(view.posAtCoords).toHaveBeenCalledWith({ x: 50, y: 20 }) expect(state.value).toBe(`before ![one](radial-image:${IMAGE_URI})after`) }) it('inserts at the caret again once the reader has moved it', async () => { upload .mockResolvedValueOnce({ uri: IMAGE_URI, markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one', }) .mockResolvedValueOnce({ uri: IMAGE_URI, markdown: `![two](radial-image:${IMAGE_URI})`, alt: 'two', }) const { state, view, content } = await open({ value: 'tail', images: true }) const drop = (name: string): void => { content.dispatchEvent( Object.assign(new Event('drop', { bubbles: true, cancelable: true }), { dataTransfer: { files: [pngFile(name)], getData: () => '' }, }), ) } view.dispatch({ selection: { anchor: 4 } }) drop('one.png') await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(1)) flushSync() // The reader takes the caret back to the top. Nothing this component remembered may override // where they put it. view.dispatch({ selection: { anchor: 0 } }) drop('two.png') await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(2)) flushSync() expect(state.value).toBe( `![two](radial-image:${IMAGE_URI})tail![one](radial-image:${IMAGE_URI})`, ) }) it('lands a dropped image where it was dropped, however much was typed while it uploaded', async () => { // An upload takes as long as it takes and the field stays editable throughout, so the caret at // the moment the network answers is not where the reader put the picture. The drop point is // fixed at the drop and carried across everything they wrote in the meantime. const land = pending({ markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one' }) const { state, view, content } = await open({ value: 'before after', images: true }) vi.spyOn(view, 'posAtCoords').mockReturnValue(7) view.dispatch({ selection: { anchor: 0 } }) content.dispatchEvent( Object.assign(new Event('drop', { bubbles: true, cancelable: true }), { clientX: 50, clientY: 20, dataTransfer: { files: [pngFile('one.png')], getData: () => '' }, }), ) await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(1)) // Mid-upload, the reader writes a sentence at the top and leaves the caret in it. view.dispatch({ changes: { from: 0, insert: 'a note first: ' }, selection: { anchor: 14 } }) land() await vi.waitFor(() => { flushSync() expect(state.uploading).toBe(false) }) expect(state.value).toBe(`a note first: before ![one](radial-image:${IMAGE_URI})after`) // …and the caret is still theirs. Selecting the new alt is an offer to correct a filename, and // an offer that arrives mid-sentence eats the next few keystrokes instead. expect(view.state.selection.main).toMatchObject({ from: 14, to: 14 }) }) it('lands a pasted image where the caret was, not where the reader took it since', async () => { const land = pending({ markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one' }) const { state, view, content } = await open({ value: 'head tail', images: true }) view.dispatch({ selection: { anchor: 5 } }) content.dispatchEvent( Object.assign(new Event('paste', { bubbles: true, cancelable: true }), { clipboardData: { files: [pngFile('one.png')] }, }), ) await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(1)) view.dispatch({ selection: { anchor: 0 } }) land() await vi.waitFor(() => { flushSync() expect(state.uploading).toBe(false) }) expect(state.value).toBe(`head ![one](radial-image:${IMAGE_URI})tail`) expect(view.state.selection.main).toMatchObject({ from: 0, to: 0 }) }) it('does not pull the caret out of another field for an image that lands late', async () => { const land = pending({ markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one' }) const { state, content } = await open({ value: '', images: true }) const title = document.createElement('input') document.body.append(title) content.dispatchEvent( Object.assign(new Event('paste', { bubbles: true, cancelable: true }), { clipboardData: { files: [pngFile('one.png')] }, }), ) await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(1)) title.focus() land() await vi.waitFor(() => { flushSync() expect(state.uploading).toBe(false) }) expect(state.value).toBe(`![one](radial-image:${IMAGE_URI})`) expect(document.activeElement).toBe(title) title.remove() }) it('selects the alt when focus remains on this editor\'s image controls', async () => { const land = pending({ markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one' }) const { view } = await open({ value: '', images: true }) const input = host.querySelector('input[type="file"]') as HTMLInputElement Object.defineProperty(input, 'files', { value: [pngFile('one.png')], configurable: true }) input.dispatchEvent(new Event('change', { bubbles: true })) await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(1)) input.focus() expect(document.activeElement).toBe(input) land() await vi.waitFor(() => expect(view.state.doc.toString()).toContain('radial-image:')) const { from, to } = view.state.selection.main expect(view.state.doc.sliceString(from, to)).toBe('one') expect(document.activeElement).toBe(view.contentDOM) }) it('inserts at the caret and selects the alt in the textarea half too', async () => { // The half nothing has tested: the editor on a network that dropped the chunk is a working // field, and a working field with an image button has to place an image the same way. chunk.held = true const land = pending({ markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one' }) const { state, box } = plain({ value: 'before after', images: true }) box.focus() box.setSelectionRange(7, 7) pick(['one.png']) await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(1)) land() await vi.waitFor(() => { flushSync() expect(state.uploading).toBe(false) }) expect(state.value).toBe(`before ![one](radial-image:${IMAGE_URI})after`) await vi.waitFor(() => expect(box.value.slice(box.selectionStart, box.selectionEnd)).toBe('one')) }) it('carries a textarea insertion point across what the reader typed, and keeps the order', async () => { chunk.held = true const second = 'at://did:plc:abc/com.disnetdev.radial.image/3mry' const land = pending( { markdown: `![one](radial-image:${IMAGE_URI})`, alt: 'one' }, { markdown: `![two](radial-image:${second})`, alt: 'two' }, ) const { state, box } = plain({ value: 'before after', images: true }) box.focus() box.setSelectionRange(7, 7) pick(['one.png', 'two.png']) await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(1)) // A textarea reports a value rather than a change, so this is the case the string diff exists // for: everything the reader typed above the insertion point moves the insertion point. type('NOTE: ') land() await vi.waitFor(() => expect(upload).toHaveBeenCalledTimes(2)) land() await vi.waitFor(() => { flushSync() expect(state.uploading).toBe(false) }) expect(state.value).toBe( `NOTE: before ![one](radial-image:${IMAGE_URI})![two](radial-image:${second})after`, ) }) it('holds `uploading` for the whole run, so a body cannot be posted mid-upload', async () => { let land = (): void => {} upload.mockImplementation( async () => new Promise((resolve) => { land = () => resolve({ uri: IMAGE_URI, markdown: `![a](radial-image:${IMAGE_URI})`, alt: 'a' }) }), ) const { state, content } = await open({ value: '', images: true }) content.dispatchEvent( Object.assign(new Event('paste', { bubbles: true, cancelable: true }), { clipboardData: { files: [pngFile('a.png')] }, }), ) await vi.waitFor(() => expect(upload).toHaveBeenCalled()) flushSync() expect(state.uploading).toBe(true) land() await vi.waitFor(() => { flushSync() expect(state.uploading).toBe(false) }) }) it('says what went wrong and inserts nothing when an upload fails', async () => { upload.mockRejectedValue(new Error('repo is read-only')) const { state, content } = await open({ value: 'untouched', images: true }) content.dispatchEvent( Object.assign(new Event('paste', { bubbles: true, cancelable: true }), { clipboardData: { files: [pngFile('a.png')] }, }), ) await vi.waitFor(() => { flushSync() expect(host.parentElement?.textContent).toContain('repo is read-only') }) expect(state.value).toBe('untouched') expect(state.uploading).toBe(false) }) it('never treats a pasted or dropped URL as a file to upload', async () => { const { state, content } = await open({ value: '', images: true }) // A link dragged from another tab arrives as text with no files. Fetching somebody else's // address and uploading the result under this human's identity is not what dropping a link // means, so the handler declines it and CodeMirror's own text handling has it. content.dispatchEvent( Object.assign(new Event('paste', { bubbles: true, cancelable: true }), { clipboardData: { files: [], getData: () => 'https://evil.test/cat.png' }, }), ) // Nor a file that is not a picture: the upload path is for images, and a PDF dropped on a // message box is a mis-drop rather than an attachment feature. CodeMirror's own drop handling // has it, which is the point — this declines rather than intercepts. content.dispatchEvent( Object.assign(new Event('drop', { bubbles: true, cancelable: true }), { dataTransfer: { files: [new File(['%PDF-1.7'], 'contract.pdf', { type: 'application/pdf' })], getData: () => '', }, }), ) await Promise.resolve() expect(upload).not.toHaveBeenCalled() expect(state.value).not.toContain('radial-image:') }) it('offers no upload controls where they are switched off', async () => { await open({ value: '', images: false }) expect(host.textContent).not.toContain('Add image') }) }) /** A `File`, as much of one as a paste event needs. */ function pngFile(name: string): File { return { name, type: 'image/png', size: 8, arrayBuffer: async () => new ArrayBuffer(8) } as File } /** Mount with the chunk held, so the editor IS the textarea it degrades to. */ function plain(props: Record): { state: { value: string; uploading: boolean } box: HTMLTextAreaElement } { const state = $state({ value: '', uploading: false, ...props }) component = mount(MarkdownEditor, { target: host, props: state }) as Record flushSync() const box = host.querySelector('textarea') as HTMLTextAreaElement expect(box.hidden).toBe(false) return { state, box } } /** Choose files in the hidden picker, which is the textarea half's only way in. */ function pick(names: string[]): void { const input = host.querySelector('input[type="file"]') as HTMLInputElement Object.defineProperty(input, 'files', { value: names.map(pngFile), configurable: true }) input.dispatchEvent(new Event('change', { bubbles: true })) } /** The reader typing at the top of the textarea, the way the DOM reports it. */ function type(text: string): void { const box = host.querySelector('textarea') as HTMLTextAreaElement box.value = `${text}${box.value}` box.setSelectionRange(text.length, text.length) box.dispatchEvent(new Event('input', { bubbles: true })) flushSync() }