import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h } from 'vue' import { mount, flushPromises } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import uiPlugin from '@nuxt/ui/vue-plugin' import UApp from '@nuxt/ui/components/App.vue' import type { Editor } from '@tiptap/core' import { NodeSelection } from '@tiptap/pm/state' import router from '@/router' import { useSessionStore } from '@/stores/session' import RootComponent from '@/App.vue' import ArticleEditor from '@/components/ArticleEditor.vue' import { messages } from '@/i18n/messages' import { setLocale } from '@/i18n' class TestPointerEvent extends MouseEvent { pointerType: string constructor(type: string, init: MouseEventInit & { pointerType?: string } = {}) { super(type, init) this.pointerType = init.pointerType ?? 'mouse' } } vi.stubGlobal('PointerEvent', TestPointerEvent) Range.prototype.getClientRects = () => Object.assign([], { item: () => null }) as unknown as DOMRectList Range.prototype.getBoundingClientRect = () => new DOMRect() const DESKTOP = { width: 1440, coarse: false } const PHONE = { width: 390, coarse: true } const URL_ERROR = 'Enter a valid URL. Use http(s), mailto, a path or #anchor.' const TEXT_ERROR = 'Enter the link text.' const SENTENCE = '

We also check the timber windows before any tiling starts.

' const LINKED = '

We also check the timber windows before any tiling starts.

' const LINKED_SAME_TAB = '

We also check the timber windows before any tiling starts.

