Add integration tests
This commit is contained in:
parent
3e8d3388f6
commit
005db2f431
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "**";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 <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>';
|
||||
}
|
||||
}
|
||||
|
||||
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 <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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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(`(?<!${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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -75,7 +75,10 @@ export class ThemeManager {
|
|||
}
|
||||
const previous = this.active;
|
||||
this.active = theme;
|
||||
this.loadCSS(name);
|
||||
// Only load CSS when switching themes, not on initial set
|
||||
if (previous !== theme) {
|
||||
this.loadCSS(name);
|
||||
}
|
||||
this.onSwitch(theme, previous);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()');
|
||||
|
|
|
|||
460
test/integration/test_fuzz.js
Normal file
460
test/integration/test_fuzz.js
Normal 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>', '&'];
|
||||
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();
|
||||
}
|
||||
})();
|
||||
454
test/integration/test_wysiwyg.js
Normal file
454
test/integration/test_wysiwyg.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
Loading…
Reference in New Issue
Block a user