From 781af3cc1ede57f6d591497cd0d67cc478f26e61 Mon Sep 17 00:00:00 2001 From: evilchili Date: Fri, 15 May 2026 20:31:27 -0700 Subject: [PATCH] wip --- package-lock.json | 70 +++ package.json | 9 +- test/integration/dev-server.js | 1 + test/integration/index.html | 3 +- test/integration/test_wysiwyg.js | 946 +++++++++++++------------------ 5 files changed, 472 insertions(+), 557 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9209a8a..59e6414 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "jest": "^29.7.0", "live-server": "^1.2.0", "node-watch": "^0.7.4", + "playwright": "^1.60.0", "selenium-webdriver": "^4.43.0", "ts-jest": "^29.4.9", "typescript": "^6.0.3" @@ -4949,6 +4950,50 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", @@ -10260,6 +10305,31 @@ "find-up": "^4.0.0" } }, + "playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.60.0" + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + } + } + }, + "playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true + }, "posix-character-classes": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", diff --git a/package.json b/package.json index ed140ea..3e093ad 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,11 @@ "esbuild": "^0.28.0", "happy-dom": "^20.9.0", "jest": "^29.7.0", + "live-server": "^1.2.0", + "node-watch": "^0.7.4", + "playwright": "^1.60.0", "selenium-webdriver": "^4.43.0", "ts-jest": "^29.4.9", - "typescript": "^6.0.3", - "live-server": "^1.2.0", - "node-watch": "^0.7.4" - }, - "dependencies": { + "typescript": "^6.0.3" } } diff --git a/test/integration/dev-server.js b/test/integration/dev-server.js index 337c88e..4d37a0e 100644 --- a/test/integration/dev-server.js +++ b/test/integration/dev-server.js @@ -7,6 +7,7 @@ var params = { root: "test/integration", mount: [ ['/static', 'dist/ribbit'], + ['/test', 'test/integration'], ], logLevel: 2, // 0 = errors only, 1 = some, 2 = lots }; diff --git a/test/integration/index.html b/test/integration/index.html index 4df85b5..21fcd76 100644 --- a/test/integration/index.html +++ b/test/integration/index.html @@ -30,8 +30,7 @@
-
**bold** and *italic* and `code` - +
| Type | To Get | |------|--------| diff --git a/test/integration/test_wysiwyg.js b/test/integration/test_wysiwyg.js index c982de6..0b8eca0 100644 --- a/test/integration/test_wysiwyg.js +++ b/test/integration/test_wysiwyg.js @@ -1,70 +1,139 @@ /** - * WYSIWYG integration tests with character-by-character typing. + * test_wysiwyg.js — Styled-source WYSIWYG integration tests. * - * Every keystroke is sent individually with a delay, matching real - * user behavior. Assertions check intermediate DOM states to verify - * transforms fire at the right moments. + * Tests the new styled-source editor implementation. Key differences + * from the old test suite: * - * Run: node test/integration/test_wysiwyg.js + * - No data-speculative, no // DOM elements. + * The editor always stores raw markdown; CSS renders it visually. + * - Inline formatting uses .md-bold, .md-italic, .md-code spans + * with .md-delim children holding the delimiter characters. + * - getMarkdown() reads textContent directly — always returns the + * original markdown source, never converted HTML. + * - Block structure uses
elements, not

/

etc. + * + * Run headless: node test/integration/test_wysiwyg.js + * Run against dev server: node test/integration/test_wysiwyg.js --port=5023 */ -const { Builder, By, Key } = require('selenium-webdriver'); -const firefox = require('selenium-webdriver/firefox'); + +const { chromium } = require('playwright'); const { createServer } = require('./server'); -let server, driver; -const DELAY = 30; +// ── Config ──────────────────────────────────────────────────────────────────── + +const HEADLESS = !process.argv.includes('--headed'); +const PORT = (() => { + const portArg = process.argv.find(arg => arg.startsWith('--port=')); + return portArg ? parseInt(portArg.split('=')[1]) : 5023; +})(); +const USE_DEV_SERVER = process.argv.includes('--port'); +const DELAY = 20; // ms between keystrokes + +// ── State ───────────────────────────────────────────────────────────────────── + +let browser, page, server; +let passed = 0, failed = 0; +const errors = []; + +// ── Setup / teardown ────────────────────────────────────────────────────────── + + +async function serverStart() { + var liveServer = require("live-server"); + var params = { + port: PORT, + host: "0.0.0.0", + open: true, + root: "test/integration", + mount: [ + ['/static', 'dist/ribbit'], + ['/test', 'test/integration'], + ], + logLevel: 2, // 0 = errors only, 1 = some, 2 = lots + }; + + console.log(`\n🐸 Ribbit dev server running on http://localhost:${params['port']}`); + liveServer.start(params); + +} + async function setup() { - server = createServer(9997); - await server.start(); - const options = new firefox.Options().addArguments('--headless'); - driver = await new Builder().forBrowser('firefox').setFirefoxOptions(options).build(); - await driver.get(server.url); - await driver.wait(async () => driver.executeScript('return window.__ribbitReady === true'), 10000); + + if (!USE_DEV_SERVER) { + await serverStart(); + } + browser = await chromium.launch({ headless: HEADLESS }); + page = await browser.newPage(); + await page.goto(`http://localhost:${PORT}`); + await page.waitForFunction(() => window.__ribbitReady === true, { timeout: 10000 }); } async function teardown() { - if (driver) { await driver.quit(); } - if (server) { await server.stop(); } + //if (browser) { await browser.close(); } + //if (server) { await server.stop(); } } -async function resetEditor() { - await driver.executeScript(` - var e = window.__ribbitEditor; - e.wysiwyg(); - e.element.innerHTML = '


