// End-to-end check of ==highlight== and footnotes. Run with `bun run test:e2e`. 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-footnotes-')); const vault = path.join(tmp, 'Vault'); fs.mkdirSync(vault); const filler = (n) => Array.from({ length: n }, (_, i) => `Filler paragraph ${i + 1} that takes up a line or two of the page.`).join('\n\n'); const NOTES = '[^2]: Second note.\n[^src]: The source,\n on two lines.\n[^lonely]: Nobody points here.'; fs.writeFileSync(path.join(vault, 'Essay.md'), [ 'Intro with a ==key idea== and a claim[^src] plus an aside^[said in passing].', 'Second claim[^2] and the first again[^src].', filler(18), NOTES, ].join('\n\n') + '\n'); app.setPath('userData', tmp); fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Essay', 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(250); }; const type = async (t) => { for (const ch of t) wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(350); }; const point = (sel) => js(`const e = ${sel}; if (!e) return null; e.scrollIntoView({ block: 'nearest' }); const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); const click = async (sel) => { const c = await point(sel); 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(500); }; const hover = async (sel) => { const c = await point(sel); wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); await sleep(250); }; const read = () => fs.readFileSync(path.join(vault, 'Essay.md'), 'utf8'); const refs = () => js(`return [...document.querySelectorAll('.blocks sup.fn')].map(s => s.textContent + (s.classList.contains('missing') ? '?' : ''))`); const notes = () => js(`return [...document.querySelectorAll('.blocks .block.b-footnote')].map(b => b.querySelector('.fn-num').textContent + (b.classList.contains('orphan') ? '(orphan)' : '') + ':' + b.querySelector('.c').textContent)`); const offsetOf = (sel) => js(`const sc = document.querySelector('.main .scroller'); const e = ${sel}; return e ? Math.round(e.getBoundingClientRect().top - sc.getBoundingClientRect().top) : null`); const fnBlock = (id) => `document.querySelector('.blocks .block.b-footnote[data-fid="${id}"]')`; const select = (word) => js(`const c = [...document.querySelectorAll('.blocks .c')].find(x => x.textContent.includes('${word}')); c.focus(); const w = document.createTreeWalker(c, NodeFilter.SHOW_TEXT); let t; while ((t = w.nextNode()) && !t.data.includes('${word}')); const a = t.data.indexOf('${word}'); const r = document.createRange(); r.setStart(t, a); r.setEnd(t, a + ${word.length}); getSelection().removeAllRanges(); getSelection().addRange(r);`); const caretEndOf = (sel) => js(`const c = ${sel}; c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); try { await sleep(2000); check('==text== shows highlighted', (await js(`return document.querySelector('.blocks mark')?.textContent`)) === 'key idea'); check('references are numbered in reading order', JSON.stringify(await refs()) === '["1","2","3","1"]', JSON.stringify(await refs())); check('footnote texts carry their number; unreferenced ones keep their id', JSON.stringify(await notes()) === JSON.stringify(['3:Second note.', '1:The source,\non two lines.', 'lonely(orphan):Nobody points here.']), JSON.stringify(await notes())); await hover(`document.querySelector('.blocks sup.fn[data-fn="src"]')`); check('hovering a reference shows its text', (await js(`const t = document.querySelector('.fn-tip'); return !t.hidden && t.textContent`)) === '1The source,\non two lines.', JSON.stringify(await js(`return document.querySelector('.fn-tip').textContent`))); await hover(`document.querySelector('.blocks sup.fn.inline')`); check('...and an inline note its text', (await js(`return document.querySelector('.fn-tip').textContent`)) === '2said in passing'); await click(`document.querySelector('.blocks sup.fn[data-fn="src"]')`); await sleep(700); const at = await offsetOf(fnBlock('src')); const flashed = await js(`return ${fnBlock('src')}.classList.contains('flash')`); // it sits near the end of the page, so it may not reach the very top; it has to be in view and flashing check('clicking a reference scrolls to its text', at !== null && at >= 0 && at < 700 && flashed, `${at}px from the top, flash ${flashed}`); await click(`${fnBlock('src')}.querySelector('.fn-num')`); await sleep(800); const back = await offsetOf(`[...document.querySelectorAll('.blocks > .block')].find(b => b.textContent.startsWith('Intro'))`); check('clicking its number goes back to the reference', back !== null && back >= -10 && back < 140, `${back}px from the top`); // ---- highlight from the toolbar and the shortcut ---- await js(`document.querySelector('.main .scroller').scrollTop = 0`); await sleep(200); await select('Second claim'); await sleep(300); await click(`document.querySelector('.pop.fmt [data-f="mark"]')`); await sleep(900); check('toolbar highlights the selection', read().includes('==Second claim==[^2]'), JSON.stringify(read().split('\n')[2])); await select('Second claim'); await sleep(200); check('the button shows it is highlighted', await js(`return document.querySelector('.pop.fmt [data-f="mark"]').classList.contains('on')`)); await key('h', ['control', 'shift']); await sleep(900); check('Ctrl+Shift+H takes it off again', read().includes('\nSecond claim[^2]'), JSON.stringify(read().split('\n')[2])); await select('Intro'); await key('h', ['control', 'shift']); await sleep(900); check('Ctrl+Shift+H highlights', read().startsWith('==Intro== with a ==key idea=='), JSON.stringify(read().split('\n')[0])); // ---- typing references ---- await caretEndOf(`[...document.querySelectorAll('.blocks .c')].find(x => x.textContent.startsWith('Second claim'))`); await type(' new point[^new]'); check('typing [^id] makes a reference', JSON.stringify(await refs()) === '["1","2","3","1","4?"]', JSON.stringify(await refs())); await click(`document.querySelector('.blocks sup.fn[data-fn="new"]')`); await sleep(500); check('clicking a reference with no text starts one', await js(`return document.activeElement?.closest('.block')?.dataset.fid === 'new'`)); await type('Fresh note'); await sleep(900); check('...saved after the other footnotes', read().includes('[^lonely]: Nobody points here.\n[^new]: Fresh note\n'), JSON.stringify(read().slice(-120))); check('the reference is no longer missing', JSON.stringify(await refs()) === '["1","2","3","1","4"]', JSON.stringify(await refs())); // ---- /footnote ---- await caretEndOf(`[...document.querySelectorAll('.blocks .c')].find(x => x.textContent.startsWith('Intro'))`); await sleep(900); // focusing the top line scrolls the page back up; scrolling closes the / menu, so let it settle await type(' Point /footn'); await key('Return'); await sleep(600); check('/footnote adds a reference with the next free number', read().includes('Point[^1]'), JSON.stringify(read().split('\n')[0])); check('...and puts you in its new text', await js(`return document.activeElement?.closest('.block')?.dataset.fid === '1'`)); await type('Auto note'); await sleep(900); check('...which is saved', read().includes('[^1]: Auto note')); check('numbers follow reading order again', (await refs()).join(',') === '1,2,3,4,1,5', (await refs()).join(',')); await key('z', ['control']); await key('z', ['control']); await sleep(900); check('undo removes it', !read().includes('[^1]'), JSON.stringify(read().split('\n')[0])); // ---- "[^x]: " opens a footnote text, ^[...] is an inline note ---- await js(`document.querySelector('.tail').click()`); await sleep(300); await type('[^x]: Typed text'); await sleep(900); check('typing "[^x]: " at the start of a line makes a footnote text', read().includes('[^x]: Typed text'), JSON.stringify(read().slice(-60))); await key('Return'); await type('Quick^[an inline one] note'); await sleep(900); check('typing ^[...] makes an inline note, with one space after it', read().includes('Quick^[an inline one] note\n'), JSON.stringify(read().slice(-60))); // ---- writing flow: footnote texts stay at the bottom, Enter goes back to the text ---- await js(`document.querySelector('.tail').click()`); await sleep(300); check('clicking below the page adds a line above the footnotes', await js(`const all = [...document.querySelectorAll('.blocks > .block')]; const i = all.findIndex(b => b.contains(document.activeElement)); return i >= 0 && all.slice(i + 1).length > 0 && all.slice(i + 1).every(b => b.classList.contains('b-footnote'))`)); await type('/footnote'); await key('Return'); await sleep(500); const fid = await js(`return document.activeElement?.closest('.block')?.dataset.fid`); await type('Written note'); await key('Return'); check('Enter at the end of a footnote text goes back after its reference', await js(`const c = document.activeElement; const s = getSelection(); const r = s.getRangeAt(0); const pre = document.createRange(); pre.selectNodeContents(c); pre.setEnd(r.startContainer, r.startOffset); const frag = pre.cloneContents(); let last = frag.lastChild; while (last && last.nodeType === 3 && !last.data) last = last.previousSibling; return c.closest('.block').classList.contains('b-p') && !!last && last.nodeName === 'SUP' && last.dataset.fn === ${JSON.stringify(fid)}`), `footnote ${fid}`); await type('. Then more'); await sleep(900); check('...where typing carries on, and the reference stays a reference', read().includes(`[^${fid}]. Then more`) && !read().includes(`1. Then more`), JSON.stringify(read().split('\n\n').find((x) => x.includes('Then more')))); check('a line starting with a reference is not turned into a list', await js(`return !!document.querySelector('.blocks .b-p sup.fn[data-fn="${fid}"]')`)); // ---- the ordinary line-start shortcuts still work ---- await js(`document.querySelector('.tail').click()`); await sleep(300); await type('1. First item'); check('"1. " still starts a numbered list', await js(`return document.activeElement?.closest('.block')?.classList.contains('b-number') && document.activeElement.textContent === 'First item'`)); await key('Return'); await key('Return'); await type('# Title'); check('"# " still makes a heading', await js(`return document.activeElement?.closest('.block')?.classList.contains('b-h1') && document.activeElement.textContent === 'Title'`)); await key('Return'); await type('---'); await sleep(300); check('"---" still makes a divider', await js(`return [...document.querySelectorAll('.blocks > .block')].some(b => b.classList.contains('b-divider'))`)); check('existing footnote lines kept as written', read().includes('[^2]: Second note.\n[^src]: The source,\n on two lines.\n[^lonely]: Nobody points here.')); } catch (err) { check('harness', false, err.stack); } fs.rmSync(tmp, { recursive: true, force: true }); console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nfootnotes e2e passed!'); app.exit(failures.length ? 1 : 0); });