// 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); });