ribbit/test/integration/test_wysiwyg.js
evilchili 781af3cc1e wip
2026-05-15 20:31:27 -07:00

531 lines
21 KiB
JavaScript

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