504 lines
17 KiB
JavaScript
504 lines
17 KiB
JavaScript
/**
|
|
* WYSIWYG integration tests with character-by-character typing.
|
|
*
|
|
* 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.
|
|
*
|
|
* Run: node test/integration/test_wysiwyg.js
|
|
*/
|
|
const { Builder, By, Key } = require('selenium-webdriver');
|
|
const firefox = require('selenium-webdriver/firefox');
|
|
const { createServer } = require('./server');
|
|
|
|
let server, driver;
|
|
const DELAY = 30;
|
|
|
|
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);
|
|
}
|
|
|
|
async function teardown() {
|
|
if (driver) { await driver.quit(); }
|
|
if (server) { await server.stop(); }
|
|
}
|
|
|
|
async function resetEditor() {
|
|
await driver.executeScript(`
|
|
var e = window.__ribbitEditor;
|
|
e.wysiwyg();
|
|
e.element.innerHTML = '<p><br></p>';
|
|
`);
|
|
await driver.findElement(By.id('ribbit')).click();
|
|
await driver.sleep(50);
|
|
}
|
|
|
|
/**
|
|
* Send a single character and wait for the editor to process it.
|
|
*/
|
|
async function typeChar(character) {
|
|
await driver.actions().sendKeys(character).perform();
|
|
await driver.sleep(DELAY);
|
|
}
|
|
|
|
/**
|
|
* Type a string one character at a time with delay between each.
|
|
*/
|
|
async function typeString(text) {
|
|
for (const character of text) {
|
|
await typeChar(character);
|
|
}
|
|
}
|
|
|
|
async function getHTML() {
|
|
return driver.executeScript('return document.getElementById("ribbit").innerHTML');
|
|
}
|
|
|
|
async function getMarkdown() {
|
|
return driver.executeScript('return window.__ribbitEditor.getMarkdown()');
|
|
}
|
|
|
|
let passed = 0, failed = 0;
|
|
const errors = [];
|
|
|
|
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);
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function runTests() {
|
|
console.log('\nWYSIWYG Integration Tests (char-by-char)\n');
|
|
|
|
// ── Headings ──
|
|
|
|
console.log(' Headings:');
|
|
|
|
await test('# transforms to h1 after space', async () => {
|
|
await resetEditor();
|
|
await typeChar('#');
|
|
let html = await getHTML();
|
|
assert(!html.includes('<h1'), `Premature h1 after just #: ${html}`);
|
|
|
|
await typeChar(' ');
|
|
html = await getHTML();
|
|
assert(html.includes('<h1'), `No h1 after "# ": ${html}`);
|
|
|
|
await typeString('Hello');
|
|
html = await getHTML();
|
|
assert(html.includes('<h1') && html.includes('Hello'), `Missing content in h1: ${html}`);
|
|
});
|
|
|
|
await test('## transforms to h2 after space', async () => {
|
|
await resetEditor();
|
|
await typeString('##');
|
|
let html = await getHTML();
|
|
assert(!html.includes('<h2'), `Premature h2: ${html}`);
|
|
|
|
await typeChar(' ');
|
|
html = await getHTML();
|
|
assert(html.includes('<h2'), `No h2 after "## ": ${html}`);
|
|
});
|
|
|
|
await test('enter after heading creates new paragraph', async () => {
|
|
await resetEditor();
|
|
await typeString('# Title');
|
|
await typeChar(Key.ENTER);
|
|
await typeString('body');
|
|
const html = await getHTML();
|
|
assert(html.includes('<h1'), `No h1: ${html}`);
|
|
assert(html.includes('body'), `No body text: ${html}`);
|
|
});
|
|
|
|
// ── Bold ──
|
|
|
|
console.log(' Bold:');
|
|
|
|
await test('** does not transform without content', async () => {
|
|
await resetEditor();
|
|
await typeString('**');
|
|
const html = await getHTML();
|
|
assert(!html.includes('<strong'), `Premature strong after just **: ${html}`);
|
|
});
|
|
|
|
await test('**x starts speculative bold', async () => {
|
|
await resetEditor();
|
|
await typeString('**');
|
|
await typeChar('x');
|
|
const html = await getHTML();
|
|
assert(html.includes('<strong'), `No strong after **x: ${html}`);
|
|
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
|
|
});
|
|
|
|
await test('**hello** completes bold', async () => {
|
|
await resetEditor();
|
|
await typeString('**hello');
|
|
let html = await getHTML();
|
|
assert(html.includes('data-speculative'), `Not speculative during typing: ${html}`);
|
|
|
|
await typeString('**');
|
|
html = await getHTML();
|
|
assert(html.includes('<strong'), `No strong after closing: ${html}`);
|
|
assert(!html.includes('data-speculative'), `Still speculative after closing: ${html}`);
|
|
assert(html.includes('hello'), `Missing content: ${html}`);
|
|
});
|
|
|
|
await test('typing after **bold** goes outside strong', async () => {
|
|
await resetEditor();
|
|
await typeString('**bold**');
|
|
await typeString(' after');
|
|
const html = await getHTML();
|
|
assert(html.includes('<strong'), `No strong: ${html}`);
|
|
assert(html.includes('after'), `Missing "after" text: ${html}`);
|
|
// "after" should NOT be inside <strong>
|
|
const strongMatch = html.match(/<strong[^>]*>.*?<\/strong>/);
|
|
if (strongMatch) {
|
|
assert(!strongMatch[0].includes('after'),
|
|
`"after" is inside strong — cursor not placed correctly: ${html}`);
|
|
}
|
|
});
|
|
|
|
// ── Italic ──
|
|
|
|
console.log(' Italic:');
|
|
|
|
await test('*x starts speculative italic', async () => {
|
|
await resetEditor();
|
|
await typeChar('*');
|
|
let html = await getHTML();
|
|
assert(!html.includes('<em'), `Premature em after just *: ${html}`);
|
|
|
|
await typeChar('x');
|
|
html = await getHTML();
|
|
assert(html.includes('<em'), `No em after *x: ${html}`);
|
|
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
|
|
});
|
|
|
|
await test('*hello* completes italic', async () => {
|
|
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('<em'), `No em: ${html}`);
|
|
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
|
|
});
|
|
|
|
// ── Code ──
|
|
|
|
console.log(' Code:');
|
|
|
|
await test('`hello` completes code span', async () => {
|
|
await resetEditor();
|
|
await typeString('`hello`');
|
|
const html = await getHTML();
|
|
assert(html.includes('<code'), `No code: ${html}`);
|
|
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
|
|
assert(html.includes('hello'), `Missing content: ${html}`);
|
|
});
|
|
|
|
// ── Nested inline ──
|
|
|
|
console.log(' Nested inline:');
|
|
|
|
await test('**bold *italic* still typing bold', async () => {
|
|
await resetEditor();
|
|
|
|
// Type **
|
|
await typeString('**');
|
|
let html = await getHTML();
|
|
assert(!html.includes('<strong'), `Premature strong after **: ${html}`);
|
|
|
|
// Type b — speculative bold starts
|
|
await typeChar('b');
|
|
html = await getHTML();
|
|
assert(html.includes('<strong'), `No strong after **b: ${html}`);
|
|
assert(html.includes('data-speculative'), `Not speculative: ${html}`);
|
|
|
|
// Type "old " — still speculative bold
|
|
await typeString('old ');
|
|
html = await getHTML();
|
|
assert(html.includes('data-speculative'), `Lost speculative during bold: ${html}`);
|
|
|
|
// Type * — just a * inside the speculative bold
|
|
await typeChar('*');
|
|
html = await getHTML();
|
|
assert(html.includes('data-speculative'), `Lost speculative after *: ${html}`);
|
|
|
|
// Type "italic" — speculative italic should nest inside speculative bold
|
|
await typeString('italic');
|
|
html = await getHTML();
|
|
// Should have both strong and em
|
|
assert(html.includes('<strong'), `Lost strong: ${html}`);
|
|
|
|
// Type * — closes italic, bold still speculative
|
|
await typeChar('*');
|
|
html = await getHTML();
|
|
assert(html.includes('<em'), `No em after closing *: ${html}`);
|
|
assert(html.includes('italic'), `Missing italic content: ${html}`);
|
|
// Bold should still be speculative (unclosed)
|
|
assert(html.includes('data-speculative'), `Bold not speculative anymore: ${html}`);
|
|
});
|
|
|
|
await test('**bold** and *italic* on same line', async () => {
|
|
await resetEditor();
|
|
await typeString('**bold**');
|
|
let html = await getHTML();
|
|
assert(html.includes('<strong'), `No strong: ${html}`);
|
|
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
|
|
|
|
await typeString(' and ');
|
|
await typeString('*italic*');
|
|
html = await getHTML();
|
|
assert(html.includes('<strong'), `Lost strong: ${html}`);
|
|
assert(html.includes('<em'), `No em: ${html}`);
|
|
assert(html.includes('italic'), `Missing italic content: ${html}`);
|
|
});
|
|
|
|
// ── Lists ──
|
|
|
|
console.log(' Lists:');
|
|
|
|
await test('- space transforms to unordered list', async () => {
|
|
await resetEditor();
|
|
await typeChar('-');
|
|
let html = await getHTML();
|
|
assert(!html.includes('<ul'), `Premature ul after just -: ${html}`);
|
|
|
|
await typeChar(' ');
|
|
html = await getHTML();
|
|
assert(html.includes('<ul') || html.includes('<li'), `No list after "- ": ${html}`);
|
|
|
|
await typeString('item');
|
|
html = await getHTML();
|
|
assert(html.includes('item'), `Missing content: ${html}`);
|
|
});
|
|
|
|
await test('1. space transforms to ordered list', async () => {
|
|
await resetEditor();
|
|
await typeString('1.');
|
|
let html = await getHTML();
|
|
assert(!html.includes('<ol'), `Premature ol: ${html}`);
|
|
|
|
await typeChar(' ');
|
|
html = await getHTML();
|
|
assert(html.includes('<ol') || html.includes('<li'), `No list after "1. ": ${html}`);
|
|
});
|
|
|
|
// ── Blockquote ──
|
|
|
|
console.log(' Blockquote:');
|
|
|
|
await test('> space transforms to blockquote', async () => {
|
|
await resetEditor();
|
|
await typeChar('>');
|
|
let html = await getHTML();
|
|
assert(!html.includes('<blockquote'), `Premature blockquote: ${html}`);
|
|
|
|
await typeChar(' ');
|
|
html = await getHTML();
|
|
assert(html.includes('<blockquote'), `No blockquote after "> ": ${html}`);
|
|
});
|
|
|
|
// ── Horizontal rule ──
|
|
|
|
console.log(' Horizontal rule:');
|
|
|
|
await test('--- transforms to hr', async () => {
|
|
await resetEditor();
|
|
await typeString('--');
|
|
let html = await getHTML();
|
|
assert(!html.includes('<hr'), `Premature hr: ${html}`);
|
|
|
|
await typeChar('-');
|
|
await driver.sleep(50);
|
|
html = await getHTML();
|
|
assert(html.includes('<hr'), `No hr after ---: ${html}`);
|
|
});
|
|
|
|
// ── Round-trip ──
|
|
|
|
console.log(' Round-trip:');
|
|
|
|
await test('**hello** round-trips to markdown', async () => {
|
|
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('<strong'), `Bold lost after mode switch: ${html}`);
|
|
assert(html.includes('<em'), `Italic lost after mode switch: ${html}`);
|
|
});
|
|
|
|
// ── Speculative closing ──
|
|
|
|
console.log(' Speculative closing:');
|
|
|
|
await test('right arrow closes speculative', async () => {
|
|
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}`);
|
|
});
|
|
|
|
// ── 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 typeString('## Section');
|
|
await typeChar(Key.ENTER);
|
|
await typeString('- item one');
|
|
|
|
await driver.sleep(100);
|
|
const html = await getHTML();
|
|
assert(html.includes('<h1'), `Missing h1: ${html}`);
|
|
assert(html.includes('<strong'), `Missing strong: ${html}`);
|
|
assert(html.includes('<h2'), `Missing h2: ${html}`);
|
|
assert(html.includes('<li') || html.includes('<ul'), `Missing list: ${html}`);
|
|
});
|
|
|
|
console.log(' Strikethrough:');
|
|
|
|
await test('~~text~~ transforms to <del>', async () => {
|
|
await resetEditor();
|
|
await typeString('~~gone~~');
|
|
const html = await getHTML();
|
|
assert(html.includes('<del'), `No <del>: ${html}`);
|
|
assert(!html.includes('data-speculative'), `Still speculative: ${html}`);
|
|
assert(html.includes('gone'), `Missing content: ${html}`);
|
|
});
|
|
|
|
await test('~~text shows speculative strikethrough', async () => {
|
|
await resetEditor();
|
|
await typeString('~~hel');
|
|
const html = await getHTML();
|
|
assert(html.includes('data-speculative'), `No speculative: ${html}`);
|
|
assert(html.includes('<del'), `No <del>: ${html}`);
|
|
});
|
|
|
|
console.log(' Alternate syntax:');
|
|
|
|
await test('~~~ transforms to fenced code', async () => {
|
|
await resetEditor();
|
|
await typeString('~~~');
|
|
await driver.sleep(50);
|
|
const html = await getHTML();
|
|
assert(html.includes('<pre') || html.includes('<code'), `No code block: ${html}`);
|
|
});
|
|
|
|
await test('+ space transforms to unordered list', async () => {
|
|
await resetEditor();
|
|
await typeChar('+');
|
|
let html = await getHTML();
|
|
assert(!html.includes('<ul'), `Premature ul: ${html}`);
|
|
|
|
await typeChar(' ');
|
|
html = await getHTML();
|
|
assert(html.includes('<ul') || html.includes('<li'), `No list after "+ ": ${html}`);
|
|
});
|
|
|
|
console.log(' Backslash escapes:');
|
|
|
|
await test('backslash is just a character in WYSIWYG', async () => {
|
|
await resetEditor();
|
|
await typeString('hello\\world');
|
|
const html = await getHTML();
|
|
assert(html.includes('hello') && html.includes('world'), `Missing content: ${html}`);
|
|
});
|
|
}
|
|
|
|
(async () => {
|
|
try {
|
|
await setup();
|
|
await runTests();
|
|
} catch (error) {
|
|
console.error('Setup failed:', error.message);
|
|
failed++;
|
|
} finally {
|
|
console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`);
|
|
if (errors.length) {
|
|
console.log('\nFailed:');
|
|
errors.forEach(error => console.log(` • ${error}`));
|
|
}
|
|
await teardown();
|
|
process.exit(failed > 0 ? 1 : 0);
|
|
}
|
|
})();
|