Add integration tests

This commit is contained in:
gsb 2026-04-29 18:12:45 +00:00
parent 3e8d3388f6
commit 005db2f431
11 changed files with 1544 additions and 114 deletions

View File

@ -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",

View File

@ -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: "**";

View File

@ -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;

View File

@ -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) {
/**
* Ensure the editor contains valid block structure.
* Wraps bare <br> and <div> elements in <p> 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 = '<br>';
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 = '<br>';
}
element.replaceWith(p);
// Restore cursor inside the new <p>
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 = '<p><br></p>';
}
}
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 <p> 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 <p>
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;
}
const cursorInfo = this.getCursorInfo();
const text = this.element.textContent || '';
const lines = text.split('\n');
// Horizontal rule: --- or *** or ___
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(text)) {
const hr = document.createElement('hr');
const p = document.createElement('p');
p.innerHTML = '<br>';
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;
}
// Speculatively close unclosed delimiters on the cursor line
if (cursorInfo) {
// 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();
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;
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(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+?)(?<!${ec})${escaped}`),
open: new RegExp(`(?<!${ec})${escaped}(?!${ec})([^\\x01\\x02]+)$`),
};
});
// Apply complete pairs repeatedly until none match
const forbiddenChildren: Record<string, string[]> = {
'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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
: match[1];
const inner = tag.name === 'boldItalic'
? `\x01<${tagName}><strong>${content}</strong></${tagName}>\x02`
: `\x01<${tagName}>${content}</${tagName}>\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;
}
}
}
const html = this.converter.toHTML(lines.join('\n'));
this.updatePreview(html, cursorInfo);
// 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 = '<br>';
}
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 = '<br>';
}
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, <em> inside <em>, <strong> inside <code>, etc.
*/
private sanitizeNesting(block: HTMLElement): void {
const rules: Record<string, string[]> = {
'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 = '<p><br></p>';
}
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
const macroEl = el as HTMLElement;
if (macroEl.dataset.editable === 'false') {

View File

@ -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,
});
}

View File