'; - `); - await driver.findElement(By.id('ribbit')).click(); - await driver.sleep(50); -} +// ── Editor helpers ──────────────────────────────────────────────────────────── /** - * Send a single character and wait for the editor to process it. + * Reset the editor to an empty state in wysiwyg mode. + * Clears the DOM and places the cursor ready for typing. */ -async function typeChar(character) { - await driver.actions().sendKeys(character).perform(); - await driver.sleep(DELAY); +async function resetEditor() { + await page.evaluate(() => { + const editor = window.__ribbitEditor; + editor.wysiwyg(); + editor.element.innerHTML = ''; + }); + await page.focus('#ribbit'); + await page.waitForTimeout(30); } /** * Type a string one character at a time with delay between each. + * Matches real user behaviour so block/inline transforms fire correctly. */ async function typeString(text) { for (const character of text) { - await typeChar(character); + await page.keyboard.type(character); + await page.waitForTimeout(DELAY); } } +/** + * Press a special key (Enter, Backspace, ArrowRight, etc). + */ +async function pressKey(key) { + await page.keyboard.press(key); + await page.waitForTimeout(DELAY); +} + +/** + * Get the editor's current innerHTML. + */ async function getHTML() { - return driver.executeScript('return document.getElementById("ribbit").innerHTML'); + return page.evaluate(() => document.getElementById('ribbit').innerHTML); } +/** + * Get the editor's current markdown via getMarkdown(). + */ async function getMarkdown() { - return driver.executeScript('return window.__ribbitEditor.getMarkdown()'); + return page.evaluate(() => window.__ribbitEditor.getMarkdown()); } -let passed = 0, failed = 0; -const errors = []; +/** + * Get all CSS classes on block divs inside the editor. + */ +async function getBlockClasses() { + return page.evaluate(() => + Array.from(document.getElementById('ribbit').children) + .map(block => block.className) + ); +} + +// ── Test runner ─────────────────────────────────────────────────────────────── function assert(condition, message) { if (!condition) { throw new Error(message); } @@ -77,606 +146,383 @@ async function test(name, fn) { console.log(` ✓ ${name}`); } catch (error) { failed++; - errors.push(name); + errors.push({ name, message: error.message }); console.log(` ✗ ${name}`); console.log(` ${error.message}`); } } +// ── Tests ───────────────────────────────────────────────────────────────────── + async function runTests() { - console.log('\nWYSIWYG Integration Tests (char-by-char)\n'); + console.log('\nStyled-source WYSIWYG Integration Tests\n'); - // ── Headings ── + // ── Block classification ─────────────────────────────────────────────────── - console.log(' Headings:'); + console.log('Block classification:'); - await test('# transforms to h1 after space', async () => { + await test('plain text becomes md-paragraph', async () => { await resetEditor(); - await typeChar('#'); - let html = await getHTML(); - assert(!html.includes(' c.includes('md-paragraph')), `Expected md-paragraph, got: ${classes}`); }); - await test('## transforms to h2 after space', async () => { + /* + await test('# space becomes md-h1', async () => { await resetEditor(); - await typeString('##'); - let html = await getHTML(); - assert(!html.includes(' c.includes('md-h')), `Premature heading after just #: ${classes}`); - await typeChar(' '); - html = await getHTML(); - assert(html.includes(' c.includes('md-h1')), `Expected md-h1 after "# ", got: ${classes}`); + + await typeString('Title'); + const markdown = await getMarkdown(); + assert(markdown.includes('# Title'), `Expected "# Title" in markdown: ${markdown}`); }); - await test('enter after heading creates new paragraph', async () => { + await test('## space becomes md-h2', async () => { await resetEditor(); - await typeString('# Title'); - await typeChar(Key.ENTER); - await typeString('body'); - const html = await getHTML(); - assert(html.includes(' c.includes('md-h2')), `Expected md-h2, got: ${classes}`); }); - // ── Bold ── - - console.log(' Bold:'); - - await test('** does not transform without content', async () => { + await test('### space becomes md-h3', async () => { await resetEditor(); - await typeString('**'); - const html = await getHTML(); - assert(!html.includes(' c.includes('md-h3')), `Expected md-h3, got: ${classes}`); }); - await test('**x starts speculative bold', async () => { + await test('> space becomes md-blockquote', async () => { await resetEditor(); - await typeString('**'); - await typeChar('x'); - const html = await getHTML(); - assert(html.includes(''); + let classes = await getBlockClasses(); + assert(!classes.some(c => c.includes('md-blockquote')), `Premature blockquote: ${classes}`); + + await typeString(' '); + classes = await getBlockClasses(); + assert(classes.some(c => c.includes('md-blockquote')), `Expected md-blockquote, got: ${classes}`); }); - await test('**hello** completes bold', async () => { + await test('- space becomes md-list-item', async () => { await resetEditor(); - await typeString('**hello'); - let html = await getHTML(); - assert(html.includes('data-speculative'), `Not speculative during typing: ${html}`); + await typeString('-'); + let classes = await getBlockClasses(); + assert(!classes.some(c => c.includes('md-list')), `Premature list: ${classes}`); - await typeString('**'); - html = await getHTML(); - assert(html.includes(' c.includes('md-list-item')), `Expected md-list-item, got: ${classes}`); }); - await test('typing after **bold** goes outside strong', async () => { + await test('1. space becomes md-ol-list-item', async () => { + await resetEditor(); + await typeString('1. '); + const classes = await getBlockClasses(); + assert(classes.some(c => c.includes('md-ol-list-item')), `Expected md-ol-list-item, got: ${classes}`); + }); + + // ── Inline formatting ────────────────────────────────────────────────────── + + console.log('\nInline formatting:'); + + await test('**bold** produces md-bold span', async () => { await resetEditor(); await typeString('**bold**'); - await typeString(' after'); const html = await getHTML(); - assert(html.includes(' - const strongMatch = html.match(/]*>.*?<\/strong>/); - if (strongMatch) { - assert(!strongMatch[0].includes('after'), - `"after" is inside strong — cursor not placed correctly: ${html}`); - } + assert(html.includes('md-bold'), `Expected md-bold span: ${html}`); + const markdown = await getMarkdown(); + assert(markdown === '**bold**', `Expected "**bold**", got: "${markdown}"`); }); - // ── Italic ── - - console.log(' Italic:'); - - await test('*x starts speculative italic', async () => { + await test('*italic* produces md-italic span', async () => { await resetEditor(); - await typeChar('*'); - let html = await getHTML(); - assert(!html.includes(' { - await resetEditor(); - await typeString('*hello'); - let html = await getHTML(); - assert(html.includes('data-speculative'), `Not speculative: ${html}`); - - await typeChar('*'); - html = await getHTML(); - assert(html.includes(' { - await resetEditor(); - await typeString('`hello`'); - const html = await getHTML(); - assert(html.includes(' { - await resetEditor(); - - // Type ** - await typeString('**'); - let html = await getHTML(); - assert(!html.includes(' { - await resetEditor(); - await typeString('**bold**'); - let html = await getHTML(); - assert(html.includes(' { - await resetEditor(); - await typeChar('-'); - let html = await getHTML(); - assert(!html.includes(' { - await resetEditor(); - await typeString('1.'); - let html = await getHTML(); - assert(!html.includes(' space transforms to blockquote', async () => { - await resetEditor(); - await typeChar('>'); - let html = await getHTML(); - assert(!html.includes(' ": ${html}`); - }); - - await test('enter inside blockquote adds new line', async () => { - await resetEditor(); - await typeString('> first line'); - let html = await getHTML(); - assert(html.includes(' foo\n> bar" — continuation, no blank lines + const html = await getHTML(); + assert(html.includes('md-italic'), `Expected md-italic span: ${html}`); const markdown = await getMarkdown(); - assert(markdown.includes('> first line'), `Missing > first line in markdown: ${markdown}`); - assert(markdown.includes('> second line'), `Missing > second line in markdown: ${markdown}`); - assert(!markdown.includes('>\n>'), `Unwanted blank > line in markdown: ${markdown}`); + assert(markdown === '*italic*', `Expected "*italic*", got: "${markdown}"`); }); - await test('blockquote paragraphs survive mode round-trip', async () => { + await test('***bold-italic*** produces md-bold-italic span', async () => { await resetEditor(); - await typeString('> foo'); - await typeChar(Key.ENTER); - await typeString('bar'); - await driver.sleep(50); - - // Switch to source and back to wysiwyg twice - await driver.executeScript('window.__ribbitEditor.edit()'); - await driver.sleep(50); - await driver.executeScript('window.__ribbitEditor.wysiwyg()'); - await driver.sleep(50); - await driver.executeScript('window.__ribbitEditor.edit()'); - await driver.sleep(50); - - const markdown = await driver.executeScript('return document.getElementById("ribbit").textContent'); - assert(markdown.includes('> foo'), `Missing > foo: ${markdown}`); - assert(markdown.includes('> bar'), `Missing > bar — second line lost its prefix: ${markdown}`); - }); - - await test('double enter exits blockquote', async () => { - await resetEditor(); - await typeString('> quoted'); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeString('after'); + await typeString('***both***'); const html = await getHTML(); - assert(html.includes(' afterBlockquote, `"after" is inside blockquote: ${html}`); - }); - - // ── Horizontal rule ── - - console.log(' Horizontal rule:'); - - await test('--- transforms to hr', async () => { - await resetEditor(); - await typeString('--'); - let html = await getHTML(); - assert(!html.includes(' { - await resetEditor(); - await typeString('**hello**'); - await driver.sleep(50); + assert(html.includes('md-bold-italic'), `Expected md-bold-italic span: ${html}`); const markdown = await getMarkdown(); - assert(markdown.includes('**hello**'), `Expected **hello** in: ${markdown}`); + assert(markdown === '***both***', `Expected "***both***", got: "${markdown}"`); }); - await test('# Title round-trips to markdown', async () => { + await test('`code` produces md-code span', async () => { await resetEditor(); - await typeString('# Title'); - await driver.sleep(50); + await typeString('`code`'); + const html = await getHTML(); + assert(html.includes('md-code'), `Expected md-code span: ${html}`); const markdown = await getMarkdown(); - assert(markdown.includes('# Title'), `Expected # Title in: ${markdown}`); + assert(markdown === '`code`', `Expected "\`code\`", got: "${markdown}"`); }); - await test('mode switch preserves content', async () => { - await resetEditor(); - await typeString('**bold**'); - await typeString(' and '); - await typeString('*italic*'); - await driver.sleep(50); - - await driver.executeScript('window.__ribbitEditor.view()'); - await driver.sleep(50); - await driver.executeScript('window.__ribbitEditor.wysiwyg()'); - await driver.sleep(50); - - const html = await getHTML(); - assert(html.includes(' { - await resetEditor(); - await typeString('**hello'); - await driver.sleep(50); - let html = await getHTML(); - assert(html.includes('data-speculative'), `No speculative: ${html}`); - - await typeChar(Key.ARROW_RIGHT); - await driver.sleep(50); - html = await getHTML(); - assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`); - }); - - await test('click outside closes speculative', async () => { - await resetEditor(); - await typeString('**hello'); - await driver.sleep(50); - let html = await getHTML(); - assert(html.includes('data-speculative'), `No speculative: ${html}`); - - // Add an element outside the editor and click it - await driver.executeScript(` - if (!document.getElementById('outside')) { - var btn = document.createElement('button'); - btn.id = 'outside'; - btn.textContent = 'outside'; - btn.style.display = 'block'; - btn.style.padding = '20px'; - document.body.appendChild(btn); - } - `); - await driver.findElement(By.id('outside')).click(); - await driver.sleep(100); - html = await getHTML(); - assert(!html.includes('data-speculative'), `Speculative not closed: ${html}`); - }); - - console.log(' Enter behavior:'); - - await test('block pattern after Enter splits and transforms', async () => { - await resetEditor(); - await typeString('foo'); - await typeChar(Key.ENTER); - await typeString('> bar'); - await driver.sleep(50); - const html = await getHTML(); - assert(html.includes(' after Enter did not create blockquote: ${html}`); - assert(html.includes('foo'), `Lost content before split: ${html}`); - assert(html.includes('bar'), `Lost content after split: ${html}`); - }); - - await test('single Enter in paragraph inserts line break', async () => { - await resetEditor(); - await typeString('line one'); - await typeChar(Key.ENTER); - await typeString('line two'); - const markdown = await getMarkdown(); - // Single Enter = one \n, not \n\n - assert(markdown.includes('line one'), `Missing line one: ${markdown}`); - assert(markdown.includes('line two'), `Missing line two: ${markdown}`); - assert(!markdown.includes('line one\n\nline two'), `Got paragraph break instead of line break: ${markdown}`); - }); - - await test('double Enter in paragraph creates new block', async () => { - await resetEditor(); - await typeString('first paragraph'); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeString('second paragraph'); - const html = await getHTML(); - // Double Enter = new

