Files
RenewCMS/web/src/components/__tests__/ArticleEditorLink.spec.ts
T

1117 lines
33 KiB
TypeScript

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 = '<p>We also check the timber windows before any tiling starts.</p>'
const LINKED =
'<p>We also check the <a href="/articles/winter-checklist" target="_blank" rel="noopener noreferrer">timber windows</a> before any tiling starts.</p>'
const LINKED_SAME_TAB =
'<p>We also check the <a href="/articles/winter-checklist">timber windows</a> before any tiling starts.</p>'
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<typeof vi.fn>
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<HTMLButtonElement>('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<HTMLElement>('[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<HTMLInputElement>(
'input[type="text"], input:not([type])',
)
return inputs[name === 'text' ? 0 : 1]!
}
function toggle() {
return dialog()!.querySelector<HTMLElement>('[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<HTMLButtonElement>('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}<img src="/static/uploadedImages/a.png" data-image-id="1">`)
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: '<p>Hello</p>',
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<HTMLElement>('[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(`<a href="/articles/winter-checklist">${text}</a>`)
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(
'<a target="_blank" rel="noopener noreferrer" href="https://example.org/windows">timber windows</a>',
)
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('<a ')
expect(editorHtml()).toContain('timber windows')
expect(isDirty()).toBe(true)
editor().commands.undo()
await settle()
expect(editorHtml()).toContain('href="/articles/winter-checklist"')
expect(editorHtml()).toContain('target="_blank"')
})
it('writes the new tab attributes only when the toggle is on', async () => {
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)',
'java&#115;cript: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('<a ')
expect(editorHtml()).not.toMatch(/href="(javascript|data|vbscript)/i)
expect(isDirty()).toBe(false)
expect(document.activeElement).toBe(field('url'))
})
it.each(['', ' '])('rejects blank link text %j and focuses it', async (text) => {
await submitWith(text, '#measuring')
expect(dialog()!.textContent).toContain(TEXT_ERROR)
expect(dialog()!.textContent).not.toContain(URL_ERROR)
expect(document.activeElement).toBe(field('text'))
expect(isDirty()).toBe(false)
})
it('shows both errors and focuses the link text', async () => {
await submitWith('', 'www.example')
expect(dialog()!.textContent).toContain(TEXT_ERROR)
expect(dialog()!.textContent).toContain(URL_ERROR)
expect(document.activeElement).toBe(field('text'))
})
it('validates the URL when focus leaves it', async () => {
await mountEditor()
select('timber windows')
await openDialog()
field('url').focus()
type(field('url'), 'www.example')
field('url').blur()
await settle()
expect(dialog()!.textContent).toContain(URL_ERROR)
expect(editorHtml()).not.toContain('<a ')
})
it.each([
['https://example.org/a?b=1#c', 'https://example.org/a?b=1#c'],
['HTTP://Example.org', 'HTTP://Example.org'],
['mailto:hello@example.org', 'mailto:hello@example.org'],
['/articles/winter-checklist', '/articles/winter-checklist'],
['#measuring', '#measuring'],
[' /page ', '/page'],
])('accepts %s as href %s', async (url, href) => {
await mountEditor()
editor().commands.setTextSelection(positionOf('before'))
await openDialog()
await fillAndSubmit('x', url)
expect(editorHtml()).toContain(`<a href="${href}">x</a>`)
})
})
describe('Editing a legacy URL', () => {
const LEGACY = '<p>See <a href="tel:+33123456789">call us</a> now.</p>'
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(
`<p>Before <a href="${href}">x</a> after</p><p><a href="/ok">ok</a></p>`,
)
document.body.querySelector<HTMLElement>('[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(`<p>See <a href="${LONG_URL}">planning guidance</a> now.</p>`)
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('<a ')
expect(isDirty()).toBe(false)
expect(document.activeElement).toBe(editor().view.dom)
const { from, to } = editor().state.selection
expect(editor().state.doc.textBetween(from, to)).toBe('timber windows')
}
it('on Cancel', async () => {
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<HTMLElement>(
'[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<HTMLElement>(
'[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<HTMLElement>(
'[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('<a ')
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
expect(isDirty()).toBe(true)
editor().commands.undo()
expect(editorHtml()).toContain('href="/articles/winter-checklist"')
})
})
describe('Toolbar overflow', () => {
function toolbar() {
return document.body.querySelector<HTMLElement>('.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 =
'<p>We also check the <a href="/a">timber windows</a> before</p><p>any tiling starts.</p>'
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('<a href="/b">before</a>')
expect(editorHtml()).toContain('<a href="/b">any tiling</a>')
})
})
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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-testid="save-button"]')!.click()
await settle()
const [sent] = patchBodies()
expect(sent).toContain('<a href="/articles/winter-checklist">timber windows</a>')
expect(sent).not.toContain('target')
expect(sent).not.toContain('rel=')
})
})
describe.each(['javascript:alert(1)', 'data:text/html,<b>', 'vbscript:msgbox(1)'])(
'An unsafe href already in a body: %s',
(href) => {
const unsafe = `<p>Before <a href="${href.replace(/"/g, '&quot;')}">x</a> after</p>`
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('<a ')
expect(editor().state.doc.textContent).toBe('Before x after')
placeCursorIn('x after')
editor().commands.setTextSelection(positionOf('x') + 1)
await settle()
expect(linkButton().getAttribute('aria-pressed')).toBe('false')
expect(isDirty()).toBe(false)
expect(fetchMock.mock.calls.length).toBe(requests)
})
it('rewrites the stored body without the href on the next save', async () => {
await mountEditor(unsafe)
editor().commands.insertContentAt(1, 'N')
await settle()
document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')!.click()
await settle()
const [sent] = patchBodies()
expect(sent).not.toMatch(/href="(javascript|data|vbscript)/i)
expect(sent).not.toContain('<a ')
})
it('neutralises the same HTML pasted into the body, and the draft is dirty', async () => {
await mountEditor('<p>Hello</p>')
editor().view.pasteHTML(
`<a href="${href.replace(/"/g, '&quot;')}">x</a>`,
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.')
})
})