From 005db2f4310bef76a0d59ed7a143fcbdc550ac9c Mon Sep 17 00:00:00 2001 From: gsb Date: Wed, 29 Apr 2026 18:12:45 +0000 Subject: [PATCH] Add integration tests --- package.json | 2 +- src/static/ribbit-core.css | 5 + src/static/themes/ribbit-default/theme.css | 2 +- src/ts/ribbit-editor.ts | 684 +++++++++++++++++---- src/ts/ribbit.ts | 6 +- src/ts/tags.ts | 22 +- src/ts/theme-manager.ts | 5 +- src/ts/types.ts | 1 - test/integration/test.js | 17 + test/integration/test_fuzz.js | 460 ++++++++++++++ test/integration/test_wysiwyg.js | 454 ++++++++++++++ 11 files changed, 1544 insertions(+), 114 deletions(-) create mode 100644 test/integration/test_fuzz.js create mode 100644 test/integration/test_wysiwyg.js diff --git a/package.json b/package.json index 0949f55..f2adae3 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build:core-min": "esbuild src/ts/ribbit-core.ts --bundle --format=iife --global-name=ribbit --minify --outfile=dist/ribbit/ribbit-core.min.js", "build:css": "cp src/static/ribbit-core.css dist/ribbit/ && cp -r src/static/themes dist/ribbit/", "test": "npm run build && jest --verbose", - "test:integration": "npm run build && node test/integration/test.js", + "test:integration": "npm run build && node test/integration/test.js && node test/integration/test_wysiwyg.js", "test:coverage": "npm run build && jest --coverage" }, "license": "MIT", diff --git a/src/static/ribbit-core.css b/src/static/ribbit-core.css index ba93025..e74db91 100644 --- a/src/static/ribbit-core.css +++ b/src/static/ribbit-core.css @@ -30,6 +30,11 @@ font-size: 0.85em; } +[data-speculative]::before, +[data-speculative]::after { + content: none !important; +} + #ribbit.wysiwyg strong.ribbit-editing::before, #ribbit.wysiwyg strong.ribbit-editing::after { content: "**"; diff --git a/src/static/themes/ribbit-default/theme.css b/src/static/themes/ribbit-default/theme.css index ed2163a..c139fcf 100644 --- a/src/static/themes/ribbit-default/theme.css +++ b/src/static/themes/ribbit-default/theme.css @@ -4,7 +4,7 @@ * Replace this file with your own theme to customize the look. */ -@import "../ribbit-core.css"; +@import "../../ribbit-core.css"; a { text-decoration: none; diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index b3b293c..e72996e 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -57,65 +57,598 @@ export class RibbitEditor extends Ribbit { #bindEvents(): void { let debounceTimer: number | undefined; - let lastThrottle = 0; this.element.addEventListener('input', () => { - if (this.state === this.states.VIEW) { + if (this.state !== this.states.WYSIWYG) { return; } - - this.invalidateCache(); - - const now = Date.now(); - if (now - lastThrottle >= 150) { - lastThrottle = now; - this.refreshPreview(); - } + this.ensureBlockStructure(); + this.transformCurrentBlock(); + this.updateEditingContext(); clearTimeout(debounceTimer); debounceTimer = window.setTimeout(() => { - this.refreshPreview(); this.notifyChange(); - }, 150); + }, 300); + }); + + this.element.addEventListener('keydown', (e: KeyboardEvent) => { + if (this.state !== this.states.WYSIWYG) { + return; + } + if (e.key === 'Enter') { + this.handleEnter(e); + } + }); + + this.element.addEventListener('keyup', (e: KeyboardEvent) => { + if (this.state !== this.states.WYSIWYG) { + return; + } + if (e.key.startsWith('Arrow')) { + this.closeOrphanedSpeculative(); + this.updateEditingContext(); + } + }); + + this.element.addEventListener('blur', () => { + if (this.state !== this.states.WYSIWYG) { + return; + } + this.closeOrphanedSpeculative(); + }); + + this.element.addEventListener('focusout', () => { + if (this.state !== this.states.WYSIWYG) { + return; + } + this.closeOrphanedSpeculative(); + }); + + document.addEventListener('click', (e: MouseEvent) => { + if (this.state !== this.states.WYSIWYG) { + return; + } + if (!this.element.contains(e.target as Node)) { + this.closeAllSpeculative(); + } + }); + + document.addEventListener('selectionchange', () => { + if (this.state !== this.states.WYSIWYG) { + return; + } + this.closeOrphanedSpeculative(); + this.updateEditingContext(); }); } /** - * Re-render the WYSIWYG preview from the current content. - * Applies speculative rendering for unclosed inline delimiters - * at the cursor position, and uses toHtmlPreview for visible syntax. + * Find the block-level element containing the cursor. */ - refreshPreview(): void { - if (this.state !== this.states.WYSIWYG) { - return; - } - - const cursorInfo = this.getCursorInfo(); - const text = this.element.textContent || ''; - const lines = text.split('\n'); - - // Speculatively close unclosed delimiters on the cursor line - if (cursorInfo) { - const inlineTags = this.converter.getInlineTags(); - const sorted = [...inlineTags].sort((a, b) => - ((a as any).precedence ?? 50) - ((b as any).precedence ?? 50) - ); - for (const tag of sorted) { - if (tag.openPattern && tag.delimiter) { - const before = lines[cursorInfo.lineIndex].slice(0, cursorInfo.offset); - const escaped = tag.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const re = new RegExp(escaped, 'g'); - const count = (before.match(re) || []).length; - if (count % 2 === 1) { - lines[cursorInfo.lineIndex] = lines[cursorInfo.lineIndex] + tag.delimiter; - break; + /** + * Ensure the editor contains valid block structure. + * Wraps bare
and
elements in

