156 lines
9.7 KiB
JavaScript
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);
|
|
});
|