308 lines
12 KiB
JavaScript
308 lines
12 KiB
JavaScript
/**
|
|
* 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('typing heading prefix in wysiwyg', async () => {
|
|
// Start fresh
|
|
await driver.executeScript(`
|
|
var e = window.__ribbitEditor;
|
|
e.wysiwyg();
|
|
e.element.innerHTML = '<p><br></p>';
|
|
`);
|
|
await clickEditor();
|
|
await driver.sleep(100);
|
|
// Type "# Hello"
|
|
await driver.actions().sendKeys('# Hello').perform();
|
|
await driver.sleep(100);
|
|
const html = await getEditorHTML();
|
|
console.log(' HTML:', html.slice(0, 200));
|
|
assert(html.includes('<h1'), `Expected <h1> in HTML: ${html.slice(0, 200)}`);
|
|
});
|
|
|
|
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);
|
|
}
|
|
})();
|