/
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 FILTER = (() => { + const filterArg = process.argv.find(arg => arg.startsWith('--filter=')); + return filterArg ? filterArg.split('=')[1] : null; +})(); +const DELAY = 20; // ms between keystrokes + +// āā State āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā + +let browser, page, server; +let passed = 0, failed = 0; +const errors = []; + +// āā Setup / teardown āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā + +async function getCaretInfo() { + return page.evaluate(() => { + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) return 'no selection'; + const range = sel.getRangeAt(0); + return { + container: range.startContainer.nodeType === 3 + ? `text:"${range.startContainer.textContent}"` + : `element:${range.startContainer.nodeName}.${range.startContainer.className}`, + offset: range.startOffset, + parentClass: range.startContainer.parentElement?.className, + }; + }); +} + +async function isPortInUse(port) { + return new Promise((resolve) => { + const net = require('net'); + const tester = net.createServer() + .once('error', () => resolve(true)) + .once('listening', () => tester.close(() => resolve(false))) + .listen(port, '127.0.0.1'); + }); +} + +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 (!await isPortInUse(PORT)) { + await serverStart(); + await new Promise(resolve => setTimeout(resolve, 500)); + } + browser = await chromium.launch({ headless: HEADLESS, channel: 'chromium' }); + 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(); } } -async function resetEditor() { - await driver.executeScript(` - var e = window.__ribbitEditor; - e.wysiwyg(); - e.element.innerHTML = '
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('Hello');
- html = await getHTML();
- assert(html.includes(' {
+ await test('plain text becomes md-paragraph', async () => {
await resetEditor();
- await typeString('##');
- let html = await getHTML();
- assert(!html.includes(' c.includes('md-paragraph')), `Expected md-paragraph, got: ${classes}`);
});
- 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}"`);
+ });
+
+ 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 anad md-italic spans', async () => {
+ await resetEditor();
+ await typeString('***both***');
+ const html = await getHTML();
+ assert(html.includes('md-bold'), `Expected md-bold-italic span: ${html}`);
+ assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
+ });
+
+ await test('nested bold and italic are handled correctly', async() => {
+ const NESTED_CASES = [
+ {
+ name: 'close-side ambiguity, outer=bold (bold *italic***)',
+ markdown: '**bold *italic***',
+ outerClass: 'md-bold',
+ innerClass: 'md-italic',
+ },
+ {
+ name: 'close-side ambiguity, outer=italic (*italic **bold***)',
+ markdown: '*italic **bold***',
+ outerClass: 'md-italic',
+ innerClass: 'md-bold',
+ },
+ {
+ name: 'open-side ambiguity, outer=bold (***italic* bold**)',
+ markdown: '***italic* bold**',
+ outerClass: 'md-bold',
+ innerClass: 'md-italic',
+ },
+ {
+ name: 'open-side ambiguity, outer=italic (***bold** italic*)',
+ markdown: '***bold** italic*',
+ outerClass: 'md-italic',
+ innerClass: 'md-bold',
+ },
+ ];
+
+ for (const testCase of NESTED_CASES) {
+ await test(testCase.name, async () => {
+ await resetEditor();
+ await typeString(testCase.markdown);
+
+ const markdown = await getMarkdown();
+ const html = await getHTML();
+ assert(
+ markdown === testCase.markdown,
+ `Round-trip failed.\nExpected: "${testCase.markdown}"\nGot: "${markdown}"`
+ );
+
+ const outerIndex = html.indexOf(testCase.outerClass);
+ const innerIndex = html.indexOf(testCase.innerClass);
+ assert(
+ outerIndex !== -1 && innerIndex !== -1,
+ `Missing expected classes (${testCase.outerClass}, ${testCase.innerClass}) in: ${html}`
+ );
+ assert(
+ outerIndex < innerIndex,
+ `Expected ${testCase.outerClass} to wrap (appear before) ${testCase.innerClass} in: ${html}`
+ );
+ });
}
});
- // āā Italic āā
-
- console.log(' Italic:');
-
- await test('*x starts speculative italic', async () => {
+ await test('`code` produces md-code 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`');
+ await typeString('`code`');
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
+ assert(html.includes('md-code'), `Expected md-code 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 === '`code`', `Expected "\`code\`", got: "${markdown}"`);
});
- await test('blockquote paragraphs survive mode round-trip', async () => {
- await resetEditor();
- await typeString('> foo');
- await typeChar(Key.ENTER);
- await typeString('bar');
- await driver.sleep(50);
-
- // Switch to source and back to wysiwyg twice
- await driver.executeScript('window.__ribbitEditor.edit()');
- await driver.sleep(50);
- await driver.executeScript('window.__ribbitEditor.wysiwyg()');
- await driver.sleep(50);
- await driver.executeScript('window.__ribbitEditor.edit()');
- await driver.sleep(50);
-
- const markdown = await driver.executeScript('return document.getElementById("ribbit").textContent');
- assert(markdown.includes('> foo'), `Missing > foo: ${markdown}`);
- assert(markdown.includes('> bar'), `Missing > bar ā second line lost its prefix: ${markdown}`);
- });
-
- await test('double enter exits blockquote', async () => {
- await resetEditor();
- await typeString('> quoted');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('after');
- const html = await getHTML();
- assert(html.includes(' afterBlockquote, `"after" is inside blockquote: ${html}`);
- });
-
- // āā Horizontal rule āā
-
- console.log(' Horizontal rule:');
-
- await test('--- transforms to hr', async () => {
- await resetEditor();
- await typeString('--');
- let html = await getHTML();
- assert(!html.includes('
{
- await resetEditor();
- await typeString('**hello**');
- await driver.sleep(50);
- const markdown = await getMarkdown();
- assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`);
- });
-
- await test('# Title round-trips to markdown', async () => {
- await resetEditor();
- await typeString('# Title');
- await driver.sleep(50);
- const markdown = await getMarkdown();
- assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`);
- });
-
- await test('mode switch preserves content', async () => {
- await resetEditor();
- await typeString('**bold**');
- await typeString(' and ');
- await typeString('*italic*');
- await driver.sleep(50);
-
- await driver.executeScript('window.__ribbitEditor.view()');
- await driver.sleep(50);
- await driver.executeScript('window.__ribbitEditor.wysiwyg()');
- await driver.sleep(50);
-
- const html = await getHTML();
- assert(html.includes(' {
- await resetEditor();
- await typeString('**hello');
- await driver.sleep(50);
- let html = await getHTML();
- assert(html.includes('data-speculative'), `No speculative: ${html}`);
-
- await typeChar(Key.ARROW_RIGHT);
- await driver.sleep(50);
- html = await getHTML();
- assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
- });
-
- await test('click outside closes speculative', async () => {
- await resetEditor();
- await typeString('**hello');
- await driver.sleep(50);
- let html = await getHTML();
- assert(html.includes('data-speculative'), `No speculative: ${html}`);
-
- // Add an element outside the editor and click it
- await driver.executeScript(`
- if (!document.getElementById('outside')) {
- var btn = document.createElement('button');
- btn.id = 'outside';
- btn.textContent = 'outside';
- btn.style.display = 'block';
- btn.style.padding = '20px';
- document.body.appendChild(btn);
- }
- `);
- await driver.findElement(By.id('outside')).click();
- await driver.sleep(100);
- html = await getHTML();
- assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
- });
-
- console.log(' Enter behavior:');
-
- await test('block pattern after Enter splits and transforms', async () => {
- await resetEditor();
- await typeString('foo');
- await typeChar(Key.ENTER);
- await typeString('> bar');
- await driver.sleep(50);
- const html = await getHTML();
- assert(html.includes(' after Enter did not create blockquote: ${html}`);
- assert(html.includes('foo'), `Lost content before split: ${html}`);
- assert(html.includes('bar'), `Lost content after split: ${html}`);
- });
-
- await test('single Enter in paragraph inserts line break', async () => {
- await resetEditor();
- await typeString('line one');
- await typeChar(Key.ENTER);
- await typeString('line two');
- const markdown = await getMarkdown();
- // Single Enter = one \n, not \n\n
- assert(markdown.includes('line one'), `Missing line one: ${markdown}`);
- assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
- assert(!markdown.includes('line one\n\nline two'), `Got paragraph break instead of line break: ${markdown}`);
- });
-
- await test('double Enter in paragraph creates new block', async () => {
- await resetEditor();
- await typeString('first paragraph');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('second paragraph');
- const html = await getHTML();
- // Double Enter = new , so two separate paragraphs
- const paragraphCount = (html.match(/
]/g) || []).length;
- assert(paragraphCount >= 2, `Expected 2+ paragraphs, got ${paragraphCount}: ${html}`);
- });
-
- await test('backspace at start of line after Enter joins lines', async () => {
- await resetEditor();
- await typeString('foo');
- await typeChar(Key.ENTER);
- await typeChar(Key.BACK_SPACE);
- await driver.sleep(50);
- const html = await getHTML();
- // The
should be removed, cursor at end of "foo"
- assert(!html.includes('
not removed: ${html}`);
- assert(html.includes('foo'), `Content lost: ${html}`);
- });
-
- await test('single Enter in list item inserts line break', async () => {
- await resetEditor();
- await typeString('- line one');
- await typeChar(Key.ENTER);
- await typeString('line two');
- const markdown = await getMarkdown();
- // Both lines in the same list item
- assert(markdown.includes('- line one'), `Missing list marker: ${markdown}`);
- assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
- // Should NOT create a second list item
- const markerCount = (markdown.match(/^- /gm) || []).length;
- assert(markerCount === 1, `Expected 1 list marker, got ${markerCount}: ${markdown}`);
- });
-
- await test('double Enter in list item creates new item', async () => {
- await resetEditor();
- await typeString('- first');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('second');
- const markdown = await getMarkdown();
- const markerCount = (markdown.match(/^- /gm) || []).length;
- assert(markerCount === 2, `Expected 2 list markers, got ${markerCount}: ${markdown}`);
- assert(markdown.includes('- first'), `Missing first item: ${markdown}`);
- assert(markdown.includes('- second'), `Missing second item: ${markdown}`);
- });
-
- await test('double Enter on empty list item exits list', async () => {
- await resetEditor();
- await typeString('- item');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('after list');
- const html = await getHTML();
- assert(html.includes('after list'), `Missing text after list: ${html}`);
- // "after list" should NOT be inside the
- const ulEnd = html.indexOf('
');
- const afterPos = html.indexOf('after list');
- assert(afterPos > ulEnd, `"after list" is inside the list: ${html}`);
- });
-
- // āā Complex document āā
-
- console.log(' Complex document:');
-
- await test('multi-element document', async () => {
- await resetEditor();
- await typeString('# Title');
- await typeChar(Key.ENTER);
- await typeString('Some **bold** text.');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('## Section');
- await typeChar(Key.ENTER);
- await typeString('- item one');
-
- await driver.sleep(100);
- const html = await getHTML();
- assert(html.includes('', async () => {
+ await 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);
+ await typeString('hello **world** and *italic*');
+ const markdown = await getMarkdown();
+ assert(
+ markdown === 'hello **world** and *italic*',
+ `Round-trip failed: "${markdown}"`
+ );
+ });
+
+ await test('bold followed by trailing space stays bold', async () => {
+ await resetEditor();
+ await typeString('**bold** ');
const html = await getHTML();
- assert(html.includes(' {
+
+ await test('heading prefix stays plain space, not nbsp, after rebuild', async () => {
await resetEditor();
- await typeChar('+');
- let html = await getHTML();
- assert(!html.includes(' {
- await resetEditor();
- await typeString('_hello_');
+ await typeString('# Title');
const html = await getHTML();
- assert(html.includes(' after _hello_: ${html}`);
- assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ // The delimiter span's text must be a literal space (U+0020),
+ // not a non-breaking space, or block classification breaks on
+ // the next keystroke read of textContent.
+ assert(
+ html.includes('# ') ||
+ html.includes('class="md-delim"># '),
+ `Unexpected delim content: ${html}`
+ );
+ // The real check: typing more text after this should still
+ // classify as md-h1, not regress to md-paragraph.
+ await typeString(' more');
+ const classes = await getBlockClasses();
+ assert(
+ classes.some(c => c.includes('md-h1')),
+ `Lost md-h1 classification after additional typing: ${JSON.stringify(classes)}`
+ );
});
- await test('_text shows speculative italic', async () => {
+ // āā getMarkdown round-trips āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+
+ console.log('\ngetMarkdown round-trips:');
+
+ await test('heading 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('# Hello World');
+ const markdown = await getMarkdown();
+ assert(markdown === '# Hello World', `Expected "# Hello World", got: "${markdown}"`);
});
- await test('__text__ transforms to bold', async () => {
+ 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}"`);
});
- console.log(' Backslash escapes:');
-
- await test('backslash is just a character in WYSIWYG', async () => {
+ await test('list item round-trips', async () => {
await resetEditor();
- await typeString('hello\\world');
- const html = await getHTML();
- assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`);
+ 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('double Enter exits list', async () => {
+ await resetEditor();
+ await typeString('- item');
+ await pressKey('Enter');
+ await pressKey('Enter');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-paragraph')),
+ `Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}`
+ );
+ });
+
+ await test('double Enter exits blockquote', async () => {
+ await resetEditor();
+ await typeString('> quote');
+ await pressKey('Enter');
+ await pressKey('Enter');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-paragraph')),
+ `Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}`
+ );
+ });
+
+ await test('ordered list increments', async () => {
+ await resetEditor();
+ await typeString('1. first');
+ await pressKey('Enter');
+ const markdown = await getMarkdown();
+ assert(
+ markdown.includes('2. '),
+ `Expected "2. " on second line, got: "${markdown}"`
+ );
+ });
+
+ await test('heading followed by Enter creates 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: ${blocks}`);
+ assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph: ${blocks}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`);
+ });
+
+ await test('heading renders correctly after typing', async () => {
+ await resetEditor();
+ await typeString('# foo');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-h1')),
+ `Expected md-h1, got: ${JSON.stringify(blocks)}`
+ );
+ const markdown = await getMarkdown();
+ assert(markdown === '# foo', `Expected "# foo", got: "${markdown}"`);
+ });
+
+ 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 on last empty block does not break editor', async () => {
+ await resetEditor();
+ await typeString('foo');
+ // Select all and delete
+ await page.keyboard.press('Control+a');
+ await page.keyboard.press('Backspace');
+ await page.keyboard.press('Backspace');
+ // Editor should still be functional
+ await typeString('# bar');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-h1')),
+ `Editor broken after double backspace, got: ${JSON.stringify(blocks)}`
+ );
+ const markdown = await getMarkdown();
+ assert(markdown === '# bar', `Expected "# bar", got: "${markdown}"`);
+ });
+
+ 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}"`);
+ });
+
+
+ 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(' ');
+ const html = await page.evaluate(() => document.getElementById('ribbit').innerHTML);
+
+ 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}`);
+ });
+
}
+// āā 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}`);
+ });
+ }
+ if (!HEADLESS) {
+ console.log('\nPress Enter to close...');
+ await new Promise(resolve => process.stdin.once('data', resolve));
}
await teardown();
process.exit(failed > 0 ? 1 : 0);
diff --git a/test/macros.test.ts b/test/macros.test.ts
index 291b805..12e13a1 100644
--- a/test/macros.test.ts
+++ b/test/macros.test.ts
@@ -1,9 +1,7 @@
-import { ribbit } from './setup';
+import { ribbit, resetDOM } from './setup';
const lib = ribbit();
-const spacePattern = / /g;
-
const macros = [
{
name: 'user',
@@ -13,7 +11,7 @@ const macros = [
name: 'npc',
toHTML: ({ keywords }: any) => {
const name = keywords.join(' ');
- return '' + name + '';
+ return '' + name + '';
},
},
{
@@ -26,7 +24,9 @@ const macros = [
},
];
-const converter = new lib.HopDown({ macros });
+const editor = new lib.Editor({macros: macros});
+const converter = editor.converter;
+
const H = (md: string) => converter.toHTML(md);
const M = (html: string) => converter.toMarkdown(html);
diff --git a/test/vim.test.ts b/test/vim.test.ts
deleted file mode 100644
index 7172c56..0000000
--- a/test/vim.test.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import { ribbit, resetDOM } from './setup';
-
-const lib = ribbit();
-
-describe('VimHandler', () => {
- beforeEach(() => resetDOM('hello world'));
-
- it('starts in insert mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- expect(editor.element.classList.contains('vim-insert')).toBe(true);
- });
-
- it('Esc enters normal mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- expect(editor.element.classList.contains('vim-normal')).toBe(true);
- expect(editor.element.classList.contains('vim-insert')).toBe(false);
- });
-
- it('i returns to insert mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- // Enter normal mode
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- // Back to insert
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
- expect(editor.element.classList.contains('vim-insert')).toBe(true);
- expect(editor.element.classList.contains('vim-normal')).toBe(false);
- });
-
- it('disables toolbar in normal mode', () => {
- const editor = new lib.Editor({
- autoToolbar: false,
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.toolbar.render();
- editor.edit();
- editor.toolbar.enable();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- const bold = editor.toolbar.buttons.get('bold');
- expect(bold?.element?.classList.contains('disabled')).toBe(true);
- });
-
- it('re-enables toolbar in insert mode', () => {
- const editor = new lib.Editor({
- autoToolbar: false,
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.toolbar.render();
- editor.edit();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
- const bold = editor.toolbar.buttons.get('bold');
- expect(bold?.element?.classList.contains('disabled')).toBe(false);
- });
-
- it('detaches when leaving edit mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- expect(editor.element.classList.contains('vim-normal')).toBe(true);
- editor.wysiwyg();
- // vim classes should be gone after mode switch
- expect(editor.element.classList.contains('vim-normal')).toBe(false);
- expect(editor.element.classList.contains('vim-insert')).toBe(false);
- });
-
- it('only activates in edit mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.wysiwyg();
- // Esc in wysiwyg should not add vim classes
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- expect(editor.element.classList.contains('vim-normal')).toBe(false);
- });
-});
{
+ await test('plain text becomes md-paragraph', async () => {
await resetEditor();
- await typeString('##');
- let html = await getHTML();
- assert(!html.includes(' c.includes('md-paragraph')), `Expected md-paragraph, got: ${classes}`);
});
- 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}"`);
+ });
+
+ 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 anad md-italic spans', async () => {
+ await resetEditor();
+ await typeString('***both***');
+ const html = await getHTML();
+ assert(html.includes('md-bold'), `Expected md-bold-italic span: ${html}`);
+ assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
+ });
+
+ await test('nested bold and italic are handled correctly', async() => {
+ const NESTED_CASES = [
+ {
+ name: 'close-side ambiguity, outer=bold (bold *italic***)',
+ markdown: '**bold *italic***',
+ outerClass: 'md-bold',
+ innerClass: 'md-italic',
+ },
+ {
+ name: 'close-side ambiguity, outer=italic (*italic **bold***)',
+ markdown: '*italic **bold***',
+ outerClass: 'md-italic',
+ innerClass: 'md-bold',
+ },
+ {
+ name: 'open-side ambiguity, outer=bold (***italic* bold**)',
+ markdown: '***italic* bold**',
+ outerClass: 'md-bold',
+ innerClass: 'md-italic',
+ },
+ {
+ name: 'open-side ambiguity, outer=italic (***bold** italic*)',
+ markdown: '***bold** italic*',
+ outerClass: 'md-italic',
+ innerClass: 'md-bold',
+ },
+ ];
+
+ for (const testCase of NESTED_CASES) {
+ await test(testCase.name, async () => {
+ await resetEditor();
+ await typeString(testCase.markdown);
+
+ const markdown = await getMarkdown();
+ const html = await getHTML();
+ assert(
+ markdown === testCase.markdown,
+ `Round-trip failed.\nExpected: "${testCase.markdown}"\nGot: "${markdown}"`
+ );
+
+ const outerIndex = html.indexOf(testCase.outerClass);
+ const innerIndex = html.indexOf(testCase.innerClass);
+ assert(
+ outerIndex !== -1 && innerIndex !== -1,
+ `Missing expected classes (${testCase.outerClass}, ${testCase.innerClass}) in: ${html}`
+ );
+ assert(
+ outerIndex < innerIndex,
+ `Expected ${testCase.outerClass} to wrap (appear before) ${testCase.innerClass} in: ${html}`
+ );
+ });
}
});
- // āā Italic āā
-
- console.log(' Italic:');
-
- await test('*x starts speculative italic', async () => {
+ await test('`code` produces md-code 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`');
+ await typeString('`code`');
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
+ assert(html.includes('md-code'), `Expected md-code 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 === '`code`', `Expected "\`code\`", got: "${markdown}"`);
});
- await test('blockquote paragraphs survive mode round-trip', async () => {
- await resetEditor();
- await typeString('> foo');
- await typeChar(Key.ENTER);
- await typeString('bar');
- await driver.sleep(50);
-
- // Switch to source and back to wysiwyg twice
- await driver.executeScript('window.__ribbitEditor.edit()');
- await driver.sleep(50);
- await driver.executeScript('window.__ribbitEditor.wysiwyg()');
- await driver.sleep(50);
- await driver.executeScript('window.__ribbitEditor.edit()');
- await driver.sleep(50);
-
- const markdown = await driver.executeScript('return document.getElementById("ribbit").textContent');
- assert(markdown.includes('> foo'), `Missing > foo: ${markdown}`);
- assert(markdown.includes('> bar'), `Missing > bar ā second line lost its prefix: ${markdown}`);
- });
-
- await test('double enter exits blockquote', async () => {
- await resetEditor();
- await typeString('> quoted');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('after');
- const html = await getHTML();
- assert(html.includes(' afterBlockquote, `"after" is inside blockquote: ${html}`);
- });
-
- // āā Horizontal rule āā
-
- console.log(' Horizontal rule:');
-
- await test('--- transforms to hr', async () => {
- await resetEditor();
- await typeString('--');
- let html = await getHTML();
- assert(!html.includes('
{
- await resetEditor();
- await typeString('**hello**');
- await driver.sleep(50);
- const markdown = await getMarkdown();
- assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`);
- });
-
- await test('# Title round-trips to markdown', async () => {
- await resetEditor();
- await typeString('# Title');
- await driver.sleep(50);
- const markdown = await getMarkdown();
- assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`);
- });
-
- await test('mode switch preserves content', async () => {
- await resetEditor();
- await typeString('**bold**');
- await typeString(' and ');
- await typeString('*italic*');
- await driver.sleep(50);
-
- await driver.executeScript('window.__ribbitEditor.view()');
- await driver.sleep(50);
- await driver.executeScript('window.__ribbitEditor.wysiwyg()');
- await driver.sleep(50);
-
- const html = await getHTML();
- assert(html.includes(' {
- await resetEditor();
- await typeString('**hello');
- await driver.sleep(50);
- let html = await getHTML();
- assert(html.includes('data-speculative'), `No speculative: ${html}`);
-
- await typeChar(Key.ARROW_RIGHT);
- await driver.sleep(50);
- html = await getHTML();
- assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
- });
-
- await test('click outside closes speculative', async () => {
- await resetEditor();
- await typeString('**hello');
- await driver.sleep(50);
- let html = await getHTML();
- assert(html.includes('data-speculative'), `No speculative: ${html}`);
-
- // Add an element outside the editor and click it
- await driver.executeScript(`
- if (!document.getElementById('outside')) {
- var btn = document.createElement('button');
- btn.id = 'outside';
- btn.textContent = 'outside';
- btn.style.display = 'block';
- btn.style.padding = '20px';
- document.body.appendChild(btn);
- }
- `);
- await driver.findElement(By.id('outside')).click();
- await driver.sleep(100);
- html = await getHTML();
- assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
- });
-
- console.log(' Enter behavior:');
-
- await test('block pattern after Enter splits and transforms', async () => {
- await resetEditor();
- await typeString('foo');
- await typeChar(Key.ENTER);
- await typeString('> bar');
- await driver.sleep(50);
- const html = await getHTML();
- assert(html.includes(' after Enter did not create blockquote: ${html}`);
- assert(html.includes('foo'), `Lost content before split: ${html}`);
- assert(html.includes('bar'), `Lost content after split: ${html}`);
- });
-
- await test('single Enter in paragraph inserts line break', async () => {
- await resetEditor();
- await typeString('line one');
- await typeChar(Key.ENTER);
- await typeString('line two');
- const markdown = await getMarkdown();
- // Single Enter = one \n, not \n\n
- assert(markdown.includes('line one'), `Missing line one: ${markdown}`);
- assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
- assert(!markdown.includes('line one\n\nline two'), `Got paragraph break instead of line break: ${markdown}`);
- });
-
- await test('double Enter in paragraph creates new block', async () => {
- await resetEditor();
- await typeString('first paragraph');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('second paragraph');
- const html = await getHTML();
- // Double Enter = new , so two separate paragraphs
- const paragraphCount = (html.match(/
]/g) || []).length;
- assert(paragraphCount >= 2, `Expected 2+ paragraphs, got ${paragraphCount}: ${html}`);
- });
-
- await test('backspace at start of line after Enter joins lines', async () => {
- await resetEditor();
- await typeString('foo');
- await typeChar(Key.ENTER);
- await typeChar(Key.BACK_SPACE);
- await driver.sleep(50);
- const html = await getHTML();
- // The
should be removed, cursor at end of "foo"
- assert(!html.includes('
not removed: ${html}`);
- assert(html.includes('foo'), `Content lost: ${html}`);
- });
-
- await test('single Enter in list item inserts line break', async () => {
- await resetEditor();
- await typeString('- line one');
- await typeChar(Key.ENTER);
- await typeString('line two');
- const markdown = await getMarkdown();
- // Both lines in the same list item
- assert(markdown.includes('- line one'), `Missing list marker: ${markdown}`);
- assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
- // Should NOT create a second list item
- const markerCount = (markdown.match(/^- /gm) || []).length;
- assert(markerCount === 1, `Expected 1 list marker, got ${markerCount}: ${markdown}`);
- });
-
- await test('double Enter in list item creates new item', async () => {
- await resetEditor();
- await typeString('- first');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('second');
- const markdown = await getMarkdown();
- const markerCount = (markdown.match(/^- /gm) || []).length;
- assert(markerCount === 2, `Expected 2 list markers, got ${markerCount}: ${markdown}`);
- assert(markdown.includes('- first'), `Missing first item: ${markdown}`);
- assert(markdown.includes('- second'), `Missing second item: ${markdown}`);
- });
-
- await test('double Enter on empty list item exits list', async () => {
- await resetEditor();
- await typeString('- item');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('after list');
- const html = await getHTML();
- assert(html.includes('after list'), `Missing text after list: ${html}`);
- // "after list" should NOT be inside the
- const ulEnd = html.indexOf('
');
- const afterPos = html.indexOf('after list');
- assert(afterPos > ulEnd, `"after list" is inside the list: ${html}`);
- });
-
- // āā Complex document āā
-
- console.log(' Complex document:');
-
- await test('multi-element document', async () => {
- await resetEditor();
- await typeString('# Title');
- await typeChar(Key.ENTER);
- await typeString('Some **bold** text.');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('## Section');
- await typeChar(Key.ENTER);
- await typeString('- item one');
-
- await driver.sleep(100);
- const html = await getHTML();
- assert(html.includes('', async () => {
+ await 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);
+ await typeString('hello **world** and *italic*');
+ const markdown = await getMarkdown();
+ assert(
+ markdown === 'hello **world** and *italic*',
+ `Round-trip failed: "${markdown}"`
+ );
+ });
+
+ await test('bold followed by trailing space stays bold', async () => {
+ await resetEditor();
+ await typeString('**bold** ');
const html = await getHTML();
- assert(html.includes(' {
+
+ await test('heading prefix stays plain space, not nbsp, after rebuild', async () => {
await resetEditor();
- await typeChar('+');
- let html = await getHTML();
- assert(!html.includes(' {
- await resetEditor();
- await typeString('_hello_');
+ await typeString('# Title');
const html = await getHTML();
- assert(html.includes(' after _hello_: ${html}`);
- assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ // The delimiter span's text must be a literal space (U+0020),
+ // not a non-breaking space, or block classification breaks on
+ // the next keystroke read of textContent.
+ assert(
+ html.includes('# ') ||
+ html.includes('class="md-delim"># '),
+ `Unexpected delim content: ${html}`
+ );
+ // The real check: typing more text after this should still
+ // classify as md-h1, not regress to md-paragraph.
+ await typeString(' more');
+ const classes = await getBlockClasses();
+ assert(
+ classes.some(c => c.includes('md-h1')),
+ `Lost md-h1 classification after additional typing: ${JSON.stringify(classes)}`
+ );
});
- await test('_text shows speculative italic', async () => {
+ // āā getMarkdown round-trips āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+
+ console.log('\ngetMarkdown round-trips:');
+
+ await test('heading 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('# Hello World');
+ const markdown = await getMarkdown();
+ assert(markdown === '# Hello World', `Expected "# Hello World", got: "${markdown}"`);
});
- await test('__text__ transforms to bold', async () => {
+ 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}"`);
});
- console.log(' Backslash escapes:');
-
- await test('backslash is just a character in WYSIWYG', async () => {
+ await test('list item round-trips', async () => {
await resetEditor();
- await typeString('hello\\world');
- const html = await getHTML();
- assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`);
+ 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('double Enter exits list', async () => {
+ await resetEditor();
+ await typeString('- item');
+ await pressKey('Enter');
+ await pressKey('Enter');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-paragraph')),
+ `Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}`
+ );
+ });
+
+ await test('double Enter exits blockquote', async () => {
+ await resetEditor();
+ await typeString('> quote');
+ await pressKey('Enter');
+ await pressKey('Enter');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-paragraph')),
+ `Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}`
+ );
+ });
+
+ await test('ordered list increments', async () => {
+ await resetEditor();
+ await typeString('1. first');
+ await pressKey('Enter');
+ const markdown = await getMarkdown();
+ assert(
+ markdown.includes('2. '),
+ `Expected "2. " on second line, got: "${markdown}"`
+ );
+ });
+
+ await test('heading followed by Enter creates 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: ${blocks}`);
+ assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph: ${blocks}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`);
+ });
+
+ await test('heading renders correctly after typing', async () => {
+ await resetEditor();
+ await typeString('# foo');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-h1')),
+ `Expected md-h1, got: ${JSON.stringify(blocks)}`
+ );
+ const markdown = await getMarkdown();
+ assert(markdown === '# foo', `Expected "# foo", got: "${markdown}"`);
+ });
+
+ 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 on last empty block does not break editor', async () => {
+ await resetEditor();
+ await typeString('foo');
+ // Select all and delete
+ await page.keyboard.press('Control+a');
+ await page.keyboard.press('Backspace');
+ await page.keyboard.press('Backspace');
+ // Editor should still be functional
+ await typeString('# bar');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-h1')),
+ `Editor broken after double backspace, got: ${JSON.stringify(blocks)}`
+ );
+ const markdown = await getMarkdown();
+ assert(markdown === '# bar', `Expected "# bar", got: "${markdown}"`);
+ });
+
+ 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}"`);
+ });
+
+
+ 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(' ');
+ const html = await page.evaluate(() => document.getElementById('ribbit').innerHTML);
+
+ 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}`);
+ });
+
}
+// āā 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}`);
+ });
+ }
+ if (!HEADLESS) {
+ console.log('\nPress Enter to close...');
+ await new Promise(resolve => process.stdin.once('data', resolve));
}
await teardown();
process.exit(failed > 0 ? 1 : 0);
diff --git a/test/macros.test.ts b/test/macros.test.ts
index 291b805..12e13a1 100644
--- a/test/macros.test.ts
+++ b/test/macros.test.ts
@@ -1,9 +1,7 @@
-import { ribbit } from './setup';
+import { ribbit, resetDOM } from './setup';
const lib = ribbit();
-const spacePattern = / /g;
-
const macros = [
{
name: 'user',
@@ -13,7 +11,7 @@ const macros = [
name: 'npc',
toHTML: ({ keywords }: any) => {
const name = keywords.join(' ');
- return '' + name + '';
+ return '' + name + '';
},
},
{
@@ -26,7 +24,9 @@ const macros = [
},
];
-const converter = new lib.HopDown({ macros });
+const editor = new lib.Editor({macros: macros});
+const converter = editor.converter;
+
const H = (md: string) => converter.toHTML(md);
const M = (html: string) => converter.toMarkdown(html);
diff --git a/test/vim.test.ts b/test/vim.test.ts
deleted file mode 100644
index 7172c56..0000000
--- a/test/vim.test.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import { ribbit, resetDOM } from './setup';
-
-const lib = ribbit();
-
-describe('VimHandler', () => {
- beforeEach(() => resetDOM('hello world'));
-
- it('starts in insert mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- expect(editor.element.classList.contains('vim-insert')).toBe(true);
- });
-
- it('Esc enters normal mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- expect(editor.element.classList.contains('vim-normal')).toBe(true);
- expect(editor.element.classList.contains('vim-insert')).toBe(false);
- });
-
- it('i returns to insert mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- // Enter normal mode
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- // Back to insert
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
- expect(editor.element.classList.contains('vim-insert')).toBe(true);
- expect(editor.element.classList.contains('vim-normal')).toBe(false);
- });
-
- it('disables toolbar in normal mode', () => {
- const editor = new lib.Editor({
- autoToolbar: false,
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.toolbar.render();
- editor.edit();
- editor.toolbar.enable();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- const bold = editor.toolbar.buttons.get('bold');
- expect(bold?.element?.classList.contains('disabled')).toBe(true);
- });
-
- it('re-enables toolbar in insert mode', () => {
- const editor = new lib.Editor({
- autoToolbar: false,
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.toolbar.render();
- editor.edit();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
- const bold = editor.toolbar.buttons.get('bold');
- expect(bold?.element?.classList.contains('disabled')).toBe(false);
- });
-
- it('detaches when leaving edit mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- expect(editor.element.classList.contains('vim-normal')).toBe(true);
- editor.wysiwyg();
- // vim classes should be gone after mode switch
- expect(editor.element.classList.contains('vim-normal')).toBe(false);
- expect(editor.element.classList.contains('vim-insert')).toBe(false);
- });
-
- it('only activates in edit mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.wysiwyg();
- // Esc in wysiwyg should not add vim classes
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- expect(editor.element.classList.contains('vim-normal')).toBe(false);
- });
-});
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}"`);
+ });
+
+ 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 anad md-italic spans', async () => {
+ await resetEditor();
+ await typeString('***both***');
+ const html = await getHTML();
+ assert(html.includes('md-bold'), `Expected md-bold-italic span: ${html}`);
+ assert(html.includes('md-italic'), `Expected md-italic span: ${html}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`);
+ });
+
+ await test('nested bold and italic are handled correctly', async() => {
+ const NESTED_CASES = [
+ {
+ name: 'close-side ambiguity, outer=bold (bold *italic***)',
+ markdown: '**bold *italic***',
+ outerClass: 'md-bold',
+ innerClass: 'md-italic',
+ },
+ {
+ name: 'close-side ambiguity, outer=italic (*italic **bold***)',
+ markdown: '*italic **bold***',
+ outerClass: 'md-italic',
+ innerClass: 'md-bold',
+ },
+ {
+ name: 'open-side ambiguity, outer=bold (***italic* bold**)',
+ markdown: '***italic* bold**',
+ outerClass: 'md-bold',
+ innerClass: 'md-italic',
+ },
+ {
+ name: 'open-side ambiguity, outer=italic (***bold** italic*)',
+ markdown: '***bold** italic*',
+ outerClass: 'md-italic',
+ innerClass: 'md-bold',
+ },
+ ];
+
+ for (const testCase of NESTED_CASES) {
+ await test(testCase.name, async () => {
+ await resetEditor();
+ await typeString(testCase.markdown);
+
+ const markdown = await getMarkdown();
+ const html = await getHTML();
+ assert(
+ markdown === testCase.markdown,
+ `Round-trip failed.\nExpected: "${testCase.markdown}"\nGot: "${markdown}"`
+ );
+
+ const outerIndex = html.indexOf(testCase.outerClass);
+ const innerIndex = html.indexOf(testCase.innerClass);
+ assert(
+ outerIndex !== -1 && innerIndex !== -1,
+ `Missing expected classes (${testCase.outerClass}, ${testCase.innerClass}) in: ${html}`
+ );
+ assert(
+ outerIndex < innerIndex,
+ `Expected ${testCase.outerClass} to wrap (appear before) ${testCase.innerClass} in: ${html}`
+ );
+ });
}
});
- // āā Italic āā
-
- console.log(' Italic:');
-
- await test('*x starts speculative italic', async () => {
+ await test('`code` produces md-code 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`');
+ await typeString('`code`');
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
+ assert(html.includes('md-code'), `Expected md-code 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 === '`code`', `Expected "\`code\`", got: "${markdown}"`);
});
- await test('blockquote paragraphs survive mode round-trip', async () => {
- await resetEditor();
- await typeString('> foo');
- await typeChar(Key.ENTER);
- await typeString('bar');
- await driver.sleep(50);
-
- // Switch to source and back to wysiwyg twice
- await driver.executeScript('window.__ribbitEditor.edit()');
- await driver.sleep(50);
- await driver.executeScript('window.__ribbitEditor.wysiwyg()');
- await driver.sleep(50);
- await driver.executeScript('window.__ribbitEditor.edit()');
- await driver.sleep(50);
-
- const markdown = await driver.executeScript('return document.getElementById("ribbit").textContent');
- assert(markdown.includes('> foo'), `Missing > foo: ${markdown}`);
- assert(markdown.includes('> bar'), `Missing > bar ā second line lost its prefix: ${markdown}`);
- });
-
- await test('double enter exits blockquote', async () => {
- await resetEditor();
- await typeString('> quoted');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('after');
- const html = await getHTML();
- assert(html.includes(' afterBlockquote, `"after" is inside blockquote: ${html}`);
- });
-
- // āā Horizontal rule āā
-
- console.log(' Horizontal rule:');
-
- await test('--- transforms to hr', async () => {
- await resetEditor();
- await typeString('--');
- let html = await getHTML();
- assert(!html.includes('
{
- await resetEditor();
- await typeString('**hello**');
- await driver.sleep(50);
- const markdown = await getMarkdown();
- assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`);
- });
-
- await test('# Title round-trips to markdown', async () => {
- await resetEditor();
- await typeString('# Title');
- await driver.sleep(50);
- const markdown = await getMarkdown();
- assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`);
- });
-
- await test('mode switch preserves content', async () => {
- await resetEditor();
- await typeString('**bold**');
- await typeString(' and ');
- await typeString('*italic*');
- await driver.sleep(50);
-
- await driver.executeScript('window.__ribbitEditor.view()');
- await driver.sleep(50);
- await driver.executeScript('window.__ribbitEditor.wysiwyg()');
- await driver.sleep(50);
-
- const html = await getHTML();
- assert(html.includes(' {
- await resetEditor();
- await typeString('**hello');
- await driver.sleep(50);
- let html = await getHTML();
- assert(html.includes('data-speculative'), `No speculative: ${html}`);
-
- await typeChar(Key.ARROW_RIGHT);
- await driver.sleep(50);
- html = await getHTML();
- assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
- });
-
- await test('click outside closes speculative', async () => {
- await resetEditor();
- await typeString('**hello');
- await driver.sleep(50);
- let html = await getHTML();
- assert(html.includes('data-speculative'), `No speculative: ${html}`);
-
- // Add an element outside the editor and click it
- await driver.executeScript(`
- if (!document.getElementById('outside')) {
- var btn = document.createElement('button');
- btn.id = 'outside';
- btn.textContent = 'outside';
- btn.style.display = 'block';
- btn.style.padding = '20px';
- document.body.appendChild(btn);
- }
- `);
- await driver.findElement(By.id('outside')).click();
- await driver.sleep(100);
- html = await getHTML();
- assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`);
- });
-
- console.log(' Enter behavior:');
-
- await test('block pattern after Enter splits and transforms', async () => {
- await resetEditor();
- await typeString('foo');
- await typeChar(Key.ENTER);
- await typeString('> bar');
- await driver.sleep(50);
- const html = await getHTML();
- assert(html.includes(' after Enter did not create blockquote: ${html}`);
- assert(html.includes('foo'), `Lost content before split: ${html}`);
- assert(html.includes('bar'), `Lost content after split: ${html}`);
- });
-
- await test('single Enter in paragraph inserts line break', async () => {
- await resetEditor();
- await typeString('line one');
- await typeChar(Key.ENTER);
- await typeString('line two');
- const markdown = await getMarkdown();
- // Single Enter = one \n, not \n\n
- assert(markdown.includes('line one'), `Missing line one: ${markdown}`);
- assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
- assert(!markdown.includes('line one\n\nline two'), `Got paragraph break instead of line break: ${markdown}`);
- });
-
- await test('double Enter in paragraph creates new block', async () => {
- await resetEditor();
- await typeString('first paragraph');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('second paragraph');
- const html = await getHTML();
- // Double Enter = new , so two separate paragraphs
- const paragraphCount = (html.match(/
]/g) || []).length;
- assert(paragraphCount >= 2, `Expected 2+ paragraphs, got ${paragraphCount}: ${html}`);
- });
-
- await test('backspace at start of line after Enter joins lines', async () => {
- await resetEditor();
- await typeString('foo');
- await typeChar(Key.ENTER);
- await typeChar(Key.BACK_SPACE);
- await driver.sleep(50);
- const html = await getHTML();
- // The
should be removed, cursor at end of "foo"
- assert(!html.includes('
not removed: ${html}`);
- assert(html.includes('foo'), `Content lost: ${html}`);
- });
-
- await test('single Enter in list item inserts line break', async () => {
- await resetEditor();
- await typeString('- line one');
- await typeChar(Key.ENTER);
- await typeString('line two');
- const markdown = await getMarkdown();
- // Both lines in the same list item
- assert(markdown.includes('- line one'), `Missing list marker: ${markdown}`);
- assert(markdown.includes('line two'), `Missing line two: ${markdown}`);
- // Should NOT create a second list item
- const markerCount = (markdown.match(/^- /gm) || []).length;
- assert(markerCount === 1, `Expected 1 list marker, got ${markerCount}: ${markdown}`);
- });
-
- await test('double Enter in list item creates new item', async () => {
- await resetEditor();
- await typeString('- first');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('second');
- const markdown = await getMarkdown();
- const markerCount = (markdown.match(/^- /gm) || []).length;
- assert(markerCount === 2, `Expected 2 list markers, got ${markerCount}: ${markdown}`);
- assert(markdown.includes('- first'), `Missing first item: ${markdown}`);
- assert(markdown.includes('- second'), `Missing second item: ${markdown}`);
- });
-
- await test('double Enter on empty list item exits list', async () => {
- await resetEditor();
- await typeString('- item');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('after list');
- const html = await getHTML();
- assert(html.includes('after list'), `Missing text after list: ${html}`);
- // "after list" should NOT be inside the
- const ulEnd = html.indexOf('
');
- const afterPos = html.indexOf('after list');
- assert(afterPos > ulEnd, `"after list" is inside the list: ${html}`);
- });
-
- // āā Complex document āā
-
- console.log(' Complex document:');
-
- await test('multi-element document', async () => {
- await resetEditor();
- await typeString('# Title');
- await typeChar(Key.ENTER);
- await typeString('Some **bold** text.');
- await typeChar(Key.ENTER);
- await typeChar(Key.ENTER);
- await typeString('## Section');
- await typeChar(Key.ENTER);
- await typeString('- item one');
-
- await driver.sleep(100);
- const html = await getHTML();
- assert(html.includes('', async () => {
+ await 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);
+ await typeString('hello **world** and *italic*');
+ const markdown = await getMarkdown();
+ assert(
+ markdown === 'hello **world** and *italic*',
+ `Round-trip failed: "${markdown}"`
+ );
+ });
+
+ await test('bold followed by trailing space stays bold', async () => {
+ await resetEditor();
+ await typeString('**bold** ');
const html = await getHTML();
- assert(html.includes(' {
+
+ await test('heading prefix stays plain space, not nbsp, after rebuild', async () => {
await resetEditor();
- await typeChar('+');
- let html = await getHTML();
- assert(!html.includes(' {
- await resetEditor();
- await typeString('_hello_');
+ await typeString('# Title');
const html = await getHTML();
- assert(html.includes(' after _hello_: ${html}`);
- assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
+ // The delimiter span's text must be a literal space (U+0020),
+ // not a non-breaking space, or block classification breaks on
+ // the next keystroke read of textContent.
+ assert(
+ html.includes('# ') ||
+ html.includes('class="md-delim"># '),
+ `Unexpected delim content: ${html}`
+ );
+ // The real check: typing more text after this should still
+ // classify as md-h1, not regress to md-paragraph.
+ await typeString(' more');
+ const classes = await getBlockClasses();
+ assert(
+ classes.some(c => c.includes('md-h1')),
+ `Lost md-h1 classification after additional typing: ${JSON.stringify(classes)}`
+ );
});
- await test('_text shows speculative italic', async () => {
+ // āā getMarkdown round-trips āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
+
+ console.log('\ngetMarkdown round-trips:');
+
+ await test('heading 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('# Hello World');
+ const markdown = await getMarkdown();
+ assert(markdown === '# Hello World', `Expected "# Hello World", got: "${markdown}"`);
});
- await test('__text__ transforms to bold', async () => {
+ 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}"`);
});
- console.log(' Backslash escapes:');
-
- await test('backslash is just a character in WYSIWYG', async () => {
+ await test('list item round-trips', async () => {
await resetEditor();
- await typeString('hello\\world');
- const html = await getHTML();
- assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`);
+ 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('double Enter exits list', async () => {
+ await resetEditor();
+ await typeString('- item');
+ await pressKey('Enter');
+ await pressKey('Enter');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-paragraph')),
+ `Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}`
+ );
+ });
+
+ await test('double Enter exits blockquote', async () => {
+ await resetEditor();
+ await typeString('> quote');
+ await pressKey('Enter');
+ await pressKey('Enter');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-paragraph')),
+ `Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}`
+ );
+ });
+
+ await test('ordered list increments', async () => {
+ await resetEditor();
+ await typeString('1. first');
+ await pressKey('Enter');
+ const markdown = await getMarkdown();
+ assert(
+ markdown.includes('2. '),
+ `Expected "2. " on second line, got: "${markdown}"`
+ );
+ });
+
+ await test('heading followed by Enter creates 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: ${blocks}`);
+ assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph: ${blocks}`);
+ const markdown = await getMarkdown();
+ assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`);
+ });
+
+ await test('heading renders correctly after typing', async () => {
+ await resetEditor();
+ await typeString('# foo');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-h1')),
+ `Expected md-h1, got: ${JSON.stringify(blocks)}`
+ );
+ const markdown = await getMarkdown();
+ assert(markdown === '# foo', `Expected "# foo", got: "${markdown}"`);
+ });
+
+ 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 on last empty block does not break editor', async () => {
+ await resetEditor();
+ await typeString('foo');
+ // Select all and delete
+ await page.keyboard.press('Control+a');
+ await page.keyboard.press('Backspace');
+ await page.keyboard.press('Backspace');
+ // Editor should still be functional
+ await typeString('# bar');
+ const blocks = await getBlockClasses();
+ assert(
+ blocks.some(c => c.includes('md-h1')),
+ `Editor broken after double backspace, got: ${JSON.stringify(blocks)}`
+ );
+ const markdown = await getMarkdown();
+ assert(markdown === '# bar', `Expected "# bar", got: "${markdown}"`);
+ });
+
+ 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}"`);
+ });
+
+
+ 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(' ');
+ const html = await page.evaluate(() => document.getElementById('ribbit').innerHTML);
+
+ 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}`);
+ });
+
}
+// āā 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}`);
+ });
+ }
+ if (!HEADLESS) {
+ console.log('\nPress Enter to close...');
+ await new Promise(resolve => process.stdin.once('data', resolve));
}
await teardown();
process.exit(failed > 0 ? 1 : 0);
diff --git a/test/macros.test.ts b/test/macros.test.ts
index 291b805..12e13a1 100644
--- a/test/macros.test.ts
+++ b/test/macros.test.ts
@@ -1,9 +1,7 @@
-import { ribbit } from './setup';
+import { ribbit, resetDOM } from './setup';
const lib = ribbit();
-const spacePattern = / /g;
-
const macros = [
{
name: 'user',
@@ -13,7 +11,7 @@ const macros = [
name: 'npc',
toHTML: ({ keywords }: any) => {
const name = keywords.join(' ');
- return '' + name + '';
+ return '' + name + '';
},
},
{
@@ -26,7 +24,9 @@ const macros = [
},
];
-const converter = new lib.HopDown({ macros });
+const editor = new lib.Editor({macros: macros});
+const converter = editor.converter;
+
const H = (md: string) => converter.toHTML(md);
const M = (html: string) => converter.toMarkdown(html);
diff --git a/test/vim.test.ts b/test/vim.test.ts
deleted file mode 100644
index 7172c56..0000000
--- a/test/vim.test.ts
+++ /dev/null
@@ -1,150 +0,0 @@
-import { ribbit, resetDOM } from './setup';
-
-const lib = ribbit();
-
-describe('VimHandler', () => {
- beforeEach(() => resetDOM('hello world'));
-
- it('starts in insert mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- expect(editor.element.classList.contains('vim-insert')).toBe(true);
- });
-
- it('Esc enters normal mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- expect(editor.element.classList.contains('vim-normal')).toBe(true);
- expect(editor.element.classList.contains('vim-insert')).toBe(false);
- });
-
- it('i returns to insert mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- // Enter normal mode
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- // Back to insert
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
- expect(editor.element.classList.contains('vim-insert')).toBe(true);
- expect(editor.element.classList.contains('vim-normal')).toBe(false);
- });
-
- it('disables toolbar in normal mode', () => {
- const editor = new lib.Editor({
- autoToolbar: false,
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.toolbar.render();
- editor.edit();
- editor.toolbar.enable();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- const bold = editor.toolbar.buttons.get('bold');
- expect(bold?.element?.classList.contains('disabled')).toBe(true);
- });
-
- it('re-enables toolbar in insert mode', () => {
- const editor = new lib.Editor({
- autoToolbar: false,
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.toolbar.render();
- editor.edit();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' }));
- const bold = editor.toolbar.buttons.get('bold');
- expect(bold?.element?.classList.contains('disabled')).toBe(false);
- });
-
- it('detaches when leaving edit mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.edit();
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- expect(editor.element.classList.contains('vim-normal')).toBe(true);
- editor.wysiwyg();
- // vim classes should be gone after mode switch
- expect(editor.element.classList.contains('vim-normal')).toBe(false);
- expect(editor.element.classList.contains('vim-insert')).toBe(false);
- });
-
- it('only activates in edit mode', () => {
- const editor = new lib.Editor({
- currentTheme: 'vim',
- themes: [{
- name: 'vim',
- features: {
- sourceMode: true,
- vim: true,
- },
- tags: lib.defaultTags,
- }],
- });
- editor.run();
- editor.wysiwyg();
- // Esc in wysiwyg should not add vim classes
- editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' }));
- expect(editor.element.classList.contains('vim-normal')).toBe(false);
- });
-});
- {
- 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 + assert(html.includes('md-code'), `Expected md-code 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 === '`code`', `Expected "\`code\`", got: "${markdown}"`); }); - await test('blockquote paragraphs survive mode round-trip', async () => { - await resetEditor(); - await typeString('> foo'); - await typeChar(Key.ENTER); - await typeString('bar'); - await driver.sleep(50); - - // Switch to source and back to wysiwyg twice - await driver.executeScript('window.__ribbitEditor.edit()'); - await driver.sleep(50); - await driver.executeScript('window.__ribbitEditor.wysiwyg()'); - await driver.sleep(50); - await driver.executeScript('window.__ribbitEditor.edit()'); - await driver.sleep(50); - - const markdown = await driver.executeScript('return document.getElementById("ribbit").textContent'); - assert(markdown.includes('> foo'), `Missing > foo: ${markdown}`); - assert(markdown.includes('> bar'), `Missing > bar ā second line lost its prefix: ${markdown}`); - }); - - await test('double enter exits blockquote', async () => { - await resetEditor(); - await typeString('> quoted'); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeString('after'); - const html = await getHTML(); - assert(html.includes('afterBlockquote, `"after" is inside blockquote: ${html}`); - }); - - // āā Horizontal rule āā - - console.log(' Horizontal rule:'); - - await test('--- transforms to hr', async () => { - await resetEditor(); - await typeString('--'); - let html = await getHTML(); - assert(!html.includes('
{ - await resetEditor(); - await typeString('**hello**'); - await driver.sleep(50); - const markdown = await getMarkdown(); - assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`); - }); - - await test('# Title round-trips to markdown', async () => { - await resetEditor(); - await typeString('# Title'); - await driver.sleep(50); - const markdown = await getMarkdown(); - assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`); - }); - - await test('mode switch preserves content', async () => { - await resetEditor(); - await typeString('**bold**'); - await typeString(' and '); - await typeString('*italic*'); - await driver.sleep(50); - - await driver.executeScript('window.__ribbitEditor.view()'); - await driver.sleep(50); - await driver.executeScript('window.__ribbitEditor.wysiwyg()'); - await driver.sleep(50); - - const html = await getHTML(); - assert(html.includes(' { - await resetEditor(); - await typeString('**hello'); - await driver.sleep(50); - let html = await getHTML(); - assert(html.includes('data-speculative'), `No speculative: ${html}`); - - await typeChar(Key.ARROW_RIGHT); - await driver.sleep(50); - html = await getHTML(); - assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`); - }); - - await test('click outside closes speculative', async () => { - await resetEditor(); - await typeString('**hello'); - await driver.sleep(50); - let html = await getHTML(); - assert(html.includes('data-speculative'), `No speculative: ${html}`); - - // Add an element outside the editor and click it - await driver.executeScript(` - if (!document.getElementById('outside')) { - var btn = document.createElement('button'); - btn.id = 'outside'; - btn.textContent = 'outside'; - btn.style.display = 'block'; - btn.style.padding = '20px'; - document.body.appendChild(btn); - } - `); - await driver.findElement(By.id('outside')).click(); - await driver.sleep(100); - html = await getHTML(); - assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`); - }); - - console.log(' Enter behavior:'); - - await test('block pattern after Enter splits and transforms', async () => { - await resetEditor(); - await typeString('foo'); - await typeChar(Key.ENTER); - await typeString('> bar'); - await driver.sleep(50); - const html = await getHTML(); - assert(html.includes('after Enter did not create blockquote: ${html}`); - assert(html.includes('foo'), `Lost content before split: ${html}`); - assert(html.includes('bar'), `Lost content after split: ${html}`); - }); - - await test('single Enter in paragraph inserts line break', async () => { - await resetEditor(); - await typeString('line one'); - await typeChar(Key.ENTER); - await typeString('line two'); - const markdown = await getMarkdown(); - // Single Enter = one \n, not \n\n - assert(markdown.includes('line one'), `Missing line one: ${markdown}`); - assert(markdown.includes('line two'), `Missing line two: ${markdown}`); - assert(!markdown.includes('line one\n\nline two'), `Got paragraph break instead of line break: ${markdown}`); - }); - - await test('double Enter in paragraph creates new block', async () => { - await resetEditor(); - await typeString('first paragraph'); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeString('second paragraph'); - const html = await getHTML(); - // Double Enter = new, so two separate paragraphs - const paragraphCount = (html.match(/
]/g) || []).length; - assert(paragraphCount >= 2, `Expected 2+ paragraphs, got ${paragraphCount}: ${html}`); - }); - - await test('backspace at start of line after Enter joins lines', async () => { - await resetEditor(); - await typeString('foo'); - await typeChar(Key.ENTER); - await typeChar(Key.BACK_SPACE); - await driver.sleep(50); - const html = await getHTML(); - // The
should be removed, cursor at end of "foo" - assert(!html.includes('
not removed: ${html}`); - assert(html.includes('foo'), `Content lost: ${html}`); - }); - - await test('single Enter in list item inserts line break', async () => { - await resetEditor(); - await typeString('- line one'); - await typeChar(Key.ENTER); - await typeString('line two'); - const markdown = await getMarkdown(); - // Both lines in the same list item - assert(markdown.includes('- line one'), `Missing list marker: ${markdown}`); - assert(markdown.includes('line two'), `Missing line two: ${markdown}`); - // Should NOT create a second list item - const markerCount = (markdown.match(/^- /gm) || []).length; - assert(markerCount === 1, `Expected 1 list marker, got ${markerCount}: ${markdown}`); - }); - - await test('double Enter in list item creates new item', async () => { - await resetEditor(); - await typeString('- first'); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeString('second'); - const markdown = await getMarkdown(); - const markerCount = (markdown.match(/^- /gm) || []).length; - assert(markerCount === 2, `Expected 2 list markers, got ${markerCount}: ${markdown}`); - assert(markdown.includes('- first'), `Missing first item: ${markdown}`); - assert(markdown.includes('- second'), `Missing second item: ${markdown}`); - }); - - await test('double Enter on empty list item exits list', async () => { - await resetEditor(); - await typeString('- item'); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeString('after list'); - const html = await getHTML(); - assert(html.includes('after list'), `Missing text after list: ${html}`); - // "after list" should NOT be inside the- const ulEnd = html.indexOf('
'); - const afterPos = html.indexOf('after list'); - assert(afterPos > ulEnd, `"after list" is inside the list: ${html}`); - }); - - // āā Complex document āā - - console.log(' Complex document:'); - - await test('multi-element document', async () => { - await resetEditor(); - await typeString('# Title'); - await typeChar(Key.ENTER); - await typeString('Some **bold** text.'); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeString('## Section'); - await typeChar(Key.ENTER); - await typeString('- item one'); - - await driver.sleep(100); - const html = await getHTML(); - assert(html.includes('', async () => { + await 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); + await typeString('hello **world** and *italic*'); + const markdown = await getMarkdown(); + assert( + markdown === 'hello **world** and *italic*', + `Round-trip failed: "${markdown}"` + ); + }); + + await test('bold followed by trailing space stays bold', async () => { + await resetEditor(); + await typeString('**bold** '); const html = await getHTML(); - assert(html.includes('{ + + await test('heading prefix stays plain space, not nbsp, after rebuild', async () => { await resetEditor(); - await typeChar('+'); - let html = await getHTML(); - assert(!html.includes('{ - await resetEditor(); - await typeString('_hello_'); + await typeString('# Title'); const html = await getHTML(); - assert(html.includes(' after _hello_: ${html}`); - assert(!html.includes('data-speculative'), `Still speculative: ${html}`); + // The delimiter span's text must be a literal space (U+0020), + // not a non-breaking space, or block classification breaks on + // the next keystroke read of textContent. + assert( + html.includes('# ') || + html.includes('class="md-delim"># '), + `Unexpected delim content: ${html}` + ); + // The real check: typing more text after this should still + // classify as md-h1, not regress to md-paragraph. + await typeString(' more'); + const classes = await getBlockClasses(); + assert( + classes.some(c => c.includes('md-h1')), + `Lost md-h1 classification after additional typing: ${JSON.stringify(classes)}` + ); }); - await test('_text shows speculative italic', async () => { + // āā getMarkdown round-trips āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā + + console.log('\ngetMarkdown round-trips:'); + + await test('heading 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('# Hello World'); + const markdown = await getMarkdown(); + assert(markdown === '# Hello World', `Expected "# Hello World", got: "${markdown}"`); }); - await test('__text__ transforms to bold', async () => { + 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}"`); }); - console.log(' Backslash escapes:'); - - await test('backslash is just a character in WYSIWYG', async () => { + await test('list item round-trips', async () => { await resetEditor(); - await typeString('hello\\world'); - const html = await getHTML(); - assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`); + 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('double Enter exits list', async () => { + await resetEditor(); + await typeString('- item'); + await pressKey('Enter'); + await pressKey('Enter'); + const blocks = await getBlockClasses(); + assert( + blocks.some(c => c.includes('md-paragraph')), + `Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}` + ); + }); + + await test('double Enter exits blockquote', async () => { + await resetEditor(); + await typeString('> quote'); + await pressKey('Enter'); + await pressKey('Enter'); + const blocks = await getBlockClasses(); + assert( + blocks.some(c => c.includes('md-paragraph')), + `Expected paragraph after double Enter, got: ${JSON.stringify(blocks)}` + ); + }); + + await test('ordered list increments', async () => { + await resetEditor(); + await typeString('1. first'); + await pressKey('Enter'); + const markdown = await getMarkdown(); + assert( + markdown.includes('2. '), + `Expected "2. " on second line, got: "${markdown}"` + ); + }); + + await test('heading followed by Enter creates 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: ${blocks}`); + assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph: ${blocks}`); + const markdown = await getMarkdown(); + assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`); + }); + + await test('heading renders correctly after typing', async () => { + await resetEditor(); + await typeString('# foo'); + const blocks = await getBlockClasses(); + assert( + blocks.some(c => c.includes('md-h1')), + `Expected md-h1, got: ${JSON.stringify(blocks)}` + ); + const markdown = await getMarkdown(); + assert(markdown === '# foo', `Expected "# foo", got: "${markdown}"`); + }); + + 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 on last empty block does not break editor', async () => { + await resetEditor(); + await typeString('foo'); + // Select all and delete + await page.keyboard.press('Control+a'); + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + // Editor should still be functional + await typeString('# bar'); + const blocks = await getBlockClasses(); + assert( + blocks.some(c => c.includes('md-h1')), + `Editor broken after double backspace, got: ${JSON.stringify(blocks)}` + ); + const markdown = await getMarkdown(); + assert(markdown === '# bar', `Expected "# bar", got: "${markdown}"`); + }); + + 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}"`); + }); + + + 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(' '); + const html = await page.evaluate(() => document.getElementById('ribbit').innerHTML); + + 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}`); + }); + } +// āā 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}`); + }); + } + if (!HEADLESS) { + console.log('\nPress Enter to close...'); + await new Promise(resolve => process.stdin.once('data', resolve)); } await teardown(); process.exit(failed > 0 ? 1 : 0); diff --git a/test/macros.test.ts b/test/macros.test.ts index 291b805..12e13a1 100644 --- a/test/macros.test.ts +++ b/test/macros.test.ts @@ -1,9 +1,7 @@ -import { ribbit } from './setup'; +import { ribbit, resetDOM } from './setup'; const lib = ribbit(); -const spacePattern = / /g; - const macros = [ { name: 'user', @@ -13,7 +11,7 @@ const macros = [ name: 'npc', toHTML: ({ keywords }: any) => { const name = keywords.join(' '); - return '' + name + ''; + return '' + name + ''; }, }, { @@ -26,7 +24,9 @@ const macros = [ }, ]; -const converter = new lib.HopDown({ macros }); +const editor = new lib.Editor({macros: macros}); +const converter = editor.converter; + const H = (md: string) => converter.toHTML(md); const M = (html: string) => converter.toMarkdown(html); diff --git a/test/vim.test.ts b/test/vim.test.ts deleted file mode 100644 index 7172c56..0000000 --- a/test/vim.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { ribbit, resetDOM } from './setup'; - -const lib = ribbit(); - -describe('VimHandler', () => { - beforeEach(() => resetDOM('hello world')); - - it('starts in insert mode', () => { - const editor = new lib.Editor({ - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.edit(); - expect(editor.element.classList.contains('vim-insert')).toBe(true); - }); - - it('Esc enters normal mode', () => { - const editor = new lib.Editor({ - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.edit(); - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - expect(editor.element.classList.contains('vim-normal')).toBe(true); - expect(editor.element.classList.contains('vim-insert')).toBe(false); - }); - - it('i returns to insert mode', () => { - const editor = new lib.Editor({ - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.edit(); - // Enter normal mode - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - // Back to insert - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' })); - expect(editor.element.classList.contains('vim-insert')).toBe(true); - expect(editor.element.classList.contains('vim-normal')).toBe(false); - }); - - it('disables toolbar in normal mode', () => { - const editor = new lib.Editor({ - autoToolbar: false, - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.toolbar.render(); - editor.edit(); - editor.toolbar.enable(); - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - const bold = editor.toolbar.buttons.get('bold'); - expect(bold?.element?.classList.contains('disabled')).toBe(true); - }); - - it('re-enables toolbar in insert mode', () => { - const editor = new lib.Editor({ - autoToolbar: false, - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.toolbar.render(); - editor.edit(); - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'i' })); - const bold = editor.toolbar.buttons.get('bold'); - expect(bold?.element?.classList.contains('disabled')).toBe(false); - }); - - it('detaches when leaving edit mode', () => { - const editor = new lib.Editor({ - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.edit(); - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - expect(editor.element.classList.contains('vim-normal')).toBe(true); - editor.wysiwyg(); - // vim classes should be gone after mode switch - expect(editor.element.classList.contains('vim-normal')).toBe(false); - expect(editor.element.classList.contains('vim-insert')).toBe(false); - }); - - it('only activates in edit mode', () => { - const editor = new lib.Editor({ - currentTheme: 'vim', - themes: [{ - name: 'vim', - features: { - sourceMode: true, - vim: true, - }, - tags: lib.defaultTags, - }], - }); - editor.run(); - editor.wysiwyg(); - // Esc in wysiwyg should not add vim classes - editor.element.dispatchEvent(new lib.window.KeyboardEvent('keydown', { key: 'Escape' })); - expect(editor.element.classList.contains('vim-normal')).toBe(false); - }); -});