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() 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('.blogeditor__preview') if (!found) throw new Error('no preview pane') return found } function frame(el: BlogEditorElement): HTMLIFrameElement { const found = el.querySelector('.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('

Hello

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

Hello

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

x

', { '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('

x

', { '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('

x

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

one

') const { el: second } = mount('

two

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

s

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

d

', { 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('

x

') 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(``) expect(doc).toContain('') // The head starts with the charset, then the CSP, then the referrer meta. expect(doc.indexOf('')).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('

a

') editor.view.dispatch(editor.state.tr.insertText('X', 1)) const toggle = button(el, 'preview') click(toggle) expect(changes).toHaveLength(1) expect(srcdoc(el)).toContain('

Xa

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

a

') previewWithTimers(el) expect(srcdoc(el)).toContain('

a

') editor.view.dispatch(editor.state.tr.insertText('X', 1)) vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS - 1) expect(srcdoc(el)).toContain('

a

') expect(srcdoc(el)).not.toContain('

Xa

') vi.advanceTimersByTime(1) expect(srcdoc(el)).toContain('

Xa

') }) it('restarts the debounce on every change', () => { const { el, editor } = mount('

a

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

YXa

') }) it('re-renders Source tab input without an extra event', () => { const { el, changes } = mount('

a

') previewWithTimers(el) el.showSource(true) const textarea = el.querySelector('textarea')! textarea.value = '

From source

' textarea.dispatchEvent(new Event('input')) vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS) expect(srcdoc(el)).toContain('

From source

') expect(changes).toEqual(['

From source

']) }) it('re-renders an external value without an event', () => { const { el, changes } = mount('

a

') previewWithTimers(el) el.value = '

New

' vi.advanceTimersByTime(PREVIEW_DEBOUNCE_MS) expect(srcdoc(el)).toContain('

New

') expect(changes).toEqual([]) }) it('schedules nothing while it is off', () => { const { el, editor } = mount('

a

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

a

') 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 = '
' 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(/' + '' const { el, changes } = mount(value) click(button(el, 'preview')) const doc = srcdoc(el) expect(doc).not.toMatch(/