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

156 lines
9.7 KiB
JavaScript

// End-to-end check of the app's own right-click menu for text. Run with `bun run test:e2e`.
const { app, BrowserWindow, clipboard } = require('electron');
const path = require('path');
const os = require('os');
const fs = require('fs');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-textmenu-'));
const vault = path.join(tmp, 'Vault');
fs.mkdirSync(vault);
fs.writeFileSync(path.join(vault, 'Essay.md'), 'The stack works on the LIFO principle, see [the docs](https://example.com) and [[Other]] for more.\n\nSecond line here.\n');
fs.writeFileSync(path.join(vault, 'Other.md'), 'Other page.\n');
app.setPath('userData', tmp);
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Essay', bounds: { width: 1280, height: 860 } }));
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 read = () => fs.readFileSync(path.join(vault, 'Essay.md'), 'utf8');
const centerOf = (sel) => 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)]`);
// Right-click like a mouse would. Returns the labels in the app's menu (null if none opened).
const rightClick = async (x, y) => {
wc.sendInputEvent({ type: 'mouseMove', x, y });
wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'right', clickCount: 1 });
wc.sendInputEvent({ type: 'mouseUp', x, y, button: 'right', clickCount: 1 });
await sleep(500);
return js(`const m = document.querySelector('.tmenu'); return m ? [...m.querySelectorAll('.mi')].map(b => b.querySelector('.mi-label').textContent + (b.disabled ? ' (off)' : '')) : null`);
};
const clickRow = async (label) => {
const c = await centerOf(`[...document.querySelectorAll('.tmenu .mi')].find(b => b.querySelector('.mi-label').textContent.startsWith(${JSON.stringify(label)}))`);
if (!c) throw new Error('no menu row ' + label);
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 select = (word) => js(`const c = [...document.querySelectorAll('.blocks .c')].find(x => x.textContent.includes('${word}')); c.focus(); const w = document.createTreeWalker(c, NodeFilter.SHOW_TEXT); let t; while ((t = w.nextNode()) && !t.data.includes('${word}')); const a = t.data.indexOf('${word}'); const r = document.createRange(); r.setStart(t, a); r.setEnd(t, a + ${word.length}); getSelection().removeAllRanges(); getSelection().addRange(r); const b = r.getBoundingClientRect(); return [Math.round(b.x + b.width / 2), Math.round(b.y + b.height / 2)]`);
const menuOpen = () => js(`return !!document.querySelector('.tmenu')`);
try {
await sleep(2000);
// ---- selected text in a note ----
let at = await select('LIFO principle');
await sleep(300);
let rows = await rightClick(...at);
check('right-click on selected text opens the app menu', Array.isArray(rows), JSON.stringify(rows));
check('with cut / copy / paste / select all', ['Cut', 'Copy', 'Paste', 'Select all'].every((l) => rows.some((r) => r.startsWith(l))), JSON.stringify(rows));
check('and "Search notes for" the selection', rows.some((r) => r === 'Search notes for “LIFO principle”'));
check('and a row of format buttons', (await js(`return document.querySelectorAll('.tmenu .tm-fmt button').length`)) === 6);
check('the floating toolbar steps aside meanwhile', await js(`return document.querySelector('.pop.fmt').hidden`));
check('the selection is untouched', (await js(`return getSelection().toString()`)) === 'LIFO principle');
await clickRow('Copy');
check('Copy puts the selection on the clipboard', clipboard.readText() === 'LIFO principle', JSON.stringify(clipboard.readText()));
check('...and closes the menu', !(await menuOpen()));
at = await select('LIFO principle');
await rightClick(...at);
const boldBtn = await centerOf(`document.querySelector('.tmenu .tm-fmt [aria-label="Bold"]')`);
wc.sendInputEvent({ type: 'mouseDown', x: boldBtn[0], y: boldBtn[1], button: 'left', clickCount: 1 });
wc.sendInputEvent({ type: 'mouseUp', x: boldBtn[0], y: boldBtn[1], button: 'left', clickCount: 1 });
await sleep(900);
check('the Bold button formats the selection', read().includes('**LIFO principle**'), JSON.stringify(read().split('\n')[0]));
// ---- keyboard ----
at = await select('Second');
await rightClick(...at);
await key('Down');
check('arrow keys move through the menu', await js(`return document.querySelector('.tmenu .mi.act')?.textContent.includes('Cut')`));
await key('Escape');
check('Escape closes it, keeping the selection', !(await menuOpen()) && (await js(`return getSelection().toString()`)) === 'Second');
// ---- paste and cut ----
clipboard.writeText('PASTED');
at = await select('Second');
await rightClick(...at);
await clickRow('Paste');
await sleep(900);
check('Paste replaces the selection with the clipboard', read().includes('PASTED line here.'), JSON.stringify(read().split('\n').slice(-2)));
at = await select('PASTED');
await rightClick(...at);
await clickRow('Cut');
await sleep(900);
check('Cut removes it and copies it', clipboard.readText() === 'PASTED' && read().includes('\n line here.'), JSON.stringify(read().split('\n').slice(-2)));
// ---- links ----
const linkAt = await centerOf(`document.querySelector('.blocks a.xl')`);
rows = await rightClick(...linkAt);
check('right-click on a web link offers link actions', ['Open link', 'Copy link address', 'Edit link…', 'Remove link'].every((l) => rows?.includes(l)), JSON.stringify(rows));
await clickRow('Copy link address');
check('Copy link address', clipboard.readText() === 'https://example.com');
await rightClick(...linkAt);
await clickRow('Remove link');
await sleep(900);
check('Remove link leaves the text', read().includes('see the docs and') && !read().includes('example.com'), JSON.stringify(read().split('\n')[0]));
const chipAt = await centerOf(`document.querySelector('.blocks .wl')`);
rows = await rightClick(...chipAt);
check('right-click on a page link offers to open it', rows?.includes('Open page'), JSON.stringify(rows));
await clickRow('Open page');
await sleep(700);
check('...which opens it', (await js(`return document.querySelector('.title')?.textContent`)) === 'Other');
await js(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Essay')).click()`);
await sleep(800);
// ---- other places ----
const titleAt = await centerOf(`document.querySelector('.title')`);
rows = await rightClick(titleAt[0], titleAt[1]);
check('the page title gets the edit items, without formatting', rows?.includes('Paste') && !(await js(`return !!document.querySelector('.tmenu .tm-fmt')`)), JSON.stringify(rows));
await key('Escape');
rows = await rightClick(330, 600); // empty space beside the page
check('empty space opens no menu', rows === null, JSON.stringify(rows));
at = await select('stack');
await rightClick(...at);
await clickRow('Search notes for');
await sleep(500);
check('"Search notes for" opens search with the text', (await js(`return document.querySelector('.panel input')?.value`)) === 'stack');
await key('Escape');
await sleep(300);
// ---- spelling (needs the dictionary, which Electron downloads on first use) ----
await js(`const c = [...document.querySelectorAll('.blocks .c')].at(-1); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`);
for (const ch of ' recieve ') { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(60); }
await sleep(1500);
const word = await centerOf(`(() => { const c = [...document.querySelectorAll('.blocks .c')].at(-1); const t = [...c.childNodes].find(n => n.nodeType === 3 && n.data.includes('recieve')); const r = document.createRange(); const a = t.data.indexOf('recieve'); r.setStart(t, a); r.setEnd(t, a + 7); return { getBoundingClientRect: () => r.getBoundingClientRect() }; })()`);
rows = await rightClick(...word);
if (rows && rows.some((r) => r.startsWith('Add “recieve”'))) {
check('a misspelled word gets suggestions and "Add to dictionary"', rows.includes('receive'), JSON.stringify(rows));
await clickRow('receive');
await sleep(900);
check('picking a suggestion fixes the word', read().includes(' receive') && !read().includes('recieve'), JSON.stringify(read().split('\n').slice(-2)));
} else {
console.log('SKIP spelling: no dictionary available in this run — ' + JSON.stringify(rows));
await key('Escape');
}
} catch (err) {
check('harness', false, err.stack);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ntext menu e2e passed!');
app.exit(failures.length ? 1 : 0);
});