/
etc. + * + * Run headless: node test/integration/test_wysiwyg.js + * Run against dev server: node test/integration/test_wysiwyg.js --port=5023 */ -const { Builder, By, Key } = require('selenium-webdriver'); -const firefox = require('selenium-webdriver/firefox'); + +const { chromium } = require('playwright'); const { createServer } = require('./server'); -let server, driver; -const DELAY = 30; +// ── 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() { - 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); + + 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 (driver) { await driver.quit(); } - if (server) { await server.stop(); } + //if (browser) { await browser.close(); } + //if (server) { await server.stop(); } } -async function resetEditor() { - await driver.executeScript(` - var e = window.__ribbitEditor; - e.wysiwyg(); - e.element.innerHTML = '
c.includes('md-paragraph')), `Expected md-paragraph, got: ${classes}`);
});
- await test('## transforms to h2 after space', async () => {
+ /*
+ await test('# space becomes md-h1', async () => {
await resetEditor();
- await typeString('##');
- let html = await getHTML();
- assert(!html.includes(' c.includes('md-h')), `Premature heading after just #: ${classes}`);
- await typeChar(' ');
- html = await getHTML();
- assert(html.includes(' 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('enter after heading creates new paragraph', async () => {
+ await test('## space becomes md-h2', async () => {
await resetEditor();
- await typeString('# Title');
- await typeChar(Key.ENTER);
- await typeString('body');
- const html = await getHTML();
- assert(html.includes(' c.includes('md-h2')), `Expected md-h2, got: ${classes}`);
});
- // ── Bold ──
-
- console.log(' Bold:');
-
- await test('** does not transform without content', async () => {
+ await test('### space becomes md-h3', async () => {
await resetEditor();
- await typeString('**');
- const html = await getHTML();
- assert(!html.includes(' c.includes('md-h3')), `Expected md-h3, got: ${classes}`);
});
- await test('**x starts speculative bold', async () => {
+ await test('> space becomes md-blockquote', async () => {
await resetEditor();
- await typeString('**');
- await typeChar('x');
- const html = await getHTML();
- assert(html.includes('');
+ 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('**hello** completes bold', async () => {
+ await test('- space becomes md-list-item', async () => {
await resetEditor();
- await typeString('**hello');
- let html = await getHTML();
- assert(html.includes('data-speculative'), `Not speculative during typing: ${html}`);
+ await typeString('-');
+ let classes = await getBlockClasses();
+ assert(!classes.some(c => c.includes('md-list')), `Premature list: ${classes}`);
- await typeString('**');
- html = await getHTML();
- assert(html.includes(' c.includes('md-list-item')), `Expected md-list-item, got: ${classes}`);
});
- await test('typing after **bold** goes outside strong', async () => {
+ 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**');
- 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}`);
- }
+ assert(html.includes('md-bold'), `Expected md-bold span: ${html}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '**bold**', `Expected "**bold**", got: "${markdown}"`);
});
- // ── Italic ──
-
- console.log(' Italic:');
-
- await test('*x starts speculative italic', async () => {
+ await test('*italic* produces md-italic span', 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 html = await getHTML();
+ assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
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}`);
+ assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`);
});
- await test('blockquote paragraphs survive mode round-trip', async () => {
+ await test('***bold-italic*** produces md-bold-italic span', 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');
+ await typeString('***both***');
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);
+ assert(html.includes('md-bold-italic'), `Expected md-bold-italic span: ${html}`);
const markdown = await getMarkdown();
- assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`);
+ assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
});
- await test('# Title round-trips to markdown', async () => {
+ await test('`code` produces md-code span', async () => {
await resetEditor();
- await typeString('# Title');
- await driver.sleep(50);
+ await typeString('`code`');
+ const html = await getHTML();
+ assert(html.includes('md-code'), `Expected md-code span: ${html}`);
const markdown = await getMarkdown();
- assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`);
+ assert(markdown === '`code`', `Expected "\`code\`", got: "${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 test('~~strike~~ produces md-strikethrough span', 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}`);
+ assert(html.includes('md-strikethrough'), `Expected md-strikethrough span: ${html}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '~~gone~~', `Expected "~~gone~~", got: "${markdown}"`);
});
- await test('~~text shows speculative strikethrough', async () => {
+ await test('delimiters are present in DOM as md-delim spans', async () => {
await resetEditor();
- await typeString('~~hel');
+ await typeString('**bold**');
const html = await getHTML();
- assert(html.includes('data-speculative'), `No speculative: ${html}`);
- assert(html.includes(': ${html}`);
+ 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}`);
});
- console.log(' Alternate syntax:');
-
- await test('~~~ transforms to fenced code', async () => {
+ await test('mixed inline on one line round-trips correctly', async () => {
await resetEditor();
- await typeString('~~~');
- await driver.sleep(50);
- const html = await getHTML();
- assert(html.includes(' {
+ // ── getMarkdown round-trips ────────────────────────────────────────────────
+
+ console.log('\ngetMarkdown round-trips:');
+
+ await test('heading round-trips', async () => {
await resetEditor();
- await typeChar('+');
- let html = await getHTML();
- assert(!html.includes(' {
+ await test('blockquote round-trips', async () => {
await resetEditor();
- await typeString('_hello_');
- const html = await getHTML();
- assert(html.includes(' after _hello_: ${html}`);
- assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ await typeString('> quoted text');
+ const markdown = await getMarkdown();
+ assert(markdown === '> quoted text', `Expected "> quoted text", got: "${markdown}"`);
});
- await test('_text shows speculative italic', async () => {
+ await test('list item round-trips', 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 typeString('- list item');
+ const markdown = await getMarkdown();
+ assert(markdown === '- list item', `Expected "- list item", got: "${markdown}"`);
});
- await test('__text__ transforms to bold', async () => {
+ await test('nested inline in heading round-trips', async () => {
await resetEditor();
- await typeString('__hello__');
- const html = await getHTML();
- assert(html.includes(' after __hello__: ${html}`);
- assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ await typeString('# Hello **world**');
+ const markdown = await getMarkdown();
+ assert(markdown === '# Hello **world**', `Expected "# Hello **world**", got: "${markdown}"`);
});
- console.log(' Backslash escapes:');
+ // ── Enter key behaviour ────────────────────────────────────────────────────
- await test('backslash is just a character in WYSIWYG', async () => {
+ console.log('\nEnter key behaviour:');
+
+ await test('Enter splits current block into two blocks', async () => {
await resetEditor();
- await typeString('hello\\world');
- const html = await getHTML();
- assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`);
+ 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('Setup failed:', error.message);
+ console.error('\nSetup failed:', error.message);
failed++;
} finally {
- console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`);
+ const total = passed + failed;
+ console.log(`\n${passed}/${total} passed — ${failed} failed`);
if (errors.length) {
- console.log('\nFailed:');
- errors.forEach(error => console.log(` • ${error}`));
+ console.log('\nFailed tests:');
+ errors.forEach(({ name, message }) => {
+ console.log(` • ${name}`);
+ console.log(` ${message}`);
+ });
}
await teardown();
process.exit(failed > 0 ? 1 : 0);
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('enter after heading creates new paragraph', async () => {
+ await test('## space becomes md-h2', async () => {
await resetEditor();
- await typeString('# Title');
- await typeChar(Key.ENTER);
- await typeString('body');
- const html = await getHTML();
- assert(html.includes(' c.includes('md-h2')), `Expected md-h2, got: ${classes}`);
});
- // ── Bold ──
-
- console.log(' Bold:');
-
- await test('** does not transform without content', async () => {
+ await test('### space becomes md-h3', async () => {
await resetEditor();
- await typeString('**');
- const html = await getHTML();
- assert(!html.includes(' c.includes('md-h3')), `Expected md-h3, got: ${classes}`);
});
- await test('**x starts speculative bold', async () => {
+ await test('> space becomes md-blockquote', async () => {
await resetEditor();
- await typeString('**');
- await typeChar('x');
- const html = await getHTML();
- assert(html.includes('');
+ 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('**hello** completes bold', async () => {
+ await test('- space becomes md-list-item', async () => {
await resetEditor();
- await typeString('**hello');
- let html = await getHTML();
- assert(html.includes('data-speculative'), `Not speculative during typing: ${html}`);
+ await typeString('-');
+ let classes = await getBlockClasses();
+ assert(!classes.some(c => c.includes('md-list')), `Premature list: ${classes}`);
- await typeString('**');
- html = await getHTML();
- assert(html.includes(' c.includes('md-list-item')), `Expected md-list-item, got: ${classes}`);
});
- await test('typing after **bold** goes outside strong', async () => {
+ 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**');
- 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}`);
- }
+ assert(html.includes('md-bold'), `Expected md-bold span: ${html}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '**bold**', `Expected "**bold**", got: "${markdown}"`);
});
- // ── Italic ──
-
- console.log(' Italic:');
-
- await test('*x starts speculative italic', async () => {
+ await test('*italic* produces md-italic span', 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 html = await getHTML();
+ assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
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}`);
+ assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`);
});
- await test('blockquote paragraphs survive mode round-trip', async () => {
+ await test('***bold-italic*** produces md-bold-italic span', 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');
+ await typeString('***both***');
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);
+ assert(html.includes('md-bold-italic'), `Expected md-bold-italic span: ${html}`);
const markdown = await getMarkdown();
- assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`);
+ assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
});
- await test('# Title round-trips to markdown', async () => {
+ await test('`code` produces md-code span', async () => {
await resetEditor();
- await typeString('# Title');
- await driver.sleep(50);
+ await typeString('`code`');
+ const html = await getHTML();
+ assert(html.includes('md-code'), `Expected md-code span: ${html}`);
const markdown = await getMarkdown();
- assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`);
+ assert(markdown === '`code`', `Expected "\`code\`", got: "${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 test('~~strike~~ produces md-strikethrough span', 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}`);
+ assert(html.includes('md-strikethrough'), `Expected md-strikethrough span: ${html}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '~~gone~~', `Expected "~~gone~~", got: "${markdown}"`);
});
- await test('~~text shows speculative strikethrough', async () => {
+ await test('delimiters are present in DOM as md-delim spans', async () => {
await resetEditor();
- await typeString('~~hel');
+ await typeString('**bold**');
const html = await getHTML();
- assert(html.includes('data-speculative'), `No speculative: ${html}`);
- assert(html.includes(': ${html}`);
+ 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}`);
});
- console.log(' Alternate syntax:');
-
- await test('~~~ transforms to fenced code', async () => {
+ await test('mixed inline on one line round-trips correctly', async () => {
await resetEditor();
- await typeString('~~~');
- await driver.sleep(50);
- const html = await getHTML();
- assert(html.includes(' {
+ // ── getMarkdown round-trips ────────────────────────────────────────────────
+
+ console.log('\ngetMarkdown round-trips:');
+
+ await test('heading round-trips', async () => {
await resetEditor();
- await typeChar('+');
- let html = await getHTML();
- assert(!html.includes(' {
+ await test('blockquote round-trips', async () => {
await resetEditor();
- await typeString('_hello_');
- const html = await getHTML();
- assert(html.includes(' after _hello_: ${html}`);
- assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ await typeString('> quoted text');
+ const markdown = await getMarkdown();
+ assert(markdown === '> quoted text', `Expected "> quoted text", got: "${markdown}"`);
});
- await test('_text shows speculative italic', async () => {
+ await test('list item round-trips', 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 typeString('- list item');
+ const markdown = await getMarkdown();
+ assert(markdown === '- list item', `Expected "- list item", got: "${markdown}"`);
});
- await test('__text__ transforms to bold', async () => {
+ await test('nested inline in heading round-trips', async () => {
await resetEditor();
- await typeString('__hello__');
- const html = await getHTML();
- assert(html.includes(' after __hello__: ${html}`);
- assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ await typeString('# Hello **world**');
+ const markdown = await getMarkdown();
+ assert(markdown === '# Hello **world**', `Expected "# Hello **world**", got: "${markdown}"`);
});
- console.log(' Backslash escapes:');
+ // ── Enter key behaviour ────────────────────────────────────────────────────
- await test('backslash is just a character in WYSIWYG', async () => {
+ console.log('\nEnter key behaviour:');
+
+ await test('Enter splits current block into two blocks', async () => {
await resetEditor();
- await typeString('hello\\world');
- const html = await getHTML();
- assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`);
+ 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('Setup failed:', error.message);
+ console.error('\nSetup failed:', error.message);
failed++;
} finally {
- console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`);
+ const total = passed + failed;
+ console.log(`\n${passed}/${total} passed — ${failed} failed`);
if (errors.length) {
- console.log('\nFailed:');
- errors.forEach(error => console.log(` • ${error}`));
+ console.log('\nFailed tests:');
+ errors.forEach(({ name, message }) => {
+ console.log(` • ${name}`);
+ console.log(` ${message}`);
+ });
}
await teardown();
process.exit(failed > 0 ? 1 : 0);
{
- 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 html = await getHTML();
+ assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
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}`);
+ assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`);
});
- await test('blockquote paragraphs survive mode round-trip', async () => {
+ await test('***bold-italic*** produces md-bold-italic span', 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');
+ await typeString('***both***');
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);
+ assert(html.includes('md-bold-italic'), `Expected md-bold-italic span: ${html}`);
const markdown = await getMarkdown();
- assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`);
+ assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
});
- await test('# Title round-trips to markdown', async () => {
+ await test('`code` produces md-code span', async () => {
await resetEditor();
- await typeString('# Title');
- await driver.sleep(50);
+ await typeString('`code`');
+ const html = await getHTML();
+ assert(html.includes('md-code'), `Expected md-code span: ${html}`);
const markdown = await getMarkdown();
- assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`);
+ assert(markdown === '`code`', `Expected "\`code\`", got: "${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 test('~~strike~~ produces md-strikethrough span', 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}`);
+ assert(html.includes('md-strikethrough'), `Expected md-strikethrough span: ${html}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '~~gone~~', `Expected "~~gone~~", got: "${markdown}"`);
});
- await test('~~text shows speculative strikethrough', async () => {
+ await test('delimiters are present in DOM as md-delim spans', async () => {
await resetEditor();
- await typeString('~~hel');
+ await typeString('**bold**');
const html = await getHTML();
- assert(html.includes('data-speculative'), `No speculative: ${html}`);
- assert(html.includes(': ${html}`);
+ 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}`);
});
- console.log(' Alternate syntax:');
-
- await test('~~~ transforms to fenced code', async () => {
+ await test('mixed inline on one line round-trips correctly', async () => {
await resetEditor();
- await typeString('~~~');
- await driver.sleep(50);
- const html = await getHTML();
- assert(html.includes(' {
+ // ── getMarkdown round-trips ────────────────────────────────────────────────
+
+ console.log('\ngetMarkdown round-trips:');
+
+ await test('heading round-trips', async () => {
await resetEditor();
- await typeChar('+');
- let html = await getHTML();
- assert(!html.includes(' {
+ await test('blockquote round-trips', async () => {
await resetEditor();
- await typeString('_hello_');
- const html = await getHTML();
- assert(html.includes(' after _hello_: ${html}`);
- assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ await typeString('> quoted text');
+ const markdown = await getMarkdown();
+ assert(markdown === '> quoted text', `Expected "> quoted text", got: "${markdown}"`);
});
- await test('_text shows speculative italic', async () => {
+ await test('list item round-trips', 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 typeString('- list item');
+ const markdown = await getMarkdown();
+ assert(markdown === '- list item', `Expected "- list item", got: "${markdown}"`);
});
- await test('__text__ transforms to bold', async () => {
+ await test('nested inline in heading round-trips', async () => {
await resetEditor();
- await typeString('__hello__');
- const html = await getHTML();
- assert(html.includes(' after __hello__: ${html}`);
- assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ await typeString('# Hello **world**');
+ const markdown = await getMarkdown();
+ assert(markdown === '# Hello **world**', `Expected "# Hello **world**", got: "${markdown}"`);
});
- console.log(' Backslash escapes:');
+ // ── Enter key behaviour ────────────────────────────────────────────────────
- await test('backslash is just a character in WYSIWYG', async () => {
+ console.log('\nEnter key behaviour:');
+
+ await test('Enter splits current block into two blocks', async () => {
await resetEditor();
- await typeString('hello\\world');
- const html = await getHTML();
- assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`);
+ 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('Setup failed:', error.message);
+ console.error('\nSetup failed:', error.message);
failed++;
} finally {
- console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`);
+ const total = passed + failed;
+ console.log(`\n${passed}/${total} passed — ${failed} failed`);
if (errors.length) {
- console.log('\nFailed:');
- errors.forEach(error => console.log(` • ${error}`));
+ console.log('\nFailed tests:');
+ errors.forEach(({ name, message }) => {
+ console.log(` • ${name}`);
+ console.log(` ${message}`);
+ });
}
await teardown();
process.exit(failed > 0 ? 1 : 0);