/** * test_wysiwyg.js — Styled-source WYSIWYG integration tests. * * Tests the new styled-source editor implementation. Key differences * from the old test suite: * * - No data-speculative, no // DOM elements. * The editor always stores raw markdown; CSS renders it visually. * - Inline formatting uses .md-bold, .md-italic, .md-code spans * with .md-delim children holding the delimiter characters. * - getMarkdown() reads textContent directly — always returns the * original markdown source, never converted HTML. * - Block structure uses
elements, not

/

etc. * * Run headless: node test/integration/test_wysiwyg.js * Run against dev server: node test/integration/test_wysiwyg.js --port=5023 */ const { chromium } = require('playwright'); const { createServer } = require('./server'); // ── Config ──────────────────────────────────────────────────────────────────── const HEADLESS = !process.argv.includes('--headed'); const PORT = (() => { const portArg = process.argv.find(arg => arg.startsWith('--port=')); return portArg ? parseInt(portArg.split('=')[1]) : 5023; })(); const USE_DEV_SERVER = process.argv.includes('--port'); const DELAY = 20; // ms between keystrokes // ── State ───────────────────────────────────────────────────────────────────── let browser, page, server; let passed = 0, failed = 0; const errors = []; // ── Setup / teardown ────────────────────────────────────────────────────────── async function serverStart() { var liveServer = require("live-server"); var params = { port: PORT, host: "0.0.0.0", open: true, root: "test/integration", mount: [ ['/static', 'dist/ribbit'], ['/test', 'test/integration'], ], logLevel: 2, // 0 = errors only, 1 = some, 2 = lots }; console.log(`\n🐸 Ribbit dev server running on http://localhost:${params['port']}`); liveServer.start(params); } async function setup() { if (!USE_DEV_SERVER) { await serverStart(); } browser = await chromium.launch({ headless: HEADLESS }); page = await browser.newPage(); await page.goto(`http://localhost:${PORT}`); await page.waitForFunction(() => window.__ribbitReady === true, { timeout: 10000 }); } async function teardown() { //if (browser) { await browser.close(); } //if (server) { await server.stop(); } } // ── Editor helpers ──────────────────────────────────────────────────────────── /** * Reset the editor to an empty state in wysiwyg mode. * Clears the DOM and places the cursor ready for typing. */ async function resetEditor() { await page.evaluate(() => { const editor = window.__ribbitEditor; editor.wysiwyg(); editor.element.innerHTML = ''; }); await page.focus('#ribbit'); await page.waitForTimeout(30); } /** * Type a string one character at a time with delay between each. * Matches real user behaviour so block/inline transforms fire correctly. */ async function typeString(text) { for (const character of text) { await page.keyboard.type(character); await page.waitForTimeout(DELAY); } } /** * Press a special key (Enter, Backspace, ArrowRight, etc). */ async function pressKey(key) { await page.keyboard.press(key); await page.waitForTimeout(DELAY); } /** * Get the editor's current innerHTML. */ async function getHTML() { return page.evaluate(() => document.getElementById('ribbit').innerHTML); } /** * Get the editor's current markdown via getMarkdown(). */ async function getMarkdown() { return page.evaluate(() => window.__ribbitEditor.getMarkdown()); } /** * Get all CSS classes on block divs inside the editor. */ async function getBlockClasses() { return page.evaluate(() => Array.from(document.getElementById('ribbit').children) .map(block => block.className) ); } // ── Test runner ─────────────────────────────────────────────────────────────── 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, message: error.message }); console.log(` ✗ ${name}`); console.log(` ${error.message}`); } } // ── Tests ───────────────────────────────────────────────────────────────────── async function runTests() { console.log('\nStyled-source WYSIWYG Integration Tests\n'); // ── Block classification ─────────────────────────────────────────────────── console.log('Block classification:'); await test('plain text becomes md-paragraph', async () => { await resetEditor(); await typeString("hello\n\n"); const classes = await getBlockClasses(); assert(classes.some(c => c.includes('md-paragraph')), `Expected md-paragraph, got: ${classes}`); }); /* await test('# space becomes md-h1', async () => { await resetEditor(); await typeString('#'); let classes = await getBlockClasses(); assert(!classes.some(c => c.includes('md-h')), `Premature heading after just #: ${classes}`); await typeString(' '); classes = await getBlockClasses(); assert(classes.some(c => c.includes('md-h1')), `Expected md-h1 after "# ", got: ${classes}`); await typeString('Title'); const markdown = await getMarkdown(); assert(markdown.includes('# Title'), `Expected "# Title" in markdown: ${markdown}`); }); await test('## space becomes md-h2', async () => { await resetEditor(); await typeString('## '); const classes = await getBlockClasses(); assert(classes.some(c => c.includes('md-h2')), `Expected md-h2, got: ${classes}`); }); await test('### space becomes md-h3', async () => { await resetEditor(); await typeString('### '); const classes = await getBlockClasses(); assert(classes.some(c => c.includes('md-h3')), `Expected md-h3, got: ${classes}`); }); await test('> space becomes md-blockquote', async () => { await resetEditor(); await typeString('>'); let classes = await getBlockClasses(); assert(!classes.some(c => c.includes('md-blockquote')), `Premature blockquote: ${classes}`); await typeString(' '); classes = await getBlockClasses(); assert(classes.some(c => c.includes('md-blockquote')), `Expected md-blockquote, got: ${classes}`); }); await test('- space becomes md-list-item', async () => { await resetEditor(); await typeString('-'); let classes = await getBlockClasses(); assert(!classes.some(c => c.includes('md-list')), `Premature list: ${classes}`); await typeString(' '); classes = await getBlockClasses(); assert(classes.some(c => c.includes('md-list-item')), `Expected md-list-item, got: ${classes}`); }); await test('1. space becomes md-ol-list-item', async () => { await resetEditor(); await typeString('1. '); const classes = await getBlockClasses(); assert(classes.some(c => c.includes('md-ol-list-item')), `Expected md-ol-list-item, got: ${classes}`); }); // ── Inline formatting ────────────────────────────────────────────────────── console.log('\nInline formatting:'); await test('**bold** produces md-bold span', async () => { await resetEditor(); await typeString('**bold**'); const html = await getHTML(); assert(html.includes('md-bold'), `Expected md-bold span: ${html}`); const markdown = await getMarkdown(); assert(markdown === '**bold**', `Expected "**bold**", got: "${markdown}"`); }); await test('*italic* produces md-italic span', async () => { await resetEditor(); await typeString('*italic*'); const html = await getHTML(); assert(html.includes('md-italic'), `Expected md-italic span: ${html}`); const markdown = await getMarkdown(); assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`); }); await test('***bold-italic*** produces md-bold-italic span', async () => { await resetEditor(); await typeString('***both***'); const html = await getHTML(); assert(html.includes('md-bold-italic'), `Expected md-bold-italic span: ${html}`); const markdown = await getMarkdown(); assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`); }); await test('`code` produces md-code span', async () => { await resetEditor(); await typeString('`code`'); const html = await getHTML(); assert(html.includes('md-code'), `Expected md-code span: ${html}`); const markdown = await getMarkdown(); assert(markdown === '`code`', `Expected "\`code\`", got: "${markdown}"`); }); await test('~~strike~~ produces md-strikethrough span', async () => { await resetEditor(); await typeString('~~gone~~'); const html = await getHTML(); assert(html.includes('md-strikethrough'), `Expected md-strikethrough span: ${html}`); const markdown = await getMarkdown(); assert(markdown === '~~gone~~', `Expected "~~gone~~", got: "${markdown}"`); }); await test('delimiters are present in DOM as md-delim spans', async () => { await resetEditor(); await typeString('**bold**'); const html = await getHTML(); assert(html.includes('md-delim'), `Expected md-delim spans: ${html}`); // The delimiter text ** must appear in the DOM assert(html.includes('**'), `Delimiter text missing from DOM: ${html}`); }); await test('mixed inline on one line round-trips correctly', async () => { await resetEditor(); await typeString('hello **world** and *italic*'); const markdown = await getMarkdown(); assert( markdown === 'hello **world** and *italic*', `Round-trip failed: "${markdown}"` ); }); // ── getMarkdown round-trips ──────────────────────────────────────────────── console.log('\ngetMarkdown round-trips:'); await test('heading round-trips', async () => { await resetEditor(); await typeString('# Hello World'); const markdown = await getMarkdown(); assert(markdown === '# Hello World', `Expected "# Hello World", got: "${markdown}"`); }); await test('blockquote round-trips', async () => { await resetEditor(); await typeString('> quoted text'); const markdown = await getMarkdown(); assert(markdown === '> quoted text', `Expected "> quoted text", got: "${markdown}"`); }); await test('list item round-trips', async () => { await resetEditor(); await typeString('- list item'); const markdown = await getMarkdown(); assert(markdown === '- list item', `Expected "- list item", got: "${markdown}"`); }); await test('nested inline in heading round-trips', async () => { await resetEditor(); await typeString('# Hello **world**'); const markdown = await getMarkdown(); assert(markdown === '# Hello **world**', `Expected "# Hello **world**", got: "${markdown}"`); }); // ── Enter key behaviour ──────────────────────────────────────────────────── console.log('\nEnter key behaviour:'); await test('Enter splits current block into two blocks', async () => { await resetEditor(); await typeString('hello'); await pressKey('Enter'); await typeString('world'); const blocks = await getBlockClasses(); assert(blocks.length === 2, `Expected 2 blocks, got ${blocks.length}: ${JSON.stringify(blocks)}`); const markdown = await getMarkdown(); assert(markdown === 'hello\nworld', `Expected "hello\\nworld", got: "${markdown}"`); }); await test('Enter after heading creates new paragraph', async () => { await resetEditor(); await typeString('# Title'); await pressKey('Enter'); await typeString('body'); const blocks = await getBlockClasses(); assert(blocks.some(c => c.includes('md-h1')), `No h1 block: ${blocks}`); assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph block: ${blocks}`); const markdown = await getMarkdown(); assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`); }); await test('Enter inside blockquote continues with > prefix', async () => { await resetEditor(); await typeString('> first line'); await pressKey('Enter'); await typeString('second line'); const markdown = await getMarkdown(); assert( markdown.includes('> first line'), `Missing "> first line" in markdown: "${markdown}"` ); assert( markdown.includes('> second line'), `Missing "> second line" — continuation prefix not added: "${markdown}"` ); }); await test('Enter inside list item continues with - prefix', async () => { await resetEditor(); await typeString('- first item'); await pressKey('Enter'); await typeString('second item'); const markdown = await getMarkdown(); assert( markdown.includes('- first item'), `Missing "- first item": "${markdown}"` ); assert( markdown.includes('- second item'), `Missing "- second item" — continuation prefix not added: "${markdown}"` ); }); // ── Backspace key behaviour ──────────────────────────────────────────────── console.log('\nBackspace key behaviour:'); await test('Backspace at start of block merges with previous block', async () => { await resetEditor(); await typeString('foo'); await pressKey('Enter'); await typeString('bar'); await pressKey('Home'); await pressKey('Backspace'); const blocks = await getBlockClasses(); assert(blocks.length === 1, `Expected 1 block after merge, got ${blocks.length}`); const markdown = await getMarkdown(); assert(markdown === 'foobar', `Expected "foobar", got: "${markdown}"`); }); await test('Backspace mid-block does not merge', async () => { await resetEditor(); await typeString('foo'); await pressKey('Enter'); await typeString('bar'); await pressKey('Backspace'); const blocks = await getBlockClasses(); assert(blocks.length === 2, `Expected 2 blocks, got ${blocks.length}`); }); // ── Mode switching ───────────────────────────────────────────────────────── console.log('\nMode switching:'); await test('view() switches to view state', async () => { await resetEditor(); await typeString('**bold**'); await page.evaluate(() => window.__ribbitEditor.view()); await page.waitForTimeout(50); const state = await page.evaluate(() => window.__ribbitEditor.getState()); assert(state === 'view', `Expected "view", got: "${state}"`); }); await test('wysiwyg() switches back to wysiwyg state', async () => { await resetEditor(); await typeString('hello'); await page.evaluate(() => window.__ribbitEditor.view()); await page.waitForTimeout(50); await page.evaluate(() => window.__ribbitEditor.wysiwyg()); await page.waitForTimeout(50); const state = await page.evaluate(() => window.__ribbitEditor.getState()); assert(state === 'wysiwyg', `Expected "wysiwyg", got: "${state}"`); }); await test('content survives wysiwyg → view → wysiwyg round-trip', async () => { await resetEditor(); await typeString('**bold** and *italic*'); const markdownBefore = await getMarkdown(); await page.evaluate(() => window.__ribbitEditor.view()); await page.waitForTimeout(50); await page.evaluate(() => window.__ribbitEditor.wysiwyg()); await page.waitForTimeout(50); const markdownAfter = await getMarkdown(); assert( markdownAfter === markdownBefore, `Markdown changed after round-trip.\nBefore: "${markdownBefore}"\nAfter: "${markdownAfter}"` ); }); await test('getMarkdown() returns source in view state', async () => { await resetEditor(); await typeString('**bold**'); const markdownInEditor = await getMarkdown(); await page.evaluate(() => window.__ribbitEditor.view()); await page.waitForTimeout(50); const markdownInView = await getMarkdown(); assert( markdownInView === markdownInEditor, `getMarkdown() changed on view switch.\nEditor: "${markdownInEditor}"\nView: "${markdownInView}"` ); }); // ── Complex documents ────────────────────────────────────────────────────── console.log('\nComplex documents:'); await test('multi-block document round-trips correctly', async () => { await resetEditor(); await typeString('# Title'); await pressKey('Enter'); await typeString('Some **bold** text.'); await pressKey('Enter'); await typeString('> A quote'); await pressKey('Enter'); await typeString('- A list item'); const markdown = await getMarkdown(); assert(markdown.includes('# Title'), `Missing heading: "${markdown}"`); assert(markdown.includes('Some **bold** text.'), `Missing bold paragraph: "${markdown}"`); assert(markdown.includes('> A quote'), `Missing blockquote: "${markdown}"`); assert(markdown.includes('- A list item'), `Missing list item: "${markdown}"`); }); await test('empty lines between blocks preserved', async () => { await resetEditor(); await typeString('first'); await pressKey('Enter'); await pressKey('Enter'); await typeString('second'); const blocks = await getBlockClasses(); assert(blocks.length === 3, `Expected 3 blocks (first, empty, second), got ${blocks.length}`); const markdown = await getMarkdown(); assert(markdown === 'first\n\nsecond', `Expected "first\\n\\nsecond", got: "${markdown}"`); }); */ } // ── Main ────────────────────────────────────────────────────────────────────── (async () => { try { await setup(); await runTests(); } catch (error) { console.error('\nSetup failed:', error.message); failed++; } finally { const total = passed + failed; console.log(`\n${passed}/${total} passed — ${failed} failed`); if (errors.length) { console.log('\nFailed tests:'); errors.forEach(({ name, message }) => { console.log(` • ${name}`); console.log(` ${message}`); }); } await teardown(); process.exit(failed > 0 ? 1 : 0); } })();