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

160 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// End-to-end check of heading links ([[Page#Heading]]) and embeds (![[Page]]). 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-embeds-'));
const vault = path.join(tmp, 'Vault');
fs.mkdirSync(vault);
const filler = (n, tag) => Array.from({ length: n }, (_, i) => `${tag} paragraph ${i + 1}, long enough to take some room on the page and make it scroll.`).join('\n\n');
fs.writeFileSync(path.join(vault, 'Roadmap.md'), [
'# Roadmap', 'Intro line', '## Q3 goals', '- ship graph\n- ship embeds', filler(14, 'q3'), '### Risks', 'few risks', '## Q4 goals', '- rest and plan', filler(14, 'q4'),
].join('\n\n') + '\n');
fs.writeFileSync(path.join(vault, 'Hub.md'), [
'Links: [[Roadmap#Q4 goals]] and [[#Local part]] and [[Roadmap#Nope]].',
'![[Roadmap#Q3 goals]]', '![[photo.png]]', filler(16, 'hub'), '## Local part', 'the local text', filler(8, 'end'),
].join('\n\n') + '\n');
fs.writeFileSync(path.join(vault, 'States.md'), ['![[Nowhere]]', '![[States]]', '![[Roadmap#Missing]]', '![[Tasks]]', '![[Roadmap]]'].join('\n\n') + '\n');
fs.writeFileSync(path.join(vault, 'Tasks.md'), '---\nkanban-plugin: basic\n---\n\n## To do\n\n- [ ] One\n');
app.setPath('userData', tmp);
const settingsFile = path.join(tmp, 'settings.json');
fs.writeFileSync(settingsFile, JSON.stringify({ vault, lastPage: 'Hub', theme: 'dark', bounds: { width: 1280, height: 900 } }));
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 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(500);
};
const read = (f) => fs.readFileSync(path.join(vault, f + '.md'), 'utf8');
const title = () => js(`return document.querySelector('.title')?.textContent`);
const chip = (text) => `[...document.querySelectorAll('.blocks .wl')].find(c => c.textContent === ${JSON.stringify(text)})`;
const scrollTop = () => js(`return Math.round(document.querySelector('.main .scroller').scrollTop)`);
const headingOffset = (text) => js(`const sc = document.querySelector('.main .scroller'); const h = [...document.querySelectorAll('.blocks > .block')].find(b => /b-h\\d/.test(b.className) && b.textContent.trim() === ${JSON.stringify(text)}); return h ? Math.round(h.getBoundingClientRect().top - sc.getBoundingClientRect().top) : null`);
const embeds = () => js(`return [...document.querySelectorAll('.blocks .emb')].map(e => ({ title: e.querySelector('.emb-title').textContent.trim(), text: e.querySelector('.emb-body').textContent, long: e.querySelector('.emb-body').classList.contains('long') }))`);
const caretAtEnd = () => js(`const cs = [...document.querySelectorAll('.blocks .c')].filter(c => c.isContentEditable); const c = cs.at(-1); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`);
try {
await sleep(2200);
// ---- an embed of a section ----
let list = await embeds();
check('embed shows the section under its heading', list[0] && list[0].title === 'Roadmap › Q3 goals' && list[0].text.includes('ship graph') && list[0].text.includes('Risks') && list[0].text.includes('few risks'), JSON.stringify(list[0]?.title));
check('...but not the next section', !list[0].text.includes('rest and plan') && !list[0].text.includes('q4 paragraph'));
check('a long embed is folded, with a button', list[0].long && (await js(`return !document.querySelector('.emb-more').hidden`)));
await js(`document.querySelector('.emb-more').scrollIntoView({ block: 'center' })`);
await sleep(300);
await click(`document.querySelector('.emb-more')`);
check('"Show more" opens it fully', (await js(`return document.querySelector('.emb').classList.contains('open')`)) && (await js(`return document.querySelector('.emb-more').textContent`)) === 'Show less');
await click(`document.querySelector('.emb-more')`);
await js(`document.querySelector('.main .scroller').scrollTop = 0`);
await sleep(300);
// ---- an embed's text is read-only ----
const hubBefore = read('Hub'), roadBefore = read('Roadmap');
await click(`[...document.querySelectorAll('.emb .c')].find(c => c.textContent.includes('ship graph'))`);
await type('zzz');
await sleep(900);
check('typing into embedded text changes nothing', read('Hub') === hubBefore && read('Roadmap') === roadBefore && !(await js(`return document.querySelector('.emb').textContent.includes('zzz')`)));
check('no formatting toolbar over embedded text', await js(`return document.querySelector('.pop.fmt')?.hidden !== false`));
// ---- heading links ----
const labels = await js(`return [...document.querySelectorAll('.blocks .wl')].map(c => c.textContent)`);
check('chips show Page › Heading, or just the heading', labels.includes('Roadmap › Q4 goals') && labels.includes('Local part'), JSON.stringify(labels));
const before = await scrollTop();
await click(chip('Local part'));
const flashing = await js(`return !!document.querySelector('.block.flash')`); // the highlight lasts ~1.3 s
await sleep(900);
const off = await headingOffset('Local part');
check('[[#Heading]] scrolls this page to it', (await title()) === 'Hub' && (await scrollTop()) > before && off !== null && off >= 0 && off < 150, `${before} -> ${await scrollTop()}, heading ${off}px from top`);
check('and flashes it', flashing);
await js(`document.querySelector('.main .scroller').scrollTop = 0`); // the chip is at the top of the page
await sleep(400);
await click(chip('Roadmap › Q4 goals'));
await sleep(1400);
const q4 = await headingOffset('Q4 goals');
check('[[Page#Heading]] opens the page at that heading', (await title()) === 'Roadmap' && q4 !== null && q4 >= 0 && q4 < 150, `title ${await title()}, heading ${q4}px from top`);
await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Hub'))`);
await sleep(900);
await js(`document.querySelector('.main .scroller').scrollTop = 0`);
await sleep(300);
await click(chip('Roadmap › Nope'));
check('a heading that does not exist says so', (await js(`return document.querySelector('.toast.show')?.textContent || ''`)).includes('No heading'));
// ---- what an embed shows when it can't show a page ----
await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('States'))`);
await sleep(1200);
const notes = await js(`return [...document.querySelectorAll('.blocks .emb')].map(e => e.querySelector('.emb-body').textContent.slice(0, 60))`);
check('missing / itself / missing heading / board', /no note called/.test(notes[0]) && /itself/.test(notes[1]) && /no heading called/.test(notes[2]) && /board/.test(notes[3]), JSON.stringify(notes));
check('a whole note embeds in full', notes[4].includes('Roadmap') && notes[4].includes('Intro line'));
// ---- typing ![[ , [[Page# ----
await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Hub'))`);
await sleep(1000);
await caretAtEnd();
await key('Return');
await type('![[Road');
check('![[ opens the page picker', (await js(`const s = document.querySelector('.pop.slash'); return !s.hidden && s.textContent.includes('Embed page') && s.textContent.includes('Roadmap')`)));
await key('Return');
await sleep(900);
check('picking a page inserts an embed block', read('Hub').includes('\n![[Roadmap]]') && (await embeds()).length === 2, JSON.stringify((await embeds()).map((e) => e.title)));
check('a line to keep typing on is left after it', await js(`const last = document.querySelector('.blocks > .block:last-child'); return last.classList.contains('b-p')`));
await type('![[Roadmap#Ris');
check('![[Page#… suggests that page\'s headings', await js(`const s = document.querySelector('.pop.slash'); return !s.hidden && s.textContent.includes('Headings') && s.textContent.includes('Risks')`));
await key('Return');
await sleep(900);
check('...and embeds just that heading', read('Hub').includes('![[Roadmap#Risks]]'), JSON.stringify(read('Hub').slice(-80)));
await type('see [[Roadmap#Q4');
check('[[Page#… suggests headings too', await js(`const s = document.querySelector('.pop.slash'); return !s.hidden && s.textContent.includes('Q4 goals')`));
await key('Return');
await sleep(900);
check('...and makes a heading link', read('Hub').includes('[[Roadmap#Q4 goals]]') && (await js(`return [...document.querySelectorAll('.blocks .wl')].some(c => c.textContent === 'Roadmap › Q4 goals')`)));
const count = (await embeds()).length;
await key('z', ['control']);
await sleep(500);
check('undo steps back', (await embeds()).length <= count);
// ---- renaming the page keeps everything pointing at it ----
await js(`await window.vault.rename('Roadmap', 'Plan')`);
await sleep(1500);
const hub = read('Hub');
check('rename rewrites embeds and heading links', hub.includes('![[Plan#Q3 goals]]') && hub.includes('[[Plan#Q4 goals]]') && hub.includes('![[Plan]]') && hub.includes('![[Plan#Risks]]') && !/!?\[\[Roadmap[^\]\n]*\]\]/.test(hub), JSON.stringify(hub.split('\n\n').filter((p) => /Plan|photo/.test(p))));
check('pictures are left alone', hub.includes('![[photo.png]]'));
const back = await js(`return (await window.vault.backlinks('Plan')).map(b => b.id).sort()`);
check('embeds count as backlinks', back.includes('Hub') && back.includes('States'), JSON.stringify(back));
const g = await js(`return (await window.vault.graph()).edges.filter(e => e[1] === 'Plan').map(e => e[0]).sort()`);
check('embeds appear in the graph', g.includes('Hub') && g.includes('States'), JSON.stringify(g));
} catch (err) {
check('harness', false, err.stack);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nembeds e2e passed!');
app.exit(failures.length ? 1 : 0);
});