' const LONG_URL = 'https://www.example.org/planning/guidance/listed-buildings/windows-and-doors/updates/2026-update.pdf' interface Media { width: number coarse: boolean } function mockMedia({ width, coarse }: Media) { window.matchMedia = ((query: string) => { const max = query.match(/max-width:\s*([\d.]+)(rem|px)/) const min = query.match(/min-width:\s*([\d.]+)(rem|px)/) const px = (m: RegExpMatchArray) => Number(m[1]) * (m[2] === 'rem' ? 16 : 1) const matches = query.includes('pointer: coarse') ? coarse : (!max || width <= px(max)) && (!min || width >= px(min)) return { matches, media: query, onchange: null, addEventListener: () => undefined, removeEventListener: () => undefined, addListener: () => undefined, removeListener: () => undefined, dispatchEvent: () => false, } }) as unknown as typeof window.matchMedia } function articleResponse(body: string) { return { id: 12, title: 'How we price a bathroom renovation', body, is_online: false, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', } } let fetchMock: ReturnType let wrapper: VueWrapper | null = null function patchBodies() { return fetchMock.mock.calls .filter(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH') .map(([, init]) => JSON.parse(String((init as RequestInit).body)).body as string) } async function mountEditor(body = SENTENCE, media: Media = DESKTOP) { mockMedia(media) setActivePinia(createPinia()) const session = useSessionStore() session.session = { verified: true, username: 'marta' } session.status = 'ready' fetchMock = vi.fn((_url: string, init?: RequestInit) => { const sent = init?.method === 'PATCH' ? JSON.parse(String(init.body)).body : body return Promise.resolve({ ok: true, status: 200, headers: { get: () => null }, json: async () => articleResponse(sent), } as unknown as Response) }) global.fetch = fetchMock as unknown as typeof fetch await router.push('/article/12/edit') wrapper = mount(RootComponent, { attachTo: document.body, global: { plugins: [router, uiPlugin] }, }) await flushPromises() return wrapper } async function settle() { await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve(null))), ) await new Promise((resolve) => setTimeout(resolve, 40)) await flushPromises() } function editor(): Editor { const vm = wrapper!.findComponent(ArticleEditor).vm as unknown as { $: { setupState: { editorInstance: Editor } } } return vm.$.setupState.editorInstance } function positionOf(text: string) { let found = -1 editor().state.doc.descendants((node, pos) => { if (found < 0 && node.isText && node.text!.includes(text)) { found = pos + node.text!.indexOf(text) } }) return found } function select(text: string, endText = text) { const from = positionOf(text) const to = positionOf(endText) + endText.length editor().commands.setTextSelection({ from, to }) } function placeCursorIn(text: string) { const at = positionOf(text) + 3 editor().commands.setTextSelection(at) } function linkButton() { return document.body.querySelector('button[aria-label="Insert link"]')! } function clickLink(anchor: HTMLElement = editor().view.dom.querySelector('a')!) { const view = editor().view anchor.addEventListener( 'click', (event) => { const pos = view.posAtDOM(anchor, 0) + 1 editor().commands.setTextSelection(pos) view.someProp('handleClick', (handle) => handle(view, pos, event)) }, { once: true }, ) anchor.click() } function dialog() { return document.body.querySelector('[role="dialog"][data-presentation]') } function pressCtrlK() { const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true, }) editor().view.dom.dispatchEvent(event) return event } function field(name: 'text' | 'url') { const inputs = dialog()!.querySelectorAll( 'input[type="text"], input:not([type])', ) return inputs[name === 'text' ? 0 : 1]! } function toggle() { return dialog()!.querySelector('[role="switch"]')! } function type(input: HTMLInputElement, value: string) { input.value = value input.dispatchEvent(new Event('input', { bubbles: true })) } function buttonLabelled(label: string) { return [...document.body.querySelectorAll('button')].find( (button) => button.textContent?.trim() === label, ) } async function press(label: string) { buttonLabelled(label)!.click() await settle() } async function fillAndSubmit(text: string, url: string, submit = 'Insert link') { type(field('text'), text) type(field('url'), url) await flushPromises() await press(submit) } async function openDialog() { pressCtrlK() await settle() } function isDirty() { return document.body.textContent!.includes('Unsaved changes') } function editorHtml() { return editor().getHTML() } beforeEach(() => { setLocale('en') }) afterEach(async () => { wrapper?.unmount() wrapper = null document.body.innerHTML = '' await router.push('/login') }) describe('The link button across its states', () => { it('is not pressed in plain text and uses the toned text colour', async () => { await mountEditor() placeCursorIn('timber windows') await settle() expect(linkButton().getAttribute('aria-pressed')).toBe('false') expect(linkButton().className).toContain('text-toned') expect(linkButton().className).not.toContain('--rc-sand') }) it('sits after the list buttons and before Clear formatting and Image', async () => { await mountEditor() const buttons = [ ...document.body.querySelectorAll('.article-editor [role="toolbar"] button'), ] const at = buttons.indexOf(linkButton()) expect(at).toBeGreaterThan(0) expect(buttons.length - at).toBe(3) expect(buttons[at + 2]!.textContent).toContain('Image') }) it('shows the tooltip with Ctrl+K after resting on it for 400 ms', async () => { vi.useFakeTimers() try { await mountEditor() linkButton().dispatchEvent( new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse', }), ) await vi.advanceTimersByTimeAsync(399) expect(document.body.textContent).not.toContain('Insert link (Ctrl+K)') await vi.advanceTimersByTimeAsync(2) expect(document.body.textContent).toContain('Insert link (Ctrl+K)') } finally { vi.useRealTimers() } }) it('shows the tooltip with the command key on macOS', async () => { vi.useFakeTimers() const platform = vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel') try { await mountEditor() linkButton().dispatchEvent( new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse', }), ) await vi.advanceTimersByTimeAsync(401) expect(document.body.textContent).toContain('Insert link (⌘K)') } finally { platform.mockRestore() vi.useRealTimers() } }) it('has no tooltip when the input is touch', async () => { vi.useFakeTimers() try { await mountEditor(SENTENCE, PHONE) linkButton().dispatchEvent( new PointerEvent('pointermove', { bubbles: true, pointerType: 'touch', }), ) await vi.advanceTimersByTimeAsync(1000) expect(document.body.textContent).not.toContain('Insert link (') expect(linkButton().hasAttribute('data-grace-area-trigger')).toBe(false) } finally { vi.useRealTimers() } }) it('opens the dialog from the keyboard with Enter', async () => { await mountEditor() linkButton().focus() linkButton().click() await settle() expect(dialog()).not.toBeNull() }) it('is pressed with the sand fill when the cursor is inside a link', async () => { await mountEditor(LINKED) placeCursorIn('timber windows') await settle() expect(linkButton().getAttribute('aria-pressed')).toBe('true') expect(linkButton().className).toContain('bg-(--rc-sand)') expect(linkButton().className).toContain('text-(--rc-on-sand)') }) it('has the same active look when part of the link text is selected', async () => { await mountEditor(LINKED) select('timber') await settle() expect(linkButton().getAttribute('aria-pressed')).toBe('true') }) it('opens the edit dialog when pressed inside a link', async () => { await mountEditor(LINKED) placeCursorIn('timber windows') await settle() linkButton().click() await settle() expect(dialog()!.textContent).toContain('Edit link') }) it('is disabled with an image selected and opens nothing from the button or Ctrl+K', async () => { await mountEditor(`${SENTENCE}`) const imagePos = editor().state.doc.firstChild!.nodeSize editor().view.dispatch( editor().state.tr.setSelection(NodeSelection.create(editor().state.doc, imagePos)), ) await settle() expect(linkButton().getAttribute('aria-disabled')).toBe('true') expect(linkButton().getAttribute('aria-pressed')).toBe('false') expect(linkButton().hasAttribute('data-grace-area-trigger')).toBe(false) linkButton().click() pressCtrlK() await settle() expect(dialog()).toBeNull() }) }) describe('Ctrl+K', () => { it('opens the dialog and prevents the browser shortcut', async () => { await mountEditor() const event = pressCtrlK() await settle() expect(event.defaultPrevented).toBe(true) expect(dialog()!.textContent).toContain('Insert link') }) it('opens nothing when the editor is disabled', async () => { wrapper = null mockMedia(DESKTOP) const Host = defineComponent({ render: () => h(UApp, null, () => h(ArticleEditor, { modelValue: '

