// End-to-end check: renaming a heading updates [[Page#Heading]] links and embeds. Run with `bun run test:e2e`. const { app, BrowserWindow, ipcMain } = require('electron'); const path = require('path'); const os = require('os'); const fs = require('fs'); // Record every call of the rename IPC, to see WHEN renames happen and how often. const calls = []; const handle = ipcMain.handle.bind(ipcMain); ipcMain.handle = (channel, fn) => handle(channel, (...a) => { if (channel === 'vault:renameHeading') calls.push(a.slice(1)); return fn(...a); }); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-headings-')); const vault = path.join(tmp, 'Vault'); fs.mkdirSync(path.join(vault, 'Archive'), { recursive: true }); const filler = (n) => Array.from({ length: n }, (_, i) => `Paragraph ${i + 1} of filler text.`).join('\n\n'); fs.writeFileSync(path.join(vault, 'Plan.md'), ['# Plan', '## Goals', 'goal text', '## Risks', 'See [[#Risks]] and [[Plan#Goals|my goals]].', '## Extra', filler(3)].join('\n\n') + '\n'); fs.writeFileSync(path.join(vault, 'Archive', 'Plan.md'), '## Goals\n\nold plan\n'); fs.writeFileSync(path.join(vault, 'Notes.md'), 'Read [[Plan#Goals]] and [[Plan#Risks|the risks]].\n\n![[Plan#Goals]]\n\nAlso [[Plan#Risks]].\n'); fs.writeFileSync(path.join(vault, 'Journal.md'), 'Old: [[Archive/Plan#Goals]] and [[Plan]].\n'); fs.writeFileSync(path.join(vault, 'Dup.md'), '## Topic\n\nfirst\n\n## Topic\n\nsecond\n'); fs.writeFileSync(path.join(vault, 'Links.md'), 'Go to [[Dup#Topic]].\n'); fs.writeFileSync(path.join(vault, 'Tasks.md'), '---\nkanban-plugin: basic\n---\n\n## To do\n\n- [ ] see [[Plan#Goals]]\n'); app.setPath('userData', tmp); fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Plan', 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, gap = 70) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(gap); } }; const read = (f) => fs.readFileSync(path.join(vault, f + '.md'), 'utf8'); const caretAtEndOf = (text) => js(`const c = [...document.querySelectorAll('.blocks .c')].find(x => x.closest('.block').className.match(/b-h\\d/) && x.textContent === ${JSON.stringify(text)}); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); const selectAllIn = (text) => js(`const c = [...document.querySelectorAll('.blocks .c')].find(x => x.closest('.block').className.match(/b-h\\d/) && x.textContent === ${JSON.stringify(text)}); c.focus(); const r = document.createRange(); r.selectNodeContents(c); getSelection().removeAllRanges(); getSelection().addRange(r);`); const headings = () => js(`return [...document.querySelectorAll('.blocks > .block')].filter(b => /b-h\\d/.test(b.className)).map(b => b.textContent.trim())`); // by id: collapsed folders keep their children in the page, and "Archive/Plan" is also called "Plan" const openPage = async (id) => { await js(`document.querySelector('.tree .row[data-id=${JSON.stringify(id)}]').click()`); await sleep(900); }; const toastText = () => js(`return document.querySelector('.toast.show')?.textContent || ''`); const chips = () => js(`return [...document.querySelectorAll('.blocks .wl')].map(c => c.dataset.ref + '=' + c.textContent)`); try { await sleep(2200); // ---- renaming a heading: one update, after you stop typing ---- await caretAtEndOf('Goals'); await type(' and more'); check('nothing is rewritten while you are still typing', calls.length === 0 && read('Notes').includes('[[Plan#Goals]]'), JSON.stringify(calls)); await sleep(3200); check('one update after a pause, with the old and new name', calls.length === 1 && calls[0][0] === 'Plan' && calls[0][1] === 'Goals' && calls[0][2] === 'Goals and more', JSON.stringify(calls)); const notes = read('Notes'); check('links and embeds in other notes follow the heading, keeping aliases', notes === 'Read [[Plan#Goals and more]] and [[Plan#Risks|the risks]].\n\n![[Plan#Goals and more]]\n\nAlso [[Plan#Risks]].\n', JSON.stringify(notes)); check('a same-named heading of another page is left alone', read('Journal') === 'Old: [[Archive/Plan#Goals]] and [[Plan]].\n'); const ref = await chips(); check('the open page\'s own links follow too, and keep their custom label', ref.includes('Plan#Goals and more=my goals') && ref.includes('#Risks=Risks'), JSON.stringify(ref)); await sleep(900); check('...and are saved', read('Plan').includes('[[Plan#Goals and more|my goals]]') && read('Plan').includes('[[#Risks]]'), JSON.stringify(read('Plan').split('\n\n')[4])); check('a link inside a board card follows too', read('Tasks').includes('[[Plan#Goals and more]]'), JSON.stringify(read('Tasks').slice(-40))); check('a toast says what was updated', /Updated 4 links to “Goals and more” in 3 notes/.test(await toastText()), await toastText()); // ---- undo takes the links back ---- await key('z', ['control']); await sleep(3400); check('Ctrl+Z on the heading restores the links', (await headings()).includes('Goals') && read('Notes').includes('[[Plan#Goals]]') && read('Notes').includes('![[Plan#Goals]]'), JSON.stringify(await headings())); check('...including the open page\'s own', (await chips()).includes('Plan#Goals=my goals'), JSON.stringify(await chips())); // ---- leaving the page applies a pending rename straight away ---- calls.length = 0; await caretAtEndOf('Goals'); await type('!'); await openPage('Notes'); check('switching pages applies it without waiting', calls.length === 1 && read('Notes').includes('[[Plan#Goals!]]'), JSON.stringify(calls)); await openPage('Plan'); // ---- things that are not renames ---- calls.length = 0; await selectAllIn('Risks'); await type('RISKS'); await sleep(3000); check('a change of case alone does not touch the links', calls.length === 0 && read('Notes').includes('[[Plan#Risks|the risks]]'), JSON.stringify(calls)); await caretAtEndOf('Extra'); await key('Return'); await type('## Fresh'); await sleep(3000); check('a new heading is not a rename', calls.length === 0, JSON.stringify(calls)); check('...but is tracked afterwards: renaming it later is one', await (async () => { await caretAtEndOf('Fresh'); await type(' ideas'); await sleep(3200); return calls.length === 1 && calls[0][1] === 'Fresh' && calls[0][2] === 'Fresh ideas'; })(), JSON.stringify(calls)); calls.length = 0; await selectAllIn('Fresh ideas'); await key('Backspace'); await sleep(3000); check('emptying a heading is not a rename', calls.length === 0, JSON.stringify(calls)); // ---- unsafe characters ---- await caretAtEndOf('Goals!'); for (let i = 0; i < 6; i++) await key('Backspace'); await type('C# [tips]'); await sleep(3400); check('characters that would break a link are cleaned in the links', read('Notes').includes('[[Plan#C tips]]') && read('Notes').includes('![[Plan#C tips]]') && calls.at(-1)?.[2] === 'C# [tips]', JSON.stringify(read('Notes').split('\n')[0])); await openPage('Notes'); await js(`document.querySelector('.blocks .wl').click()`); await sleep(1400); check('...and such a link still finds the heading', (await js(`return document.querySelector('.title')?.textContent`)) === 'Plan' && !(await toastText()).includes('No heading'), await toastText()); await js(`document.querySelector('.main .scroller').scrollTop = 0`); // ---- duplicate names: links follow the first heading only ---- await openPage('Dup'); calls.length = 0; await js(`const hs = [...document.querySelectorAll('.blocks .c')].filter(c => c.closest('.block').className.match(/b-h\\d/)); const c = hs[1]; c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); await type(' two'); await sleep(3200); check('renaming the SECOND heading of a name leaves the links alone', calls.length === 0 && read('Links') === 'Go to [[Dup#Topic]].\n', JSON.stringify(calls)); await js(`const hs = [...document.querySelectorAll('.blocks .c')].filter(c => c.closest('.block').className.match(/b-h\\d/)); const c = hs[0]; c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); await type(' one'); await sleep(3200); check('renaming the FIRST one updates them', read('Links') === 'Go to [[Dup#Topic one]].\n', JSON.stringify(read('Links'))); } catch (err) { check('harness', false, err.stack); } fs.rmSync(tmp, { recursive: true, force: true }); console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nheadings e2e passed!'); app.exit(failures.length ? 1 : 0); });