- The preview re-renders 300 ms after the last WYSIWYG edit, Source input or external value, never while off or after detach. - YouTube placeholders show as img.youtube.com thumbnail cards with no player; the preview copy drops active elements and on* attributes, and its stylesheet uses colours resolved from the host's --c-* values.
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import fixture from './fixtures/imported-post.html?raw'
|
|
import { serializeDocument } from '../../assets/src/blogeditor/serializer'
|
|
import { PREVIEW_CSP, PREVIEW_DEBOUNCE_MS, PREVIEW_DEFAULTS, safeCssValue } from '../../assets/src/blogeditor/preview'
|
|
import type { BlogEditorElement } from '../../assets/src/blogeditor/index'
|
|
import { button, click, mount } from './helpers'
|
|
|
|
// Wrap the real serializer so the tests can tell whether the preview path
|
|
// ever serializes an untouched document.
|
|
vi.mock('../../assets/src/blogeditor/serializer', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../assets/src/blogeditor/serializer')>()
|
|
return { ...actual, serializeDocument: vi.fn(actual.serializeDocument) }
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
vi.mocked(serializeDocument).mockClear()
|
|
document.body.replaceChildren()
|
|
})
|
|
|
|
function pane(el: BlogEditorElement): HTMLElement {
|
|
const found = el.querySelector<HTMLElement>('.blogeditor__preview')
|
|
if (!found) throw new Error('no preview pane')
|
|
return found
|
|
}
|
|
|
|
function frame(el: BlogEditorElement): HTMLIFrameElement {
|
|
const found = el.querySelector<HTMLIFrameElement>('.blogeditor__preview iframe')
|
|
if (!found) throw new Error('no preview frame')
|
|
return found
|
|
}
|
|
|
|
function srcdoc(el: BlogEditorElement): string {
|
|
return frame(el).getAttribute('srcdoc') ?? ''
|
|
}
|
|
|
|
describe('blog editor preview: toggle', () => {
|
|
it('is off by default and toggles the split pane with aria-pressed', () => {
|
|
const { el } = mount('<p>Hello</p>')
|
|
const toggle = button(el, 'preview')
|
|
expect(toggle.textContent).toBe('Preview')
|
|
expect(toggle.getAttribute('aria-label')).toBe('Preview')
|
|
expect(toggle.getAttribute('aria-pressed')).toBe('false')
|
|
expect(pane(el).hidden).toBe(true)
|
|
|
|
click(toggle)
|
|
expect(toggle.getAttribute('aria-pressed')).toBe('true')
|
|
expect(pane(el).hidden).toBe(false)
|
|
expect(el.querySelector('.blogeditor__panes')!.classList.contains('blogeditor__panes--split')).toBe(true)
|
|
// Rendered right away, with no timer advanced.
|
|
expect(srcdoc(el)).toContain('<p>Hello</p>')
|
|
|
|
click(toggle)
|
|
expect(toggle.getAttribute('aria-pressed')).toBe('false')
|
|
expect(pane(el).hidden).toBe(true)
|
|
expect(el.querySelector('.blogeditor__panes')!.classList.contains('blogeditor__panes--split')).toBe(false)
|
|
})
|
|
|
|
it('is labelled through the ui-locale', () => {
|
|
const { el: pl } = mount('<p>x</p>', { 'ui-locale': 'pl' })
|
|
expect(button(pl, 'preview').textContent).toBe('Podgląd')
|
|
expect(button(pl, 'preview').getAttribute('aria-label')).toBe('Podgląd')
|
|
click(button(pl, 'preview'))
|
|
expect(frame(pl).title).toBe('Podgląd wpisu')
|
|
|
|
const { el: en } = mount('<p>x</p>', { 'ui-locale': 'en' })
|
|
click(button(en, 'preview'))
|
|
expect(frame(en).title).toBe('Post preview')
|
|
})
|
|
|
|
it('creates no iframe until the preview is first switched on', () => {
|
|
const { el } = mount('<p>x</p>')
|
|
expect(el.querySelector('iframe')).toBeNull()
|
|
click(button(el, 'preview'))
|
|
expect(el.querySelectorAll('iframe')).toHaveLength(1)
|
|
click(button(el, 'preview'))
|
|
click(button(el, 'preview'))
|
|
expect(el.querySelectorAll('iframe')).toHaveLength(1)
|
|
})
|
|
|
|
it('keeps its state per element', () => {
|
|
const { el: first } = mount('<p>one</p>')
|
|
const { el: second } = mount('<p>two</p>')
|
|
click(button(first, 'preview'))
|
|
expect(button(first, 'preview').getAttribute('aria-pressed')).toBe('true')
|
|
expect(button(second, 'preview').getAttribute('aria-pressed')).toBe('false')
|
|
expect(pane(second).hidden).toBe(true)
|
|
})
|
|
|
|
it('stays usable in the Source tab and when disabled', () => {
|
|
const { el: source } = mount('<p>s</p>')
|
|
source.showSource(true)
|
|
expect(button(source, 'preview').disabled).toBe(false)
|
|
click(button(source, 'preview'))
|
|
expect(button(source, 'preview').getAttribute('aria-pressed')).toBe('true')
|
|
expect(pane(source).hidden).toBe(false)
|
|
|
|
const { el: disabled } = mount('<p>d</p>', { disabled: '' })
|
|
expect(button(disabled, 'preview').disabled).toBe(false)
|
|
click(button(disabled, 'preview'))
|
|
expect(button(disabled, 'preview').getAttribute('aria-pressed')).toBe('true')
|
|
expect(pane(disabled).hidden).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('blog editor preview: sandbox', () => {
|
|
it('uses an empty sandbox and only srcdoc', () => {
|
|
const { el } = mount('<p>x</p>')
|
|
click(button(el, 'preview'))
|
|
const f = frame(el)
|
|
expect(f.getAttribute('sandbox')).toBe('')
|
|
const sandbox = f.getAttribute('sandbox') ?? ''
|
|
expect(sandbox).not.toMatch(/allow-scripts/)
|
|
expect(sandbox).not.toMatch(/allow-same-origin/)
|
|
expect(f.hasAttribute('src')).toBe(false)
|
|
expect(f.hasAttribute('allow')).toBe(false)
|
|
expect(f.getAttribute('referrerpolicy')).toBe('no-referrer')
|
|
const doc = srcdoc(el)
|
|
expect(doc).toContain(`<meta http-equiv="Content-Security-Policy" content="${PREVIEW_CSP}">`)
|
|
expect(doc).toContain('<meta name="referrer" content="no-referrer">')
|
|
// The head starts with the charset, then the CSP, then the referrer meta.
|
|
expect(doc.indexOf('<meta charset="utf-8">')).toBeLessThan(doc.indexOf('Content-Security-Policy'))
|
|
expect(doc.indexOf('Content-Security-Policy')).toBeLessThan(doc.indexOf('name="referrer"'))
|
|
})
|
|
})
|
|
|
|
describe('blog editor preview: value contract', () => {
|
|
it('never touches an untouched imported post', () => {
|
|
const { el, editor, changes } = mount(fixture)
|
|
const before = editor.state.doc
|
|
const toggle = button(el, 'preview')
|
|
click(toggle)
|
|
click(toggle)
|
|
click(toggle)
|
|
expect(changes).toEqual([])
|
|
expect(el.value).toBe(fixture)
|
|
expect(editor.state.doc).toBe(before)
|
|
expect(serializeDocument).not.toHaveBeenCalled()
|
|
expect(srcdoc(el)).toContain('Jan Kowalski')
|
|
|
|
el.value = fixture
|
|
expect(changes).toEqual([])
|
|
})
|
|
|
|
it('reads a touched document through the value getter without an event', () => {
|
|
const { el, editor, changes } = mount('<p>a</p>')
|
|
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
|
const toggle = button(el, 'preview')
|
|
click(toggle)
|
|
expect(changes).toHaveLength(1)
|
|
expect(srcdoc(el)).toContain('<p>Xa</p>')
|
|
expect(serializeDocument).toHaveBeenCalled()
|
|
|
|
click(toggle)
|
|
click(toggle)
|
|
expect(changes).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
/** Switches fake timers on (after mount, before the toggle) and the preview on. */
|
|
function previewWithTimers(el: BlogEditorElement): void {
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
click(button(el, 'preview'))
|
|
}
|
|
|
|
describe('blog editor preview: live updates', () => {
|
|
it('re-renders a WYSIWYG edit after the debounce', () => {
|
|
const { el, editor } = mount('<p>a</p>')
|
|
previewWithTimers(el)
|
|
expect(srcdoc(el)).toContain('<p>a</p>')
|
|
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
|
vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS - 1)
|
|
expect(srcdoc(el)).toContain('<p>a</p>')
|
|
expect(srcdoc(el)).not.toContain('<p>Xa</p>')
|
|
vi.advanceTimersByTime(1)
|
|
expect(srcdoc(el)).toContain('<p>Xa</p>')
|
|
})
|
|
|
|
it('restarts the debounce on every change', () => {
|
|
const { el, editor } = mount('<p>a</p>')
|
|
previewWithTimers(el)
|
|
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
|
vi.advanceTimersByTime(200)
|
|
editor.view.dispatch(editor.state.tr.insertText('Y', 1))
|
|
vi.advanceTimersByTime(200)
|
|
expect(srcdoc(el)).not.toContain('Xa')
|
|
expect(srcdoc(el)).not.toContain('YXa')
|
|
vi.advanceTimersByTime(100)
|
|
expect(srcdoc(el)).toContain('<p>YXa</p>')
|
|
})
|
|
|
|
it('re-renders Source tab input without an extra event', () => {
|
|
const { el, changes } = mount('<p>a</p>')
|
|
previewWithTimers(el)
|
|
el.showSource(true)
|
|
const textarea = el.querySelector('textarea')!
|
|
textarea.value = '<h2>From source</h2>'
|
|
textarea.dispatchEvent(new Event('input'))
|
|
vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS)
|
|
expect(srcdoc(el)).toContain('<h2>From source</h2>')
|
|
expect(changes).toEqual(['<h2>From source</h2>'])
|
|
})
|
|
|
|
it('re-renders an external value without an event', () => {
|
|
const { el, changes } = mount('<p>a</p>')
|
|
previewWithTimers(el)
|
|
el.value = '<p>New</p>'
|
|
vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS)
|
|
expect(srcdoc(el)).toContain('<p>New</p>')
|
|
expect(changes).toEqual([])
|
|
})
|
|
|
|
it('schedules nothing while it is off', () => {
|
|
const { el, editor } = mount('<p>a</p>')
|
|
previewWithTimers(el)
|
|
click(button(el, 'preview'))
|
|
const last = srcdoc(el)
|
|
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
|
vi.advanceTimersByTime(1000)
|
|
expect(srcdoc(el)).toBe(last)
|
|
})
|
|
|
|
it('drops a pending render when the element is detached', () => {
|
|
const { el, editor } = mount('<p>a</p>')
|
|
previewWithTimers(el)
|
|
const last = srcdoc(el)
|
|
editor.view.dispatch(editor.state.tr.insertText('X', 1))
|
|
el.remove()
|
|
expect(() => vi.advanceTimersByTime(1000)).not.toThrow()
|
|
expect(srcdoc(el)).toBe(last)
|
|
})
|
|
|
|
it('keeps an untouched post exact after renders', () => {
|
|
const { el, changes } = mount(fixture)
|
|
previewWithTimers(el)
|
|
vi.advanceTimersByTime(1000)
|
|
expect(changes).toEqual([])
|
|
expect(el.value).toBe(fixture)
|
|
expect(serializeDocument).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('blog editor preview: content', () => {
|
|
const placeholder = '<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>'
|
|
|
|
it('shows a YouTube placeholder as a thumbnail card, never a player', () => {
|
|
const { el } = mount(placeholder)
|
|
click(button(el, 'preview'))
|
|
const doc = srcdoc(el)
|
|
expect(doc).toContain('https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg')
|
|
expect(doc).toContain('referrerpolicy="no-referrer"')
|
|
expect(doc).toContain('Embedded video: YouTube dQw4w9WgXcQ')
|
|
expect(doc).not.toMatch(/<iframe/i)
|
|
expect(el.value).toBe(placeholder)
|
|
|
|
const { el: pl } = mount(placeholder, { 'ui-locale': 'pl' })
|
|
click(button(pl, 'preview'))
|
|
expect(srcdoc(pl)).toContain('Osadzone wideo: YouTube dQw4w9WgXcQ')
|
|
})
|
|
|
|
it('requests no thumbnail for an invalid placeholder', () => {
|
|
const { el } = mount('<figure data-embed="youtube" data-id="short"></figure>')
|
|
click(button(el, 'preview'))
|
|
expect(srcdoc(el)).not.toContain('img.youtube.com')
|
|
})
|
|
|
|
it('drops active content from the preview copy only', () => {
|
|
const value =
|
|
'<p>a</p><script>alert(1)</script><iframe src="https://example.test/"></iframe>' +
|
|
'<img src="/x.png" onerror="alert(1)"><meta http-equiv="refresh" content="0;url=https://example.test/">'
|
|
const { el, changes } = mount(value)
|
|
click(button(el, 'preview'))
|
|
const doc = srcdoc(el)
|
|
expect(doc).not.toMatch(/<script/i)
|
|
expect(doc).not.toMatch(/<iframe/i)
|
|
expect(doc).not.toMatch(/onerror/i)
|
|
expect(doc).not.toMatch(/http-equiv="refresh"/i)
|
|
expect(doc).toContain('<p>a</p>')
|
|
expect(doc).toContain('src="/x.png"')
|
|
expect(el.value).toBe(value)
|
|
expect(changes).toEqual([])
|
|
})
|
|
|
|
it('resolves colours from the host or uses the defaults', () => {
|
|
const { el } = mount('<p>a</p>')
|
|
el.style.setProperty('--c-text', 'rgb(1, 2, 3)')
|
|
click(button(el, 'preview'))
|
|
expect(srcdoc(el)).toContain('rgb(1, 2, 3)')
|
|
expect(srcdoc(el)).not.toContain('var(--c-')
|
|
|
|
const { el: plain } = mount('<p>b</p>')
|
|
click(button(plain, 'preview'))
|
|
expect(srcdoc(plain)).toContain(PREVIEW_DEFAULTS.colors.text)
|
|
expect(srcdoc(plain)).not.toContain('var(--c-')
|
|
})
|
|
|
|
it('sets the document language and the post-body typography', () => {
|
|
const { el } = mount('<p>a</p>', { locale: 'pl' })
|
|
click(button(el, 'preview'))
|
|
expect(srcdoc(el)).toContain('<html lang="pl">')
|
|
|
|
const { el: odd } = mount('<p>a</p>', { locale: 'pl"><x' })
|
|
click(button(odd, 'preview'))
|
|
const doc = srcdoc(odd)
|
|
expect(doc).toContain('<html>')
|
|
expect(doc).not.toContain('lang=')
|
|
expect(doc.match(/<style/g)).toHaveLength(1)
|
|
for (const rule of ['.post-body', 'blockquote', 'figcaption', '.embed-card']) expect(doc).toContain(rule)
|
|
expect(doc).toContain('<article class="post-body">')
|
|
})
|
|
})
|
|
|
|
describe('safeCssValue', () => {
|
|
it('falls back for anything that could leave the declaration', () => {
|
|
for (const bad of ['', ' ', 'red}', 'a<b', 'red; color: blue', 'x'.repeat(201), 'red\\', 'red /* x */', 'a>b', 'a{b'])
|
|
expect(safeCssValue(bad, 'fallback')).toBe('fallback')
|
|
})
|
|
|
|
it('returns a safe value trimmed', () => {
|
|
expect(safeCssValue(' rgb(1, 2, 3) ', 'fallback')).toBe('rgb(1, 2, 3)')
|
|
expect(safeCssValue('"DM Sans", sans-serif', 'fallback')).toBe('"DM Sans", sans-serif')
|
|
})
|
|
})
|