Hello

', articleId: 1, disabled: true, }), ), }) const host = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] }, }) await settle() const vm = host.findComponent(ArticleEditor).vm as unknown as { $: { setupState: { editorInstance: Editor } } } vm.$.setupState.editorInstance.view.dom.dispatchEvent( new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, }), ) await settle() expect(dialog()).toBeNull() host.unmount() }) it('opens nothing in Preview', async () => { await mountEditor() const preview = document.body.querySelector('[role="switch"]')! preview.click() await settle() document.dispatchEvent( new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, }), ) await settle() expect(dialog()).toBeNull() }) }) describe.each([ { start: 'timber windows is selected', selected: 'timber windows', prefill: 'timber windows', focusedId: 'url', text: 'timber windows', }, { start: 'timber windows is selected, then the text is edited', selected: 'timber windows', prefill: 'timber windows', focusedId: 'url', text: 'timber sash windows', }, { start: 'nothing is selected', selected: '', prefill: '', focusedId: 'text', text: 'winter checklist', }, ])('Creating a link: $start', ({ selected, prefill, focusedId, text }) => { async function start() { await mountEditor() if (selected) { select(selected) } else { editor().commands.setTextSelection(positionOf('before')) } await openDialog() } it('opens the dialog titled Insert link with the right prefill and focus', async () => { await start() expect(dialog()!.textContent).toContain('Insert link') expect(field('text').value).toBe(prefill) if (!selected) { expect(field('text').placeholder).toBe('What the reader sees') } expect(document.activeElement).toBe(field(focusedId as 'text' | 'url')) expect(toggle().getAttribute('data-state')).toBe('unchecked') expect(buttonLabelled('Cancel')).toBeDefined() expect(buttonLabelled('Remove link')).toBeUndefined() }) it('inserts the anchor, returns focus to the editor, marks the draft dirty and sends nothing', async () => { await start() const requestsBefore = fetchMock.mock.calls.length await fillAndSubmit(text, '/articles/winter-checklist') expect(editorHtml()).toContain(`${text}`) expect(dialog()).toBeNull() expect(document.activeElement).toBe(editor().view.dom) expect(isDirty()).toBe(true) expect(fetchMock.mock.calls.length).toBe(requestsBefore) }) }) describe('Editing and removing an existing link', () => { async function openEdit(how: 'ctrl-k' | 'popover') { await mountEditor(LINKED) if (how === 'ctrl-k') { placeCursorIn('timber windows') await openDialog() } else { clickLink() await settle() await press('Edit') } } it.each(['ctrl-k', 'popover'] as const)( 'opens the edit dialog from %s with the fields and the toggle prefilled', async (how) => { await openEdit(how) expect(dialog()!.textContent).toContain('Edit link') expect(field('text').value).toBe('timber windows') expect(field('url').value).toBe('/articles/winter-checklist') expect(document.activeElement).toBe(field('url')) expect(toggle().getAttribute('data-state')).toBe('checked') expect(buttonLabelled('Remove link')).toBeDefined() expect(buttonLabelled('Save link')).toBeDefined() }, ) it('changes the href of the same anchor and keeps the new tab', async () => { await openEdit('popover') type(field('url'), 'https://example.org/windows') await settle() await press('Save link') expect(editorHtml()).toContain( 'timber windows', ) expect(editor().state.doc.textContent).toContain('timber windows') expect(isDirty()).toBe(true) }) it('leaves a Saved draft Saved when nothing changed', async () => { await openEdit('ctrl-k') await press('Save link') expect(dialog()).toBeNull() expect(isDirty()).toBe(false) expect(editorHtml()).toContain('href="/articles/winter-checklist"') }) it('removes the link without a confirmation, and undo brings it back with its target', async () => { await openEdit('ctrl-k') await press('Remove link') expect(dialog()).toBeNull() expect(document.body.querySelector('[role="alertdialog"]')).toBeNull() expect(editorHtml()).not.toContain(' { await mountEditor() select('timber windows') await openDialog() type(field('url'), '/a') toggle().click() await settle() await press('Insert link') expect(editorHtml()).toContain('target="_blank"') expect(editorHtml()).toContain('rel="noopener noreferrer"') placeCursorIn('timber windows') await openDialog() toggle().click() await settle() await press('Save link') expect(editorHtml()).not.toContain('target=') expect(editorHtml()).not.toContain('rel=') }) }) describe('Validation in the dialog', () => { async function submitWith(text: string, url: string) { await mountEditor() select('timber windows') await openDialog() await fillAndSubmit(text, url) } it.each([ 'www.example', 'example.com', 'javascript:alert(1)', 'JaVaScRiPt:alert(1)', ' javascript:alert(1)', 'javascript:alert(1)', 'data:text/html;base64,PHA+', 'vbscript:msgbox(1)', 'file:///etc/passwd', 'ftp://example.org/a', 'tel:+33123456789', '//evil.example', 'page.html', '../x', 'http://', 'https://exa mple.org', ])('rejects the URL %s and inserts nothing', async (url) => { await submitWith('timber windows', url) expect(dialog()!.textContent).toContain(URL_ERROR) expect(editorHtml()).not.toContain('x`) }) }) describe('Editing a legacy URL', () => { const LEGACY = '

