Files
sm-journal-plugin/tests/blogeditor/preview.test.ts
Jakub Zych df67939251 feat(editor): toggleable preview pane in a sandboxed frame
- A per-element Preview toggle (aria-pressed, off by default) splits the editor 50/50 with an auto-fit grid that wraps the pane below on narrow widths.
- The pane is an iframe with an empty sandbox, content set only through srcdoc, created on first use; it reads only the value getter, so previewing never edits or emits summer-change.
2026-10-10 13:53:48 +02:00

159 lines
6.2 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 } 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)
})
})