173 lines
12 KiB
JavaScript
173 lines
12 KiB
JavaScript
// 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);
|
|
});
|