// End-to-end check of mouse selection across blocks: a drag may start in one block and end in another. 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-selection-')); const vault = path.join(tmp, 'Vault'); fs.mkdirSync(vault); fs.writeFileSync(path.join(vault, 'Note.md'), 'First paragraph of text here.\n\nSecond paragraph with more words.\n\n- a bullet item\n\nFourth paragraph at the end.\n'); fs.writeFileSync(path.join(vault, 'Keys.md'), 'First paragraph of text here.\n\nSecond paragraph with more words.\n\n- a bullet item\n\nFourth paragraph at the end.\n'); app.setPath('userData', tmp); fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Note', bounds: { width: 1200, height: 800 } })); 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(350); }; const fields = () => js(`return [...document.querySelectorAll('.block .c')].map(c => { const r = c.getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y + r.height / 2), w: Math.round(r.width), text: c.textContent }; })`); const drag = async (from, to, steps = 12) => { wc.sendInputEvent({ type: 'mouseMove', x: from[0], y: from[1] }); wc.sendInputEvent({ type: 'mouseDown', x: from[0], y: from[1], button: 'left', clickCount: 1 }); for (let i = 1; i <= steps; i++) { wc.sendInputEvent({ type: 'mouseMove', x: Math.round(from[0] + ((to[0] - from[0]) * i) / steps), y: Math.round(from[1] + ((to[1] - from[1]) * i) / steps), button: 'left' }); await sleep(25); } wc.sendInputEvent({ type: 'mouseUp', x: to[0], y: to[1], button: 'left', clickCount: 1 }); await sleep(300); }; const state = () => js(`const s = getSelection(); const r = s.rangeCount ? s.getRangeAt(0) : null; return { blocks: r ? [...document.querySelectorAll('.block')].filter((b) => r.intersectsNode(b)).length : 0, text: r ? r.toString() : '', collapsed: s.isCollapsed, cls: document.body.className }`); const texts = async () => (await fields()).map((f) => f.text); try { await sleep(1800); let f = await fields(); // inside one block: still the browser's own selection await drag([f[0].x + 30, f[0].y], [f[0].x + 120, f[0].y + 1]); let s = await state(); check('a drag inside one block selects text there', s.blocks === 1 && s.text.length > 3, JSON.stringify(s.text)); // from the first block down into the last await drag([f[0].x + 40, f[0].y], [f[3].x + 90, f[3].y]); s = await state(); check('a drag from one block into another selects across them', s.blocks === 4 && !s.collapsed, JSON.stringify(s)); check('...from the first character it began at to where it ended', s.text.startsWith('paragraph') || s.text.startsWith(' paragraph') || s.text.startsWith('t paragraph') || /^[a-z ]+paragraph/.test(s.text.slice(0, 12)), JSON.stringify(s.text.slice(0, 30))); check('...including the text of the blocks between', s.text.includes('Second paragraph with more words.') && s.text.includes('a bullet item')); check('...and the browser gets its own selection back afterwards', !s.cls.includes('xsel')); // typing replaces it, like any selection over several blocks await key('Delete'); await sleep(400); const after = await texts(); check('Delete removes it and joins what is left of the first and last blocks', after.length === 1 && /^Fir.*end\.$/.test(after[0]), JSON.stringify(after)); await key('z', ['control']); await sleep(500); check('...and undo brings it all back', (await texts()).length === 4); // upwards, from the last block to the second f = await fields(); await drag([f[3].x + 60, f[3].y], [f[1].x + 50, f[1].y]); s = await state(); check('a drag upwards works too', s.blocks === 3, JSON.stringify(s)); // a click afterwards puts the caret back and drops the selection await drag([f[2].x + 10, f[2].y], [f[2].x + 10, f[2].y], 1); s = await state(); check('clicking again clears it', s.collapsed); // a double click still selects a word wc.sendInputEvent({ type: 'mouseDown', x: f[1].x + 20, y: f[1].y, button: 'left', clickCount: 2 }); wc.sendInputEvent({ type: 'mouseUp', x: f[1].x + 20, y: f[1].y, button: 'left', clickCount: 2 }); await sleep(300); s = await state(); check('double-click still selects a word', s.blocks === 1 && /^\w+$/.test(s.text.trim()), JSON.stringify(s.text)); // ---- the keyboard (on a fresh page) ---- await js(`document.querySelector('.row[data-id="Keys"]').click()`); await sleep(1500); f = await fields(); const click = async (x, y, shift = false) => { wc.sendInputEvent({ type: 'mouseMove', x, y }); wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1, modifiers: shift ? ['shift'] : [] }); wc.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1, modifiers: shift ? ['shift'] : [] }); await sleep(300); }; await click(f[0].x + 2, f[0].y); await key('Down', ['shift']); s = await state(); check('Shift+Down selects to the end of the line', s.blocks === 1 && s.text === 'First paragraph of text here.', JSON.stringify(s)); await key('Down', ['shift']); s = await state(); check('...and once more carries it into the next block', s.blocks === 2 && s.text.startsWith('First paragraph of text here.'), JSON.stringify(s.text)); await key('Down', ['shift']); s = await state(); check('...where it goes on (to the end of that block)', s.blocks === 2 && s.text === 'First paragraph of text here.Second paragraph with more words.', JSON.stringify(s.text)); await key('Up', ['shift']); await key('Up', ['shift']); s = await state(); check('...and back', s.blocks === 1, JSON.stringify(s)); await key('Down', ['shift']); await key('Down', ['shift']); await key('Up', ['shift']); s = await state(); check('Shift+Up takes it back', s.blocks === 1, JSON.stringify(s)); await key('End', ['shift', 'control']); s = await state(); check('Ctrl+Shift+End selects to the end of the page', s.blocks === 4 && s.text.endsWith('at the end.'), JSON.stringify(s.text.slice(-20))); await key('Home', ['shift', 'control']); s = await state(); check('...and Ctrl+Shift+Home from there back to the start', s.blocks === 1 && s.collapsed === true || s.text === '', JSON.stringify(s)); await click(f[1].x + 2, f[1].y); await key('Left', ['shift']); s = await state(); check('Shift+Left at the start of a block reaches back into the one before', s.blocks === 2, JSON.stringify(s)); await key('Left', ['shift', 'control']); s = await state(); check('Ctrl+Shift+Left extends by words', s.blocks === 2 && s.text.length > 3, JSON.stringify(s.text)); await click(f[0].x + 20, f[0].y); await key('Right', ['shift', 'control']); s = await state(); check('Ctrl+Shift+Right selects a word', s.blocks === 1 && /^\w+$/.test(s.text.trim()), JSON.stringify(s.text)); await click(f[0].x + 20, f[0].y); await click(f[3].x + 40, f[3].y, true); s = await state(); check('Shift+click extends the selection across blocks', s.blocks === 4 && !s.collapsed, JSON.stringify(s)); await key('Backspace'); await sleep(300); const left = await texts(); check('...and Backspace removes it', left.length === 1, JSON.stringify(left)); } catch (err) { check('harness', false, err.stack); } fs.rmSync(tmp, { recursive: true, force: true }); console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nselection e2e passed!'); app.exit(failures.length ? 1 : 0); });