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