tags. + */ + private ensureBlockStructure(): void { + for (const child of Array.from(this.element.childNodes)) { + if (child.nodeType === 1) { + const element = child as HTMLElement; + if (element.tagName === 'BR') { + const p = document.createElement('p'); + p.innerHTML = '
'; + element.replaceWith(p); + } else if (element.tagName === 'DIV') { + const p = document.createElement('p'); + while (element.firstChild) { + p.appendChild(element.firstChild); + } + if (!p.firstChild) { + p.innerHTML = '
'; + } + element.replaceWith(p); + // Restore cursor inside the new

+ const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = document.createRange(); + const target = p.lastChild || p; + if (target.nodeType === 3) { + range.setStart(target, target.textContent?.length || 0); + } else { + range.selectNodeContents(target); + range.collapse(false); + } + sel.removeAllRanges(); + sel.addRange(range); } } } } + if (!this.element.firstChild) { + this.element.innerHTML = '


'; + } + } - const html = this.converter.toHTML(lines.join('\n')); - this.updatePreview(html, cursorInfo); + private findCurrentBlock(): HTMLElement | null { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) { + return null; + } + let node: Node | null = sel.anchorNode; + + // If cursor is in a text node directly inside the editor, + // wrap it in a

first (browsers don't always do this). + if (node && node.nodeType === 3 && node.parentNode === this.element) { + const p = document.createElement('p'); + node.parentNode.insertBefore(p, node); + p.appendChild(node); + // Restore cursor inside the new

+ const range = document.createRange(); + range.setStart(node, sel.anchorOffset); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + return p; + } + + while (node && node !== this.element) { + if (node.nodeType === 1) { + const element = node as HTMLElement; + if (element.tagName === 'LI' || element.parentNode === this.element) { + return element; + } + } + node = node.parentNode; + } + return null; + } + + /** + * Check the current block's text for markdown patterns and + * transform the DOM element in-place if a pattern matches. + */ + private transformCurrentBlock(): void { + const block = this.findCurrentBlock(); + if (!block) { + return; + } + const text = (block.textContent || '').replace(/\u00A0/g, ' '); + + // Heading: # through ###### + const headingMatch = text.match(/^(#{1,6})\s/); + if (headingMatch) { + const level = headingMatch[1].length; + const targetTag = 'H' + level; + if (block.tagName !== targetTag) { + this.replaceBlock(block, targetTag, headingMatch[0].length); + return; + } + } + + // Blockquote: > + if (text.startsWith('> ') && block.tagName !== 'BLOCKQUOTE') { + this.replaceBlock(block, 'BLOCKQUOTE', 2); + return; + } + + // Horizontal rule: --- or *** or ___ + if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) { + const hr = document.createElement('hr'); + const p = document.createElement('p'); + p.innerHTML = '
'; + block.replaceWith(hr, p); + const range = document.createRange(); + range.setStart(p, 0); + range.collapse(true); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + return; + } + + // Unordered list: - or * + if (/^[-*]\s/.test(text) && block.tagName !== 'LI') { + this.replaceBlockWithList(block, 'ul', text.indexOf(' ') + 1); + return; + } + + // Ordered list: 1. + if (/^\d+\.\s/.test(text) && block.tagName !== 'LI') { + this.replaceBlockWithList(block, 'ol', text.indexOf(' ') + 1); + return; + } + + // Fenced code: ``` + if (text.startsWith('```') && block.tagName !== 'PRE') { + const pre = document.createElement('pre'); + const code = document.createElement('code'); + code.textContent = ''; + pre.appendChild(code); + block.replaceWith(pre); + const range = document.createRange(); + range.setStart(code, 0); + range.collapse(true); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + return; + } + + // Inline transforms: flatten to markdown, transform, rebuild DOM + this.transformInline(block); + } + + /** + * Convert a block's DOM children to a mixed string where completed + * inline elements are preserved as HTML and only speculative/text + * content is flattened to markdown. Completed elements are wrapped + * in sentinel markers so the regex engine skips them. + */ + private blockToMarkdown(block: HTMLElement): string { + let md = ''; + for (const child of Array.from(block.childNodes)) { + md += this.nodeToMarkdown(child); + } + return md; + } + + private nodeToMarkdown(node: Node): string { + if (node.nodeType === 3) { + return (node.textContent || '').replace(/\u200B/g, ''); + } + if (node.nodeType !== 1) { return ''; } + const element = node as HTMLElement; + const specDelim = element.getAttribute('data-speculative'); + + if (specDelim) { + // Speculative: restore opener delimiter + flatten children + let inner = ''; + for (const child of Array.from(element.childNodes)) { + inner += this.nodeToMarkdown(child); + } + return specDelim + inner; + } + + const tag = this.findTagForElement(element); + if (tag?.delimiter) { + // Completed element: preserve as HTML, wrapped in sentinels + // so the complete-pair regex won't match across it + return '\x01' + element.outerHTML + '\x02'; + } + + // Unknown element: flatten children + let inner = ''; + for (const child of Array.from(element.childNodes)) { + inner += this.nodeToMarkdown(child); + } + return inner; + } + + /** + * Find the Tag definition that matches an HTML element. + */ + private findTagForElement(el: HTMLElement): { delimiter?: string; name: string } | null { + const inlineTags = this.converter.getInlineTags(); + for (const tag of inlineTags) { + if (!tag.delimiter) continue; + if (typeof tag.selector === 'string') { + const selectors = tag.selector.split(','); + if (selectors.some(s => el.tagName === s.trim())) { + return tag; + } + } + } + return null; + } + + /** + * Flatten the block to markdown, find and apply inline transforms, + * then rebuild the DOM from the result. + */ + private transformInline(block: HTMLElement): void { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + + let md = this.blockToMarkdown(block); + if (md.replace(/\s/g, '').length < 2) return; + + const inlineTags = this.converter.getInlineTags(); + const sorted = [...inlineTags] + .filter(tag => tag.delimiter) + .sort((a, b) => (a.precedence ?? 50) - (b.precedence ?? 50)); + + // Build regex for each tag with exact-delimiter matching. + // [^\x01\x02] prevents matching across preserved HTML elements. + const tagRegexes = sorted.map(tag => { + const delim = tag.delimiter!; + const escaped = delim.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const ec = delim[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return { + tag, + complete: new RegExp(`(? = { + 'strong': ['strong', 'b'], + 'em': ['em', 'i'], + 'code': ['code', 'strong', 'b', 'em', 'i', 'a'], + }; + + let changed = true; + while (changed) { + changed = false; + for (const { tag, complete } of tagRegexes) { + const match = md.match(complete); + if (match && match.index !== undefined) { + const tagName = tag.name === 'boldItalic' ? 'em' : (tag.selector as string).split(',')[0].toLowerCase(); + + // Skip if wrapping would create forbidden nesting + const banned = forbiddenChildren[tagName]; + if (banned && banned.some(t => match[1].includes('<' + t))) { + continue; + } + + const content = tagName === 'code' + ? match[1].replace(/&/g, '&').replace(//g, '>') + : match[1]; + const inner = tag.name === 'boldItalic' + ? `\x01<${tagName}>${content}\x02` + : `\x01<${tagName}>${content}\x02`; + md = md.slice(0, match.index) + inner + md.slice(match.index + match[0].length); + changed = true; + break; + } + } + } + + // Strip sentinel markers now that complete-pair matching is done + md = md.replace(/[\x01\x02]/g, ''); + + // Check for one unclosed opener (speculative) + let speculativeTag: typeof sorted[0] | null = null; + let speculativeMatch: RegExpMatchArray | null = null; + for (const { tag, open } of tagRegexes) { + const match = md.match(open); + if (match && match.index !== undefined) { + // Make sure this isn't inside an HTML tag we just created + const before = md.slice(0, match.index); + if (!before.endsWith('<') && !before.endsWith('/')) { + speculativeTag = tag; + speculativeMatch = match; + break; + } + } + } + + // Rebuild the DOM + if (speculativeMatch && speculativeTag) { + const tagName = speculativeTag.name === 'boldItalic' ? 'em' : (speculativeTag.selector as string).split(',')[0].toLowerCase(); + const inside = md.slice(speculativeMatch.index! + speculativeTag.delimiter!.length); + + // Check for forbidden nesting before wrapping + const probe = document.createElement('div'); + probe.innerHTML = inside; + const banned = forbiddenChildren[tagName]; + const wouldNest = banned && banned.some(tag => probe.querySelector(tag)); + + if (!wouldNest) { + const before = md.slice(0, speculativeMatch.index!); + const wrapper = document.createElement(tagName); + wrapper.classList.add('ribbit-editing'); + wrapper.setAttribute('data-speculative', speculativeTag.delimiter!); + wrapper.innerHTML = inside; + this.sanitizeNesting(wrapper); + + block.innerHTML = ''; + if (before) { + block.appendChild(document.createTextNode(before)); + } + block.appendChild(wrapper); + // ZWS after wrapper so arrow-right can escape the element + block.appendChild(document.createTextNode('\u200B')); + + // Cursor at end of speculative element + this.placeCursorAtEnd(wrapper); + } else { + // Forbidden nesting — fall through to plain innerHTML + block.innerHTML = md; + this.sanitizeNesting(block); + if (block.lastChild && block.lastChild.nodeType === 1) { + block.appendChild(document.createTextNode('\u200B')); + } + this.placeCursorAtEnd(block); + } + } else { + block.innerHTML = md; + this.sanitizeNesting(block); + // If the block ends with an HTML element, append a ZWS text + // node so the cursor lands outside the element, not inside it. + if (block.lastChild && block.lastChild.nodeType === 1) { + block.appendChild(document.createTextNode('\u200B')); + } + this.placeCursorAtEnd(block); + } + } + + /** + * Place the cursor at the end of an element's content. + */ + private placeCursorAtEnd(el: HTMLElement): void { + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + // Find the deepest last text node + let target: Node = el; + while (target.lastChild) { + target = target.lastChild; + } + if (target.nodeType === 3) { + range.setStart(target, target.textContent?.length || 0); + } else { + range.selectNodeContents(target); + range.collapse(false); + } + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + + /** + + /** + * Replace a block element with a new tag, stripping the prefix + * and preserving cursor position. + */ + private replaceBlock(block: HTMLElement, newTag: string, prefixLength: number): void { + const newEl = document.createElement(newTag); + const content = (block.textContent || '').slice(prefixLength); + if (content) { + newEl.textContent = content; + } else { + newEl.innerHTML = '
'; + } + block.replaceWith(newEl); + newEl.classList.add('ribbit-editing'); + + // Place cursor at start of content + const range = document.createRange(); + if (newEl.firstChild && newEl.firstChild.nodeType === 3) { + range.setStart(newEl.firstChild, 0); + } else { + range.setStart(newEl, 0); + } + range.collapse(true); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + } + + /** + * Replace a block element with a list (ul/ol) containing one item. + */ + private replaceBlockWithList(block: HTMLElement, listTag: string, prefixLength: number): void { + const list = document.createElement(listTag); + const li = document.createElement('li'); + const content = (block.textContent || '').slice(prefixLength); + if (content) { + li.textContent = content; + } else { + li.innerHTML = '
'; + } + list.appendChild(li); + block.replaceWith(list); + + const range = document.createRange(); + if (li.firstChild && li.firstChild.nodeType === 3) { + range.setStart(li.firstChild, 0); + } else { + range.setStart(li, 0); + } + range.collapse(true); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + } + + /** + * Handle Enter key: strip syntax decorations from the current + * block before the browser creates a new line. + */ + private handleEnter(e: KeyboardEvent): void { + const prev = this.element.querySelector('.ribbit-editing'); + if (prev) { + prev.classList.remove('ribbit-editing'); + prev.removeAttribute('data-speculative'); + } + } + + /** + * Close any speculative elements that the cursor is no longer inside. + * Called on every selection change — handles arrow keys, clicks, + * tab switches, and any other cursor movement. + */ + /** + * Unwrap a speculative element, replacing it with its children. + * An orphaned speculative element was never completed — it should + * not become permanent formatting. + */ + private unwrapSpeculative(element: HTMLElement): void { + this.unwrapElement(element); + } + + /** + * Replace an element with its children, preserving content. + */ + private unwrapElement(element: HTMLElement): void { + const parent = element.parentNode; + if (!parent) { return; } + while (element.firstChild) { + parent.insertBefore(element.firstChild, element); + } + parent.removeChild(element); + } + + /** + * Remove forbidden nesting from a block element. + * For example, inside , inside , etc. + */ + private sanitizeNesting(block: HTMLElement): void { + const rules: Record = { + 'STRONG': ['STRONG', 'B'], + 'B': ['STRONG', 'B'], + 'EM': ['EM', 'I'], + 'I': ['EM', 'I'], + 'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A'], + }; + let found = true; + while (found) { + found = false; + for (const [parent, forbidden] of Object.entries(rules)) { + const parents = block.querySelectorAll(parent.toLowerCase()); + for (const parentEl of Array.from(parents)) { + for (const tag of forbidden) { + const nested = parentEl.querySelector(tag.toLowerCase()); + if (nested && nested !== parentEl) { + this.unwrapElement(nested as HTMLElement); + found = true; + } + } + } + } + } + } + + private closeAllSpeculative(): void { + for (const element of Array.from(this.element.querySelectorAll('[data-speculative]'))) { + this.unwrapSpeculative(element as HTMLElement); + } + } + + private closeOrphanedSpeculative(): void { + const speculative = this.element.querySelectorAll('[data-speculative]'); + if (speculative.length === 0) { return; } + + const sel = window.getSelection(); + const anchor = sel?.anchorNode; + + for (const el of Array.from(speculative)) { + const htmlEl = el as HTMLElement; + let inside = false; + let node: Node | null = anchor || null; + while (node) { + if (node === htmlEl) { + inside = true; + break; + } + node = node.parentNode; + } + if (!inside) { + this.unwrapSpeculative(htmlEl); + } + } } /** @@ -144,79 +677,20 @@ export class RibbitEditor extends Ribbit { } } - /** - * Get the cursor's line index and offset within that line. - */ - private getCursorInfo(): { lineIndex: number; offset: number; absoluteOffset: number } | null { - const sel = window.getSelection(); - if (!sel || sel.rangeCount === 0) { - return null; - } - const range = sel.getRangeAt(0); - const preRange = document.createRange(); - preRange.selectNodeContents(this.element); - preRange.setEnd(range.startContainer, range.startOffset); - const absoluteOffset = preRange.toString().length; - - const text = this.element.textContent || ''; - const beforeCursor = text.slice(0, absoluteOffset); - const lineIndex = beforeCursor.split('\n').length - 1; - const lineStart = beforeCursor.lastIndexOf('\n') + 1; - const offset = absoluteOffset - lineStart; - - return { lineIndex, offset, absoluteOffset }; - } - - /** - * Replace the editor's HTML and restore the cursor to its - * previous text offset position. - */ - private updatePreview(html: string, cursorInfo: { absoluteOffset: number } | null): void { - this.element.innerHTML = html; - - if (!cursorInfo) { - return; - } - - const walker = document.createTreeWalker(this.element, NodeFilter.SHOW_TEXT); - let remaining = cursorInfo.absoluteOffset; - let node: Text | null; - - while ((node = walker.nextNode() as Text | null)) { - if (remaining <= node.length) { - const sel = window.getSelection()!; - const range = document.createRange(); - range.setStart(node, remaining); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - break; - } - remaining -= node.length; - } - - this.updateEditingContext(); - } - htmlToMarkdown(html?: string): string { return this.converter.toMarkdown(html || this.element.innerHTML); } getMarkdown(): string { - if (this.cachedMarkdown !== null) { - return this.cachedMarkdown; - } if (this.getState() === this.states.EDIT) { let html = this.element.innerHTML; html = html.replace(/<(?:div|br)>/ig, ''); html = html.replace(/<\/div>/ig, '\n'); - this.cachedMarkdown = decodeHtmlEntities(html); + return decodeHtmlEntities(html); } else if (this.getState() === this.states.WYSIWYG) { - this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML); - } else { - this.cachedMarkdown = this.element.textContent || ''; + return this.htmlToMarkdown(this.element.innerHTML); } - return this.cachedMarkdown; + return this.element.textContent || ''; } wysiwyg(): void { @@ -229,6 +703,10 @@ export class RibbitEditor extends Ribbit { } this.element.contentEditable = 'true'; this.element.innerHTML = this.getHTML(); + // Ensure there's at least one block element for the cursor + if (!this.element.firstElementChild) { + this.element.innerHTML = '


'; + } Array.from(this.element.querySelectorAll('.macro')).forEach(el => { const macroEl = el as HTMLElement; if (macroEl.dataset.editable === 'false') { diff --git a/src/ts/ribbit.ts b/src/ts/ribbit.ts index c4b9211..87c430b 100644 --- a/src/ts/ribbit.ts +++ b/src/ts/ribbit.ts @@ -249,14 +249,14 @@ export class Ribbit { const revision = await this.collaboration.getRevision(id); if (!revision) return; this.cachedMarkdown = revision.content; - this.cachedHTML = null; + this.cachedHTML = this.markdownToHTML(revision.content); this.collaboration.sendUpdate(revision.content); if (this.getState() !== this.states.VIEW) { - this.element.innerHTML = this.getHTML(); + this.element.innerHTML = this.cachedHTML; } this.emitter.emit('change', { markdown: revision.content, - html: this.getHTML(), + html: this.cachedHTML, }); } diff --git a/src/ts/tags.ts b/src/ts/tags.ts index 1e2b209..eed470b 100644 --- a/src/ts/tags.ts +++ b/src/ts/tags.ts @@ -21,9 +21,24 @@ import type { Tag, Converter, ListItem, ListResult, InlineTagDef } from './types */ export function inlineTag(def: InlineTagDef): Tag { const escaped = def.delimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const matchPattern = new RegExp('^' + escaped + '(.+?)' + escaped); - const globalPattern = new RegExp(escaped + '(.+?)' + escaped, 'g'); - const openPattern = new RegExp(escaped + '(.+)$'); + + /* + * CommonMark flanking delimiter rules: + * - Opening delimiter must be preceded by start/whitespace/punctuation + * and followed by a non-whitespace character. + * - Closing delimiter must be preceded by a non-whitespace character + * and followed by end/whitespace/punctuation. + * + * This prevents mid-word matches like "2*3*4" from being treated as + * emphasis while still allowing "**bold** text" and "*italic*." to work. + */ + const punctuation = `[\\s.,;:!?'"()\\[\\]{}<>\\-/\\\\~#@&^|]`; + const ec = def.delimiter[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const openFlank = `(?:^|(?<=${punctuation}))${escaped}(?!${ec})(?=\\S)`; + const closeFlank = `(?<=\\S)(? string; selector: string | ((element: HTMLElement) => boolean); toMarkdown: (element: HTMLElement, convert: Converter) => string; - openPattern?: RegExp; delimiter?: string; precedence?: number; recursive?: boolean; diff --git a/test/integration/test.js b/test/integration/test.js index e70abbe..86ec495 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -253,6 +253,23 @@ async function runTests() { assert(md.includes('paragraph'), `Missing paragraph in: ${md}`); }); + await test('typing heading prefix in wysiwyg', async () => { + // Start fresh + await driver.executeScript(` + var e = window.__ribbitEditor; + e.wysiwyg(); + e.element.innerHTML = '


'; + `); + await clickEditor(); + await driver.sleep(100); + // Type "# Hello" + await driver.actions().sendKeys('# Hello').perform(); + await driver.sleep(100); + const html = await getEditorHTML(); + console.log(' HTML:', html.slice(0, 200)); + assert(html.includes(' in HTML: ${html.slice(0, 200)}`); + }); + await test('Ctrl+B shortcut works in wysiwyg', async () => { // Switch to wysiwyg await driver.executeScript('window.__ribbitEditor.wysiwyg()'); diff --git a/test/integration/test_fuzz.js b/test/integration/test_fuzz.js new file mode 100644 index 0000000..52d669c --- /dev/null +++ b/test/integration/test_fuzz.js @@ -0,0 +1,460 @@ +/** + * WYSIWYG fuzz test. + * + * Generates random keystroke sequences, types them char-by-char, + * and checks structural invariants after every keystroke. When a + * failure is found, the seed is logged for deterministic replay + * and the sequence is shrunk to a minimal reproducing case. + * + * Run: + * node test/integration/test_fuzz.js + * node test/integration/test_fuzz.js --seed 12345 + * node test/integration/test_fuzz.js --rounds 200 + * node test/integration/test_fuzz.js --seed 12345 --shrink + */ +const { Builder, By, Key } = require('selenium-webdriver'); +const firefox = require('selenium-webdriver/firefox'); +const { createServer } = require('./server'); + +let server, driver; +const DELAY = 20; + +/* ── Seeded PRNG (mulberry32) ── */ + +function mulberry32(seed) { + return function () { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/* ── Keystroke generation ── */ + +const PRINTABLE = 'abcdefghijklmnopqrstuvwxyz 0123456789.,!?'; +const DELIMITERS = ['*', '**', '***', '`', '~~']; +const BLOCK_PREFIXES = ['# ', '## ', '### ', '- ', '1. ', '> ', '---']; +const SPECIAL_KEYS = [ + { name: 'Enter', keys: Key.ENTER, isSpecial: true }, + { name: 'Backspace', keys: Key.BACK_SPACE, isSpecial: true }, + { name: 'ArrowLeft', keys: Key.ARROW_LEFT, isSpecial: true }, + { name: 'ArrowRight', keys: Key.ARROW_RIGHT, isSpecial: true }, +]; + +/** + * Generate a random keystroke sequence. + * Returns array of { name, keys } where keys is a string or Key constant. + */ +function generateSequence(random, length) { + const sequence = []; + for (let i = 0; i < length; i++) { + const roll = random(); + if (roll < 0.50) { + /* printable character */ + const character = PRINTABLE[Math.floor(random() * PRINTABLE.length)]; + sequence.push({ name: character === ' ' ? 'Space' : character, keys: character }); + } else if (roll < 0.70) { + /* delimiter */ + const delimiter = DELIMITERS[Math.floor(random() * DELIMITERS.length)]; + sequence.push({ name: delimiter, keys: delimiter }); + } else if (roll < 0.80) { + /* special key */ + const special = SPECIAL_KEYS[Math.floor(random() * SPECIAL_KEYS.length)]; + sequence.push(special); + } else if (roll < 0.88) { + /* block prefix (only useful at line start, but fuzz doesn't care) */ + const prefix = BLOCK_PREFIXES[Math.floor(random() * BLOCK_PREFIXES.length)]; + sequence.push({ name: `"${prefix.trim()}"`, keys: prefix }); + } else if (roll < 0.94) { + /* repeated delimiter (stress test) */ + const count = 2 + Math.floor(random() * 4); + const character = '*'; + sequence.push({ name: character.repeat(count), keys: character.repeat(count) }); + } else { + /* angle bracket / HTML-like content */ + const fragments = ['<', '>', '
', '
', '', '&']; + const fragment = fragments[Math.floor(random() * fragments.length)]; + sequence.push({ name: fragment, keys: fragment }); + } + } + return sequence; +} + +/* ── Invariant checks ── */ + +/** + * Valid direct children of the editor element. + * Everything the WYSIWYG produces must be one of these. + */ +const VALID_BLOCK_TAGS = new Set([ + 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', + 'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'HR', 'TABLE', +]); + +/** + * Valid inline elements that can appear inside block content. + */ +const VALID_INLINE_TAGS = new Set([ + 'STRONG', 'B', 'EM', 'I', 'CODE', 'A', 'BR', +]); + +/** + * Elements that can only contain specific children. + */ +const REQUIRED_CHILDREN = { + 'UL': ['LI'], + 'OL': ['LI'], + 'TABLE': ['THEAD', 'TBODY', 'TR', 'CAPTION', 'COLGROUP'], + 'THEAD': ['TR'], + 'TBODY': ['TR'], + 'TR': ['TH', 'TD'], +}; + +/** + * Elements that must not contain certain descendants. + */ +const FORBIDDEN_NESTING = { + 'LI': ['TABLE'], + 'A': ['A'], + 'STRONG': ['STRONG', 'B'], + 'B': ['STRONG', 'B'], + 'EM': ['EM', 'I'], + 'I': ['EM', 'I'], + 'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A'], +}; + +/** + * Run all invariant checks on the current editor state. + * Returns null if all pass, or a string describing the violation. + */ +async function checkInvariants() { + return driver.executeScript(function () { + var editor = document.getElementById('ribbit'); + if (!editor) { return 'Editor element not found'; } + if (editor.contentEditable !== 'true') { return 'contentEditable is not true'; } + + /* Invariant 1: all direct children are valid block elements */ + for (var i = 0; i < editor.childNodes.length; i++) { + var child = editor.childNodes[i]; + if (child.nodeType === 3) { + if (child.textContent.replace(/[\u200B\s]/g, '').length > 0) { + return 'Bare text node in editor: "' + child.textContent.slice(0, 40) + '"'; + } + continue; + } + if (child.nodeType !== 1) { continue; } + var validBlocks = ['P','H1','H2','H3','H4','H5','H6','UL','OL','BLOCKQUOTE','PRE','HR','TABLE']; + if (validBlocks.indexOf(child.nodeName) === -1) { + return 'Invalid block element: <' + child.nodeName.toLowerCase() + '>'; + } + } + + /* Invariant 2: no nested speculative elements */ + var specs = editor.querySelectorAll('[data-speculative]'); + for (var s = 0; s < specs.length; s++) { + if (specs[s].querySelector('[data-speculative]')) { + return 'Nested speculative elements'; + } + } + + /* Invariant 3: required children (UL must contain LI, etc.) */ + var parentChildRules = { + 'UL': ['LI'], 'OL': ['LI'], + 'TABLE': ['THEAD','TBODY','TR','CAPTION','COLGROUP'], + 'THEAD': ['TR'], 'TBODY': ['TR'], 'TR': ['TH','TD'], + }; + function checkChildren(element) { + var allowed = parentChildRules[element.nodeName]; + if (!allowed) { return null; } + for (var c = 0; c < element.children.length; c++) { + if (allowed.indexOf(element.children[c].nodeName) === -1) { + return '<' + element.children[c].nodeName.toLowerCase() + + '> inside <' + element.nodeName.toLowerCase() + + '> (allowed: ' + allowed.join(', ') + ')'; + } + } + for (var c = 0; c < element.children.length; c++) { + var result = checkChildren(element.children[c]); + if (result) { return result; } + } + return null; + } + var childViolation = checkChildren(editor); + if (childViolation) { return 'Invalid nesting: ' + childViolation; } + + /* Invariant 4: forbidden nesting (no inside , etc.) */ + var forbiddenRules = { + 'STRONG': ['STRONG','B'], 'B': ['STRONG','B'], + 'EM': ['EM','I'], 'I': ['EM','I'], + 'CODE': ['CODE','STRONG','B','EM','I','A'], + 'A': ['A'], + }; + var allElements = editor.querySelectorAll('*'); + for (var e = 0; e < allElements.length; e++) { + var el = allElements[e]; + var forbidden = forbiddenRules[el.nodeName]; + if (!forbidden) { continue; } + for (var f = 0; f < forbidden.length; f++) { + if (el.querySelector(forbidden[f].toLowerCase() + ',' + forbidden[f])) { + return 'Forbidden nesting: <' + forbidden[f].toLowerCase() + + '> inside <' + el.nodeName.toLowerCase() + '>'; + } + } + } + + /* Invariant 5: getMarkdown() must not throw */ + try { + window.__ribbitEditor.getMarkdown(); + } catch (err) { + return 'getMarkdown() threw: ' + err.message; + } + + /* Invariant 6: rendered HTML is stable through markdown round-trip. + md → toHTML → toMarkdown → toHTML must produce the same HTML. + The markdown representation may change (e.g. ***** → ***) but + the rendered output must be identical. + Skip if there are speculative elements (in-progress editing). */ + var hasSpeculative = editor.querySelector('[data-speculative]'); + if (!hasSpeculative) { + try { + var md = window.__ribbitEditor.getMarkdown(); + var converter = window.__ribbitEditor.converter; + var html1 = converter.toHTML(md); + var md2 = converter.toMarkdown(html1); + var html2 = converter.toHTML(md2); + /* Compare the rendered HTML, not the markdown */ + var div1 = document.createElement('div'); + div1.innerHTML = html1; + var div2 = document.createElement('div'); + div2.innerHTML = html2; + var text1 = div1.textContent.replace(/\s+/g, ' ').trim(); + var text2 = div2.textContent.replace(/\s+/g, ' ').trim(); + if (text1 !== text2) { + return 'Round-trip HTML mismatch:\n html1: "' + text1.slice(0, 80) + + '"\n html2: "' + text2.slice(0, 80) + '"'; + } + } catch (err) { + return 'Round-trip check threw: ' + err.message; + } + } + + /* Invariant 7: only valid inline elements inside block content */ + var validInline = ['STRONG','B','EM','I','CODE','A','BR']; + var blocks = editor.querySelectorAll('p,h1,h2,h3,h4,h5,h6,li,blockquote,td,th'); + for (var b = 0; b < blocks.length; b++) { + var inlineEls = blocks[b].querySelectorAll('*'); + for (var ie = 0; ie < inlineEls.length; ie++) { + var inEl = inlineEls[ie]; + /* Skip nested block elements (blockquote can contain blocks) */ + if (inEl.parentElement !== blocks[b] && inEl.closest('blockquote,ul,ol,table,pre') !== blocks[b]) { + continue; + } + if (validInline.indexOf(inEl.nodeName) === -1 && + ['P','H1','H2','H3','H4','H5','H6','UL','OL','BLOCKQUOTE','PRE','HR','TABLE','LI','THEAD','TBODY','TR','TH','TD','CAPTION','COLGROUP'].indexOf(inEl.nodeName) === -1) { + return 'Invalid inline element <' + inEl.nodeName.toLowerCase() + + '> inside <' + blocks[b].nodeName.toLowerCase() + '>'; + } + } + } + + return null; + }); +} + +/* ── Test runner ── */ + +async function setup() { + server = createServer(9996); + await server.start(); + const options = new firefox.Options().addArguments('--headless'); + driver = await new Builder().forBrowser('firefox').setFirefoxOptions(options).build(); + await driver.get(server.url); + await driver.wait(async () => driver.executeScript('return window.__ribbitReady === true'), 10000); +} + +async function teardown() { + if (driver) { await driver.quit(); } + if (server) { await server.stop(); } +} + +async function resetEditor() { + await driver.executeScript(` + var e = window.__ribbitEditor; + e.wysiwyg(); + e.element.innerHTML = '


'; + `); + await driver.findElement(By.id('ribbit')).click(); + await driver.sleep(50); +} + +async function typeKeystroke(keystroke) { + const keys = keystroke.keys; + if (typeof keys !== 'string') { + throw new Error('Invalid keystroke: ' + JSON.stringify(keystroke)); + } + if (keys.length === 1 || keystroke.isSpecial) { + await driver.actions().sendKeys(keys).perform(); + await driver.sleep(DELAY); + } else { + /* Multi-char string: type char by char */ + for (const character of keys) { + await driver.actions().sendKeys(character).perform(); + await driver.sleep(DELAY); + } + } +} + +function formatSequence(sequence, upTo) { + return sequence.slice(0, upTo + 1).map(s => s.name).join(' '); +} + +/** + * Replay a sequence and return the index of the first invariant failure, + * or -1 if no failure. + */ +async function replaySequence(sequence) { + await resetEditor(); + for (let i = 0; i < sequence.length; i++) { + await typeKeystroke(sequence[i]); + const violation = await checkInvariants(); + if (violation) { return { index: i, violation }; } + } + return null; +} + +/** + * Shrink a failing sequence to find the minimal reproducing prefix. + * Uses binary search on the sequence length. + */ +async function shrinkSequence(sequence, failIndex) { + let lo = 0; + let hi = failIndex; + let bestSequence = sequence.slice(0, failIndex + 1); + let bestViolation = ''; + + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + const candidate = sequence.slice(0, mid + 1); + const result = await replaySequence(candidate); + if (result) { + hi = mid; + bestSequence = candidate; + bestViolation = result.violation; + } else { + lo = mid + 1; + } + } + + /* Try removing individual keystrokes from the beginning */ + let shrunk = true; + while (shrunk) { + shrunk = false; + for (let i = 0; i < bestSequence.length - 1; i++) { + const candidate = [...bestSequence.slice(0, i), ...bestSequence.slice(i + 1)]; + const result = await replaySequence(candidate); + if (result) { + bestSequence = candidate; + bestViolation = result.violation; + shrunk = true; + break; + } + } + } + + return { sequence: bestSequence, violation: bestViolation }; +} + +async function runFuzz(options) { + const { rounds, minLength, maxLength, seed: baseSeed, doShrink } = options; + let totalKeystrokes = 0; + let failures = 0; + + console.log(`\nWYSIWYG Fuzz Test — ${rounds} rounds, seed ${baseSeed}\n`); + + for (let round = 0; round < rounds; round++) { + const roundSeed = baseSeed + round; + const random = mulberry32(roundSeed); + const length = minLength + Math.floor(random() * (maxLength - minLength)); + const sequence = generateSequence(random, length); + + await resetEditor(); + let failed = false; + + for (let i = 0; i < sequence.length; i++) { + await typeKeystroke(sequence[i]); + const violation = await checkInvariants(); + + if (violation) { + failures++; + failed = true; + const html = await driver.executeScript('return document.getElementById("ribbit").innerHTML'); + + console.log(` ✗ Round ${round + 1} [seed=${roundSeed}] — keystroke ${i + 1}/${length}`); + console.log(` Invariant: ${violation}`); + console.log(` Sequence: ${formatSequence(sequence, i)}`); + console.log(` HTML: ${html.slice(0, 200)}`); + + if (doShrink) { + console.log(` Shrinking...`); + const shrunk = await shrinkSequence(sequence, i); + console.log(` Minimal (${shrunk.sequence.length} keystrokes): ${shrunk.sequence.map(s => s.name).join(' ')}`); + console.log(` Violation: ${shrunk.violation}`); + } + + console.log(` Replay: node test/integration/test_fuzz.js --seed ${roundSeed}\n`); + break; + } + } + + if (!failed) { + totalKeystrokes += length; + if ((round + 1) % 10 === 0 || round === rounds - 1) { + process.stdout.write(` ✓ ${round + 1}/${rounds} rounds (${totalKeystrokes} keystrokes)\r`); + } + } + } + + console.log(`\n\n${rounds - failures}/${rounds} rounds passed — ${totalKeystrokes} keystrokes checked`); + if (failures > 0) { + console.log(`${failures} failure(s) found`); + } + return failures; +} + +/* ── CLI ── */ + +function parseArgs() { + const args = process.argv.slice(2); + const options = { + rounds: 50, + minLength: 20, + maxLength: 80, + seed: Date.now() % 100000, + doShrink: true, + }; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--seed' && args[i + 1]) { options.seed = parseInt(args[i + 1]); i++; } + if (args[i] === '--rounds' && args[i + 1]) { options.rounds = parseInt(args[i + 1]); i++; } + if (args[i] === '--min' && args[i + 1]) { options.minLength = parseInt(args[i + 1]); i++; } + if (args[i] === '--max' && args[i + 1]) { options.maxLength = parseInt(args[i + 1]); i++; } + if (args[i] === '--no-shrink') { options.doShrink = false; } + if (args[i] === '--shrink') { options.doShrink = true; } + } + return options; +} + +(async () => { + const options = parseArgs(); + try { + await setup(); + const failures = await runFuzz(options); + process.exitCode = failures > 0 ? 1 : 0; + } catch (error) { + console.error('Setup failed:', error.message); + process.exitCode = 1; + } finally { + await teardown(); + } +})(); diff --git a/test/integration/test_wysiwyg.js b/test/integration/test_wysiwyg.js new file mode 100644 index 0000000..6b7813e --- /dev/null +++ b/test/integration/test_wysiwyg.js @@ -0,0 +1,454 @@ +/** + * WYSIWYG integration tests with character-by-character typing. + * + * Every keystroke is sent individually with a delay, matching real + * user behavior. Assertions check intermediate DOM states to verify + * transforms fire at the right moments. + * + * Run: node test/integration/test_wysiwyg.js + */ +const { Builder, By, Key } = require('selenium-webdriver'); +const firefox = require('selenium-webdriver/firefox'); +const { createServer } = require('./server'); + +let server, driver; +const DELAY = 30; + +async function setup() { + server = createServer(9997); + await server.start(); + const options = new firefox.Options().addArguments('--headless'); + driver = await new Builder().forBrowser('firefox').setFirefoxOptions(options).build(); + await driver.get(server.url); + await driver.wait(async () => driver.executeScript('return window.__ribbitReady === true'), 10000); +} + +async function teardown() { + if (driver) { await driver.quit(); } + if (server) { await server.stop(); } +} + +async function resetEditor() { + await driver.executeScript(` + var e = window.__ribbitEditor; + e.wysiwyg(); + e.element.innerHTML = '


'; + `); + await driver.findElement(By.id('ribbit')).click(); + await driver.sleep(50); +} + +/** + * Send a single character and wait for the editor to process it. + */ +async function typeChar(character) { + await driver.actions().sendKeys(character).perform(); + await driver.sleep(DELAY); +} + +/** + * Type a string one character at a time with delay between each. + */ +async function typeString(text) { + for (const character of text) { + await typeChar(character); + } +} + +async function getHTML() { + return driver.executeScript('return document.getElementById("ribbit").innerHTML'); +} + +async function getMarkdown() { + return driver.executeScript('return window.__ribbitEditor.getMarkdown()'); +} + +let passed = 0, failed = 0; +const errors = []; + +function assert(condition, message) { + if (!condition) { throw new Error(message); } +} + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(` ✓ ${name}`); + } catch (error) { + failed++; + errors.push(name); + console.log(` ✗ ${name}`); + console.log(` ${error.message}`); + } +} + +async function runTests() { + console.log('\nWYSIWYG Integration Tests (char-by-char)\n'); + + // ── Headings ── + + console.log(' Headings:'); + + await test('# transforms to h1 after space', async () => { + await resetEditor(); + await typeChar('#'); + let html = await getHTML(); + assert(!html.includes(' { + await resetEditor(); + await typeString('##'); + let html = await getHTML(); + assert(!html.includes(' { + await resetEditor(); + await typeString('# Title'); + await typeChar(Key.ENTER); + await typeString('body'); + const html = await getHTML(); + assert(html.includes(' { + await resetEditor(); + await typeString('**'); + const html = await getHTML(); + assert(!html.includes(' { + await resetEditor(); + await typeString('**'); + await typeChar('x'); + const html = await getHTML(); + assert(html.includes(' { + await resetEditor(); + await typeString('**hello'); + let html = await getHTML(); + assert(html.includes('data-speculative'), `Not speculative during typing: ${html}`); + + await typeString('**'); + html = await getHTML(); + assert(html.includes(' { + await resetEditor(); + await typeString('**bold**'); + await typeString(' after'); + const html = await getHTML(); + assert(html.includes(' + const strongMatch = html.match(/]*>.*?<\/strong>/); + if (strongMatch) { + assert(!strongMatch[0].includes('after'), + `"after" is inside strong — cursor not placed correctly: ${html}`); + } + }); + + // ── Italic ── + + console.log(' Italic:'); + + await test('*x starts speculative italic', async () => { + await resetEditor(); + await typeChar('*'); + let html = await getHTML(); + assert(!html.includes(' { + await resetEditor(); + await typeString('*hello'); + let html = await getHTML(); + assert(html.includes('data-speculative'), `Not speculative: ${html}`); + + await typeChar('*'); + html = await getHTML(); + assert(html.includes(' { + await resetEditor(); + await typeString('`hello`'); + const html = await getHTML(); + assert(html.includes(' { + await resetEditor(); + + // Type ** + await typeString('**'); + let html = await getHTML(); + assert(!html.includes(' { + await resetEditor(); + await typeString('**bold**'); + let html = await getHTML(); + assert(html.includes(' { + await resetEditor(); + await typeChar('-'); + let html = await getHTML(); + assert(!html.includes(' { + await resetEditor(); + await typeString('1.'); + let html = await getHTML(); + assert(!html.includes(' space transforms to blockquote', async () => { + await resetEditor(); + await typeChar('>'); + let html = await getHTML(); + assert(!html.includes(' ": ${html}`); + }); + + // ── Horizontal rule ── + + console.log(' Horizontal rule:'); + + await test('--- transforms to hr', async () => { + await resetEditor(); + await typeString('--'); + let html = await getHTML(); + assert(!html.includes(' { + await resetEditor(); + await typeString('**hello**'); + await driver.sleep(50); + const markdown = await getMarkdown(); + assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`); + }); + + await test('# Title round-trips to markdown', async () => { + await resetEditor(); + await typeString('# Title'); + await driver.sleep(50); + const markdown = await getMarkdown(); + assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`); + }); + + await test('mode switch preserves content', async () => { + await resetEditor(); + await typeString('**bold**'); + await typeString(' and '); + await typeString('*italic*'); + await driver.sleep(50); + + await driver.executeScript('window.__ribbitEditor.view()'); + await driver.sleep(50); + await driver.executeScript('window.__ribbitEditor.wysiwyg()'); + await driver.sleep(50); + + const html = await getHTML(); + assert(html.includes(' { + await resetEditor(); + await typeString('**hello'); + await driver.sleep(50); + let html = await getHTML(); + assert(html.includes('data-speculative'), `No speculative: ${html}`); + + await typeChar(Key.ARROW_RIGHT); + await driver.sleep(50); + html = await getHTML(); + assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`); + }); + + await test('click outside closes speculative', async () => { + await resetEditor(); + await typeString('**hello'); + await driver.sleep(50); + let html = await getHTML(); + assert(html.includes('data-speculative'), `No speculative: ${html}`); + + // Add an element outside the editor and click it + await driver.executeScript(` + if (!document.getElementById('outside')) { + var btn = document.createElement('button'); + btn.id = 'outside'; + btn.textContent = 'outside'; + btn.style.display = 'block'; + btn.style.padding = '20px'; + document.body.appendChild(btn); + } + `); + await driver.findElement(By.id('outside')).click(); + await driver.sleep(100); + html = await getHTML(); + assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`); + }); + + // ── Complex document ── + + console.log(' Complex document:'); + + await test('multi-element document', async () => { + await resetEditor(); + await typeString('# Title'); + await typeChar(Key.ENTER); + await typeString('Some **bold** text.'); + await typeChar(Key.ENTER); + await typeString('## Section'); + await typeChar(Key.ENTER); + await typeString('- item one'); + + await driver.sleep(100); + const html = await getHTML(); + assert(html.includes(' { + try { + await setup(); + await runTests(); + } catch (error) { + console.error('Setup failed:', error.message); + failed++; + } finally { + console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`); + if (errors.length) { + console.log('\nFailed:'); + errors.forEach(error => console.log(` • ${error}`)); + } + await teardown(); + process.exit(failed > 0 ? 1 : 0); + } +})();