Files
sm-journal-plugin/tests/blogeditor/preview.test.ts
Jakub Zych c1f6a83c2f feat(editor): live preview with embed cards and post typography
- 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.
2026-10-10 13:56:57 +02:00

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')
})
})