See call us now.

' it.each(['tel:+33123456789'])('saves a text-only edit with the unchanged URL %s', async () => { await mountEditor(LEGACY) placeCursorIn('call us') await openDialog() type(field('text'), 'phone us') await flushPromises() await press('Save link') expect(dialog()).toBeNull() expect(editorHtml()).toContain('href="tel:+33123456789"') expect(editorHtml()).toContain('phone us') }) it('rejects a changed URL that breaks the strict rule', async () => { await mountEditor(LEGACY) placeCursorIn('call us') await openDialog() type(field('url'), 'tel:+33999') await flushPromises() await press('Save link') expect(dialog()!.textContent).toContain(URL_ERROR) expect(editorHtml()).toContain('href="tel:+33123456789"') }) }) describe('Preview neutralises unsafe links', () => { it.each(['javascript:alert(1)', 'data:text/html,b', 'vbscript:msgbox(1)'])( 'renders no live %s link', async (href) => { await mountEditor( `

Before x after

ok

`, ) document.body.querySelector('[role="switch"]')!.click() await settle() const preview = document.body.querySelector('[data-testid="article-preview"]')! expect(preview.innerHTML).not.toMatch(/href="(javascript|data|vbscript)/i) expect(preview.textContent).toContain('Before x after') expect(preview.querySelector('a[href="/ok"]')).not.toBeNull() }, ) }) describe('Long URL', () => { it('keeps the URL on one line with the full value in its title and Save link reachable', async () => { await mountEditor(`

See planning guidance now.

