test: unit and end-to-end suites

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent 27fafa0bcd
commit 9b00e21c44
49 files changed
+6694 -1

No files matched your search

+682
View File
@@ -0,0 +1,682 @@
// 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('<html><head><title>fallback</title><meta property="og:title" content="JSON Canvas &amp; friends"><meta name="description" content="An open file format for infinite canvas data."></head></html>', { 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('<svg') && svgText.includes('foreignObject') && svgText.includes('Idea') && svgText.includes('leads to') && !/class="cv-dot"/.test(svgText), JSON.stringify({ len: svgText.length, head: svgText.slice(0, 80), idea: svgText.includes('Idea'), lbl: svgText.includes('leads to'), dot: svgText.includes('cv-dot') }));
const pdf = await exportTo('Export as PDF', 'pdf');
check('Export as PDF writes a one-page PDF', !!pdf && fs.readFileSync(pdf).subarray(0, 4).toString() === '%PDF' && (fs.readFileSync(pdf, 'latin1').match(/\/Type\s*\/Page[^s]/g) || []).length === 1);
check('exporting leaves the canvas file alone', file() === beforeExport);
// ---- version history ----
const nowText = file();
const histDir = path.join(vault, '.notes', 'history', 'Plan.canvas');
fs.mkdirSync(histDir, { recursive: true });
fs.writeFileSync(path.join(histDir, `${Date.now() - 3600 * 1000}.md`), PLAN);
await js(`document.querySelector('[aria-label="Page options"]').click()`);
await sleep(250);
await js(`[...document.querySelectorAll('.menu .mi')].find((b) => 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);
});