// 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); });