test: unit and end-to-end suites
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
27fafa0bcd
commit
9b00e21c44
49 files changed
+6694
-1
No files matched your search
@@ -0,0 +1,134 @@
|
||||
// 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);
|
||||
});
|
||||
Reference in new issue
Block a user