/** * 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 delimiters = ['*', '_', '~']; const character = delimiters[Math.floor(random() * delimiters.length)]; sequence.push({ name: character.repeat(count), keys: character.repeat(count) }); } else if (roll < 0.97) { /* backslash sequences */ const escaped = ['\\*', '\\_', '\\`', '\\~', '\\\\', '\\']; const fragment = escaped[Math.floor(random() * escaped.length)]; sequence.push({ name: fragment, keys: fragment }); } else { /* angle bracket / HTML-like content */ const fragments = ['<', '>', '
', '
', '', '&']; const fragment = fragments[Math.floor(random() * fragments.length)]; sequence.push({ name: fragment, keys: fragment }); } } return sequence; } /* ── Invariant checks ── */ /** * Valid direct children of the editor element. * Everything the WYSIWYG produces must be one of these. */ const VALID_BLOCK_TAGS = new Set([ 'P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'BLOCKQUOTE', 'PRE', 'HR', 'TABLE', ]); /** * Valid inline elements that can appear inside block content. */ const VALID_INLINE_TAGS = new Set([ 'STRONG', 'B', 'EM', 'I', 'CODE', 'A', 'BR', ]); /** * Elements that can only contain specific children. */ const REQUIRED_CHILDREN = { 'UL': ['LI'], 'OL': ['LI'], 'TABLE': ['THEAD', 'TBODY', 'TR', 'CAPTION', 'COLGROUP'], 'THEAD': ['TR'], 'TBODY': ['TR'], 'TR': ['TH', 'TD'], }; /** * Elements that must not contain certain descendants. */ const FORBIDDEN_NESTING = { 'LI': ['TABLE'], 'A': ['A'], 'STRONG': ['STRONG', 'B'], 'B': ['STRONG', 'B'], 'EM': ['EM', 'I'], 'I': ['EM', 'I'], 'CODE': ['CODE', 'STRONG', 'B', 'EM', 'I', 'A'], }; /** * Run all invariant checks on the current editor state. * Returns null if all pass, or a string describing the violation. */ async function checkInvariants() { return driver.executeScript(function () { var editor = document.getElementById('ribbit'); if (!editor) { return 'Editor element not found'; } if (editor.contentEditable !== 'true') { return 'contentEditable is not true'; } /* Invariant 1: all direct children are valid block elements */ for (var i = 0; i < editor.childNodes.length; i++) { var child = editor.childNodes[i]; if (child.nodeType === 3) { if (child.textContent.replace(/[\u200B\s]/g, '').length > 0) { return 'Bare text node in editor: "' + child.textContent.slice(0, 40) + '"'; } continue; } if (child.nodeType !== 1) { continue; } var validBlocks = ['P','H1','H2','H3','H4','H5','H6','UL','OL','BLOCKQUOTE','PRE','HR','TABLE']; if (validBlocks.indexOf(child.nodeName) === -1) { return 'Invalid block element: <' + child.nodeName.toLowerCase() + '>'; } } /* Invariant 2: no nested speculative elements */ var specs = editor.querySelectorAll('[data-speculative]'); for (var s = 0; s < specs.length; s++) { if (specs[s].querySelector('[data-speculative]')) { return 'Nested speculative elements'; } } /* Invariant 3: required children (UL must contain LI, etc.) */ var parentChildRules = { 'UL': ['LI'], 'OL': ['LI'], 'TABLE': ['THEAD','TBODY','TR','CAPTION','COLGROUP'], 'THEAD': ['TR'], 'TBODY': ['TR'], 'TR': ['TH','TD'], }; function checkChildren(element) { var allowed = parentChildRules[element.nodeName]; if (!allowed) { return null; } for (var c = 0; c < element.children.length; c++) { if (allowed.indexOf(element.children[c].nodeName) === -1) { return '<' + element.children[c].nodeName.toLowerCase() + '> inside <' + element.nodeName.toLowerCase() + '> (allowed: ' + allowed.join(', ') + ')'; } } for (var c = 0; c < element.children.length; c++) { var result = checkChildren(element.children[c]); if (result) { return result; } } return null; } var childViolation = checkChildren(editor); if (childViolation) { return 'Invalid nesting: ' + childViolation; } /* Invariant 4: forbidden nesting (no inside , etc.) */ var forbiddenRules = { 'STRONG': ['STRONG','B'], 'B': ['STRONG','B'], 'EM': ['EM','I'], 'I': ['EM','I'], 'CODE': ['CODE','STRONG','B','EM','I','A','DEL'], 'DEL': ['DEL','S','STRIKE'], 'S': ['DEL','S','STRIKE'], 'STRIKE': ['DEL','S','STRIKE'], '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 eventually stabilize. The first round-trip may change the HTML (e.g. literal in text becomes a real element via HTML passthrough, then serializes as **). But the second round-trip must be stable. 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; // Two round-trips: allow the first to normalize, check // that the second produces identical HTML var html1 = converter.toHTML(md); var md2 = converter.toMarkdown(html1); var html2 = converter.toHTML(md2); var md3 = converter.toMarkdown(html2); var html3 = converter.toHTML(md3); var normalize = function(html) { return html .replace(/\s*id='[^']*'/g, '') .replace(/\s+/g, ' ') .trim(); }; if (normalize(html2) !== normalize(html3)) { return 'Round-trip HTML not stable after 2 passes:\n pass2: "' + normalize(html2).slice(0, 80) + '"\n pass3: "' + normalize(html3).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','DEL','S','STRIKE']; var blocks = editor.querySelectorAll('p,h1,h2,h3,h4,h5,h6,li,blockquote,td,th'); for (var b = 0; b < blocks.length; b++) { var inlineEls = blocks[b].querySelectorAll('*'); for (var ie = 0; ie < inlineEls.length; ie++) { var inEl = inlineEls[ie]; /* Skip nested block elements (blockquote can contain blocks) */ if (inEl.parentElement !== blocks[b] && inEl.closest('blockquote,ul,ol,table,pre') !== blocks[b]) { continue; } if (validInline.indexOf(inEl.nodeName) === -1 && ['P','H1','H2','H3','H4','H5','H6','UL','OL','BLOCKQUOTE','PRE','HR','TABLE','LI','THEAD','TBODY','TR','TH','TD','CAPTION','COLGROUP'].indexOf(inEl.nodeName) === -1) { return 'Invalid inline element <' + inEl.nodeName.toLowerCase() + '> inside <' + blocks[b].nodeName.toLowerCase() + '>'; } } } return null; }); } /* ── Test runner ── */ async function setup() { server = createServer(9996); await server.start(); const options = new firefox.Options().addArguments('--headless'); driver = await new Builder().forBrowser('firefox').setFirefoxOptions(options).build(); await driver.get(server.url); await driver.wait(async () => driver.executeScript('return window.__ribbitReady === true'), 10000); } async function teardown() { if (driver) { await driver.quit(); } if (server) { await server.stop(); } } async function resetEditor() { await driver.executeScript(` var e = window.__ribbitEditor; e.wysiwyg(); e.element.innerHTML = '


'; `); await driver.findElement(By.id('ribbit')).click(); await driver.sleep(50); } async function typeKeystroke(keystroke) { const keys = keystroke.keys; if (typeof keys !== 'string') { throw new Error('Invalid keystroke: ' + JSON.stringify(keystroke)); } if (keys.length === 1 || keystroke.isSpecial) { await driver.actions().sendKeys(keys).perform(); await driver.sleep(DELAY); } else { /* Multi-char string: type char by char */ for (const character of keys) { await driver.actions().sendKeys(character).perform(); await driver.sleep(DELAY); } } } function formatSequence(sequence, upTo) { return sequence.slice(0, upTo + 1).map(s => s.name).join(' '); } /** * Replay a sequence and return the index of the first invariant failure, * or -1 if no failure. */ async function replaySequence(sequence) { await resetEditor(); for (let i = 0; i < sequence.length; i++) { await typeKeystroke(sequence[i]); const violation = await checkInvariants(); if (violation) { return { index: i, violation }; } } return null; } /** * Shrink a failing sequence to find the minimal reproducing prefix. * Uses binary search on the sequence length. */ async function shrinkSequence(sequence, failIndex) { let lo = 0; let hi = failIndex; let bestSequence = sequence.slice(0, failIndex + 1); let bestViolation = ''; while (lo < hi) { const mid = Math.floor((lo + hi) / 2); const candidate = sequence.slice(0, mid + 1); const result = await replaySequence(candidate); if (result) { hi = mid; bestSequence = candidate; bestViolation = result.violation; } else { lo = mid + 1; } } /* Try removing individual keystrokes from the beginning */ let shrunk = true; while (shrunk) { shrunk = false; for (let i = 0; i < bestSequence.length - 1; i++) { const candidate = [...bestSequence.slice(0, i), ...bestSequence.slice(i + 1)]; const result = await replaySequence(candidate); if (result) { bestSequence = candidate; bestViolation = result.violation; shrunk = true; break; } } } return { sequence: bestSequence, violation: bestViolation }; } async function runFuzz(options) { const { rounds, minLength, maxLength, seed: baseSeed, doShrink } = options; let totalKeystrokes = 0; let failures = 0; console.log(`\nWYSIWYG Fuzz Test — ${rounds} rounds, seed ${baseSeed}\n`); for (let round = 0; round < rounds; round++) { const roundSeed = baseSeed + round; const random = mulberry32(roundSeed); const length = minLength + Math.floor(random() * (maxLength - minLength)); const sequence = generateSequence(random, length); await resetEditor(); let failed = false; for (let i = 0; i < sequence.length; i++) { await typeKeystroke(sequence[i]); const violation = await checkInvariants(); if (violation) { failures++; failed = true; const html = await driver.executeScript('return document.getElementById("ribbit").innerHTML'); console.log(` ✗ Round ${round + 1} [seed=${roundSeed}] — keystroke ${i + 1}/${length}`); console.log(` Invariant: ${violation}`); console.log(` Sequence: ${formatSequence(sequence, i)}`); console.log(` HTML: ${html.slice(0, 200)}`); if (doShrink) { console.log(` Shrinking...`); const shrunk = await shrinkSequence(sequence, i); console.log(` Minimal (${shrunk.sequence.length} keystrokes): ${shrunk.sequence.map(s => s.name).join(' ')}`); console.log(` Violation: ${shrunk.violation}`); } console.log(` Replay: node test/integration/test_fuzz.js --seed ${roundSeed}\n`); break; } } if (!failed) { totalKeystrokes += length; if ((round + 1) % 10 === 0 || round === rounds - 1) { process.stdout.write(` ✓ ${round + 1}/${rounds} rounds (${totalKeystrokes} keystrokes)\r`); } } } console.log(`\n\n${rounds - failures}/${rounds} rounds passed — ${totalKeystrokes} keystrokes checked`); if (failures > 0) { console.log(`${failures} failure(s) found`); } return failures; } /* ── CLI ── */ function parseArgs() { const args = process.argv.slice(2); const options = { rounds: 50, minLength: 20, maxLength: 80, seed: Date.now() % 100000, doShrink: true, }; for (let i = 0; i < args.length; i++) { if (args[i] === '--seed' && args[i + 1]) { options.seed = parseInt(args[i + 1]); i++; } if (args[i] === '--rounds' && args[i + 1]) { options.rounds = parseInt(args[i + 1]); i++; } if (args[i] === '--min' && args[i + 1]) { options.minLength = parseInt(args[i + 1]); i++; } if (args[i] === '--max' && args[i + 1]) { options.maxLength = parseInt(args[i + 1]); i++; } if (args[i] === '--no-shrink') { options.doShrink = false; } if (args[i] === '--shrink') { options.doShrink = true; } } return options; } (async () => { const options = parseArgs(); try { await setup(); const failures = await runFuzz(options); process.exitCode = failures > 0 ? 1 : 0; } catch (error) { console.error('Setup failed:', error.message); process.exitCode = 1; } finally { await teardown(); } })();