461 lines
17 KiB
JavaScript
461 lines
17 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|
|
})();
|