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