@ -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)(?<!${ec})${escaped}(?=$|${punctuation}|(?!${ec}))`;
const matchPattern = new RegExp(`^${escaped}(?!${ec})(?=\\S)(.+?)(?<=\\S)(?<!${ec})${escaped}`);
const globalPattern = new RegExp(openFlank + '(.+?)' + closeFlank, 'g');
const upperTag = def.htmlTag.toUpperCase();
const selector = [upperTag, ...(def.aliases || '').split(',').filter(Boolean)].join(',');
const recursive = def.recursive !== false;
@ -33,7 +48,6 @@ export function inlineTag(def: InlineTagDef): Tag {
precedence: def.precedence ?? 50,
recursive,
pattern: globalPattern,
openPattern,
delimiter: def.delimiter,
template: `${def.delimiter}text${def.delimiter}`,
replaceSelection: true,

View File

@ -75,7 +75,10 @@ export class ThemeManager {
}
const previous = this.active;
this.active = theme;
// Only load CSS when switching themes, not on initial set
if (previous !== theme) {
this.loadCSS(name);
}
this.onSwitch(theme, previous);
}

View File

@ -36,7 +36,6 @@ export interface Tag {
toHTML: (token: SourceToken, convert: Converter) => string;
selector: string | ((element: HTMLElement) => boolean);
toMarkdown: (element: HTMLElement, convert: Converter) => string;
openPattern?: RegExp;
delimiter?: string;
precedence?: number;
recursive?: boolean;

View File

@ -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 = '<p><br></p>';
`);
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('<h1'), `Expected <h1> in HTML: ${html.slice(0, 200)}`);
});
await test('Ctrl+B shortcut works in wysiwyg', async () => {
// Switch to wysiwyg
await driver.executeScript('window.__ribbitEditor.wysiwyg()');

View File

@ -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 = ['<', '>', '<div>', '</div>', '<b>', '&amp;'];
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 <strong> inside <strong>, 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 = '<p><br></p>';
`);
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();
}
})();

View File

@ -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 = '<p><br></p>';
`);
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('<h1'), `Premature h1 after just #: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<h1'), `No h1 after "# ": ${html}`);
await typeString('Hello');
html = await getHTML();
assert(html.includes('<h1') && html.includes('Hello'), `Missing content in h1: ${html}`);
});
await test('## transforms to h2 after space', async () => {
await resetEditor();
await typeString('##');
let html = await getHTML();
assert(!html.includes('<h2'), `Premature h2: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<h2'), `No h2 after "## ": ${html}`);
});
await test('enter after heading creates new paragraph', async () => {
await resetEditor();
await typeString('# Title');
await typeChar(Key.ENTER);
await typeString('body');
const html = await getHTML();
assert(html.includes('<h1'), `No h1: ${html}`);
assert(html.includes('body'), `No body text: ${html}`);
});
// ── Bold ──
console.log(' Bold:');
await test('** does not transform without content', async () => {
await resetEditor();
await typeString('**');
const html = await getHTML();
assert(!html.includes('<strong'), `Premature strong after just **: ${html}`);
});
await test('**x starts speculative bold', async () => {
await resetEditor();
await typeString('**');
await typeChar('x');
const html = await getHTML();
assert(html.includes('<strong'), `No strong after **x: ${html}`);
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
});
await test('**hello** completes bold', async () => {
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('<strong'), `No strong after closing: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative after closing: ${html}`);
assert(html.includes('hello'), `Missing content: ${html}`);
});
await test('typing after **bold** goes outside strong', async () => {
await resetEditor();
await typeString('**bold**');
await typeString(' after');
const html = await getHTML();
assert(html.includes('<strong'), `No strong: ${html}`);
assert(html.includes('after'), `Missing "after" text: ${html}`);
// "after" should NOT be inside <strong>
const strongMatch = html.match(/<strong[^>]*>.*?<\/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('<em'), `Premature em after just *: ${html}`);
await typeChar('x');
html = await getHTML();
assert(html.includes('<em'), `No em after *x: ${html}`);
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
});
await test('*hello* completes italic', async () => {
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('<em'), `No em: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
});
// ── Code ──
console.log(' Code:');
await test('`hello` completes code span', async () => {
await resetEditor();
await typeString('`hello`');
const html = await getHTML();
assert(html.includes('<code'), `No code: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
assert(html.includes('hello'), `Missing content: ${html}`);
});
// ── Nested inline ──
console.log(' Nested inline:');
await test('**bold *italic* still typing bold', async () => {
await resetEditor();
// Type **
await typeString('**');
let html = await getHTML();
assert(!html.includes('<strong'), `Premature strong after **: ${html}`);
// Type b — speculative bold starts
await typeChar('b');
html = await getHTML();
assert(html.includes('<strong'), `No strong after **b: ${html}`);
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
// Type "old " — still speculative bold
await typeString('old ');
html = await getHTML();
assert(html.includes('data-speculative'), `Lost speculative during bold: ${html}`);
// Type * — just a * inside the speculative bold
await typeChar('*');
html = await getHTML();
assert(html.includes('data-speculative'), `Lost speculative after *: ${html}`);
// Type "italic" — speculative italic should nest inside speculative bold
await typeString('italic');
html = await getHTML();
// Should have both strong and em
assert(html.includes('<strong'), `Lost strong: ${html}`);
// Type * — closes italic, bold still speculative
await typeChar('*');
html = await getHTML();
assert(html.includes('<em'), `No em after closing *: ${html}`);
assert(html.includes('italic'), `Missing italic content: ${html}`);
// Bold should still be speculative (unclosed)
assert(html.includes('data-speculative'), `Bold not speculative anymore: ${html}`);
});
await test('**bold** and *italic* on same line', async () => {
await resetEditor();
await typeString('**bold**');
let html = await getHTML();
assert(html.includes('<strong'), `No strong: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
await typeString(' and ');
await typeString('*italic*');
html = await getHTML();
assert(html.includes('<strong'), `Lost strong: ${html}`);
assert(html.includes('<em'), `No em: ${html}`);
assert(html.includes('italic'), `Missing italic content: ${html}`);
});
// ── Lists ──
console.log(' Lists:');
await test('- space transforms to unordered list', async () => {
await resetEditor();
await typeChar('-');
let html = await getHTML();
assert(!html.includes('<ul'), `Premature ul after just -: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<ul') || html.includes('<li'), `No list after "- ": ${html}`);
await typeString('item');
html = await getHTML();
assert(html.includes('item'), `Missing content: ${html}`);
});
await test('1. space transforms to ordered list', async () => {
await resetEditor();
await typeString('1.');
let html = await getHTML();
assert(!html.includes('<ol'), `Premature ol: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<ol') || html.includes('<li'), `No list after "1. ": ${html}`);
});
// ── Blockquote ──
console.log(' Blockquote:');
await test('> space transforms to blockquote', async () => {
await resetEditor();
await typeChar('>');
let html = await getHTML();
assert(!html.includes('<blockquote'), `Premature blockquote: ${html}`);
await typeChar(' ');
html = await getHTML();
assert(html.includes('<blockquote'), `No blockquote after "> ": ${html}`);
});
// ── Horizontal rule ──
console.log(' Horizontal rule:');
await test('--- transforms to hr', async () => {
await resetEditor();
await typeString('--');
let html = await getHTML();
assert(!html.includes('<hr'), `Premature hr: ${html}`);
await typeChar('-');
await driver.sleep(50);
html = await getHTML();
assert(html.includes('<hr'), `No hr after ---: ${html}`);
});
// ── Round-trip ──
console.log(' Round-trip:');
await test('**hello** round-trips to markdown', async () => {
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('<strong'), `Bold lost after mode switch: ${html}`);
assert(html.includes('<em'), `Italic lost after mode switch: ${html}`);
});
// ── Speculative closing ──
console.log(' Speculative closing:');
await test('right arrow closes speculative', async () => {
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('<h1'), `Missing h1: ${html}`);
assert(html.includes('<strong'), `Missing strong: ${html}`);
assert(html.includes('<h2'), `Missing h2: ${html}`);
assert(html.includes('<li') || html.includes('<ul'), `Missing list: ${html}`);
});
}
(async () => {
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);
}
})();