Files
ink-note-app/test/e2e-outline.test.cjs
T
2026-10-15 11:57:09 +02:00

123 lines
8.0 KiB
JavaScript

// End-to-end check of the outline panel. Run with `bun run test:e2e` (needs a display or --ozone-platform=headless).
const { app, BrowserWindow } = require('electron');
const path = require('path');
const os = require('os');
const fs = require('fs');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-outline-'));
const vault = path.join(tmp, 'Vault');
fs.mkdirSync(vault);
const filler = (n) => Array.from({ length: n }, (_, i) => `Paragraph ${i + 1} with a few words so the page gets tall enough to scroll.`).join('\n\n');
fs.writeFileSync(path.join(vault, 'Long.md'), [
'# Intro', filler(14), '## Setup', filler(14), '### Install', filler(14), '## Usage', filler(14), '# Appendix', filler(14), 'Last line',
].join('\n\n') + '\n');
fs.writeFileSync(path.join(vault, 'Plain.md'), 'Just text, no headings.\n');
fs.writeFileSync(path.join(vault, 'Tasks.md'), '---\nkanban-plugin: basic\n---\n\n## To do\n\n- [ ] One\n\n\n## Done\n');
app.setPath('userData', tmp);
const settingsFile = path.join(tmp, 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({ vault, lastPage: 'Long', theme: 'dark', bounds: { width: 1280, height: 860 } }));
require('../electron/main.cjs');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const failures = [];
const check = (name, ok, extra = '') => {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
if (!ok) failures.push(name);
};
app.whenReady().then(async () => {
let win;
for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100);
const wc = win.webContents;
if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
const key = async (keyCode, modifiers = []) => {
wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers });
wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers });
await sleep(200);
};
const type = async (t) => { for (const ch of t) wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(200); };
const click = async (sel) => {
const c = await js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`);
if (!c) throw new Error('not found: ' + sel);
wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] });
wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 });
wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 });
await sleep(350);
};
const items = () => js(`return [...document.querySelectorAll('.ol-item')].map(b => ({ t: b.textContent.trim(), lv: b.style.getPropertyValue('--lv'), on: b.classList.contains('on') }))`);
const outlineBtn = `document.querySelector('.topbar [aria-label="Outline"]')`;
const scrollTop = () => js(`return Math.round(document.querySelector('.main .scroller').scrollTop)`);
const headingTop = (text) => js(`const sc = document.querySelector('.main .scroller'); const h = [...document.querySelectorAll('.blocks .block')].find(b => /^b-h/.test(b.className.match(/b-\\w+/)[0]) && b.textContent.trim() === ${JSON.stringify(text)}); return h ? Math.round(h.getBoundingClientRect().top - sc.getBoundingClientRect().top) : null`);
// Settings reach the disk ~200 ms after a change.
const saved = async () => { await sleep(500); return JSON.parse(fs.readFileSync(settingsFile, 'utf8')); };
try {
await sleep(1800);
check('closed by default', !(await js(`return !!document.querySelector('.outline')`)));
// (a layout wrapper once reused the class of the property values and silently greyed all page text)
check('page text has the normal text colour', await js(`return getComputedStyle(document.querySelector('.blocks .c')).color === getComputedStyle(document.body).color`));
await click(outlineBtn);
const list = await items();
check('lists the headings in order', JSON.stringify(list.map((i) => i.t)) === '["Intro","Setup","Install","Usage","Appendix"]', JSON.stringify(list.map((i) => i.t)));
check('indents by level', JSON.stringify(list.map((i) => i.lv)) === '["0","1","2","1","0"]', JSON.stringify(list.map((i) => i.lv)));
check('count shown', (await js(`return document.querySelector('.ol-head em')?.textContent`)) === '5');
check('remembered in settings', (await saved()).outline === true);
await click(`[...document.querySelectorAll('.ol-item')].find(b => b.textContent.trim() === 'Usage')`);
check('landing flash on the heading', await js(`return !!document.querySelector('.block.flash')`));
await sleep(900);
const top = await headingTop('Usage');
check('click scrolls to the heading', top !== null && top > 0 && top < 120, `heading ${top}px below the top`);
check('clicked entry is highlighted', (await items()).filter((i) => i.on).map((i) => i.t).join() === 'Usage');
await js(`document.querySelector('.main .scroller').scrollTop = 0`);
await sleep(500);
check('nothing highlighted above the first heading', (await items()).every((i) => !i.on), JSON.stringify((await items()).filter((i) => i.on)));
const setupAt = await js(`const sc = document.querySelector('.main .scroller'); const h = [...document.querySelectorAll('.blocks .block')].find(b => b.textContent.trim() === 'Setup'); return Math.round(h.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop)`);
await js(`document.querySelector('.main .scroller').scrollTop = ${setupAt + 200}`);
await sleep(500);
check('highlight follows scrolling', (await items()).filter((i) => i.on).map((i) => i.t).join() === 'Setup', JSON.stringify(await items()));
// typing: a new heading appears, its text updates live, deleting it removes it
await js(`const c = [...document.querySelectorAll('.blocks .c')].at(-1); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`);
await key('Return');
await type('## Extra');
check('typing "## " adds an entry', (await items()).map((i) => i.t).join() === 'Intro,Setup,Install,Usage,Appendix,Extra', (await items()).map((i) => i.t).join());
await type(' stuff');
check('entry text follows typing', (await items()).at(-1).t === 'Extra stuff', (await items()).at(-1).t);
await key('z', ['control']);
await key('z', ['control']);
await key('z', ['control']);
await sleep(300);
check('undo removes it', (await items()).length === 5, (await items()).map((i) => i.t).join());
// pages without headings, and boards
await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Plain'))`);
await sleep(500);
check('page without headings: hint', (await js(`return document.querySelector('.ol-empty')?.textContent || ''`)).includes('No headings yet'));
await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Tasks'))`);
await sleep(600);
check('boards: no panel, button disabled', !(await js(`return !!document.querySelector('.outline')`)) && (await js(`return ${outlineBtn}.disabled`)));
await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Long'))`);
await sleep(600);
check('back on a page: panel is back', (await items()).length === 5);
// shortcut and close button
await key('o', ['control', 'shift']);
check('Ctrl+Shift+O closes', !(await js(`return !!document.querySelector('.outline')`)) && (await saved()).outline === false);
await key('o', ['control', 'shift']);
check('Ctrl+Shift+O opens', await js(`return !!document.querySelector('.outline')`));
await click(`document.querySelector('.outline [aria-label="Close outline"]')`);
check('close button', !(await js(`return !!document.querySelector('.outline')`)));
} catch (err) {
check('harness', false, err.stack);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log(failures.length ? `\n${failures.length} check(s) failed` : '\noutline e2e passed!');
app.exit(failures.length ? 1 : 0);
});