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,159 @@
|
||||
// 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);
|
||||
});
|
||||
Reference in new issue
Block a user