/** * 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 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(' { await resetEditor(); await typeString('##'); let html = await getHTML(); assert(!html.includes(' { await resetEditor(); await typeString('# Title'); await typeChar(Key.ENTER); await typeString('body'); const html = await getHTML(); assert(html.includes(' { await resetEditor(); await typeString('**'); const html = await getHTML(); assert(!html.includes(' { await resetEditor(); await typeString('**'); await typeChar('x'); const html = await getHTML(); assert(html.includes(' { 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(' { await resetEditor(); await typeString('**bold**'); await typeString(' after'); const html = await getHTML(); assert(html.includes(' const strongMatch = html.match(/]*>.*?<\/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(' { 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(' { await resetEditor(); await typeString('`hello`'); const html = await getHTML(); assert(html.includes(' { 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); } })();