`) placeCursorIn('planning guidance') await openDialog() expect(field('url').value).toBe(LONG_URL) expect(field('url').getAttribute('title')).toBe(LONG_URL) expect(field('url').tagName).toBe('INPUT') expect(buttonLabelled('Save link')!.disabled).toBe(false) expect(dialog()!.className).not.toContain('overflow-x') }) }) describe('Leaving the dialog without saving', () => { async function openWithChanges() { await mountEditor() select('timber windows') await openDialog() type(field('text'), 'changed') type(field('url'), '/changed') await settle() } function expectUntouched() { expect(dialog()).toBeNull() expect(editorHtml()).not.toContain(' { await openWithChanges() await press('Cancel') expectUntouched() }) it('on Esc', async () => { await openWithChanges() field('text').dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true, }), ) await settle() expectUntouched() }) it('on a click outside', async () => { await openWithChanges() const overlay = document.body.querySelector( '[data-state="open"][data-slot="overlay"]', ) overlay!.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) overlay!.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) overlay!.click() await settle() expectUntouched() }) }) describe('Dialog or sheet by viewport, popover or sheet by input', () => { it('is a dialog at 1440 px wide', async () => { await mountEditor(LINKED, DESKTOP) await openDialog() expect(dialog()!.getAttribute('data-presentation')).toBe('dialog') }) it('is a bottom sheet at 390 px wide with the same fields and footer', async () => { await mountEditor(LINKED, PHONE) select('timber windows') await openDialog() expect(dialog()!.getAttribute('data-presentation')).toBe('sheet') expect(field('text')).not.toBeNull() expect(field('url')).not.toBeNull() expect(buttonLabelled('Cancel')).toBeDefined() }) it('follows the viewport, not the pointer, for the dialog', async () => { await mountEditor(LINKED, { width: 1440, coarse: true }) select('timber windows') await openDialog() expect(dialog()!.getAttribute('data-presentation')).toBe('dialog') }) it('opens a 288 px popover under the link for a fine pointer, with the link button active', async () => { await mountEditor(LINKED_SAME_TAB) clickLink() await settle() const popover = document.body.querySelector( '[role="dialog"][aria-label="Link"]', )! expect(popover.textContent).toContain('/articles/winter-checklist') expect(buttonLabelled('Edit')).toBeDefined() expect(buttonLabelled('Open')).toBeDefined() expect(buttonLabelled('Remove')).toBeDefined() expect(popover.closest('[data-slot="content"]')!.className).toContain('w-72') expect(linkButton().getAttribute('aria-pressed')).toBe('true') }) it('closes the popover with Esc and keeps the cursor in the link', async () => { await mountEditor(LINKED_SAME_TAB) clickLink() await settle() document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) await settle() expect(document.body.querySelector('[aria-label="Link"]')).toBeNull() expect(linkButton().getAttribute('aria-pressed')).toBe('true') }) it('opens the popover on Enter from the link element', async () => { await mountEditor(LINKED_SAME_TAB) editor() .view.dom.querySelector('a')! .dispatchEvent( new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, }), ) await settle() expect(document.body.querySelector('[aria-label="Link"]')).not.toBeNull() }) it('opens a sheet with three rows for a coarse pointer', async () => { await mountEditor(LINKED_SAME_TAB, PHONE) clickLink() await settle() const sheet = document.body.querySelector( '[role="dialog"][aria-label="Link"]', )! expect(sheet.textContent).toContain('timber windows') expect(sheet.textContent).toContain('/articles/winter-checklist') for (const label of ['Edit link', 'Open link', 'Remove link']) { expect(buttonLabelled(label)!.className).toContain('h-12') } }) it('opens the URL in a new tab without opener or referrer on Open', async () => { const open = vi.spyOn(window, 'open').mockReturnValue(null) await mountEditor(LINKED_SAME_TAB) clickLink() await settle() await press('Open') expect(open).toHaveBeenCalledWith( '/articles/winter-checklist', '_blank', 'noopener,noreferrer', ) open.mockRestore() }) it.each([ ['Remove', DESKTOP], ['Remove link', PHONE], ])('removes the link with %s, undoably and without a confirmation', async (label, media) => { await mountEditor(LINKED_SAME_TAB, media) clickLink() await settle() await press(label) expect(editorHtml()).not.toContain(' { function toolbar() { return document.body.querySelector('.article-toolbar')! } function layout(scrollLeft: number) { const element = toolbar() Object.defineProperty(element, 'clientWidth', { configurable: true, value: 390, }) Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 1100, }) element.scrollLeft = scrollLeft element.dispatchEvent(new Event('scroll')) } it('shows no fade when nothing is hidden', async () => { await mountEditor() expect(document.body.querySelector('[data-testid="toolbar-fade-start"]')).toBeNull() expect(document.body.querySelector('[data-testid="toolbar-fade-end"]')).toBeNull() }) it('fades only the side that has hidden buttons', async () => { await mountEditor(SENTENCE, PHONE) layout(0) await settle() expect(document.body.querySelector('[data-testid="toolbar-fade-start"]')).toBeNull() expect(document.body.querySelector('[data-testid="toolbar-fade-end"]')).not.toBeNull() layout(100) await settle() expect(document.body.querySelector('[data-testid="toolbar-fade-start"]')).not.toBeNull() expect(document.body.querySelector('[data-testid="toolbar-fade-end"]')).not.toBeNull() layout(710) await settle() expect(document.body.querySelector('[data-testid="toolbar-fade-start"]')).not.toBeNull() expect(document.body.querySelector('[data-testid="toolbar-fade-end"]')).toBeNull() }) it('keeps the toolbar in one scrolling row', async () => { await mountEditor(SENTENCE, PHONE) expect(toolbar().className).toContain('overflow-x-auto') expect(toolbar().className).not.toContain('flex-wrap') }) }) describe('Selections that straddle a link or span blocks', () => { const BODY = '

We also check the timber windows before

any tiling starts.

' it.each(['check the timber', 'windows before'])( 'covers the selection "%s" with the new link', async (selection) => { await mountEditor(BODY) const [first, last] = [selection.split(' ')[0]!, selection.split(' ').at(-1)!] const from = positionOf(first) editor().commands.setTextSelection({ from, to: positionOf(last) + last.length, }) const before = editor().state.doc.textContent await openDialog() type(field('url'), '/b') await settle() await press('Insert link') expect(editor().state.doc.textContent).toBe(before) expect(editorHtml()).toContain('href="/b"') }, ) it('spans two paragraphs without losing text', async () => { await mountEditor(BODY) editor().commands.setTextSelection({ from: positionOf('before'), to: positionOf('any tiling') + 'any tiling'.length, }) const before = editor().state.doc.textContent await openDialog() type(field('url'), '/b') await settle() await press('Insert link') expect(editor().state.doc.textContent).toBe(before) expect(editorHtml()).toContain('before') expect(editorHtml()).toContain('any tiling') }) }) describe('Links survive the HTML body format', () => { it('sends the anchor with href, target and rel on save and restores the toggle on reload', async () => { await mountEditor() select('timber windows') await openDialog() type(field('url'), '/articles/winter-checklist') toggle().click() await settle() await press('Insert link') document.body.querySelector('[data-testid="save-button"]')!.click() await settle() const [sent] = patchBodies() expect(sent).toContain('href="/articles/winter-checklist"') expect(sent).toContain('target="_blank"') expect(sent).toContain('rel="noopener noreferrer"') wrapper!.unmount() await mountEditor(sent) placeCursorIn('timber windows') await openDialog() expect(toggle().getAttribute('data-state')).toBe('checked') }) it('sends neither target nor rel without the toggle', async () => { await mountEditor() select('timber windows') await openDialog() await fillAndSubmit('timber windows', '/articles/winter-checklist') document.body.querySelector('[data-testid="save-button"]')!.click() await settle() const [sent] = patchBodies() expect(sent).toContain('timber windows') expect(sent).not.toContain('target') expect(sent).not.toContain('rel=') }) }) describe.each(['javascript:alert(1)', 'data:text/html,', 'vbscript:msgbox(1)'])( 'An unsafe href already in a body: %s', (href) => { const unsafe = `

