test: unit and end-to-end suites

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent 27fafa0bcd
commit 9b00e21c44
49 files changed
+6694 -1

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
// 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);
});