1117 lines
33 KiB
TypeScript
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)',
|
|
'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('<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, '"')}">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, '"')}">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.')
|
|
})
|
|
})
|