Add Selenium integration test framework

This commit is contained in:
gsb
2026-04-29 22:15:02 +00:00
parent 1198791505
commit 3e8d3388f6
12 changed files with 703 additions and 7 deletions
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Ribbit Integration Test Page</title>
<link rel="stylesheet" href="/ribbit/themes/ribbit-default/theme.css">
<style>
body { font-family: sans-serif; margin: 20px; }
#ribbit { border: 1px solid #ccc; padding: 20px; min-height: 200px; }
.ribbit-toolbar { background: #f5f5f5; border: 1px solid #ccc; padding: 4px; margin-bottom: 8px; }
.ribbit-toolbar ul { list-style: none; margin: 0; padding: 0; display: flex; gap: 2px; }
.ribbit-toolbar button { padding: 4px 8px; border: 1px solid #ddd; border-radius: 3px; background: white; cursor: pointer; font-size: 12px; }
.ribbit-toolbar button.active { background: #d0d0ff; }
.ribbit-toolbar button.disabled { opacity: 0.3; }
.ribbit-toolbar .spacer { width: 12px; }
.ribbit-dropdown { position: absolute; background: white; border: 1px solid #ccc; padding: 4px; }
.ribbit-dropdown button { display: block; width: 100%; }
</style>
</head>
<body>
<article id="ribbit">**bold** and *italic* and `code`
## Heading
- list item 1
- list item 2
> a blockquote
| A | B |
|---|---|
| 1 | 2 |
</article>
<script src="/ribbit/ribbit.js"></script>
<script>
const editor = new ribbit.Editor({
on: {
ready: () => { window.__ribbitReady = true; },
},
});
editor.run();
window.__ribbitEditor = editor;
</script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
/**
* Minimal static file server for e2e tests.
* Serves the test page and ribbit dist files.
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const MIME = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.map': 'application/json',
};
function createServer(port = 9999) {
const distDir = path.join(__dirname, '..', '..', 'dist', 'ribbit');
const testDir = __dirname;
const server = http.createServer((req, res) => {
let filePath;
if (req.url === '/' || req.url === '/index.html') {
filePath = path.join(testDir, 'index.html');
} else if (req.url.startsWith('/ribbit/')) {
filePath = path.join(distDir, req.url.replace('/ribbit/', ''));
} else {
res.writeHead(404);
res.end('Not found');
return;
}
const ext = path.extname(filePath);
const mime = MIME[ext] || 'application/octet-stream';
try {
const content = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': mime });
res.end(content);
} catch {
res.writeHead(404);
res.end('Not found');
}
});
return {
start() {
return new Promise((resolve) => {
server.listen(port, () => resolve());
});
},
stop() {
return new Promise((resolve) => {
server.close(() => resolve());
});
},
url: `http://localhost:${port}`,
};
}
module.exports = { createServer };
+290
View File
@@ -0,0 +1,290 @@
/**
* Integration tests for the ribbit editor using Selenium + Firefox.
*
* Run: npm run test:e2e
*/
const { Builder, By, Key, until } = require('selenium-webdriver');
const firefox = require('selenium-webdriver/firefox');
const { createServer } = require('./server');
let server;
let driver;
async function setup() {
server = createServer(9999);
await server.start();
const options = new firefox.Options().addArguments('--headless');
driver = await new Builder()
.forBrowser('firefox')
.setFirefoxOptions(options)
.build();
await driver.get(server.url);
// Wait for ribbit to initialize
await driver.wait(async () => {
return driver.executeScript('return window.__ribbitReady === true');
}, 10000).catch(async () => {
const logs = await driver.manage().logs().get('browser').catch(() => []);
console.log('Browser logs:', logs.map(l => l.message));
const ready = await driver.executeScript('return { ready: window.__ribbitReady, ribbit: typeof window.ribbit, editor: typeof window.__ribbitEditor }');
console.log('State:', ready);
throw new Error('Editor did not become ready');
});
}
async function teardown() {
if (driver) await driver.quit();
if (server) await server.stop();
}
// Test helpers
async function getEditorHTML() {
return driver.executeScript('return document.getElementById("ribbit").innerHTML');
}
async function getEditorText() {
return driver.executeScript('return document.getElementById("ribbit").textContent');
}
async function getState() {
return driver.executeScript('return window.__ribbitEditor.getState()');
}
async function clickButton(label) {
const buttons = await driver.findElements(By.css('.ribbit-toolbar button'));
for (const btn of buttons) {
const text = await btn.getText();
if (text === label) {
await btn.click();
return;
}
}
throw new Error(`Button "${label}" not found`);
}
async function clickEditor() {
const editor = await driver.findElement(By.id('ribbit'));
await editor.click();
}
// Test runner
let passed = 0;
let failed = 0;
const errors = [];
async function test(name, fn) {
try {
await fn();
passed++;
console.log(`${name}`);
} catch (e) {
failed++;
errors.push(name);
console.log(`${name}`);
console.log(` ${e.message}`);
}
}
function assert(condition, message) {
if (!condition) throw new Error(message || 'Assertion failed');
}
// Tests
async function runTests() {
console.log('\nRibbit Integration Tests\n');
await test('page loads', async () => {
const title = await driver.getTitle();
assert(title === 'Ribbit Integration Test Page', `Title: ${title}`);
});
await test('editor renders in view mode', async () => {
const state = await getState();
assert(state === 'view', `State: ${state}`);
});
await test('editor renders markdown as HTML', async () => {
const html = await getEditorHTML();
assert(html.includes('<strong>bold</strong>'), 'Missing bold');
assert(html.includes('<em>italic</em>'), 'Missing italic');
assert(html.includes('<code>code</code>'), 'Missing code');
});
await test('editor renders headings', async () => {
const html = await getEditorHTML();
assert(html.includes('<h2'), 'Missing h2');
});
await test('editor renders lists', async () => {
const html = await getEditorHTML();
assert(html.includes('<ul>'), 'Missing ul');
assert(html.includes('<li>'), 'Missing li');
});
await test('editor renders tables', async () => {
const html = await getEditorHTML();
assert(html.includes('<table>'), 'Missing table');
});
await test('editor renders blockquotes', async () => {
const html = await getEditorHTML();
assert(html.includes('<blockquote>'), 'Missing blockquote');
});
await test('toolbar is rendered', async () => {
const toolbar = await driver.findElements(By.css('.ribbit-toolbar'));
assert(toolbar.length > 0, 'No toolbar found');
});
await test('toolbar has buttons with labels', async () => {
const buttons = await driver.findElements(By.css('.ribbit-toolbar button'));
assert(buttons.length > 5, `Only ${buttons.length} buttons`);
const text = await buttons[0].getText();
assert(text.length > 0, 'Button has no label');
});
await test('toggle button switches to wysiwyg', async () => {
await clickButton('Edit');
const state = await getState();
assert(state === 'wysiwyg', `State: ${state}`);
});
await test('editor is contentEditable in wysiwyg', async () => {
const editable = await driver.executeScript(
'return document.getElementById("ribbit").contentEditable'
);
assert(editable === 'true', `contentEditable: ${editable}`);
});
await test('can type in wysiwyg mode', async () => {
await clickEditor();
// Move to end and type
await driver.actions().keyDown(Key.CONTROL).sendKeys(Key.END).keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys('\nhello from selenium').perform();
const text = await getEditorText();
assert(text.includes('hello from selenium'), 'Typed text not found');
});
await test('source button switches to edit mode', async () => {
await clickButton('Source');
const state = await getState();
assert(state === 'edit', `State: ${state}`);
});
await test('edit mode shows raw markdown', async () => {
const text = await getEditorText();
assert(text.includes('**bold**'), 'Missing raw markdown');
});
await test('toggle back to view mode', async () => {
await clickButton('Edit');
const state = await getState();
assert(state === 'view', `State: ${state}`);
});
await test('view mode renders HTML again', async () => {
const html = await getEditorHTML();
assert(html.includes('<strong>bold</strong>'), 'Not rendered as HTML');
});
await test('save button fires save event', async () => {
await driver.executeScript('window.__saved = false; window.__ribbitEditor.on("save", () => { window.__saved = true; })');
await clickButton('Edit');
await clickButton('Save');
const saved = await driver.executeScript('return window.__saved');
assert(saved === true, 'Save event not fired');
});
await test('enter key creates new line in wysiwyg', async () => {
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
// Clear and type two lines
await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys(Key.DELETE).perform();
await driver.actions().sendKeys('line one').perform();
await driver.actions().sendKeys(Key.ENTER).perform();
await driver.actions().sendKeys('line two').perform();
const text = await getEditorText();
assert(text.includes('line one'), `Missing "line one" in: ${text}`);
assert(text.includes('line two'), `Missing "line two" in: ${text}`);
// Check that they're on separate lines (not concatenated)
const html = await getEditorHTML();
const hasBreak = html.includes('<br') || html.includes('<div') || html.includes('<p');
assert(hasBreak, `No line break in HTML: ${html}`);
});
await test('enter key in wysiwyg produces valid markdown', async () => {
// Get the markdown from the content typed above
const md = await driver.executeScript('return window.__ribbitEditor.getMarkdown()');
assert(md.includes('line one'), `Missing "line one" in markdown: ${md}`);
assert(md.includes('line two'), `Missing "line two" in markdown: ${md}`);
// Lines should be separate (not on same line)
const lines = md.split('\n').filter(l => l.trim());
const hasLineOne = lines.some(l => l.includes('line one'));
const hasLineTwo = lines.some(l => l.includes('line two'));
assert(hasLineOne, `"line one" not on its own line in: ${md}`);
assert(hasLineTwo, `"line two" not on its own line in: ${md}`);
});
await test('multiple enters create blank lines in wysiwyg', async () => {
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys(Key.DELETE).perform();
await driver.actions().sendKeys('para one').perform();
await driver.actions().sendKeys(Key.ENTER, Key.ENTER).perform();
await driver.actions().sendKeys('para two').perform();
const text = await getEditorText();
assert(text.includes('para one'), `Missing "para one" in: ${text}`);
assert(text.includes('para two'), `Missing "para two" in: ${text}`);
});
await test('enter after heading in wysiwyg', async () => {
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
await driver.actions().keyDown(Key.CONTROL).sendKeys('a').keyUp(Key.CONTROL).perform();
await driver.actions().sendKeys(Key.DELETE).perform();
await driver.actions().sendKeys('## My Heading').perform();
await driver.actions().sendKeys(Key.ENTER).perform();
await driver.actions().sendKeys('paragraph text').perform();
const md = await driver.executeScript('return window.__ribbitEditor.getMarkdown()');
assert(md.includes('Heading') || md.includes('heading'), `Missing heading in: ${md}`);
assert(md.includes('paragraph'), `Missing paragraph in: ${md}`);
});
await test('Ctrl+B shortcut works in wysiwyg', async () => {
// Switch to wysiwyg
await driver.executeScript('window.__ribbitEditor.wysiwyg()');
await clickEditor();
// Type and select
await driver.actions().sendKeys('test text').perform();
await driver.actions()
.keyDown(Key.SHIFT)
.sendKeys(Key.ARROW_LEFT, Key.ARROW_LEFT, Key.ARROW_LEFT, Key.ARROW_LEFT)
.keyUp(Key.SHIFT)
.perform();
// Ctrl+B
await driver.actions().keyDown(Key.CONTROL).sendKeys('b').keyUp(Key.CONTROL).perform();
const html = await getEditorHTML();
assert(html.includes('**'), 'Bold delimiter not inserted');
});
}
(async () => {
try {
await setup();
await runTests();
} catch (e) {
console.error('Setup failed:', e.message);
failed++;
} finally {
console.log(`\n${passed}/${passed + failed} passed — ${failed} failed`);
if (errors.length) {
console.log('\nFailed:');
errors.forEach(e => console.log(`${e}`));
}
await teardown();
process.exit(failed > 0 ? 1 : 0);
}
})();