/**
* 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();
}
})();