// End-to-end check of canvases (".canvas" files, as in Obsidian). Run with `bun run test:e2e`. const { app, BrowserWindow, clipboard, dialog, nativeImage } = require('electron'); const path = require('path'); const os = require('os'); const fs = require('fs'); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-canvas-')); const vault = path.join(tmp, 'Vault'); fs.mkdirSync(path.join(vault, 'Projects'), { recursive: true }); fs.writeFileSync(path.join(vault, 'Projects', 'Roadmap.md'), '# Roadmap\n\nIntro text.\n\n## Goals\n\nShip the canvas.\n\n## Later\n\nNot shown.\n'); fs.writeFileSync(path.join(vault, 'Notes.md'), 'Open [[Plan.canvas]] to see the plan.\n'); // as Obsidian writes it const PLAN = `{ \t"nodes":[ \t\t{"id":"aaaaaaaaaaaaaaaa","type":"text","text":"## Idea\\n\\nA **bold** start","x":0,"y":0,"width":260,"height":140}, \t\t{"id":"bbbbbbbbbbbbbbbb","type":"file","file":"Projects/Roadmap.md","subpath":"#Goals","x":400,"y":0,"width":320,"height":240}, \t\t{"id":"cccccccccccccccc","type":"link","url":"https://jsoncanvas.org","x":0,"y":300,"width":260,"height":120} \t], \t"edges":[ \t\t{"id":"eeeeeeeeeeeeeeee","fromNode":"aaaaaaaaaaaaaaaa","fromSide":"right","toNode":"bbbbbbbbbbbbbbbb","toSide":"left","label":"leads to"} \t] }`; fs.writeFileSync(path.join(vault, 'Plan.canvas'), PLAN); fs.writeFileSync(path.join(vault, 'Broken.canvas'), '{ "nodes": [ oops'); // no real network: one address answers with a page, anything else is "not found" const linkpreview = require('../electron/linkpreview.cjs'); linkpreview.hooks.lookup = async () => [{ address: '93.184.216.34' }]; linkpreview.hooks.fetch = async (url) => (new URL(url).hostname === 'jsoncanvas.org' ? new Response('fallback', { headers: { 'content-type': 'text/html; charset=utf-8' } }) : new Response('nope', { status: 404 })); app.setPath('userData', tmp); fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Notes', theme: 'dark', bounds: { width: 1400, 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 }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(250); }; const type = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(30); } await sleep(150); }; const m = (type, x, y, extra = {}) => wc.sendInputEvent({ type, x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1, ...extra }); const click = async (x, y, mods = []) => { m('mouseMove', x, y); m('mouseDown', x, y, { modifiers: mods }); m('mouseUp', x, y, { modifiers: mods }); await sleep(250); }; const dbl = async (x, y) => { m('mouseMove', x, y); m('mouseDown', x, y); m('mouseUp', x, y); m('mouseDown', x, y, { clickCount: 2 }); m('mouseUp', x, y, { clickCount: 2 }); await sleep(400); }; const drag = async (x1, y1, x2, y2, steps = 12) => { m('mouseMove', x1, y1); m('mouseDown', x1, y1); await sleep(30); for (let i = 1; i <= steps; i++) { m('mouseMove', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps); await sleep(20); } await sleep(60); m('mouseUp', x2, y2); await sleep(400); }; const box = (sel) => js(`const e = ${sel}; 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 }`); const node = (id) => `document.querySelector('.cv-node[data-id="${id}"]')`; const file = (f = 'Plan.canvas') => fs.readFileSync(path.join(vault, f), 'utf8'); const canvasOf = (f) => JSON.parse(file(f)); const openRow = async (id) => { await js(`document.querySelector('.tree .row[data-id=${JSON.stringify(id)}]').click()`); await sleep(1000); }; try { await sleep(2000); // ---- it shows up and opens ---- check('a .canvas file is listed in the sidebar with the canvas icon, by its name', await js(`const r = document.querySelector('.tree .row[data-id="Plan.canvas"]'); return !!r && r.querySelector('.name').textContent === 'Plan'`)); await js(`document.querySelector('.blocks .wl').click()`); await sleep(1200); check('a [[Plan.canvas]] link opens it', !!(await js(`return document.querySelector('.cv')`))); check('three items, one arrow with its label', (await js(`return document.querySelectorAll('.cv-node').length`)) === 3 && (await js(`return document.querySelectorAll('.cv-line').length`)) === 1 && (await js(`return document.querySelector('.cv-elabel')?.textContent`)) === 'leads to'); check('a text card shows its Markdown', (await js(`return document.querySelector(${JSON.stringify(`.cv-node[data-id="aaaaaaaaaaaaaaaa"] .eb.b-h2`)})?.textContent`)) === 'Idea' && !!(await js(`return ${node('aaaaaaaaaaaaaaaa')}.querySelector('b,strong')`))); await sleep(500); const preview = await js(`return ${node('bbbbbbbbbbbbbbbb')}.querySelector('.cv-scroll').textContent`); check('a note shows its section (#Goals) only', preview.includes('Ship the canvas.') && !preview.includes('Not shown') && !preview.includes('Intro'), JSON.stringify(preview)); await sleep(800); check('a web link card shows the page title and description', (await js(`return ${node('cccccccccccccccc')}.querySelector('.cv-ftitle').textContent`)) === 'JSON Canvas & friends' && (await js(`return ${node('cccccccccccccccc')}.querySelector('.cv-desc')?.textContent`)) === 'An open file format for infinite canvas data.' && (await js(`return ${node('cccccccccccccccc')}.querySelector('.cv-url').textContent`)) === 'jsoncanvas.org'); await js(`await window.vault.setSettings({ linkPreviews: false })`); check('with previews turned off in settings, nothing is fetched', (await js(`return (await window.vault.linkPreview('https://jsoncanvas.org')).off`)) === true); await js(`await window.vault.setSettings({ linkPreviews: true })`); // ---- right-click menu ---- const rclick = async (x, y) => { m('mouseMove', x, y); m('mouseDown', x, y, { button: 'right' }); m('mouseUp', x, y, { button: 'right' }); await sleep(350); }; const menuItems = () => js(`return [...document.querySelectorAll('.cv-menu .mi')].map((b) => b.textContent.trim())`); const menuClick = async (label) => { const b = await box(`[...document.querySelectorAll('.cv-menu .mi')].find((x) => x.textContent.trim() === ${JSON.stringify(label)})`); await click(b.cx, b.cy); }; const text = await box(node('aaaaaaaaaaaaaaaa')); await rclick(text.cx, text.cy); const onCard = await menuItems(); check('right-clicking a card opens its menu and selects it', onCard.includes('Edit') && onCard.includes('Duplicate') && onCard.includes('Bring to front') && onCard.includes('Delete') && (await js(`return ${node('aaaaaaaaaaaaaaaa')}.classList.contains('sel')`))); await key('Escape'); check('Escape closes the menu', !(await js(`return document.querySelector('.cv-menu')`))); // a point on the arrow (its label, or the line) that nothing else covers const lbl = await js(`const r = document.querySelector('.cv-elabel[data-edge]').getBoundingClientRect(); for (let x = r.left + 2; x < r.right - 2; x += 3) { const y = r.top + r.height / 2; const h = document.elementFromPoint(x, y); if (h && h.closest('[data-edge]')) return { cx: x, cy: y }; } return null`); await rclick(lbl.cx, lbl.cy); const arrowItems = await menuItems(); check('right-clicking an arrow offers its label and direction', arrowItems.includes('Reverse direction') && arrowItems.includes('Edit label'), JSON.stringify(arrowItems)); await menuClick('Reverse direction'); await sleep(700); const rev = canvasOf().edges.find((e) => e.id === 'eeeeeeeeeeeeeeee'); check('"Reverse direction" swaps the arrow\'s ends in the file', rev && rev.fromNode === 'bbbbbbbbbbbbbbbb' && rev.toNode === 'aaaaaaaaaaaaaaaa' && rev.fromSide === 'left' && rev.toSide === 'right', JSON.stringify(rev)); const link = await box(node('cccccccccccccccc')); await rclick(link.cx, link.cy); check('a web link card offers "Open in browser"', (await menuItems()).includes('Open in browser')); await menuClick('Bring to front'); await sleep(700); const order = canvasOf().nodes.map((n) => n.id); check('"Bring to front" moves it to the end of the file', order[order.length - 1] === 'cccccccccccccccc', order.join()); const countBefore = canvasOf().nodes.length; const vp = await box(`document.querySelector('.cv')`); await rclick(vp.x + vp.w - 120, vp.y + 120); check('right-clicking empty space offers canvas actions', (await menuItems()).includes('Add card here') && (await menuItems()).includes('Show everything')); await menuClick('Add card here'); await sleep(300); check('"Add card here" starts a card in edit mode', !!(await js(`return document.querySelector('.cv-ed')`))); await key('Escape'); await sleep(500); check('...and left empty it goes again', canvasOf().nodes.length === countBefore); const l2 = await box(node('cccccccccccccccc')); await rclick(l2.cx, l2.cy); await menuClick('Delete'); await sleep(700); check('"Delete" in the menu removes the card from the file', !canvasOf().nodes.some((n) => n.id === 'cccccccccccccccc')); await key('z', ['control']); await sleep(600); check('...and Ctrl+Z brings it back', canvasOf().nodes.some((n) => n.id === 'cccccccccccccccc')); await key('z', ['control']); await key('z', ['control']); await sleep(500); check('undoing the menu actions restores the file as it was', file() === PLAN); // ---- align and distribute ---- const freePoint = (id) => js(`const n = document.querySelector('.cv-node[data-id="${id}"]'); const r = n.getBoundingClientRect(); for (let fy = 0.1; fy < 0.9; fy += 0.05) for (let fx = 0.1; fx < 0.9; fx += 0.05) { const x = r.left + r.width * fx, y = r.top + r.height * fy; const h = document.elementFromPoint(x, y); if (h && h.closest('.cv-node')?.dataset.id === '${id}' && !h.closest('.cv-ui,.cv-resize,.cv-dot')) return { cx: x, cy: y }; } return null`); const place = (id) => { const n = canvasOf().nodes.find((x) => x.id === id); return n; }; const pickCards = async (ids) => { let first = true; for (const id of ids) { const p = await freePoint(id); await click(p.cx, p.cy, first ? [] : ['shift']); first = false; } }; const trio = ['aaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbb', 'cccccccccccccccc']; const vpA = await box(`document.querySelector('.cv')`); const beforeAlign = file(); await pickCards(trio); const offered = (await js(`return [...document.querySelectorAll('.cv-selbar [data-align]')].map((b) => b.dataset.align)`)).join(); check('with several cards selected the bar offers align and spread', offered === 'left,centerX,right,top,centerY,bottom,spreadX,spreadY', offered); const minX = Math.min(...trio.map((id) => place(id).x)); let ab = await box(`document.querySelector('.cv-selbar [data-align="left"]')`); await click(ab.cx, ab.cy); await sleep(700); check('"Align left" puts the left edges on the leftmost one', trio.every((id) => place(id).x === minX), JSON.stringify(trio.map((id) => place(id).x))); await click(vpA.x + vpA.w - 40, vpA.y + 40); // empty space: back to the canvas, selection cleared await key('z', ['control']); await sleep(500); check('...and Ctrl+Z puts them back', file() === beforeAlign); await pickCards(trio); ab = await box(`document.querySelector('.cv-selbar [data-align="top"]')`); await click(ab.cx, ab.cy); await sleep(700); const minY = Math.min(...trio.map((id) => beforeY(id))); function beforeY(id) { return JSON.parse(beforeAlign).nodes.find((n) => n.id === id).y; } check('"Align top" puts the top edges level', trio.every((id) => place(id).y === minY)); await click(vpA.x + vpA.w - 40, vpA.y + 40); await key('z', ['control']); await sleep(500); await pickCards(trio); ab = await box(`document.querySelector('.cv-selbar [data-align="spreadX"]')`); await click(ab.cx, ab.cy); await sleep(700); const row = trio.map((id) => place(id)).sort((a, b) => a.x + a.width / 2 - (b.x + b.width / 2)); const g1 = row[1].x - (row[0].x + row[0].width), g2 = row[2].x - (row[1].x + row[1].width); check('"Space evenly across" makes the gaps equal', Math.abs(g1 - g2) <= 1, `${g1} vs ${g2}`); await click(vpA.x + vpA.w - 40, vpA.y + 40); await key('z', ['control']); await sleep(500); check('...and it undoes in one step', file() === beforeAlign); // ---- find in canvas ---- const findState = () => js(`const f = document.querySelector('.cv-find'); return { open: !!f, count: f?.querySelector('.cv-count')?.textContent || '', focused: document.activeElement === f?.querySelector('input'), found: [...document.querySelectorAll('.cv-node.found, .cv-elabel.found')].map((n) => n.dataset.id || 'label'), cur: [...document.querySelectorAll('.found-cur')].map((n) => n.dataset.id || 'label'), dimmed: document.querySelector('.cv')?.classList.contains('finding') }`); const beforeFind = file(); await key('f', ['control']); let fs0 = await findState(); check('Ctrl+F opens a find bar with the cursor in it', fs0.open && fs0.focused, JSON.stringify(fs0)); check('the find bar opens with the same pop animation as the page find bar, staying centred', await js(`const f = document.querySelector('.cv-find'); const cs = getComputedStyle(f); const r = f.getBoundingClientRect(); const v = document.querySelector('.cv').getBoundingClientRect(); return cs.animationName === 'pop' && parseFloat(cs.animationDuration) > 0 && Math.abs((r.left + r.width / 2) - (v.left + v.width / 2)) < 3`)); await type('bold'); fs0 = await findState(); check('a card containing the text is lit up, with a count', fs0.found.join() === 'aaaaaaaaaaaaaaaa' && fs0.count === '1 of 1' && fs0.cur.join() === 'aaaaaaaaaaaaaaaa' && fs0.dimmed, JSON.stringify(fs0)); await key('a', ['control']); await type('canvas'); await sleep(300); fs0 = await findState(); check('notes (what they show) and links (title) are searched too, in reading order', fs0.found.sort().join() === 'bbbbbbbbbbbbbbbb,cccccccccccccccc' && fs0.count === '1 of 2' && fs0.cur.join() === 'bbbbbbbbbbbbbbbb', JSON.stringify(fs0)); await key('Return'); fs0 = await findState(); check('Enter goes to the next match and brings it to the middle of the screen', fs0.count === '2 of 2' && fs0.cur.join() === 'cccccccccccccccc'); const centreBox = await box(node('cccccccccccccccc')); const viewBox = await box(`document.querySelector('.cv')`); check('...', Math.abs(centreBox.cx - viewBox.cx) <= 3 && Math.abs(centreBox.cy - viewBox.cy) <= 3, `${centreBox.cx},${centreBox.cy} vs ${viewBox.cx},${viewBox.cy}`); await key('Return', ['shift']); check('Shift+Enter goes back', (await findState()).count === '1 of 2'); await key('a', ['control']); await type('leads'); await sleep(200); fs0 = await findState(); check('arrow labels are found too', fs0.found.join() === 'label' && fs0.count === '1 of 1', JSON.stringify(fs0)); await key('a', ['control']); await type('zzzz'); await sleep(200); fs0 = await findState(); check('no match says so and does not grey everything out', fs0.count === 'No matches' && fs0.found.length === 0 && !fs0.dimmed, JSON.stringify(fs0)); await key('Escape'); fs0 = await findState(); check('Escape closes the bar and clears the highlights', !fs0.open && fs0.found.length === 0 && !fs0.dimmed); check('finding never changes the file', file() === beforeFind); await key('1', ['shift']); // jumping around moved the view: show everything again, as it was on opening await sleep(300); // ---- arrow styles ---- const beforeStyles = file(); const arrowPoint = () => js(`const r = document.querySelector('.cv-elabel[data-edge]').getBoundingClientRect(); for (let x = r.left + 2; x < r.right - 2; x += 3) { const y = r.top + r.height / 2; const h = document.elementFromPoint(x, y); if (h && h.closest('[data-edge]')) return { cx: x, cy: y }; } return null`); const edgeInFile = () => canvasOf().edges.find((e) => e.id === 'eeeeeeeeeeeeeeee'); const styleMenu = async (label) => { const p = await arrowPoint(); await rclick(p.cx, p.cy); await menuClick(label); await sleep(600); }; await styleMenu('Straight line'); check('"Straight line" is saved on the arrow and drawn without a curve', edgeInFile().style === 'straight' && !/ C/.test(await js(`return document.querySelector('.cv-edge .cv-line').getAttribute('d')`))); await styleMenu('Right angles'); check('"Right angles" draws square corners', edgeInFile().style === 'orthogonal' && /Q|L/.test(await js(`return document.querySelector('.cv-edge .cv-line').getAttribute('d')`))); await styleMenu('Arrow at the start'); check('"Arrow at the start" puts a head on that end too', edgeInFile().fromEnd === 'arrow' && (await js(`return document.querySelectorAll('.cv-edge .cv-arrow').length`)) === 2); await styleMenu('Arrow at the end'); check('"Arrow at the end" turns the end head off', edgeInFile().toEnd === 'none' && (await js(`return document.querySelectorAll('.cv-edge .cv-arrow').length`)) === 1); check('the menu shows what is on', await (async () => { const p = await arrowPoint(); await rclick(p.cx, p.cy); const state = await js(`return [...document.querySelectorAll('.cv-menu [aria-checked="true"]')].map((b) => b.textContent.trim())`); await key('Escape'); return state.join() === 'Right angles,Arrow at the start'; })()); await styleMenu('Curved line'); check('"Curved line" goes back to the default and leaves no style in the file', edgeInFile().style === undefined); // the bar's own button opens the same menu const arrowAt = await arrowPoint(); await click(arrowAt.cx, arrowAt.cy); const lsb = await box(`document.querySelector('.cv-selbar [data-act="line-style"]')`); await click(lsb.cx, lsb.cy); check('the selection bar has a line style button that opens the menu', (await menuItems()).includes('Straight line')); await key('Escape'); for (let i = 0; i < 5; i++) await key('z', ['control']); await sleep(500); check('...and every style change undoes', file() === beforeStyles, ''); // ---- alignment guides ---- const beforeGuides = file(); const gz = (await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`)); const gp = await freePoint('cccccccccccccccc'); // drag the link card so its centre lands within a couple of pixels of the note's centre (which is off the 20-unit grid) m('mouseMove', gp.cx, gp.cy); m('mouseDown', gp.cx, gp.cy); for (let i = 1; i <= 12; i++) { m('mouseMove', gp.cx + (428 * gz * i) / 12, gp.cy); await sleep(25); } await sleep(150); const lines = await js(`return [...document.querySelectorAll('.cv-guide')].map((g) => ({ w: g.offsetWidth, h: g.offsetHeight }))`); check('while dragging, a guide line shows where it lines up with another card', lines.length === 1 && lines[0].h > lines[0].w, JSON.stringify(lines)); m('mouseUp', gp.cx + 428 * gz, gp.cy); await sleep(700); check('...and the card snaps onto the line (off the grid)', place('cccccccccccccccc').x === 430 && place('cccccccccccccccc').y === 300, JSON.stringify([place('cccccccccccccccc').x, place('cccccccccccccccc').y])); check('the guide goes when it is let go', !(await js(`return !!document.querySelector('.cv-guide')`))); await click(vpA.x + vpA.w - 40, vpA.y + 40); await key('z', ['control']); await sleep(500); check('...and it undoes', file() === beforeGuides); // holding Alt moves freely: no snapping to the line m('mouseMove', gp.cx, gp.cy); m('mouseDown', gp.cx, gp.cy); for (let i = 1; i <= 12; i++) { m('mouseMove', gp.cx + (428 * gz * i) / 12, gp.cy, { modifiers: ['alt'] }); await sleep(25); } await sleep(150); check('Alt turns the guides off', (await js(`return document.querySelectorAll('.cv-guide').length`)) === 0); m('mouseUp', gp.cx + 428 * gz, gp.cy, { modifiers: ['alt'] }); await sleep(500); await click(vpA.x + vpA.w - 40, vpA.y + 40); await key('z', ['control']); await sleep(500); check('...and that undoes too', file() === beforeGuides); const view = await box(`document.querySelector('.cv')`); const inView = await js(`const v = document.querySelector('.cv').getBoundingClientRect(); return [...document.querySelectorAll('.cv-node')].every(n => { const r = n.getBoundingClientRect(); return r.left >= v.left && r.right <= v.right && r.top >= v.top && r.bottom <= v.bottom; })`); check('everything is in view when it opens', inView); check('no word count or outline button for a canvas', await js(`return !document.querySelector('.topbar .wc')`)); await sleep(900); check('opening it leaves the file untouched', file() === PLAN); // ---- a new card ---- const empty = [view.x + view.w - 260, view.y + 120]; await dbl(...empty); check('double-clicking empty space starts a new card', await js(`return !!document.activeElement?.closest('.cv-ed')`)); await type('Hello canvas'); await click(view.x + view.w - 120, view.y + view.h - 120); // somewhere empty await sleep(900); let c = canvasOf(); const made = c.nodes.find((n) => n.text === 'Hello canvas'); check('...and it is saved as a text node', !!made && made.type === 'text' && /^[0-9a-f]{16}$/.test(made.id), JSON.stringify(c.nodes.map((n) => n.text || n.file || n.url))); check('the file stays in Obsidian\'s layout', file().startsWith('{\n\t"nodes":[\n\t\t{"id":"aaaaaaaaaaaaaaaa"')); const leftEmpty = (await js(`return document.querySelectorAll('.cv-node').length`)); await dbl(view.x + view.w - 160, view.y + view.h - 200); await click(view.x + view.w - 120, view.y + view.h - 60); check('a new card left empty goes away again', (await js(`return document.querySelectorAll('.cv-node').length`)) === leftEmpty); // ---- the block editor in a card ---- const spot = [view.x + view.w - 300, view.y + view.h - 270]; await dbl(...spot); const h0 = await js(`return document.querySelector('.cv-card.editing').offsetHeight`); await type('a few plain words'); check('typing in a new card does not make it taller', (await js(`return document.querySelector('.cv-card.editing').offsetHeight`)) === h0, String(h0)); await key('a', ['control']); await key('Backspace'); await type('# Title'); await key('Return'); await type('/'); await sleep(200); check('the slash menu works inside a card', !!(await js(`const s = document.querySelector('.slash'); return !!s && !s.hidden`))); await key('Escape'); check('Escape closes the slash menu without leaving the card', !!(await js(`return !!document.querySelector('.cv-ed') && document.querySelector('.slash').hidden`))); await key('Backspace'); await type('- one'); await key('Return'); await type('two'); check('the card is a real block editor: heading and bullets', await js(`const e = document.querySelector('.cv-ed'); return !!e.querySelector('.block.b-h1') && e.querySelectorAll('.block.b-bullet').length === 2`)); await key('Escape'); await sleep(900); check('...and is saved as Markdown', canvasOf().nodes.some((n) => n.text === '# Title\n\n- one\n- two'), JSON.stringify(canvasOf().nodes.map((n) => n.text))); check('a finished card shows the rendered heading and list', await js(`const t = [...document.querySelectorAll('.cv-card.cv-text .cv-md')].find((e) => e.textContent.includes('Title')); return !!t && t.textContent.includes('one') && t.textContent.includes('two')`)); const mk = canvasOf().nodes.find((n) => n.text === '# Title\n\n- one\n- two'); const mkBox = await box(node(mk.id)); await click(mkBox.cx, mkBox.cy); await key('Delete'); await sleep(700); check('...and can be deleted again', !canvasOf().nodes.some((n) => n.id === mk.id)); // ---- moving ---- const z = await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`); const card = await box(node(made.id)); await drag(card.cx, card.cy, card.cx - 200 * z, card.cy + 40 * z); await sleep(900); c = canvasOf(); const moved = c.nodes.find((n) => n.id === made.id); check('dragging a card moves it, lined up on the grid', Math.abs(moved.x - (made.x - 200)) <= 20 && Math.abs(moved.y - (made.y + 40)) <= 20 && moved.x % 20 === 0 && moved.y % 20 === 0, `${made.x},${made.y} -> ${moved.x},${moved.y}`); // ---- an arrow ---- await js(`${node(made.id)}.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }))`); const dot = await box(`${node(made.id)}.querySelector('.cv-dot[data-side="top"]')`); const idea = await box(node('cccccccccccccccc')); await drag(dot.cx, dot.cy, idea.cx, idea.cy + 10, 16); await sleep(900); c = canvasOf(); const edge = c.edges.find((e) => e.fromNode === made.id); check('dragging a side dot onto another card joins them with an arrow', !!edge && edge.toNode === 'cccccccccccccccc' && edge.fromSide === 'top' && ['top', 'bottom', 'left', 'right'].includes(edge.toSide), JSON.stringify(c.edges)); // ---- delete and undo ---- const now = await box(node(made.id)); await click(now.cx, now.cy); await key('Delete'); await sleep(900); c = canvasOf(); check('Delete removes the card and its arrows', !c.nodes.some((n) => n.id === made.id) && !c.edges.some((e) => e.fromNode === made.id)); await key('z', ['control']); await sleep(900); c = canvasOf(); check('Ctrl+Z brings both back', c.nodes.some((n) => n.id === made.id) && c.edges.some((e) => e.fromNode === made.id)); // ---- colour ---- const ideaBox = await box(node('aaaaaaaaaaaaaaaa')); await click(ideaBox.cx, ideaBox.cy); await sleep(200); const sw = await box(`document.querySelector('.cv-selbar [aria-label="Colour 4"]')`); await click(sw.cx, sw.cy); await sleep(900); check('the selection bar colours a card', canvasOf().nodes.find((n) => n.id === 'aaaaaaaaaaaaaaaa').color === '4' && (await js(`return ${node('aaaaaaaaaaaaaaaa')}.classList.contains('colored')`))); // ---- box select + group ---- const a = await box(node('aaaaaaaaaaaaaaaa')), l = await box(node('cccccccccccccccc')); await drag(a.x - 30, a.y - 30, l.x + l.w + 15, l.y + l.h + 15); const picked = await js(`return [...document.querySelectorAll('.cv-node.sel')].map(n => n.dataset.id).sort()`); check('dragging over empty space selects with a box', JSON.stringify(picked) === JSON.stringify(['aaaaaaaaaaaaaaaa', 'cccccccccccccccc']), JSON.stringify(picked)); const gb = await box(`document.querySelector('.cv-bar [aria-label="Add a group"]')`); await click(gb.cx, gb.cy); await sleep(200); await js(`document.execCommand('selectAll')`); await type('Phase 1'); await key('Return'); await sleep(900); c = canvasOf(); const g = c.nodes.find((n) => n.type === 'group'); check('"Group" puts a named group around the selection', !!g && g.label === 'Phase 1' && c.nodes.indexOf(g) === 0 && ['aaaaaaaaaaaaaaaa', 'cccccccccccccccc'].every((i) => { const n = c.nodes.find((x) => x.id === i); return n.x >= g.x && n.y >= g.y && n.x + n.width <= g.x + g.width && n.y + n.height <= g.y + g.height; }), JSON.stringify(g)); // moving the group moves what's in it const before = c.nodes.find((n) => n.id === 'cccccccccccccccc'); const label = await box(`document.querySelector('.cv-group .cv-glabel')`); const z2 = await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`); await drag(label.cx, label.cy, label.cx + 100 * z2, label.cy); await sleep(900); const after = canvasOf().nodes.find((n) => n.id === 'cccccccccccccccc'); check('dragging a group takes its contents along', Math.abs(after.x - before.x - 100) <= 20 && after.y === before.y, `${before.x} -> ${after.x}`); // ---- folding a group away, and cards joining one ---- const gid = g.id; const groupFile = () => canvasOf().nodes.find((n) => n.id === gid); const inGroup = ['aaaaaaaaaaaaaaaa', 'cccccccccccccccc']; const beforeFold = file(); const toggle = await box(`${node(gid)}.querySelector('.cv-gtoggle')`); await js(`${node(gid)}.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))`); await click(toggle.cx, toggle.cy); await sleep(700); check('the toggle folds the group: what is in it is hidden, the group becomes a strip', groupFile().collapsed === true && (await js(`return ${inGroup.map((i) => `!${node(i)}`).join(' && ')}`)) && (await js(`return ${node(gid)}.offsetHeight`)) === 44); check('...and the strip says how much it holds', (await js(`return ${node(gid)}.querySelector('.cv-gcount')?.textContent`)) === '2 items'); const folded = canvasOf().edges; check('arrows to what is hidden now meet the strip', folded.length > 0 && (await Promise.all(folded.map((e) => js(`return !!document.querySelector('.cv-hit[data-edge="${e.id}"]')`)))).every(Boolean)); await key('f', ['control']); await type('bold'); await sleep(250); check('finding something that is folded away lights up the group', (await js(`return ${node(gid)}.classList.contains('found-cur')`)) && (await js(`return document.querySelector('.cv-count').textContent`)) === '1 of 1'); await key('Escape'); const t2 = await box(`${node(gid)}.querySelector('.cv-gtoggle')`); await click(t2.cx, t2.cy); await sleep(700); check('unfolding brings everything back, with nothing extra left in the file', groupFile().collapsed === undefined && (await js(`return ${inGroup.map((i) => `!!${node(i)}`).join(' && ')}`)) && file() === beforeFold); // a card let go with its middle inside the group becomes part of it const gNow = groupFile(); const vpJ = await box(`document.querySelector('.cv')`); await dbl(vpJ.x + vpJ.w - 140, vpJ.y + 90); await type('Joiner'); await key('Escape'); await sleep(600); const mine = { id: canvasOf().nodes.find((n) => n.text === 'Joiner')?.id }; const gBox = await box(node(gid)); const zz = await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`); const hp = await freePoint(mine.id); const hb = await box(node(mine.id)); // its middle goes to a point just inside the group's right edge, so most of it hangs outside const want = [gBox.x + gBox.w - 30 * zz, gBox.y + gBox.h / 2]; const shift = [want[0] - hb.cx, want[1] - hb.cy]; const beforeJoin = file(); m('mouseMove', hp.cx, hp.cy); m('mouseDown', hp.cx, hp.cy); for (let i = 1; i <= 12; i++) { m('mouseMove', hp.cx + (shift[0] * i) / 12, hp.cy + (shift[1] * i) / 12, { modifiers: ['alt'] }); await sleep(25); } await sleep(150); check('a card held over a group shows that it will join it', await js(`return ${node(gid)}.classList.contains('target')`)); m('mouseUp', hp.cx + shift[0], hp.cy + shift[1], { modifiers: ['alt'] }); await sleep(800); const joined = canvasOf().nodes.find((n) => n.id === mine.id), gAfter = groupFile(); check('...and on letting go it is moved fully inside', joined.x >= gAfter.x && joined.y >= gAfter.y && joined.x + joined.width <= gAfter.x + gAfter.width && joined.y + joined.height <= gAfter.y + gAfter.height && gAfter.width === gNow.width, JSON.stringify({ joined, gAfter })); check('...so the group takes it along when moved', await (async () => { const lb = await box(`${node(gid)}.querySelector('.cv-glabel')`); const h0 = canvasOf().nodes.find((n) => n.id === mine.id).x; await drag(lb.cx, lb.cy, lb.cx + 60 * zz, lb.cy); await sleep(800); const moved = canvasOf().nodes.find((n) => n.id === mine.id).x; await key('z', ['control']); await sleep(500); return Math.abs(moved - h0 - 60) <= 20; })()); check('...and joining is a single undo step', (await (async () => { await click(vpA.x + vpA.w - 40, vpA.y + 40); await key('z', ['control']); await sleep(500); return file(); })()) === beforeJoin); const jp = await freePoint(mine.id); await click(jp.cx, jp.cy); await key('Delete'); await sleep(500); // ---- card <-> note ---- await dbl(vpJ.x + vpJ.w - 140, vpJ.y + 90); await type('# Trip ideas'); await key('Return'); await type('Pack light'); await key('Escape'); await sleep(600); const tripCard = canvasOf().nodes.find((n) => String(n.text || '').startsWith('# Trip ideas')); const tp = await freePoint(tripCard.id); await rclick(tp.cx, tp.cy); const cardItems = await menuItems(); check('a text card offers "Turn into note" (and not "Embed in card")', cardItems.includes('Turn into note') && !cardItems.includes('Embed in card'), JSON.stringify(cardItems)); const sizeBefore = [tripCard.x, tripCard.y]; await menuClick('Turn into note'); await sleep(1500); const tripFile = path.join(vault, 'Trip ideas.md'); check('"Turn into note" makes a page named after the first line, with the rest as its text', fs.existsSync(tripFile) && /Pack light\n$/.test(fs.readFileSync(tripFile, 'utf8')) && !/# Trip ideas/.test(fs.readFileSync(tripFile, 'utf8').split('---').pop()), fs.existsSync(tripFile) ? fs.readFileSync(tripFile, 'utf8') : 'missing'); const asNote = canvasOf().nodes.find((n) => n.id === tripCard.id); check('...and the same card becomes a note card in the same place (so its arrows stay)', asNote.type === 'file' && asNote.file === 'Trip ideas.md' && asNote.text === undefined && asNote.x === sizeBefore[0] && asNote.y === sizeBefore[1], JSON.stringify(asNote)); await sleep(600); check('...which shows the note', (await js(`return ${node(tripCard.id)}.textContent.includes('Pack light') && ${node(tripCard.id)}.textContent.includes('Trip ideas')`))); const np = await freePoint(tripCard.id); await rclick(np.cx, np.cy); const noteItems = await menuItems(); check('a note card offers "Embed in card"', noteItems.includes('Embed in card') && !noteItems.includes('Turn into note')); await menuClick('Embed in card'); await sleep(900); const asEmbed = canvasOf().nodes.find((n) => n.id === tripCard.id); check('"Embed in card" makes a text card holding ![[Note]]', asEmbed.type === 'text' && asEmbed.text === '![[Trip ideas]]' && asEmbed.file === undefined, JSON.stringify(asEmbed)); check('...which shows the note\'s text in place, with a title that opens it', await js(`const c = ${node(tripCard.id)}; const t = c.querySelector('.emb-title'); return !!t && t.textContent.includes('Trip ideas') && c.querySelector('.emb-body').textContent.includes('Pack light')`)); check('...and the page itself is untouched', /Pack light\n$/.test(fs.readFileSync(tripFile, 'utf8'))); const ep = await freePoint(tripCard.id); await click(ep.cx, ep.cy); await key('Delete'); await sleep(500); // ---- view ---- const pct = () => js(`return document.querySelector('.cv-pct').textContent`); const p0 = await pct(); const mid = [view.x + view.w / 2, view.y + view.h / 2]; wc.sendInputEvent({ type: 'mouseWheel', x: mid[0], y: mid[1], deltaX: 0, deltaY: 240, modifiers: ['control'] }); await sleep(400); check('Ctrl + scroll zooms', (await pct()) !== p0, `${p0} -> ${await pct()}`); const t0 = await js(`return document.querySelector('.cv-world').style.transform`); wc.sendInputEvent({ type: 'mouseWheel', x: view.x + 40, y: view.y + 40, deltaX: 0, deltaY: -240 }); await sleep(400); const t1 = await js(`return document.querySelector('.cv-world').style.transform`); check('scrolling moves around', t0 !== t1 && (await pct()) === (await pct()), `${t0} -> ${t1}`); const fitB = await box(`document.querySelector('.cv-zoom [aria-label="Show everything"]')`); await click(fitB.cx, fitB.cy); check('"Show everything" brings it all into view', await js(`const v = document.querySelector('.cv').getBoundingClientRect(); return [...document.querySelectorAll('.cv-node')].every(n => { const r = n.getBoundingClientRect(); return r.left >= v.left - 1 && r.right <= v.right + 1 && r.top >= v.top - 1 && r.bottom <= v.bottom + 1; })`)); // ---- copy and paste ---- const count = canvasOf().nodes.length; const card2 = await box(node('cccccccccccccccc')); await click(card2.cx, card2.cy); await key('c', ['control']); await sleep(200); check('Ctrl+C copies the selection as canvas text', clipboard.readText().includes('"url":"https://jsoncanvas.org"')); await key('v', ['control']); await sleep(900); check('Ctrl+V pastes a copy (new id)', canvasOf().nodes.filter((n) => n.url === 'https://jsoncanvas.org').length === 2 && canvasOf().nodes.length === count + 1); clipboard.writeText('https://example.com/page'); await key('v', ['control']); await sleep(900); check('pasting a web address makes a link card', canvasOf().nodes.some((n) => n.type === 'link' && n.url === 'https://example.com/page')); await sleep(700); check('a link whose page can\'t be read shows its address', await js(`const c = [...document.querySelectorAll('.cv-link')].find((e) => e.textContent.includes('example.com')); return !!c && c.querySelector('.cv-ftitle').textContent === 'example.com' && c.querySelector('.cv-url').textContent === 'https://example.com/page' && !c.querySelector('.cv-desc')`)); // ---- a note on the canvas opens, and follows a rename ---- const leftAt = await js(`return document.querySelector('.cv-world').style.transform`); const title = await box(`${node('bbbbbbbbbbbbbbbb')}.querySelector('.cv-ftitle')`); await click(title.cx, title.cy); await sleep(1200); check('clicking a note\'s title opens it (at its heading)', (await js(`return document.querySelector('.title')?.textContent`)) === 'Roadmap'); await js(`await window.vault.rename('Projects/Roadmap', 'Plan 2027')`); await sleep(600); const renamed = canvasOf().nodes.find((n) => n.id === 'bbbbbbbbbbbbbbbb'); check('renaming the note updates the canvas', renamed.file === 'Projects/Plan 2027.md' && renamed.subpath === '#Goals', JSON.stringify(renamed)); // ---- search and backlinks ---- check('search finds text on a canvas', (await js(`return (await window.vault.search('Hello canvas')).map(r => r.id)`)).includes('Plan.canvas')); check('a note on a canvas has the canvas as a backlink', (await js(`return (await window.vault.backlinks('Projects/Plan 2027')).map(b => b.id)`)).includes('Plan.canvas')); // ---- add a note from the bar (it lands in the middle, on top of things: done last) ---- await openRow('Plan.canvas'); const backAt = await js(`return document.querySelector('.cv-world').style.transform`); check('coming back, the canvas is where you left it', backAt === leftAt, `${leftAt} / ${backAt}`); const nb = await box(`document.querySelector('.cv-bar [aria-label="Add a note"]')`); await click(nb.cx, nb.cy); await type('Notes'); await key('Return'); await sleep(900); check('"Add a note" puts a note on the canvas', canvasOf().nodes.some((n) => n.type === 'file' && n.file === 'Notes.md')); // ---- a page dragged in from the sidebar ---- const notesBefore = canvasOf().nodes.filter((n) => n.file === 'Notes.md').length; const dragRow = await box(`document.querySelector('.tree .row[data-id="Notes"]')`); const dropVp = await box(`document.querySelector('.cv')`); const dropAt = [dropVp.x + dropVp.w - 220, dropVp.y + dropVp.h - 220]; // let go in the sidebar's own space after Escape: nothing is added m('mouseMove', dragRow.cx, dragRow.cy); m('mouseDown', dragRow.cx, dragRow.cy); for (let i = 1; i <= 10; i++) { m('mouseMove', dragRow.cx + ((dropAt[0] - dragRow.cx) * i) / 10, dragRow.cy + ((dropAt[1] - dragRow.cy) * i) / 10); await sleep(25); } await sleep(100); check('a page held over the canvas lights it up as a drop target', !!(await js(`return document.querySelector('.cv')?.classList.contains('dropping')`))); await key('Escape'); m('mouseUp', dropAt[0], dropAt[1]); await sleep(700); check('Escape cancels the drag: nothing is added', canvasOf().nodes.filter((n) => n.file === 'Notes.md').length === notesBefore); await drag(dragRow.cx, dragRow.cy, dropAt[0], dropAt[1], 14); await sleep(700); const dropped = canvasOf().nodes.filter((n) => n.file === 'Notes.md'); check('dropping a page from the sidebar puts a note card there', dropped.length === notesBefore + 1); const fresh = dropped[dropped.length - 1]; const fb = await box(node(fresh.id)); check('...centred where it was let go', Math.abs(fb.cx - dropAt[0]) <= 24 && Math.abs(fb.cy - dropAt[1]) <= 24, `${fb.cx},${fb.cy} vs ${dropAt}`); check('...and the page was not moved or opened', fs.existsSync(path.join(vault, 'Notes.md')) && (await js(`return !document.querySelector('.cv-broken') && !!document.querySelector('.cv')`))); check('the drop highlight is gone', !(await js(`return document.querySelector('.cv')?.classList.contains('dropping')`))); // ---- export ---- const exportTo = async (item, ext) => { const out = path.join(tmp, 'out.' + ext); fs.rmSync(out, { force: true }); dialog.showSaveDialog = async () => ({ canceled: false, filePath: out }); await js(`document.querySelector('[aria-label="Page options"]').click()`); await sleep(250); await js(`[...document.querySelectorAll('.menu .mi')].find((b) => b.textContent.includes(${JSON.stringify(item)})).click()`); for (let i = 0; i < 60 && !fs.existsSync(out); i++) await sleep(250); await sleep(300); return fs.existsSync(out) ? out : null; }; const beforeExport = file(); const planNodes = canvasOf().nodes; const spanW = Math.max(...planNodes.map((n) => n.x + n.width)) - Math.min(...planNodes.map((n) => n.x)); const spanH = Math.max(...planNodes.map((n) => n.y + n.height)) - Math.min(...planNodes.map((n) => n.y)); const png = await exportTo('Export as PNG', 'png'); check('Export as PNG writes a picture of the whole canvas', !!png); if (png) { const img = nativeImage.createFromPath(png); const sz = img.getSize(); check('...sized to the items plus a margin (at 2x)', Math.abs(sz.width - (spanW + 96) * 2) <= 4 && Math.abs(sz.height - (spanH + 96) * 2) <= 4, JSON.stringify(sz) + ` expected ~${(spanW + 96) * 2}x${(spanH + 96) * 2}`); const px = img.toBitmap(); const seen = new Set(); for (let i = 0; i < px.length; i += 4 * 97) seen.add(px.readUInt32LE(i)); check('...and is drawn, not blank', seen.size > 8, String(seen.size)); } const svgFile = await exportTo('Export as SVG', 'svg'); const svgText = svgFile ? fs.readFileSync(svgFile, 'utf8') : ''; check('Export as SVG writes a vector file with the cards in it', svgText.startsWith(' b.textContent.includes('Version history')).click()`); await sleep(700); const rows = await js(`return [...document.querySelectorAll('.panel .tr')].map((r) => r.querySelector('.tr-t').textContent)`); check('a canvas has a version history that says what each version held', rows.some((t) => t.includes('1 card · 1 note · 1 link · 1 arrow')), JSON.stringify(rows)); await js(`[...document.querySelectorAll('.panel .tr')].filter((r) => r.textContent.includes('1 card · 1 note · 1 link · 1 arrow')).pop().querySelector('.btn').click()`); await sleep(1500); check('restoring a version puts that canvas file back exactly', file() === PLAN); check('...and the open canvas shows it', (await js(`return document.querySelectorAll('.cv-node').length`)) === 3); const kept = fs.readdirSync(histDir).map((f) => fs.readFileSync(path.join(histDir, f), 'utf8')); check('...and the canvas as it was before is kept as a version', kept.includes(nowText)); // ---- duplicate, from the sidebar's right-click menu ---- const sidebarDuplicate = async (rowId) => { const r = await box(`document.querySelector('.tree .row[data-id=${JSON.stringify(rowId)}]')`); await rclick(r.cx, r.cy); const items = await js(`return [...document.querySelectorAll('.menu.ctx .mi')].map((b) => b.textContent.trim())`); const b = await box(`[...document.querySelectorAll('.menu.ctx .mi')].find((x) => x.textContent.trim() === 'Duplicate')`); await click(b.cx, b.cy); await sleep(1500); return items; }; const menuHad = await sidebarDuplicate('Plan.canvas'); check('the sidebar menu has Duplicate', menuHad.includes('Duplicate'), JSON.stringify(menuHad)); const copyOfPlan = path.join(vault, 'Plan copy.canvas'); check('duplicating a canvas writes an identical file beside it, and opens it', fs.existsSync(copyOfPlan) && fs.readFileSync(copyOfPlan, 'utf8') === file() && !!(await js(`return document.querySelector('.cv') && document.querySelector('.tree .row.active')?.dataset.id === 'Plan copy.canvas'`))); check('...and it is in the sidebar under its own name', !!(await js(`return document.querySelector('.tree .row[data-id="Plan copy.canvas"] .name')?.textContent === 'Plan copy'`))); await sidebarDuplicate('Notes'); check('duplicating a page makes "Notes copy" with the same text', fs.existsSync(path.join(vault, 'Notes copy.md')) && fs.readFileSync(path.join(vault, 'Notes copy.md'), 'utf8') === fs.readFileSync(path.join(vault, 'Notes.md'), 'utf8')); await openRow('Plan.canvas'); // ---- a new canvas ---- await key('n', ['control']); await sleep(500); await js(`document.querySelector('.tpl-card[data-canvas]').click()`); await sleep(1400); check('Ctrl+N → Canvas makes an empty canvas file and opens it', fs.existsSync(path.join(vault, 'Untitled canvas.canvas')) && file('Untitled canvas.canvas') === '{\n\t"nodes":[],\n\t"edges":[]\n}' && !!(await js(`return document.querySelector('.cv-empty')`))); // ---- a file it can't read ---- await openRow('Broken.canvas'); check('an unreadable canvas says so', !!(await js(`return document.querySelector('.cv-broken')`))); await dbl(view.x + 300, view.y + 300); await sleep(900); check('...and is never written over', file('Broken.canvas') === '{ "nodes": [ oops'); } catch (err) { check('harness', false, err.stack); } fs.rmSync(tmp, { recursive: true, force: true }); console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ncanvas e2e passed!'); app.exit(failures.length ? 1 : 0); });