// End-to-end check of columns: dragging into, out of and beside them, the 3-column cap, resizing, Backspace in an // empty column, arrow keys, and select-all + delete. Run with `bun run e2e columns`. 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-columns-')); const vault = path.join(tmp, 'Vault'); fs.mkdirSync(vault); const PAGE = 'Top line one\n\nTop line two\n\n:::: columns\n::: column\nLeft A\n\nLeft B\n:::\n::: column\nRight A\n:::\n::::\n\nBottom line\n\nLast line\n'; fs.writeFileSync(path.join(vault, 'Cols.md'), PAGE); app.setPath('userData', tmp); fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Cols', 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 }); if (keyCode.length === 1 && !modifiers.length) wc.sendInputEvent({ type: 'char', keyCode }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(200); }; const file = () => fs.readFileSync(path.join(vault, 'Cols.md'), 'utf8'); // the page as [top-level text | [column texts...]] in order const shape = () => js(`return [...document.querySelector('.blocks').children].filter((n) => n.matches('.block, .cols')).map((n) => n.matches('.cols') ? [...n.querySelectorAll(':scope > .col')].map((c) => [...c.querySelectorAll(':scope > .block .c')].map((x) => x.textContent)) : n.querySelector('.c')?.textContent)`); const rectOf = (expr) => js(`const e = ${expr}; if (!e) return null; const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, cx: r.x + r.width / 2, cy: r.y + r.height / 2, right: r.right, bottom: r.bottom }`); const blockEl = (text) => `[...document.querySelectorAll('.blocks .block')].find((b) => b.querySelector('.c')?.textContent === ${JSON.stringify(text)})`; const m = (type, x, y, extra = {}) => wc.sendInputEvent({ type, x: Math.round(x), y: Math.round(y), ...extra }); // drag a block by its grip to (x, y) const dragBlock = async (text, x, y) => { const b = await rectOf(blockEl(text)); m('mouseMove', b.x + 20, b.cy); await sleep(150); const g = await rectOf(`${blockEl(text)}.querySelector('.g-drag')`); m('mouseMove', g.cx, g.cy); m('mouseDown', g.cx, g.cy, { button: 'left', clickCount: 1 }); for (let i = 1; i <= 14; i++) { m('mouseMove', g.cx + ((x - g.cx) * i) / 14, g.cy + ((y - g.cy) * i) / 14, { button: 'left' }); await sleep(20); } await sleep(120); m('mouseUp', x, y, { button: 'left', clickCount: 1 }); await sleep(500); }; const S = (v) => JSON.stringify(v); try { await sleep(2200); check('the page opens with its columns', S(await shape()) === S(['Top line one', 'Top line two', [['Left A', 'Left B'], ['Right A']], 'Bottom line', 'Last line']), S(await shape())); // ---- dragging ---- let ra = await rectOf(blockEl('Right A')); await dragBlock('Top line two', ra.cx, ra.bottom + 6); check('a block dragged into a column lands in it', S(await shape()) === S(['Top line one', [['Left A', 'Left B'], ['Right A', 'Top line two']], 'Bottom line', 'Last line']), S(await shape())); let bl = await rectOf(blockEl('Bottom line')); await dragBlock('Left B', bl.x + 60, bl.bottom + 4); check('a block dragged out of a column becomes an ordinary block again', S(await shape()) === S(['Top line one', [['Left A'], ['Right A', 'Top line two']], 'Bottom line', 'Left B', 'Last line']), S(await shape())); bl = await rectOf(blockEl('Last line')); await dragBlock('Left A', bl.x + 60, bl.bottom + 4); check('emptying a column removes it; a single column left is no group any more', S(await shape()) === S(['Top line one', 'Right A', 'Top line two', 'Bottom line', 'Left B', 'Last line', 'Left A']), S(await shape())); await sleep(1500); check('...and the file has no columns then', !file().includes(':::'), S(file())); // beside a block: the two become columns let t1 = await rectOf(blockEl('Top line one')); await dragBlock('Bottom line', t1.right - 30, t1.cy); check('dropping beside a block makes columns of the two', S(await shape()) === S([[['Top line one'], ['Bottom line']], 'Right A', 'Top line two', 'Left B', 'Last line', 'Left A']), S(await shape())); // the gap / outer edge of a group: a new column let cols = await rectOf(`document.querySelector('.cols')`); await dragBlock('Left B', cols.right - 4, cols.cy); check('dropping at the edge of a group adds a column', S(await shape()) === S([[['Top line one'], ['Bottom line'], ['Left B']], 'Right A', 'Top line two', 'Last line', 'Left A']), S(await shape())); // ---- at most three ---- check('with three columns the + button is gone', await js(`const w = document.querySelector('.cols'); return w.classList.contains('full') && getComputedStyle(w.querySelector('.cols-add')).display === 'none'`)); await js(`document.querySelector('.cols .cols-add').click()`); await sleep(300); cols = await rectOf(`document.querySelector('.cols')`); await dragBlock('Top line two', cols.right - 4, cols.cy); const c0 = await rectOf(`document.querySelectorAll('.cols > .col')[0]`), c1 = await rectOf(`document.querySelectorAll('.cols > .col')[1]`); await dragBlock('Right A', (c0.right + c1.x) / 2, c0.cy); check('...and neither the button nor a drop makes a fourth', (await shape())[0].length === 3, S(await shape())); await sleep(1500); check('three columns are saved', (file().match(/^::: column$/gm) || []).length === 3, S(file())); // ---- resizing ---- const h = await rectOf(`document.querySelectorAll('.cols > .col')[1].querySelector('.col-rs')`); m('mouseMove', h.cx, h.cy); m('mouseDown', h.cx, h.cy, { button: 'left', clickCount: 1 }); for (let i = 1; i <= 8; i++) { m('mouseMove', h.cx + i * 10, h.cy, { button: 'left' }); await sleep(20); } m('mouseUp', h.cx + 80, h.cy, { button: 'left', clickCount: 1 }); await sleep(400); const w0 = (await rectOf(`document.querySelectorAll('.cols > .col')[0]`)).w, w1 = (await rectOf(`document.querySelectorAll('.cols > .col')[1]`)).w; check('dragging the handle between two columns changes their widths', w0 - w1 > 100, `${w0} vs ${w1}`); await sleep(1500); check('...and the widths are saved', /::: \{\.column width="[\d.]+%"\}/.test(file()), S(file().slice(0, 200))); const h2 = await rectOf(`document.querySelectorAll('.cols > .col')[1].querySelector('.col-rs')`); m('mouseMove', h2.cx, h2.cy); m('mouseDown', h2.cx, h2.cy, { button: 'left', clickCount: 1 }); m('mouseUp', h2.cx, h2.cy, { button: 'left', clickCount: 1 }); m('mouseDown', h2.cx, h2.cy, { button: 'left', clickCount: 2 }); m('mouseUp', h2.cx, h2.cy, { button: 'left', clickCount: 2 }); await sleep(1800); const ws = await js(`return [...document.querySelectorAll('.cols > .col')].map((c) => Math.round(c.getBoundingClientRect().width))`); check('double-clicking the handle makes them equal again', Math.max(...ws) - Math.min(...ws) <= 2 && !file().includes('width='), S(ws)); // ---- Backspace in an empty column, arrow keys ---- await js(`const c = document.querySelectorAll('.cols > .col')[2].querySelector('.c'); c.focus();`); await sleep(100); await key('End'); await key('a', ['control']); // (Left B's column: empty it first) await key('Backspace'); await key('Backspace'); await sleep(300); check('Backspace in an empty column removes that column', (await shape())[0].length === 2, S(await shape())); await js(`const c = document.querySelectorAll('.cols > .col')[0].querySelector('.c'); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); await key('Down'); check('ArrowDown at the bottom of a column leaves the group (not into the next column)', await js(`return !document.activeElement.closest('.cols') && document.activeElement.textContent === 'Right A'`), await js(`return document.activeElement.textContent`)); // ---- select all, then delete ---- await js(`const c = ${blockEl('Last line')}.querySelector('.c'); c.focus(); const r = document.createRange(); r.setStart(c.firstChild, 2); r.collapse(true); getSelection().removeAllRanges(); getSelection().addRange(r);`); // a selection over two lines: the start of one and the end of the other join await js(`const a = ${blockEl('Top line two')}.querySelector('.c').firstChild, z = ${blockEl('Last line')}.querySelector('.c').firstChild; const r = document.createRange(); r.setStart(a, 3); r.setEnd(z, 4); getSelection().removeAllRanges(); getSelection().addRange(r);`); await key('Backspace'); check('Backspace over a selection across lines deletes it and joins the two ends', S((await shape()).slice(1)) === S(['Right A', 'Top line', 'Left A']), S(await shape())); await key('a', ['control']); await key('a', ['control']); check('(Ctrl+A twice selects the whole page)', await js(`return getSelection().toString().includes('Top line one') && getSelection().toString().includes('Left A')`)); await key('Backspace'); await sleep(300); check('...and Backspace then empties the page, columns and all', S(await shape()) === S(['']) && !(await js(`return !!document.querySelector('.cols')`)), S(await shape())); await key('x'); await sleep(1500); check('typing goes into the now empty page', S(await shape()) === S(['x']) && file().trim().endsWith('x') && !file().includes('Top line'), S(file())); await key('z', ['control']); await sleep(400); check('Ctrl+Z brings it all back', (await shape()).length > 2, S(await shape())); } catch (err) { check('harness', false, err.stack); } fs.rmSync(tmp, { recursive: true, force: true }); console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ncolumns e2e passed!'); app.exit(failures.length ? 1 : 0); });