/** * 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 = '
{
await resetEditor();
// Type **
await typeString('**');
let html = await getHTML();
assert(!html.includes(' {
await resetEditor();
await typeString('**bold**');
let html = await getHTML();
assert(html.includes(' {
await resetEditor();
await typeChar('-');
let html = await getHTML();
assert(!html.includes(' {
await resetEditor();
await typeString('1.');
let html = await getHTML();
assert(!html.includes(' space transforms to blockquote', async () => {
await resetEditor();
await typeChar('>');
let html = await getHTML();
assert(!html.includes(' ": ${html}`);
});
await test('enter inside blockquote adds new line', async () => {
await resetEditor();
await typeString('> first line');
let html = await getHTML();
assert(html.includes(' foo\n> bar" — continuation, no blank lines
const markdown = await getMarkdown();
assert(markdown.includes('> first line'), `Missing > first line in markdown: ${markdown}`);
assert(markdown.includes('> second line'), `Missing > second line in markdown: ${markdown}`);
assert(!markdown.includes('>\n>'), `Unwanted blank > line in markdown: ${markdown}`);
});
await test('blockquote paragraphs survive mode round-trip', async () => {
await resetEditor();
await typeString('> foo');
await typeChar(Key.ENTER);
await typeString('bar');
await driver.sleep(50);
// Switch to source and back to wysiwyg twice
await driver.executeScript('window.__ribbitEditor.edit()');
await driver.sleep(50);
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await driver.sleep(50);
await driver.executeScript('window.__ribbitEditor.edit()');
await driver.sleep(50);
const markdown = await driver.executeScript('return document.getElementById("ribbit").textContent');
assert(markdown.includes('> foo'), `Missing > foo: ${markdown}`);
assert(markdown.includes('> bar'), `Missing > bar — second line lost its prefix: ${markdown}`);
});
await test('double enter exits blockquote', async () => {
await resetEditor();
await typeString('> quoted');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('after');
const html = await getHTML();
assert(html.includes(' afterBlockquote, `"after" is inside blockquote: ${html}`);
});
// ── Horizontal rule ──
console.log(' Horizontal rule:');
await test('--- transforms to hr', async () => {
await resetEditor();
await typeString('--');
let html = await getHTML();
assert(!html.includes('
{
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(' {
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}`);
});
console.log(' Enter behavior:');
await test('block pattern after Enter splits and transforms', async () => {
await resetEditor();
await typeString('foo');
await typeChar(Key.ENTER);
await typeString('> bar');
await driver.sleep(50);
const html = await getHTML();
assert(html.includes(' after Enter did not create blockquote: ${html}`);
assert(html.includes('foo'), `Lost content before split: ${html}`);
assert(html.includes('bar'), `Lost content after split: ${html}`);
});
await test('single Enter in paragraph inserts line break', async () => {
await resetEditor();
await typeString('line one');
await typeChar(Key.ENTER);
await typeString('line two');
const markdown = await getMarkdown();
// Single Enter = one \n, not \n\n
assert(markdown.includes('line one'), `Missing line one: ${markdown}`);
assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
assert(!markdown.includes('line one\n\nline two'), `Got paragraph break instead of line break: ${markdown}`);
});
await test('double Enter in paragraph creates new block', async () => {
await resetEditor();
await typeString('first paragraph');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('second paragraph');
const html = await getHTML();
// Double Enter = new , so two separate paragraphs
const paragraphCount = (html.match(/
]/g) || []).length;
assert(paragraphCount >= 2, `Expected 2+ paragraphs, got ${paragraphCount}: ${html}`);
});
await test('backspace at start of line after Enter joins lines', async () => {
await resetEditor();
await typeString('foo');
await typeChar(Key.ENTER);
await typeChar(Key.BACK_SPACE);
await driver.sleep(50);
const html = await getHTML();
// The
should be removed, cursor at end of "foo"
assert(!html.includes('
not removed: ${html}`);
assert(html.includes('foo'), `Content lost: ${html}`);
});
await test('single Enter in list item inserts line break', async () => {
await resetEditor();
await typeString('- line one');
await typeChar(Key.ENTER);
await typeString('line two');
const markdown = await getMarkdown();
// Both lines in the same list item
assert(markdown.includes('- line one'), `Missing list marker: ${markdown}`);
assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
// Should NOT create a second list item
const markerCount = (markdown.match(/^- /gm) || []).length;
assert(markerCount === 1, `Expected 1 list marker, got ${markerCount}: ${markdown}`);
});
await test('double Enter in list item creates new item', async () => {
await resetEditor();
await typeString('- first');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('second');
const markdown = await getMarkdown();
const markerCount = (markdown.match(/^- /gm) || []).length;
assert(markerCount === 2, `Expected 2 list markers, got ${markerCount}: ${markdown}`);
assert(markdown.includes('- first'), `Missing first item: ${markdown}`);
assert(markdown.includes('- second'), `Missing second item: ${markdown}`);
});
await test('double Enter on empty list item exits list', async () => {
await resetEditor();
await typeString('- item');
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeChar(Key.ENTER);
await typeString('after list');
const html = await getHTML();
assert(html.includes('after list'), `Missing text after list: ${html}`);
// "after list" should NOT be inside the
const ulEnd = html.indexOf('
');
const afterPos = html.indexOf('after list');
assert(afterPos > ulEnd, `"after list" is inside the list: ${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 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('', async () => {
await resetEditor();
await typeString('~~gone~~');
const html = await getHTML();
assert(html.includes(': ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
assert(html.includes('gone'), `Missing content: ${html}`);
});
await test('~~text shows speculative strikethrough', async () => {
await resetEditor();
await typeString('~~hel');
const html = await getHTML();
assert(html.includes('data-speculative'), `No speculative: ${html}`);
assert(html.includes(': ${html}`);
});
console.log(' Alternate syntax:');
await test('~~~ transforms to fenced code', async () => {
await resetEditor();
await typeString('~~~');
await driver.sleep(50);
const html = await getHTML();
assert(html.includes(' {
await resetEditor();
await typeChar('+');
let html = await getHTML();
assert(!html.includes(' {
await resetEditor();
await typeString('_hello_');
const html = await getHTML();
assert(html.includes(' after _hello_: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
});
await test('_text shows speculative italic', async () => {
await resetEditor();
await typeString('_hel');
const html = await getHTML();
assert(html.includes(' after _hel: ${html}`);
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
});
await test('__text__ transforms to bold', async () => {
await resetEditor();
await typeString('__hello__');
const html = await getHTML();
assert(html.includes(' after __hello__: ${html}`);
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
});
console.log(' Backslash escapes:');
await test('backslash is just a character in WYSIWYG', async () => {
await resetEditor();
await typeString('hello\\world');
const html = await getHTML();
assert(html.includes('hello') && html.includes('world'), `Missing content: ${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);
}
})();