Before x after

` it('keeps the text without a link, leaves the button off, and stays clean without a request', async () => { await mountEditor(unsafe) const requests = fetchMock.mock.calls.length expect(editorHtml()).not.toContain(' { await mountEditor(unsafe) editor().commands.insertContentAt(1, 'N') await settle() document.body.querySelector('[data-testid="save-button"]')!.click() await settle() const [sent] = patchBodies() expect(sent).not.toMatch(/href="(javascript|data|vbscript)/i) expect(sent).not.toContain(' { await mountEditor('

Hello

') editor().view.pasteHTML( `
x`, new Event('paste') as ClipboardEvent, ) await settle() expect(editorHtml()).not.toMatch(/href="(javascript|data|vbscript)/i) expect(editorHtml()).toContain('x') expect(isDirty()).toBe(true) }) }, ) describe('French completeness', () => { const NEW_STRINGS = [ 'Insert link', 'Insert link (Ctrl+K)', 'Insert link (⌘K)', 'Edit link', 'Save link', 'Remove link', 'Open link', 'Link', 'Link text', 'What the reader sees', 'https://example.com, /page, mailto: or #anchor', 'Open in new tab', 'Links open in the same tab unless you turn on a new tab.', TEXT_ERROR, URL_ERROR, 'Open', 'Remove', ] it.each(NEW_STRINGS)('has a French entry for %s', (key) => { const entry = messages[key as keyof typeof messages] expect(typeof entry).toBe('string') expect(entry).not.toBe('') expect(entry).not.toBe(key) }) it('shows the French dialog title and both errors', async () => { await mountEditor() setLocale('fr') select('timber windows') await openDialog() expect(dialog()!.textContent).toContain('Insérer un lien') await fillAndSubmit('', 'www.example', 'Insérer un lien') expect(dialog()!.textContent).toContain('Saisissez le texte du lien.') expect(dialog()!.textContent).toContain('Saisissez une URL valide.') }) })