, so two separate paragraphs - const paragraphCount = (html.match(/]/g) || []).length; - assert(paragraphCount >= 2, `Expected 2+ paragraphs, got ${paragraphCount}: ${html}`); - }); - - await test('backspace at start of line after Enter joins lines', async () => { - await resetEditor(); - await typeString('foo'); - await typeChar(Key.ENTER); - await typeChar(Key.BACK_SPACE); - await driver.sleep(50); - const html = await getHTML(); - // The
should be removed, cursor at end of "foo" - assert(!html.includes(' not removed: ${html}`); - assert(html.includes('foo'), `Content lost: ${html}`); - }); - - await test('single Enter in list item inserts line break', async () => { - await resetEditor(); - await typeString('- line one'); - await typeChar(Key.ENTER); - await typeString('line two'); - const markdown = await getMarkdown(); - // Both lines in the same list item - assert(markdown.includes('- line one'), `Missing list marker: ${markdown}`); - assert(markdown.includes('line two'), `Missing line two: ${markdown}`); - // Should NOT create a second list item - const markerCount = (markdown.match(/^- /gm) || []).length; - assert(markerCount === 1, `Expected 1 list marker, got ${markerCount}: ${markdown}`); - }); - - await test('double Enter in list item creates new item', async () => { - await resetEditor(); - await typeString('- first'); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeString('second'); - const markdown = await getMarkdown(); - const markerCount = (markdown.match(/^- /gm) || []).length; - assert(markerCount === 2, `Expected 2 list markers, got ${markerCount}: ${markdown}`); - assert(markdown.includes('- first'), `Missing first item: ${markdown}`); - assert(markdown.includes('- second'), `Missing second item: ${markdown}`); - }); - - await test('double Enter on empty list item exits list', async () => { - await resetEditor(); - await typeString('- item'); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeString('after list'); - const html = await getHTML(); - assert(html.includes('after list'), `Missing text after list: ${html}`); - // "after list" should NOT be inside the

    - const ulEnd = html.indexOf('
'); - const afterPos = html.indexOf('after list'); - assert(afterPos > ulEnd, `"after list" is inside the list: ${html}`); - }); - - // ── Complex document ── - - console.log(' Complex document:'); - - await test('multi-element document', async () => { - await resetEditor(); - await typeString('# Title'); - await typeChar(Key.ENTER); - await typeString('Some **bold** text.'); - await typeChar(Key.ENTER); - await typeChar(Key.ENTER); - await typeString('## Section'); - await typeChar(Key.ENTER); - await typeString('- item one'); - - await driver.sleep(100); - const html = await getHTML(); - assert(html.includes('', async () => { + await test('~~strike~~ produces md-strikethrough span', async () => { await resetEditor(); await typeString('~~gone~~'); const html = await getHTML(); - assert(html.includes(': ${html}`); - assert(!html.includes('data-speculative'), `Still speculative: ${html}`); - assert(html.includes('gone'), `Missing content: ${html}`); + assert(html.includes('md-strikethrough'), `Expected md-strikethrough span: ${html}`); + const markdown = await getMarkdown(); + assert(markdown === '~~gone~~', `Expected "~~gone~~", got: "${markdown}"`); }); - await test('~~text shows speculative strikethrough', async () => { + await test('delimiters are present in DOM as md-delim spans', async () => { await resetEditor(); - await typeString('~~hel'); + await typeString('**bold**'); const html = await getHTML(); - assert(html.includes('data-speculative'), `No speculative: ${html}`); - assert(html.includes(': ${html}`); + assert(html.includes('md-delim'), `Expected md-delim spans: ${html}`); + // The delimiter text ** must appear in the DOM + assert(html.includes('**'), `Delimiter text missing from DOM: ${html}`); }); - console.log(' Alternate syntax:'); - - await test('~~~ transforms to fenced code', async () => { + await test('mixed inline on one line round-trips correctly', async () => { await resetEditor(); - await typeString('~~~'); - await driver.sleep(50); - const html = await getHTML(); - assert(html.includes(' { + // ── getMarkdown round-trips ──────────────────────────────────────────────── + + console.log('\ngetMarkdown round-trips:'); + + await test('heading round-trips', async () => { await resetEditor(); - await typeChar('+'); - let html = await getHTML(); - assert(!html.includes(' { + await test('blockquote round-trips', async () => { await resetEditor(); - await typeString('_hello_'); - const html = await getHTML(); - assert(html.includes(' after _hello_: ${html}`); - assert(!html.includes('data-speculative'), `Still speculative: ${html}`); + await typeString('> quoted text'); + const markdown = await getMarkdown(); + assert(markdown === '> quoted text', `Expected "> quoted text", got: "${markdown}"`); }); - await test('_text shows speculative italic', async () => { + await test('list item round-trips', async () => { await resetEditor(); - await typeString('_hel'); - const html = await getHTML(); - assert(html.includes(' after _hel: ${html}`); - assert(html.includes('data-speculative'), `Not speculative: ${html}`); + await typeString('- list item'); + const markdown = await getMarkdown(); + assert(markdown === '- list item', `Expected "- list item", got: "${markdown}"`); }); - await test('__text__ transforms to bold', async () => { + await test('nested inline in heading round-trips', async () => { await resetEditor(); - await typeString('__hello__'); - const html = await getHTML(); - assert(html.includes(' after __hello__: ${html}`); - assert(!html.includes('data-speculative'), `Still speculative: ${html}`); + await typeString('# Hello **world**'); + const markdown = await getMarkdown(); + assert(markdown === '# Hello **world**', `Expected "# Hello **world**", got: "${markdown}"`); }); - console.log(' Backslash escapes:'); + // ── Enter key behaviour ──────────────────────────────────────────────────── - await test('backslash is just a character in WYSIWYG', async () => { + console.log('\nEnter key behaviour:'); + + await test('Enter splits current block into two blocks', async () => { await resetEditor(); - await typeString('hello\\world'); - const html = await getHTML(); - assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`); + await typeString('hello'); + await pressKey('Enter'); + await typeString('world'); + const blocks = await getBlockClasses(); + assert(blocks.length === 2, `Expected 2 blocks, got ${blocks.length}: ${JSON.stringify(blocks)}`); + const markdown = await getMarkdown(); + assert(markdown === 'hello\nworld', `Expected "hello\\nworld", got: "${markdown}"`); }); + + await test('Enter after heading creates new paragraph', async () => { + await resetEditor(); + await typeString('# Title'); + await pressKey('Enter'); + await typeString('body'); + const blocks = await getBlockClasses(); + assert(blocks.some(c => c.includes('md-h1')), `No h1 block: ${blocks}`); + assert(blocks.some(c => c.includes('md-paragraph')), `No paragraph block: ${blocks}`); + const markdown = await getMarkdown(); + assert(markdown === '# Title\nbody', `Expected "# Title\\nbody", got: "${markdown}"`); + }); + + await test('Enter inside blockquote continues with > prefix', async () => { + await resetEditor(); + await typeString('> first line'); + await pressKey('Enter'); + await typeString('second line'); + const markdown = await getMarkdown(); + assert( + markdown.includes('> first line'), + `Missing "> first line" in markdown: "${markdown}"` + ); + assert( + markdown.includes('> second line'), + `Missing "> second line" — continuation prefix not added: "${markdown}"` + ); + }); + + await test('Enter inside list item continues with - prefix', async () => { + await resetEditor(); + await typeString('- first item'); + await pressKey('Enter'); + await typeString('second item'); + const markdown = await getMarkdown(); + assert( + markdown.includes('- first item'), + `Missing "- first item": "${markdown}"` + ); + assert( + markdown.includes('- second item'), + `Missing "- second item" — continuation prefix not added: "${markdown}"` + ); + }); + + // ── Backspace key behaviour ──────────────────────────────────────────────── + + console.log('\nBackspace key behaviour:'); + + await test('Backspace at start of block merges with previous block', async () => { + await resetEditor(); + await typeString('foo'); + await pressKey('Enter'); + await typeString('bar'); + await pressKey('Home'); + await pressKey('Backspace'); + const blocks = await getBlockClasses(); + assert(blocks.length === 1, `Expected 1 block after merge, got ${blocks.length}`); + const markdown = await getMarkdown(); + assert(markdown === 'foobar', `Expected "foobar", got: "${markdown}"`); + }); + + await test('Backspace mid-block does not merge', async () => { + await resetEditor(); + await typeString('foo'); + await pressKey('Enter'); + await typeString('bar'); + await pressKey('Backspace'); + const blocks = await getBlockClasses(); + assert(blocks.length === 2, `Expected 2 blocks, got ${blocks.length}`); + }); + + // ── Mode switching ───────────────────────────────────────────────────────── + + console.log('\nMode switching:'); + + await test('view() switches to view state', async () => { + await resetEditor(); + await typeString('**bold**'); + await page.evaluate(() => window.__ribbitEditor.view()); + await page.waitForTimeout(50); + const state = await page.evaluate(() => window.__ribbitEditor.getState()); + assert(state === 'view', `Expected "view", got: "${state}"`); + }); + + await test('wysiwyg() switches back to wysiwyg state', async () => { + await resetEditor(); + await typeString('hello'); + await page.evaluate(() => window.__ribbitEditor.view()); + await page.waitForTimeout(50); + await page.evaluate(() => window.__ribbitEditor.wysiwyg()); + await page.waitForTimeout(50); + const state = await page.evaluate(() => window.__ribbitEditor.getState()); + assert(state === 'wysiwyg', `Expected "wysiwyg", got: "${state}"`); + }); + + await test('content survives wysiwyg → view → wysiwyg round-trip', async () => { + await resetEditor(); + await typeString('**bold** and *italic*'); + const markdownBefore = await getMarkdown(); + + await page.evaluate(() => window.__ribbitEditor.view()); + await page.waitForTimeout(50); + await page.evaluate(() => window.__ribbitEditor.wysiwyg()); + await page.waitForTimeout(50); + + const markdownAfter = await getMarkdown(); + assert( + markdownAfter === markdownBefore, + `Markdown changed after round-trip.\nBefore: "${markdownBefore}"\nAfter: "${markdownAfter}"` + ); + }); + + await test('getMarkdown() returns source in view state', async () => { + await resetEditor(); + await typeString('**bold**'); + const markdownInEditor = await getMarkdown(); + + await page.evaluate(() => window.__ribbitEditor.view()); + await page.waitForTimeout(50); + + const markdownInView = await getMarkdown(); + assert( + markdownInView === markdownInEditor, + `getMarkdown() changed on view switch.\nEditor: "${markdownInEditor}"\nView: "${markdownInView}"` + ); + }); + + // ── Complex documents ────────────────────────────────────────────────────── + + console.log('\nComplex documents:'); + + await test('multi-block document round-trips correctly', async () => { + await resetEditor(); + await typeString('# Title'); + await pressKey('Enter'); + await typeString('Some **bold** text.'); + await pressKey('Enter'); + await typeString('> A quote'); + await pressKey('Enter'); + await typeString('- A list item'); + + const markdown = await getMarkdown(); + assert(markdown.includes('# Title'), `Missing heading: "${markdown}"`); + assert(markdown.includes('Some **bold** text.'), `Missing bold paragraph: "${markdown}"`); + assert(markdown.includes('> A quote'), `Missing blockquote: "${markdown}"`); + assert(markdown.includes('- A list item'), `Missing list item: "${markdown}"`); + }); + + await test('empty lines between blocks preserved', async () => { + await resetEditor(); + await typeString('first'); + await pressKey('Enter'); + await pressKey('Enter'); + await typeString('second'); + + const blocks = await getBlockClasses(); + assert(blocks.length === 3, `Expected 3 blocks (first, empty, second), got ${blocks.length}`); + const markdown = await getMarkdown(); + assert(markdown === 'first\n\nsecond', `Expected "first\\n\\nsecond", got: "${markdown}"`); + }); + */ } +// ── Main ────────────────────────────────────────────────────────────────────── + (async () => { try { await setup(); await runTests(); } catch (error) { - console.error('Setup failed:', error.message); + console.error('\nSetup failed:', error.message); failed++; } finally { - console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`); + const total = passed + failed; + console.log(`\n${passed}/${total} passed — ${failed} failed`); if (errors.length) { - console.log('\nFailed:'); - errors.forEach(error => console.log(` • ${error}`)); + console.log('\nFailed tests:'); + errors.forEach(({ name, message }) => { + console.log(` • ${name}`); + console.log(` ${message}`); + }); } await teardown(); process.exit(failed > 0 ? 1 : 0);