From 9b00e21c44ca32f7d822b32f027404acaacb3c5b Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 15 Oct 2026 11:57:09 +0200 Subject: [PATCH] test: unit and end-to-end suites Co-Authored-By: Claude Sonnet 5.5 --- test/canvas.test.js | 311 +++++++++++++ test/csv.test.js | 101 ++++ test/daily.test.js | 33 ++ test/database.test.js | 266 +++++++++++ test/e2e-a11y.test.cjs | 95 ++++ test/e2e-board-drag.test.cjs | 145 ++++++ test/e2e-canvas.test.cjs | 682 ++++++++++++++++++++++++++++ test/e2e-columns.test.cjs | 144 ++++++ test/e2e-conflict.test.cjs | 74 +++ test/e2e-contextmenu.test.cjs | 105 +++++ test/e2e-cover.test.cjs | 143 ++++++ test/e2e-daily.test.cjs | 100 ++++ test/e2e-database-extras.test.cjs | 197 ++++++++ test/e2e-database-formulas.test.cjs | 137 ++++++ test/e2e-database.test.cjs | 365 +++++++++++++++ test/e2e-embeds.test.cjs | 159 +++++++ test/e2e-footnotes.test.cjs | 172 +++++++ test/e2e-graph.test.cjs | 2 +- test/e2e-headings.test.cjs | 134 ++++++ test/e2e-kanban-cards.test.cjs | 146 ++++++ test/e2e-kanban-colors.test.cjs | 84 ++++ test/e2e-math.test.cjs | 98 ++++ test/e2e-navigation.test.cjs | 105 +++++ test/e2e-outline.test.cjs | 122 +++++ test/e2e-selection.test.cjs | 151 ++++++ test/e2e-settings.test.cjs | 204 +++++++++ test/e2e-slash.test.cjs | 270 +++++++++++ test/e2e-tags.test.cjs | 265 +++++++++++ test/e2e-tasks.test.cjs | 78 ++++ test/e2e-templates.test.cjs | 119 +++++ test/e2e-textmenu.test.cjs | 155 +++++++ test/e2e-toggle.test.cjs | 105 +++++ test/e2e-workflow.test.cjs | 109 +++++ test/e2e.test.cjs | 72 +++ test/embed.test.js | 76 ++++ test/formula.test.js | 89 ++++ test/graph.test.js | 57 +++ test/headings.test.js | 40 ++ test/icons.test.js | 31 ++ test/imagefetch.test.js | 57 +++ test/importer.test.js | 72 +++ test/kanban.test.js | 48 ++ test/linkpreview.test.js | 79 ++++ test/markdown.test.js | 222 +++++++++ test/prefs.test.js | 125 +++++ test/rename-heading.test.cjs | 45 ++ test/shortcuts.test.js | 52 +++ test/tasks.test.js | 48 ++ test/vault.test.js | 206 +++++++++ 49 files changed, 6694 insertions(+), 1 deletion(-) create mode 100644 test/canvas.test.js create mode 100644 test/csv.test.js create mode 100644 test/daily.test.js create mode 100644 test/database.test.js create mode 100644 test/e2e-a11y.test.cjs create mode 100644 test/e2e-board-drag.test.cjs create mode 100644 test/e2e-canvas.test.cjs create mode 100644 test/e2e-columns.test.cjs create mode 100644 test/e2e-conflict.test.cjs create mode 100644 test/e2e-contextmenu.test.cjs create mode 100644 test/e2e-cover.test.cjs create mode 100644 test/e2e-daily.test.cjs create mode 100644 test/e2e-database-extras.test.cjs create mode 100644 test/e2e-database-formulas.test.cjs create mode 100644 test/e2e-database.test.cjs create mode 100644 test/e2e-embeds.test.cjs create mode 100644 test/e2e-footnotes.test.cjs create mode 100644 test/e2e-headings.test.cjs create mode 100644 test/e2e-kanban-cards.test.cjs create mode 100644 test/e2e-kanban-colors.test.cjs create mode 100644 test/e2e-math.test.cjs create mode 100644 test/e2e-navigation.test.cjs create mode 100644 test/e2e-outline.test.cjs create mode 100644 test/e2e-selection.test.cjs create mode 100644 test/e2e-settings.test.cjs create mode 100644 test/e2e-slash.test.cjs create mode 100644 test/e2e-tags.test.cjs create mode 100644 test/e2e-tasks.test.cjs create mode 100644 test/e2e-templates.test.cjs create mode 100644 test/e2e-textmenu.test.cjs create mode 100644 test/e2e-toggle.test.cjs create mode 100644 test/e2e-workflow.test.cjs create mode 100644 test/e2e.test.cjs create mode 100644 test/embed.test.js create mode 100644 test/formula.test.js create mode 100644 test/graph.test.js create mode 100644 test/headings.test.js create mode 100644 test/icons.test.js create mode 100644 test/imagefetch.test.js create mode 100644 test/importer.test.js create mode 100644 test/kanban.test.js create mode 100644 test/linkpreview.test.js create mode 100644 test/markdown.test.js create mode 100644 test/prefs.test.js create mode 100644 test/rename-heading.test.cjs create mode 100644 test/shortcuts.test.js create mode 100644 test/tasks.test.js diff --git a/test/canvas.test.js b/test/canvas.test.js new file mode 100644 index 0000000..47e78bb --- /dev/null +++ b/test/canvas.test.js @@ -0,0 +1,311 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { mkdtemp, readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import * as C from '../src/lib/canvas.js'; +import { resolveLink, refFor } from '../src/lib/links.js'; + +const require = createRequire(import.meta.url); +const disk = require('../electron/canvas.cjs'); +const links = require('../electron/links.cjs'); +const { Vault } = require('../electron/vault.cjs'); + +const tmp = () => mkdtemp(path.join(tmpdir(), 'ink-canvas-test-')); + +// A canvas as Obsidian writes it. +const OBSIDIAN = `{ +\t"nodes":[ +\t\t{"id":"a1b2c3d4e5f60718","type":"group","x":-300,"y":-260,"width":760,"height":520,"label":"Plan","color":"4"}, +\t\t{"id":"0f1e2d3c4b5a6978","type":"text","text":"# Idea\\n\\nSee [[Roadmap]] and [[Roadmap#Goals|goals]]","x":-260,"y":-200,"width":300,"height":160}, +\t\t{"id":"1122334455667788","type":"file","file":"Projects/Roadmap.md","subpath":"#Goals","x":100,"y":-200,"width":320,"height":400}, +\t\t{"id":"99aabbccddeeff00","type":"file","file":"Projects/diagram.png","x":-260,"y":0,"width":300,"height":200}, +\t\t{"id":"abcdefabcdefabcd","type":"link","url":"https://jsoncanvas.org","x":500,"y":0,"width":400,"height":220,"someFutureField":true} +\t], +\t"edges":[ +\t\t{"id":"e1e1e1e1e1e1e1e1","fromNode":"0f1e2d3c4b5a6978","fromSide":"right","toNode":"1122334455667788","toSide":"left","label":"details"} +\t], +\t"metadata":{"version":"1.0"} +}`; + +test('canvas files read and write exactly as Obsidian writes them, on both sides', () => { + for (const m of [C, disk]) { + const c = m.parseCanvas(OBSIDIAN); + assert.equal(c.nodes.length, 5); + assert.equal(c.edges.length, 1); + assert.deepEqual(c.extra, { metadata: { version: '1.0' } }, 'unknown top-level keys are kept'); + assert.equal(m.serializeCanvas(c), OBSIDIAN, 'unchanged canvas = unchanged file'); + assert.equal(c.nodes[4].someFutureField, true, 'unknown node fields are kept'); + assert.deepEqual(m.parseCanvas(''), { nodes: [], edges: [], extra: {} }, 'an empty file is an empty canvas'); + assert.deepEqual(m.parseCanvas('{}'), { nodes: [], edges: [], extra: {} }); + assert.throws(() => m.parseCanvas('{ not json')); + assert.throws(() => m.parseCanvas('[1,2]')); + } + assert.equal(C.serializeCanvas({}), disk.EMPTY_CANVAS); + assert.equal(disk.EMPTY_CANVAS, '{\n\t"nodes":[],\n\t"edges":[]\n}'); +}); + +test('a canvas reads as text with [[links]] to the notes on it (search, backlinks, graph)', () => { + const t = disk.canvasText(OBSIDIAN); + assert.ok(t.includes('# Idea') && t.includes('[[Roadmap]]'), 'text cards'); + assert.ok(t.includes('[[Projects/Roadmap#Goals]]'), 'a note on the canvas is a link to it'); + assert.ok(!t.includes('diagram.png'), 'pictures are not page links'); + assert.ok(t.includes('Plan') && t.includes('details') && t.includes('https://jsoncanvas.org')); + assert.equal(disk.canvasText('{ broken'), ''); + const pages = [ + { id: 'Projects/Roadmap', title: 'Roadmap', exists: true, body: '## Goals' }, + { id: 'Ideas.canvas', title: 'Ideas', exists: true, body: t } + ]; + assert.deepEqual(links.backlinks('Projects/Roadmap', pages).map((b) => b.id), ['Ideas.canvas']); +}); + +test('[[Name.canvas]] finds a canvas; [[Name]] never does', () => { + const pages = [ + { id: 'Maps/Plan.canvas', title: 'Plan', exists: true }, + { id: 'Notes/Plan', title: 'Plan', exists: true }, + { id: 'Only.canvas', title: 'Only', exists: true } + ]; + for (const resolve of [resolveLink, links.resolveLink]) { + assert.equal(resolve('Plan.canvas', pages), 'Maps/Plan.canvas'); + assert.equal(resolve('Plan', pages), 'Notes/Plan'); + assert.equal(resolve('Only', pages), null); + assert.equal(resolve('Only.canvas', pages), 'Only.canvas'); + } + assert.equal(refFor('Maps/Plan.canvas', pages), 'Plan.canvas'); + assert.equal(links.refFor('Notes/Plan', pages), 'Plan'); +}); + +test('moving and renaming notes keeps canvases pointing at them', () => { + const before = [{ id: 'Projects/Roadmap', title: 'Roadmap', exists: true }, { id: 'Projects', title: 'Projects', exists: true }]; + const after = [{ id: 'Archive/Projects/Roadmap', title: 'Roadmap', exists: true }, { id: 'Archive/Projects', title: 'Projects', exists: true }]; + const moved = (id) => (id === 'Projects' ? 'Archive/Projects' : id.startsWith('Projects/') ? 'Archive/' + id : null); + const out = disk.rewriteCanvas(OBSIDIAN, before, after, moved); + const c = disk.parseCanvas(out); + assert.equal(c.nodes[2].file, 'Archive/Projects/Roadmap.md', 'note path'); + assert.equal(c.nodes[3].file, 'Archive/Projects/diagram.png', 'a picture in the moved folder'); + assert.equal(c.nodes[1].text, '# Idea\n\nSee [[Roadmap]] and [[Roadmap#Goals|goals]]', 'a link that still resolves by name is left as written'); + assert.equal(disk.rewriteCanvas(OBSIDIAN, before, before, () => null), null, 'nothing moved: no rewrite'); + + const renamed = disk.rewriteCanvas(OBSIDIAN, [{ id: 'Projects/Roadmap', title: 'Roadmap', exists: true }], [{ id: 'Projects/Plan 2027', title: 'Plan 2027', exists: true }], (id) => (id === 'Projects/Roadmap' ? 'Projects/Plan 2027' : null)); + const r = disk.parseCanvas(renamed); + assert.equal(r.nodes[2].file, 'Projects/Plan 2027.md'); + assert.equal(r.nodes[1].text, '# Idea\n\nSee [[Plan 2027]] and [[Plan 2027#Goals|goals]]'); +}); + +test('renaming a heading updates a note shown at that heading and links in cards', () => { + const pages = [{ id: 'Projects/Roadmap', title: 'Roadmap', exists: true }]; + const r = disk.renameHeadingInCanvas(OBSIDIAN, pages, 'Projects/Roadmap', 'Goals', 'Aims [2027]'); + assert.equal(r.count, 2); + const c = disk.parseCanvas(r.body); + assert.equal(c.nodes[2].subpath, '#Aims 2027'); + assert.equal(c.nodes[1].text, '# Idea\n\nSee [[Roadmap]] and [[Roadmap#Aims 2027|goals]]'); + assert.equal(disk.renameHeadingInCanvas(OBSIDIAN, pages, 'Projects/Roadmap', 'Nope', 'X'), null); +}); + +test('geometry: sides, paths, fitting', () => { + const n = { x: 0, y: 0, width: 200, height: 100 }; + assert.deepEqual(C.sidePoint(n, 'right'), [200, 50]); + assert.deepEqual(C.sidePoint(n, 'top'), [100, 0]); + assert.equal(C.nearestSide(n, 500, 60), 'right'); + assert.equal(C.nearestSide(n, 90, -300), 'top'); + assert.equal(C.nearestSide(n, -5, 50), 'left'); + assert.deepEqual(C.autoSides(n, { x: 0, y: 400, width: 200, height: 100 }), ['bottom', 'top']); + const p = C.edgePath([200, 50], 'right', [400, 50], 'left'); + assert.match(p.d, /^M200,50 C/); + assert.deepEqual(p.mid, [300, 50]); + assert.deepEqual(p.end.dir, [1, 0]); + const v = C.fitView({ x: 0, y: 0, width: 1000, height: 500 }, 600, 400, 50); + assert.equal(v.z, 0.5); + assert.deepEqual([v.x, v.y], [300 - 500 * 0.5, 200 - 250 * 0.5]); + assert.equal(C.fitView({ x: 0, y: 0, width: 10, height: 10 }, 800, 600).z, 1, 'never zooms in past 100%'); + assert.equal(C.relPath('A/B', 'A/img/x.png'), '../img/x.png'); + assert.equal(C.relPath('', 'img/x.png'), 'img/x.png'); + assert.equal(C.relPath('A', 'A/x.png'), 'x.png'); + assert.equal(C.fileKind('a/b.md'), 'note'); + assert.equal(C.fileKind('a/b.canvas'), 'canvas'); + assert.equal(C.fileKind('a/b.JPG'), 'image'); + assert.equal(C.colorOf('3'), C.PRESETS[3]); + assert.equal(C.colorOf('#ff0000'), '#ff0000'); + assert.equal(C.colorOf('url(x)'), null); + assert.match(C.newId(), /^[0-9a-f]{16}$/); +}); + +test('the vault lists, writes, renames, moves and trashes canvases as plain .canvas files', async () => { + const dir = await tmp(); + await mkdir(path.join(dir, 'Maps')); + await writeFile(path.join(dir, 'Maps', 'Plan.canvas'), OBSIDIAN); + await writeFile(path.join(dir, 'Note.md'), 'Hello\n'); + const v = new Vault(dir); + + const list = await v.list(); + const cv = list.find((p) => p.id === 'Maps/Plan.canvas'); + assert.ok(cv, JSON.stringify(list.map((p) => p.id))); + assert.equal(cv.title, 'Plan'); + assert.equal(cv.kind, 'canvas'); + assert.ok(cv.body.includes('[[Projects/Roadmap#Goals]]'), 'listed with its text'); + + const r = await v.read('Maps/Plan.canvas'); + assert.equal(r.body, OBSIDIAN, 'read gives the file as is'); + assert.deepEqual(r.meta, {}); + await v.write('Maps/Plan.canvas', { tags: ['x'] }, '{\n\t"nodes":[],\n\t"edges":[]\n}'); + assert.equal(await readFile(path.join(dir, 'Maps', 'Plan.canvas'), 'utf8'), '{\n\t"nodes":[],\n\t"edges":[]\n}', 'no front matter in a canvas'); + + const made = await v.createCanvas(null, 'Board ideas'); + assert.equal(made, 'Board ideas.canvas'); + assert.equal(await readFile(path.join(dir, 'Board ideas.canvas'), 'utf8'), disk.EMPTY_CANVAS); + assert.equal(await v.createCanvas(null, 'Board ideas'), 'Board ideas 2.canvas', 'a free name'); + + assert.equal(await v.rename('Board ideas.canvas', 'Mind map'), 'Mind map.canvas'); + assert.equal(await v.move('Mind map.canvas', 'Maps'), 'Maps/Mind map.canvas'); + await assert.rejects(v.create('Maps/Mind map.canvas', 'Inside'), /canvas cannot hold pages/); + await assert.rejects(v.move('Note', 'Maps/Mind map.canvas'), /canvas cannot hold pages/); + + const entry = await v.remove('Maps/Mind map.canvas'); + assert.ok(!(await readdir(path.join(dir, 'Maps'))).includes('Mind map.canvas')); + assert.equal((await v.listTrash())[0].title, 'Mind map'); + assert.equal(await v.restore(entry), 'Maps/Mind map.canvas'); + assert.equal(await readFile(path.join(dir, 'Maps', 'Mind map.canvas'), 'utf8'), disk.EMPTY_CANVAS); +}); + +test('aligning a selection: each edge or centre of the group, nothing else moves', () => { + const a = { id: 'a', x: 0, y: 0, width: 100, height: 50 }; + const b = { id: 'b', x: 200, y: 80, width: 60, height: 40 }; + const c = { id: 'c', x: 50, y: 300, width: 200, height: 100 }; + const all = [a, b, c]; + const at = (how) => Object.fromEntries([...C.alignNodes(all, how)].map(([id, p]) => [id, p])); + assert.deepEqual(at('left'), { b: { x: 0, y: 80 }, c: { x: 0, y: 300 } }); + assert.deepEqual(at('right'), { a: { x: 160, y: 0 }, c: { x: 60, y: 300 } }, 'b already ends at the right edge (260)'); + assert.deepEqual(at('top'), { b: { x: 200, y: 0 }, c: { x: 50, y: 0 } }); + assert.deepEqual(at('bottom'), { a: { x: 0, y: 350 }, b: { x: 200, y: 360 } }); + assert.deepEqual(at('centerX'), { a: { x: 80, y: 0 }, b: { x: 100, y: 80 }, c: { x: 30, y: 300 } }); + assert.deepEqual(at('centerY'), { a: { x: 0, y: 175 }, b: { x: 200, y: 180 }, c: { x: 50, y: 150 } }); + assert.equal(C.alignNodes([a], 'left').size, 0, 'one node has nothing to line up with'); + assert.equal(C.alignNodes(all, 'sideways').size, 0); +}); + +test('distributing: equal gaps, the two outermost stay put, order follows position', () => { + const n = (id, x, w, y = 0, h = 40) => ({ id, x, y, width: w, height: h }); + const row = [n('c', 400, 100), n('a', 0, 100), n('b', 120, 50)]; // out of order, and uneven + const moved = C.distributeNodes(row, 'x'); + assert.deepEqual([...moved], [['b', { x: 225, y: 0 }]], 'gaps of 125: 100 | 125 | 50 | 125 | 100'); + const col = [n('a', 0, 80, 0, 40), n('b', 0, 80, 50, 60), n('c', 0, 80, 300, 20)]; + const down = C.distributeNodes(col, 'y'); + assert.deepEqual([...down], [['b', { x: 0, y: 140 }]]); + assert.equal(C.distributeNodes([n('a', 0, 10), n('b', 50, 10)], 'x').size, 0, 'two nodes: nothing to spread'); + // already even: nothing to do + assert.equal(C.distributeNodes([n('a', 0, 100), n('b', 150, 100), n('c', 300, 100)], 'x').size, 0); +}); + +test('guides: an edge or centre close to another card snaps onto it, and lines are drawn for what lines up', () => { + const other = { id: 'o', x: 400, y: 100, width: 200, height: 100 }; // lines: x 400 / 500 / 600, y 100 / 150 / 200 + const box = (x, y, w = 100, h = 60) => ({ x, y, width: w, height: h }); + // left edge 3 off the other's left edge; vertically far away + let g = C.guideSnap(box(397, 500), [other], 6); + assert.deepEqual([g.dx, g.sx, g.sy], [3, true, false]); + // centre (x + 50) 4 left of the other's centre 500: moves right + g = C.guideSnap(box(446, 500), [other], 6); + assert.deepEqual([g.dx, g.sx], [4, true]); + // too far: nothing + g = C.guideSnap(box(380, 500), [other], 6); + assert.deepEqual([g.sx, g.sy, g.dx, g.dy], [false, false, 0, 0]); + // the closest line wins, per axis + g = C.guideSnap(box(405, 138), [other], 8); // x: left 405 -> 400 (-5); y: centre 168 vs 150 / bottom 198 vs 200 (+2) + assert.deepEqual([g.dx, g.dy], [-5, 2]); + // only the lines asked for count (resizing: the right edge) + g = C.guideSnap({ x: 598, y: 0, width: 0, height: 0 }, [other], 6, { x: [0], y: [0] }); + assert.deepEqual([g.dx, g.sx, g.sy], [2, true, false]); + + const lines = C.guideLines(box(400, 400, 80), [other]); + assert.deepEqual(lines, [{ axis: 'x', at: 400, from: 100, to: 460 }], 'the left edges line up; the line spans both cards'); + assert.deepEqual(C.guideLines(box(450, 400), [other]), [{ axis: 'x', at: 500, from: 100, to: 460 }], 'centre on centre'); + assert.deepEqual(C.guideLines(box(0, 0), [other]), []); + // two cards lined up on the same edge give one line, not two + const two = [other, { id: 'p', x: 400, y: 700, width: 50, height: 50 }]; + assert.equal(C.guideLines(box(400, 400, 80), two).length, 1); + assert.deepEqual(C.guideLines(box(400, 400, 80), two)[0], { axis: 'x', at: 400, from: 100, to: 750 }); +}); + +test('arrow styles: straight and right-angled lines, with heads at the right ends', () => { + const st = C.edgePath([0, 0], 'right', [100, 0], 'left', 'straight'); + assert.equal(st.d, 'M0,0 L100,0'); + assert.deepEqual([st.mid, st.end.dir, st.start.dir], [[50, 0], [1, 0], [-1, 0]]); + const diag = C.edgePath([0, 0], 'bottom', [30, 40], 'top', 'straight'); + assert.deepEqual(diag.end.dir, [0.6, 0.8]); + + // right angles: leave sideways, one bend in the middle, arrive sideways; the stubs vanish into the runs + const z = C.edgePath([0, 0], 'right', [200, 100], 'left', 'orthogonal'); + assert.deepEqual(z.pts, [[0, 0], [100, 0], [100, 100], [200, 100]]); + assert.deepEqual([z.end.dir, z.start.dir], [[1, 0], [-1, 0]]); + assert.deepEqual(z.mid, [100, 50]); + // from the right of one card into the top of another: a single corner + const l = C.edgePath([0, 0], 'right', [100, 100], 'top', 'orthogonal'); + assert.deepEqual(l.pts, [[0, 0], [100, 0], [100, 100]]); + assert.deepEqual(l.end.dir, [0, 1]); + // straight on: no bends at all + assert.deepEqual(C.edgePath([0, 0], 'right', [200, 0], 'left', 'orthogonal').pts, [[0, 0], [200, 0]]); + // an arrow still being drawn has a loose end: it arrives along the longer way + const loose = C.edgePath([0, 0], 'right', [100, 10], null, 'orthogonal'); + assert.deepEqual(loose.pts, [[0, 0], [62, 0], [62, 10], [100, 10]]); + assert.deepEqual(loose.end.dir, [1, 0]); + // corners are rounded in the path that is drawn + assert.match(z.d, /Q/); + assert.match(z.d, /^M0,0 L/); + // the default is the curve; unknown styles fall back to it + assert.match(C.edgePath([0, 0], 'right', [100, 0], 'left').d, / C/); + assert.equal(C.shapeOf({ style: 'straight' }), 'straight'); + assert.equal(C.shapeOf({ style: 'wavy' }), 'curved'); + assert.equal(C.shapeOf({}), 'curved'); +}); + +test('groups: what a collapsed group hides, how it is drawn, and where a dropped card ends up', () => { + const g = { id: 'g', type: 'group', x: 0, y: 0, width: 400, height: 300 }; + const a = { id: 'a', type: 'text', x: 40, y: 40, width: 100, height: 60 }; + const b = { id: 'b', type: 'text', x: 200, y: 100, width: 100, height: 60 }; + const out = { id: 'out', type: 'text', x: 500, y: 40, width: 100, height: 60 }; + const nodes = [g, a, b, out]; + assert.equal(C.hiddenMap(nodes).size, 0, 'nothing is hidden while the group is open'); + g.collapsed = true; + const h = C.hiddenMap(nodes); + assert.deepEqual([...h.keys()].sort(), ['a', 'b']); + assert.equal(h.get('a'), g); + assert.deepEqual(C.displayBox(g), { x: 0, y: 0, width: 400, height: C.COLLAPSED_H }); + assert.equal(C.displayBox(a), a, 'ordinary nodes are drawn as they are'); + // an outer group that is folded hides an inner folded group's members too, and the outer one wins + const outer = { id: 'o', type: 'group', x: -50, y: -50, width: 600, height: 500, collapsed: true }; + assert.equal(C.hiddenMap([outer, ...nodes]).get('a'), outer); + assert.equal(C.hiddenMap([outer, ...nodes]).get('g'), outer); + delete g.collapsed; + + // which group a card lands in: the smallest one holding its middle; open groups only + const inner = { id: 'i', type: 'group', x: 20, y: 20, width: 160, height: 120 }; + assert.equal(C.groupFor({ id: 'c', x: 50, y: 50, width: 40, height: 40 }, [g, inner]).id, 'i'); + assert.equal(C.groupFor({ id: 'c', x: 250, y: 200, width: 40, height: 40 }, [g, inner]).id, 'g'); + assert.equal(C.groupFor({ id: 'c', x: 600, y: 600, width: 40, height: 40 }, [g, inner]), null); + assert.equal(C.groupFor({ id: 'c', x: 50, y: 50, width: 40, height: 40 }, [{ ...g, collapsed: true }]), null, 'folded groups take nothing'); + // dropped half over the edge: pushed in + assert.equal(C.fitInto({ id: 'c', x: 100, y: 100, width: 80, height: 50 }, g), null, 'already inside'); + assert.deepEqual(C.fitInto({ id: 'c', x: 350, y: 130, width: 100, height: 60 }, g), { x: 280, y: 130 }); + assert.deepEqual(C.fitInto({ id: 'c', x: 10, y: 280, width: 100, height: 60 }, g), { x: 20, y: 220 }); + // too big for the group: the group grows to hold it + assert.deepEqual(C.fitInto({ id: 'c', x: 100, y: 100, width: 500, height: 100 }, g), { group: { x: 0, y: 0, width: 620, height: 300 } }); +}); + +test('card <-> note: a page name from the text, and the text a page starts with', () => { + assert.equal(C.titleFromText('# My Plan\n\nSome text'), 'My Plan'); + assert.equal(C.titleFromText('## Sub **heading**'), 'Sub heading'); + assert.equal(C.titleFromText('**Buy** milk and [[Notes/Eggs|eggs]] from [the shop](https://x.y)'), 'Buy milk and eggs from the shop'); + assert.equal(C.titleFromText('- [ ] call the bank\n- [x] other'), 'call the bank'); + assert.equal(C.titleFromText('\n\n---\n \nreal first line'), 'real first line'); + assert.equal(C.titleFromText(''), 'Untitled'); + assert.equal(C.titleFromText('***'), 'Untitled'); + const long = C.titleFromText('word '.repeat(40)); + assert.ok(long.length <= 60 && !long.endsWith(' '), long); + + assert.equal(C.bodyAfterTitle('# My Plan\n\nSome text\nmore'), 'Some text\nmore\n'); + assert.equal(C.bodyAfterTitle('\n## Only a heading'), ''); + assert.equal(C.bodyAfterTitle('Plain first line\nsecond'), 'Plain first line\nsecond\n', 'a plain first line stays: it is text, not a title'); + assert.equal(C.bodyAfterTitle('#hashtag not a heading\ntext'), '#hashtag not a heading\ntext\n'); + assert.equal(C.bodyAfterTitle(''), ''); +}); + diff --git a/test/csv.test.js b/test/csv.test.js new file mode 100644 index 0000000..34ef97b --- /dev/null +++ b/test/csv.test.js @@ -0,0 +1,101 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseCsv, toCsv, databaseCsv, parseNumber, parseDate, inferType, autoMap, planImport } from '../src/lib/csv.js'; + +test('CSV: quotes, embedded commas and newlines, CRLF, BOM and other delimiters', () => { + const t = 'Name,Note\r\n"Smith, J","He said ""hi""\nand left"\r\nPlain,\r\n\r\n'; + const { rows, delimiter } = parseCsv(t); + assert.equal(delimiter, ','); + assert.deepEqual(rows, [['Name', 'Note'], ['Smith, J', 'He said "hi"\nand left'], ['Plain', '']]); + assert.equal(parseCsv('a;b;c\n1;2;3').delimiter, ';'); + assert.deepEqual(parseCsv('a\tb\n1\t2').rows, [['a', 'b'], ['1', '2']]); + assert.deepEqual(parseCsv('a,b\n1,2').rows, [['a', 'b'], ['1', '2']]); // no trailing newline + assert.deepEqual(parseCsv('').rows, []); +}); + +test('CSV: what toCsv writes parseCsv reads back', () => { + const rows = [['Name', 'Tags'], ['A, B', 'x"y'], [' pad ', 'line\nbreak'], ['', 'ü ☃']]; + assert.deepEqual(parseCsv(toCsv(rows)).rows, rows.map((r) => r.map((c) => c))); +}); + +test('numbers and dates come in many shapes', () => { + assert.equal(parseNumber('$1,234.50'), 1234.5); + assert.equal(parseNumber('12%'), 12); + assert.equal(parseNumber('1.234,5'), 1234.5); + assert.equal(parseNumber('12,5'), 12.5); + assert.equal(parseNumber('5,200'), 5200); + assert.equal(parseNumber('$1,234,567'), 1234567); + assert.equal(parseNumber('abc'), null); + assert.equal(parseNumber(''), null); + assert.equal(parseDate('2026-03-01T10:00:00Z'), '2026-03-01'); + assert.equal(parseDate('3/1/2026'), '2026-03-01'); + assert.equal(parseDate('25/12/2026'), '2026-12-25'); + assert.equal(parseDate('1.3.2026'), '2026-03-01'); + assert.equal(parseDate('Mar 5, 2026'), '2026-03-05'); + assert.equal(parseDate('2026-02-31'), ''); + assert.equal(parseDate('soon'), ''); +}); + +test('a column\'s type is guessed from its values', () => { + assert.equal(inferType(['1', '2.5', '', '10']).type, 'number'); + assert.equal(inferType(['2026-01-01', '2026-02-03']).type, 'date'); + assert.equal(inferType(['yes', 'no', 'yes']).type, 'checkbox'); + assert.equal(inferType(['1', '0', '1']).type, 'number'); // only 0/1 stays a number + assert.equal(inferType(['https://a.b', 'http://c.d']).type, 'url'); + assert.equal(inferType(['Todo', 'Done', 'Todo', 'Done', 'Doing', 'Done']).type, 'select'); + assert.equal(inferType(['ui, api', 'api', 'ui, docs', 'api, docs', 'ui']).type, 'multiselect'); + assert.equal(inferType(['one thing', 'another', 'a third', 'and more text here', 'x']).type, 'text'); + assert.equal(inferType(['', '']).type, 'text'); +}); + +test('importing maps columns, makes properties and gathers options', () => { + const { rows } = parseCsv('Title,Status,Due,Points,Done,Labels,Extra\nA,Todo,2026-03-01,5,yes,"ui, api",\nB,Done,3/2/2026,"1,200",no,api,\nC,Todo,,x,,,\nD,Todo,,,,ui,'); + const [header, ...data] = rows; + const existing = [{ key: 'status', name: 'Status', type: 'select', options: ['Todo'] }]; + const cols = autoMap(header, data, existing); + assert.deepEqual(cols.map((c) => c.target), ['title', 'existing', 'new', 'new', 'new', 'new', 'skip']); + assert.equal(cols[2].type, 'date'); + assert.equal(cols[3].type, 'text'); // "x" is in the numbers + const plan = planImport({ rows: data, columns: cols, existing }); + assert.deepEqual(plan.properties.map((p) => [p.key, p.type]), [['due', 'date'], ['points', 'text'], ['done', 'checkbox'], ['labels', 'multiselect']]); + assert.deepEqual(plan.options, { status: ['Done'] }); // Todo was there already + assert.deepEqual(plan.properties[3].options, ['ui', 'api']); + assert.deepEqual(plan.items[0], { title: 'A', meta: { status: 'Todo', due: '2026-03-01', points: '5', done: 'true', labels: ['ui', 'api'] } }); + assert.equal(plan.items[1].meta.due, '2026-03-02'); + assert.equal(plan.items[1].meta.points, '1,200'); + assert.deepEqual(plan.items[2].meta, { status: 'Todo', points: 'x' }); +}); + +test('a number column imports as numbers and names that clash get their own keys', () => { + const rows = [['Name', 'Tags', 'Due date', 'Due-date'], ['A', 't', '2026-01-01', '2026-02-02'], ['B', '', '2026-01-03', '']]; + const cols = autoMap(rows[0], rows.slice(1), []); + const plan = planImport({ rows: rows.slice(1), columns: cols, existing: [] }); + assert.deepEqual(plan.properties.map((p) => p.key), ['tags-2', 'due-date', 'due-date-2']); + assert.equal(plan.items[0].meta['due-date-2'], '2026-02-02'); + const n = planImport({ rows: [['A', '7.5'], ['B', '']], columns: [{ index: 0, target: 'title' }, { index: 1, target: 'new', name: 'Score', type: 'number' }], existing: [] }); + assert.deepEqual(n.items.map((i) => i.meta.score), ['7.5', undefined]); +}); + +test('export writes a Name column and one per property, and imports back the same', () => { + const db = { properties: [ + { key: 'status', name: 'Status', type: 'select', options: ['Todo'] }, + { key: 'points', name: 'Points', type: 'number' }, + { key: 'done', name: 'Done', type: 'checkbox' }, + { key: 'who', name: 'Who', type: 'people' } + ] }; + const recs = [ + { title: 'Write, docs', props: { status: 'Todo', points: '5', done: 'true', who: ['Ana', 'Ben'] } }, + { title: 'Plan', props: {} } + ]; + const csv = databaseCsv(db, recs); + assert.deepEqual(parseCsv(csv).rows, [ + ['Name', 'Status', 'Points', 'Done', 'Who'], + ['Write, docs', 'Todo', '5', 'true', 'Ana, Ben'], + ['Plan', '', '', 'false', ''] + ]); + const [h, ...data] = parseCsv(csv).rows; + const cols = autoMap(h, data, db.properties); + assert.deepEqual(cols.map((c) => c.target), ['title', 'existing', 'existing', 'existing', 'existing']); + const plan = planImport({ rows: data, columns: cols, existing: db.properties }); + assert.deepEqual(plan.items[0].meta, { status: 'Todo', points: '5', done: 'true', who: ['Ana', 'Ben'] }); +}); diff --git a/test/daily.test.js b/test/daily.test.js new file mode 100644 index 0000000..ddcb018 --- /dev/null +++ b/test/daily.test.js @@ -0,0 +1,33 @@ +// Where daily notes go. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cleanFolder, autoFolder, dailyId, DEFAULT_FOLDER } from '../src/lib/daily.js'; + +const page = (id, exists = true) => ({ id, title: id.slice(id.lastIndexOf('/') + 1), exists }); + +test('a typed folder path is tidied', () => { + assert.equal(cleanFolder('Private/Daily'), 'Private/Daily'); + assert.equal(cleanFolder(' /Private//Daily/ '), 'Private/Daily'); + assert.equal(cleanFolder('Private\\Daily'), 'Private/Daily'); + assert.equal(cleanFolder('../../etc'), 'etc', 'no way out of the notes folder'); + assert.equal(cleanFolder('a/./b'), 'a/b'); + assert.equal(cleanFolder('We: "ird"?'), 'We- -ird--'); + assert.equal(cleanFolder(' '), ''); + assert.equal(cleanFolder(null), ''); +}); + +test('with nothing chosen, an existing Daily is found where it is', () => { + assert.equal(autoFolder([]), DEFAULT_FOLDER); + assert.equal(autoFolder([page('Daily'), page('Private/Daily')]), 'Daily', 'the top-level one wins'); + assert.equal(autoFolder([page('Private'), page('Private/Daily'), page('Private/Daily/2026-01-01')]), 'Private/Daily'); + assert.equal(autoFolder([page('A/Daily'), page('B/Daily')]), 'Daily', 'two candidates: not guessing'); + assert.equal(autoFolder([page('Private/Daily', false)]), 'Daily', 'a folder with no page of its own does not count'); + assert.equal(autoFolder([page('Maps/Daily.canvas')]), 'Daily'); +}); + +test('the id of a day\'s note', () => { + const d = new Date(2026, 0, 5); + assert.equal(dailyId('Private/Daily', d), 'Private/Daily/2026-01-05'); + assert.equal(dailyId('', d), 'Daily/2026-01-05'); + assert.equal(dailyId('Daily', new Date(2026, 11, 31)), 'Daily/2026-12-31'); +}); diff --git a/test/database.test.js b/test/database.test.js new file mode 100644 index 0000000..60a29dc --- /dev/null +++ b/test/database.test.js @@ -0,0 +1,266 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isDatabase, defaultDatabase, parseDatabase, databaseToMd, keyFor, read, write, select, columnsFor, + monthGrid, byDay, timeline, matches, dayNum, isoOfDay, valueOf, formatNumber, barFraction, weekOf, optionColor, parseEmbedAlias, embedAlias, computeFormulas, withFormulas, formulaDisplay, aggregate, aggregatesFor, AGGREGATES, opsFor +} from '../src/lib/database.js'; + +const props = [ + { key: 'status', name: 'Status', type: 'select', options: ['Todo', 'Doing', 'Done'] }, + { key: 'due', name: 'Due', type: 'date' }, + { key: 'end', name: 'End', type: 'date' }, + { key: 'points', name: 'Points', type: 'number' }, + { key: 'done', name: 'Done', type: 'checkbox' }, + { key: 'who', name: 'Who', type: 'people' } +]; +const rec = (title, p = {}) => ({ id: 'DB/' + title, title, props: p }); + +test('marker and schema round trip', () => { + assert.equal(isDatabase({ 'ink-database': 'basic' }), true); + assert.equal(isDatabase({}), false); + const db = defaultDatabase(); + const md = databaseToMd(db); + assert.match(md, /^%% ink-database\n/); + const back = parseDatabase(md); + assert.deepEqual(back.properties, db.properties); + assert.deepEqual(back.views.map((v) => v.type), ['table', 'calendar']); // a board is added by hand, with + + assert.equal(databaseToMd(back), md); +}); + +test('text around the schema block is kept; damaged JSON falls back to a table', () => { + const md = 'Intro line\n\n%% ink-database\n{"properties":[{"key":"a","name":"A","type":"text"}],"views":[]}\n%%\n\nTail'; + const db = parseDatabase(md); + assert.equal(db.properties[0].key, 'a'); + assert.equal(db.views.length, 1); + assert.match(databaseToMd(db), /Intro line\n\nTail/); + const bad = parseDatabase('%% ink-database\n{nope\n%%\n'); + assert.deepEqual(bad.properties, []); + assert.equal(bad.views[0].type, 'table'); + assert.equal(parseDatabase('').properties.length, 2); // a new empty page gets the default schema +}); + +test('keys are file-safe, unique and not reserved', () => { + assert.equal(keyFor('Due date'), 'due-date'); + assert.equal(keyFor('Due date', ['due-date']), 'due-date-2'); + assert.equal(keyFor('Tags'), 'tags-2'); + assert.equal(keyFor('???'), 'property'); +}); + +test('values read and write by type', () => { + assert.equal(read('3,5', 'number'), 3.5); + assert.equal(read('abc', 'number'), null); + assert.equal(read('true', 'checkbox'), true); + assert.equal(read('', 'checkbox'), false); + assert.deepEqual(read('a, b', 'multiselect'), ['a', 'b']); + assert.deepEqual(read(['a'], 'people'), ['a']); + assert.equal(write(true, 'checkbox'), 'true'); + assert.equal(write(null, 'number'), ''); + assert.equal(write(12, 'number'), '12'); + assert.deepEqual(write(['x', ''], 'people'), ['x']); +}); + +test('filters and sort', () => { + const rs = [ + rec('B', { status: 'Doing', points: '5', due: '2026-03-02', done: 'false' }), + rec('A', { status: 'Todo', points: '2', due: '2026-03-01', done: 'true' }), + rec('C', { points: '9' }), + rec('D', { status: 'Done', who: ['Ana'], due: '2026-04-01' }) + ]; + const v = (o) => ({ filters: [], sort: null, ...o }); + assert.deepEqual(select(rs, v({}), props).map((r) => r.title), ['A', 'B', 'C', 'D']); + assert.deepEqual(select(rs, v({ sort: { key: 'status', dir: 'asc' } }), props).map((r) => r.title), ['A', 'B', 'D', 'C']); // option order, empty last + assert.deepEqual(select(rs, v({ sort: { key: 'points', dir: 'desc' } }), props).map((r) => r.title), ['C', 'B', 'A', 'D']); + assert.deepEqual(select(rs, v({ filters: [{ key: 'points', op: '≥', value: '5' }] }), props).map((r) => r.title), ['B', 'C']); + assert.deepEqual(select(rs, v({ filters: [{ key: 'due', op: 'before', value: '2026-03-15' }] }), props).map((r) => r.title), ['A', 'B']); + assert.deepEqual(select(rs, v({ filters: [{ key: 'who', op: 'contains', value: 'ana' }] }), props).map((r) => r.title), ['D']); + assert.deepEqual(select(rs, v({ filters: [{ key: 'done', op: 'is checked' }] }), props).map((r) => r.title), ['A']); + assert.deepEqual(select(rs, v({ filters: [{ key: 'status', op: 'is empty' }] }), props).map((r) => r.title), ['C']); + assert.deepEqual(select(rs, v({ filters: [{ key: 'title', op: 'contains', value: 'b' }] }), props).map((r) => r.title), ['B']); + assert.equal(matches(rs[0], { key: 'status', op: 'is', value: '' }, props), true); // half-made filter +}); + +test('board columns: none first, options in order, strays last', () => { + const rs = [rec('a', { status: 'Done' }), rec('b'), rec('c', { status: 'Later' }), rec('d', { status: 'Todo' })]; + const cols = columnsFor(rs, props[0]); + assert.deepEqual(cols.map((c) => c.title), ['No status', 'Todo', 'Doing', 'Done', 'Later']); + assert.deepEqual(cols.map((c) => c.records.map((r) => r.title)), [['b'], ['d'], [], ['a'], ['c']]); + const chk = columnsFor([rec('x', { done: 'true' }), rec('y')], props[4]); + assert.deepEqual(chk.map((c) => c.records.length), [1, 1]); +}); + +test('calendar grid covers whole weeks, Monday first', () => { + const g = monthGrid(2026, 1); // February 2026 starts on a Sunday + assert.equal(g.length, 5); + assert.equal(g[0][0].iso, '2026-01-26'); + assert.equal(g[0][6].iso, '2026-02-01'); + assert.equal(g[4][6].iso, '2026-03-01'); + assert.equal(g[0][0].inMonth, false); + assert.equal(g[1][0].inMonth, true); + assert.equal(monthGrid(2026, 2).length, 6); // March 2026: Sunday the 1st, 31 days + assert.equal(monthGrid(2025, 5).length, 6); +}); + +test('records land on the days they cover', () => { + const rs = [rec('one', { due: '2026-03-01' }), rec('span', { due: '2026-03-02', end: '2026-03-04' }), rec('none')]; + const m = byDay(rs, props[1], props[2]); + assert.deepEqual([...m.keys()], ['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04']); + assert.equal(m.get('2026-03-03')[0].title, 'span'); + assert.equal(isoOfDay(dayNum('2026-03-01') + 31), '2026-04-01'); +}); + +test('timeline bars and undated records', () => { + const rs = [rec('b', { due: '2026-03-10' }), rec('a', { due: '2026-03-02', end: '2026-03-05' }), rec('u')]; + const t = timeline(rs, props[1], props[2]); + assert.deepEqual(t.bars.map((b) => b.rec.title), ['a', 'b']); + assert.equal(t.bars[0].end - t.bars[0].start, 3); + assert.deepEqual(t.undated.map((r) => r.title), ['u']); + assert.ok(t.from < t.bars[0].start && t.to > t.bars[1].end); + assert.equal(valueOf(rs[0], 'title'), 'b'); +}); + +test('number formats show the plain number as currency, percent or a bar', () => { + assert.equal(formatNumber(1234.5, { format: 'commas' }), '1,234.5'); + assert.equal(formatNumber(42, { format: 'percent' }), '42%'); + assert.equal(formatNumber(1234.5, { format: 'dollar' }), '$1,234.50'); + assert.equal(formatNumber(7, { format: 'euro' }), '€7.00'); + assert.equal(formatNumber(3, {}), '3'); + assert.equal(formatNumber(null, { format: 'percent' }), ''); + assert.equal(barFraction(50, { format: 'bar' }), 0.5); + assert.equal(barFraction(3, { format: 'bar', max: 4 }), 0.75); + assert.equal(barFraction(500, { format: 'bar' }), 1); + assert.equal(barFraction(-5, { format: 'bar' }), 0); +}); + +test('format, max, option colours and column widths survive the file; bad ones are dropped', () => { + const db = { + properties: [ + { key: 'n', name: 'N', type: 'number', format: 'bar', max: 20 }, + { key: 'm', name: 'M', type: 'number', format: 'nonsense', max: 5 }, + { key: 's', name: 'S', type: 'select', options: ['a', 'b'], colors: { a: 'green', b: 'chartreuse' } } + ], + views: [{ id: 'v', name: 'T', type: 'table', widths: { n: 300, s: 5, title: 9000, x: 'wide' } }] + }; + const back = parseDatabase(databaseToMd(parseDatabase(databaseToMd({ ...db, rest: '' })))); + assert.equal(back.properties[0].format, 'bar'); + assert.equal(back.properties[0].max, 20); + assert.equal(back.properties[1].format, undefined); + assert.equal(back.properties[1].max, undefined); + assert.deepEqual(back.properties[2].colors, { a: 'green' }); + assert.deepEqual(back.views[0].widths, { n: 300, s: 80, title: 720 }); + assert.equal(optionColor('a', back.properties[2]), '#44ad6e'); + assert.equal(optionColor('b', back.properties[2]), optionColor('b')); +}); + +test('a week is Monday to Sunday around any day; relations read as lists', () => { + const w = weekOf('2026-03-04'); // a Wednesday + assert.equal(w[0].iso, '2026-03-02'); + assert.equal(w[6].iso, '2026-03-08'); + assert.deepEqual(weekOf('2026-03-01').map((d) => d.iso)[0], '2026-02-23'); // Sunday belongs to the week before + assert.deepEqual(read('A, B', 'relation'), ['A', 'B']); + assert.deepEqual(weekOf('nope'), []); +}); + +test('an embed keeps its own view, sort, filters and hidden properties in its alias', () => { + const o = { + view: 'My board', + sort: { key: 'due', dir: 'desc' }, + filters: [{ key: 'status', op: 'is', value: 'In progress' }, { key: 'points', op: '≥', value: '5' }, { key: 'who', op: 'contains', value: 'a;b|c]d:e' }], + hidden: ['owner', 'done'] + }; + const alias = embedAlias(o); + assert.ok(!/[|\]\[]/.test(alias.replace(/%7C|%5D/g, '')), alias); + assert.deepEqual(parseEmbedAlias(alias), o); + assert.equal(embedAlias({ view: '', sort: null, filters: [], hidden: [] }), ''); + assert.deepEqual(parseEmbedAlias('a display name'), { view: '', sort: null, filters: [], hidden: [] }); + assert.deepEqual(parseEmbedAlias('sort=due').sort, { key: 'due', dir: 'asc' }); +}); + +const fprops = [ + { key: 'price', name: 'Price', type: 'number' }, + { key: 'qty', name: 'Quantity', type: 'number' }, + { key: 'opened', name: 'Opened', type: 'date' }, + { key: 'expiry', name: 'Expiry', type: 'date' }, + { key: 'total', name: 'Total', type: 'formula', expression: 'prop("Price") * prop("Quantity")' }, + { key: 'days', name: 'Days left', type: 'formula', expression: 'if(prop("Expiry"), dateBetween(prop("Expiry"), today(), "days"), "")' }, + { key: 'label', name: 'Label', type: 'formula', expression: 'concat(prop("Name"), ": ", prop("Total"))' }, + { key: 'bad', name: 'Bad', type: 'formula', expression: 'prop("Price") / 0' }, + { key: 'loop', name: 'Loop', type: 'formula', expression: 'prop("Loop2")' }, + { key: 'loop2', name: 'Loop2', type: 'formula', expression: 'prop("Loop")' } +]; +const frec = (title, p) => ({ id: 'DB/' + title, title, props: p }); + +test('formula properties are computed from the other values, including other formulas', () => { + const r = frec('Serum', { price: '12.5', qty: '3', expiry: '2026-04-10' }); + const c = computeFormulas(fprops, r, '2026-03-31'); + assert.equal(c.total.value, 37.5); + assert.equal(c.days.value, 10); + assert.equal(c.label.text, 'Serum: 37.5'); // title by name, and a formula using a formula + assert.match(c.bad.error, /Division by zero/); + assert.match(c.loop.error, /refers to itself|Loop/); + const none = computeFormulas(fprops, frec('Empty', {}), '2026-03-31'); + assert.equal(none.total.value, 0); // empty numbers count as 0 + assert.equal(none.days.text, ''); // no expiry: the if() gives nothing +}); + +test('withFormulas puts the results into props as text and keeps errors apart', () => { + const recs = [frec('A', { price: '2', qty: '5' }), frec('B', { price: '4', qty: '1' })]; + const out = withFormulas(recs, fprops, '2026-03-31'); + assert.equal(out[0].props.total, '10'); + assert.equal(out[1].props.total, '4'); + assert.ok(out[0].fx.bad); + assert.equal(recs[0].props.total, undefined); // the originals are untouched + assert.equal(withFormulas(recs, fprops.slice(0, 4)), recs); // no formulas: the same list + // sorting and filtering work on the results, numerically + const sorted = select(withFormulas([frec('A', { price: '9', qty: '1' }), frec('B', { price: '2', qty: '10' }), frec('C', { price: '1', qty: '1' })], fprops, '2026-03-31'), { filters: [], sort: { key: 'total', dir: 'desc' } }, fprops); + assert.deepEqual(sorted.map((r) => r.title), ['B', 'A', 'C']); // 20, 9, 1 (not "9" > "20" as text) + const big = select(withFormulas(recs, fprops, '2026-03-31'), { filters: [{ key: 'total', op: '>', value: '5' }], sort: null }, fprops); + assert.deepEqual(big.map((r) => r.title), ['A']); + assert.ok(opsFor('formula').includes('≥')); +}); + +test('a formula shows numbers in its format and survives the file', () => { + assert.equal(formulaDisplay({ format: 'dollar' }, '1234.5'), '$1,234.50'); + assert.equal(formulaDisplay({}, '1234.5'), '1234.5'); + assert.equal(formulaDisplay({ format: 'dollar' }, '2026-03-01'), '2026-03-01'); // a date is not a price + assert.equal(formulaDisplay({ format: 'percent' }, 'abc'), 'abc'); + const md = databaseToMd({ properties: [{ key: 't', name: 'T', type: 'formula', expression: 'prop("A") + 1', format: 'euro' }], views: [], rest: '' }); + const back = parseDatabase(md).properties[0]; + assert.equal(back.expression, 'prop("A") + 1'); + assert.equal(back.format, 'euro'); +}); + +test('totals: the aggregates for each kind of column', () => { + const agg = (kind, values, type, p) => aggregate(kind, values, type, p).text; + const nums = [10, 20, null, 30]; + assert.equal(agg('count', nums, 'number'), '4'); + assert.equal(agg('filled', nums, 'number'), '3'); + assert.equal(agg('empty', nums, 'number'), '1'); + assert.equal(agg('sum', nums, 'number'), '60'); + assert.equal(agg('avg', nums, 'number'), '20'); + assert.equal(agg('median', [1, 5, 3, 7], 'number'), '4'); + assert.equal(agg('min', nums, 'number'), '10'); + assert.equal(agg('max', nums, 'number'), '30'); + assert.equal(agg('range', nums, 'number'), '20'); + assert.equal(agg('sum', [], 'number'), '0'); + assert.equal(agg('avg', [], 'number'), ''); + assert.equal(agg('sum', [10, 20.5], 'number', { type: 'number', format: 'euro' }), '€30.50'); // in the property's format + assert.equal(agg('sum', ['1.5', '2', '', '2026-01-01'], 'formula'), '3.5'); // numeric text from a formula + assert.equal(agg('percentFilled', ['a', '', 'b', ''], 'text'), '50%'); + assert.equal(agg('unique', [['a', 'b'], ['B'], []], 'multiselect'), '2'); + assert.equal(agg('checked', [true, false, true], 'checkbox'), '2'); + assert.equal(agg('percentChecked', [true, false, true, true], 'checkbox'), '75%'); + assert.equal(agg('earliest', ['2026-03-05', '', '2026-01-02'], 'date'), '2026-01-02'); + assert.equal(agg('latest', ['2026-03-05', '', '2026-01-02'], 'date'), '2026-03-05'); + assert.equal(agg('span', ['2026-03-05', '2026-01-02'], 'date'), '62'); + assert.deepEqual(aggregatesFor('number').map((a) => a.value).slice(0, 5), ['count', 'filled', 'empty', 'unique', 'percentFilled']); + assert.ok(aggregatesFor('number').some((a) => a.value === 'sum')); + assert.ok(!aggregatesFor('text').some((a) => a.value === 'sum')); + assert.ok(aggregatesFor('checkbox').some((a) => a.value === 'percentChecked')); + assert.ok(AGGREGATES.length >= 15); +}); + +test('a view keeps its totals in the file, and drops unknown ones', () => { + const db = { properties: [{ key: 'p', name: 'P', type: 'number' }], views: [{ id: 'v', name: 'T', type: 'table', calc: { p: 'sum', title: 'count', x: 'nonsense' } }], rest: '' }; + const back = parseDatabase(databaseToMd(db)); + assert.deepEqual(back.views[0].calc, { p: 'sum', title: 'count' }); +}); diff --git a/test/e2e-a11y.test.cjs b/test/e2e-a11y.test.cjs new file mode 100644 index 0000000..8bb26eb --- /dev/null +++ b/test/e2e-a11y.test.cjs @@ -0,0 +1,95 @@ +// End-to-end: keyboard use of the sidebar, focus inside dialogs, rebinding a shortcut, long folders shown in steps. +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-a11y-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(path.join(vault, 'Big'), { recursive: true }); +fs.writeFileSync(path.join(vault, 'Big.md'), 'big\n'); +for (let i = 0; i < 250; i++) fs.writeFileSync(path.join(vault, 'Big', `Item ${String(i).padStart(3, '0')}.md`), `item ${i}\n`); +fs.writeFileSync(path.join(vault, 'Alpha.md'), 'a\n'); +fs.writeFileSync(path.join(vault, 'Beta.md'), 'b\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Alpha', theme: 'dark', bounds: { width: 1200, height: 800 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(250); }; + const active = () => js(`const a = document.activeElement; return a && (a.dataset.id || a.getAttribute('aria-label') || a.className)`); + try { + await sleep(2200); + + // sidebar: arrow keys + await js(`document.querySelector('.tree .row[data-id="Alpha"]').focus()`); + await key('Down'); + check('Down moves to the next row', (await active()) === 'Beta', await active()); + await key('Up'); + await key('Up'); + check('Up moves back, and stops at the first row', (await active()) === 'Alpha', await active()); + await key('End'); + check('End goes to the last row', (await active()) === 'Big', await active()); + check('only the open page is a tab stop in the list', (await js(`return document.querySelectorAll('.tree .row[tabindex="0"]').length`)) === 1); + + // long folders come in steps + await js(`document.querySelector('.tree .row[data-id="Big"]').focus()`); + await key('Right'); + await sleep(400); + check('a long folder shows its first 100', (await js(`return document.querySelectorAll('.tree .row[data-id^="Big/"]').length`)) === 100); + await js(`document.querySelector('.tree .row.more').click()`); + await sleep(300); + check('...and "show more" adds the next 100', (await js(`return document.querySelectorAll('.tree .row[data-id^="Big/"]').length`)) === 200); + + // dialogs keep focus + await js(`document.querySelector('.sidebar button[aria-label="Tasks"]').focus()`); + await js(`document.querySelector('.sidebar button[aria-label="Tasks"]').click()`); + await sleep(500); + check('a dialog is modal and holds focus', (await js(`const d = document.querySelector('[role=dialog]'); return d.getAttribute('aria-modal') === 'true' && d.contains(document.activeElement)`)) === true); + for (let i = 0; i < 12; i++) await key('Tab'); + check('Tab stays inside it', (await js(`return document.querySelector('[role=dialog]').contains(document.activeElement)`)) === true); + await key('Escape'); + await sleep(600); + check('Escape closes it and focus goes back', (await js(`return !document.querySelector('[role=dialog]') && document.activeElement?.getAttribute('aria-label') === 'Tasks'`)) === true); + + // rebinding + await key('Y', ['control', 'shift']); + check('Ctrl+Shift+Y does nothing yet', (await js(`return !document.querySelector('.tasks-panel')`)) === true); + await js(`document.querySelector('.sidebar button[aria-label="Settings"]').click()`); + await sleep(400); + await js(`[...document.querySelectorAll('.set-nav button')].find((b) => b.textContent.includes('Shortcuts')).click()`); + await sleep(200); + await js(`document.querySelector('button[aria-label="Change shortcut for Tasks"]').click()`); + await sleep(150); + await key('Y', ['control', 'shift']); + await sleep(400); + const saved = JSON.parse(fs.readFileSync(path.join(tmp, 'settings.json'), 'utf8')); + check('the new shortcut is saved', saved.prefs && saved.prefs.shortcuts && saved.prefs.shortcuts.tasks === 'Mod+Shift+Y', JSON.stringify(saved.prefs && saved.prefs.shortcuts)); + await key('Escape'); + await sleep(500); + await key('Y', ['control', 'shift']); + await sleep(400); + check('...and opens Tasks', (await js(`return !!document.querySelector('.tasks-panel')`)) === true); + await key('Escape'); + await sleep(300); + await key('T', ['control', 'shift']); + check('the old one no longer does', (await js(`return !document.querySelector('.tasks-panel')`)) === true); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\na11y e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-board-drag.test.cjs b/test/e2e-board-drag.test.cjs new file mode 100644 index 0000000..cd4715d --- /dev/null +++ b/test/e2e-board-drag.test.cjs @@ -0,0 +1,145 @@ +// End-to-end check of dragging in a Kanban board: the card follows the pointer at once, never starts from the +// window's corner, and costs little per move. Run with `bun run test:e2e`. +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-board-drag-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +const cards = (n, tag) => Array.from({ length: n }, (_, i) => `- [ ] ${tag} card ${i + 1} with a few words of text`).join('\n'); +fs.writeFileSync(path.join(vault, 'Board.md'), `---\nkanban-plugin: basic\n---\n\n## To do\n\n${cards(4, 'todo')}\n\n\n## Doing\n\n${cards(2, 'doing')}\n\n\n## Done\n\n`); +// a big board, to see what a drag costs when there is a lot on the page +fs.writeFileSync(path.join(vault, 'Big.md'), `---\nkanban-plugin: basic\n---\n\n` + ['A', 'B', 'C', 'D'].map((c) => `## Column ${c}\n\n${cards(60, c)}\n`).join('\n\n') + '\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Board', theme: 'dark', bounds: { width: 1400, height: 900 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +// the board file as { column: [card texts] } +const columnsOf = (md) => Object.fromEntries(md.split(/\n## /).slice(1).map((sec) => { const [title, ...rest] = sec.split('\n'); return [title.trim(), rest.filter((l) => l.startsWith('- [')).map((l) => l.replace(/^- \[.\] /, '').replace(/ with a few words of text$/, ''))]; })); +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 mouse = (type, x, y) => wc.sendInputEvent({ type, x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1 }); + const read = () => fs.readFileSync(path.join(vault, 'Board.md'), 'utf8'); + const centerOf = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2, r.x, r.y, r.width, r.height]`); + const ghost = () => js(`const g = document.querySelector('.kghost'); if (!g) return null; const r = g.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2, getComputedStyle(g).transitionProperty, getComputedStyle(g).transitionDuration]`); + const card = (text) => `[...document.querySelectorAll('.kcard')].find(k => k.textContent.includes(${JSON.stringify(text)}))`; + + try { + await sleep(2200); + // a frame log: where is the floating card at every animation frame? + await js(`window.__frames = []; const tick = () => { const g = document.querySelector('.kghost'); if (g) { const r = g.getBoundingClientRect(); window.__frames.push([Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]); } requestAnimationFrame(tick); }; tick();`); + + // ---- grab a card and start moving ---- + const c = await centerOf(card('todo card 2')); + mouse('mouseMove', c[0], c[1]); + mouse('mouseDown', c[0], c[1]); + await sleep(40); + // The pointer's offset inside the card is kept, so the floating card's centre = pointer + (card centre - grab point). + const dx = c[0] - c[0], dy = c[1] - c[1]; + const moves = [[c[0] + 30, c[1] + 10], [c[0] + 120, c[1] + 40], [c[0] + 260, c[1] + 70]]; + const seen = []; + for (const [x, y] of moves) { + mouse('mouseMove', x, y); + await sleep(40); // two frames: more than enough for something that follows the pointer + const g = await ghost(); + seen.push({ want: [Math.round(x + dx), Math.round(y + dy)], got: g && [Math.round(g[0]), Math.round(g[1])], tr: g && g[2] + ' ' + g[3] }); + } + const off = seen.map((s) => (s.got ? Math.hypot(s.got[0] - s.want[0], s.got[1] - s.want[1]) : Infinity)); + check('the floating card is under the pointer 40 ms after every move', off.every((d) => d < 12), JSON.stringify(seen)); + check('its position is not animated (no transition on it)', seen.every((s) => /^0s(, 0s)*$/.test((s.tr || '').split(' ').slice(1).join(' ')) || /none/.test(s.tr?.split(' ')[0] || '')), JSON.stringify(seen.map((s) => s.tr))); + const frames = await js(`return window.__frames`); + const nearCorner = frames.filter(([x, y]) => x < 150 && y < 150); + check('it never shows up in the window\'s top-left corner', nearCorner.length === 0, `${nearCorner.length} of ${frames.length} frames: ${JSON.stringify(nearCorner.slice(0, 4))}`); + + // ---- quick, fine-grained movement ---- + const last = moves[moves.length - 1]; + for (let i = 1; i <= 40; i++) { mouse('mouseMove', last[0] + i * 4, last[1] + i * 2); await sleep(6); } + await sleep(40); + const g2 = await ghost(); + const want2 = [last[0] + 160, last[1] + 80]; + check('it keeps up with fast movement', g2 && Math.hypot(g2[0] - want2[0], g2[1] - want2[1]) < 12, JSON.stringify({ want: want2.map(Math.round), got: g2 && g2.slice(0, 2).map(Math.round) })); + + // ---- finishing the drag still moves the card ---- + const target = await centerOf(card('doing card 1')); + mouse('mouseMove', target[0], target[1] + 5); + await sleep(100); + mouse('mouseUp', target[0], target[1] + 5); + await sleep(900); + const cols = columnsOf(read()); + check('dropping moves the card to the other column, at the spot it was dropped', JSON.stringify(cols['To do']) === '["todo card 1","todo card 3","todo card 4"]' && JSON.stringify(cols.Doing) === '["doing card 1","todo card 2","doing card 2"]', JSON.stringify(cols)); + + // ---- dragging a whole column ---- + const head = await centerOf(`[...document.querySelectorAll('.kcol')][0].querySelector('.kh-n')`); + const doneHead = await centerOf(`[...document.querySelectorAll('.kcol')][2].querySelector('.khead')`); + mouse('mouseMove', head[0], head[1]); + mouse('mouseDown', head[0], head[1]); + await sleep(40); + mouse('mouseMove', head[0] + 60, head[1] + 4); + await sleep(60); + const gc = await ghost(); + check('a dragged column follows the pointer too', gc && Math.hypot(gc[0] - (head[0] + 60 + (await centerOf(`[...document.querySelectorAll('.kcol')][0]`))[0] - head[0]), gc[1] - (head[1] + 4 + (await centerOf(`[...document.querySelectorAll('.kcol')][0]`))[1] - head[1])) < 14 && /none/.test(gc[2]), JSON.stringify(gc)); + mouse('mouseMove', doneHead[0] + 120, doneHead[1]); + await sleep(100); + mouse('mouseUp', doneHead[0] + 120, doneHead[1]); + await sleep(900); + check('...and is dropped in its new place', Object.keys(columnsOf(read())).join() === 'Doing,Done,To do', Object.keys(columnsOf(read())).join()); + + // ---- a lot on the page: moving over it costs little ---- + await js(`[...document.querySelectorAll('.tree .row')].find(r => r.dataset.id === 'Big').click()`); + await sleep(1800); + await js(`window.__long = []; try { new PerformanceObserver((l) => l.getEntries().forEach((e) => window.__long.push(Math.round(e.duration)))).observe({ entryTypes: ['longtask'] }); } catch {}`); + await js(`window.__muts = 0; window.__mo = new MutationObserver((l) => { window.__muts += l.length; }); window.__mo.observe(document.querySelector('.board'), { subtree: true, attributes: true, childList: true });`); + const b = await centerOf(card('A card 5')); + mouse('mouseMove', b[0], b[1]); + mouse('mouseDown', b[0], b[1]); + await sleep(60); + mouse('mouseMove', b[0] + 20, b[1] + 20); + await sleep(120); + await js(`window.__muts = 0`); + // 60 pointer events that all stay over the same drop position + for (let i = 0; i < 60; i++) { mouse('mouseMove', b[0] + 20 + (i % 6), b[1] + 20 + (i % 4)); await sleep(5); } + await sleep(100); + const muts = await js(`return window.__muts`); + check('moving within one drop position barely touches the page', muts <= 6, `${muts} DOM changes for 60 moves`); + // sweep down a column and across + for (let i = 0; i < 80; i++) { mouse('mouseMove', b[0] + i * 7, b[1] + 20 + i * 5); await sleep(8); } + await sleep(150); + mouse('mouseUp', b[0] + 80 * 7, b[1] + 20 + 80 * 5); + await sleep(500); + // the card is let go over the right edge of the board: sit there without moving + const edge = await js(`const r = document.querySelector('.board').getBoundingClientRect(); return [Math.round(r.right - 20), Math.round(r.top + 120)]`); + mouse('mouseMove', b[0], b[1]); + mouse('mouseDown', b[0], b[1]); + await sleep(60); + mouse('mouseMove', b[0] + 15, b[1] + 15); + await sleep(60); + mouse('mouseMove', edge[0], edge[1]); + await sleep(150); + const s1 = await js(`return document.querySelector('.board').scrollLeft`); + await sleep(400); + const s2 = await js(`return document.querySelector('.board').scrollLeft`); + mouse('mouseUp', edge[0], edge[1]); + await sleep(500); + check('holding a card at the board\'s edge keeps scrolling, without moving the mouse', s2 > s1 + 20, `${s1} -> ${s2}`); + const long = await js(`return window.__long`); + check('no long freezes while dragging over 240 cards', long.every((d) => d < 120), JSON.stringify(long)); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nboard drag e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-canvas.test.cjs b/test/e2e-canvas.test.cjs new file mode 100644 index 0000000..50e97af --- /dev/null +++ b/test/e2e-canvas.test.cjs @@ -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('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); +}); diff --git a/test/e2e-columns.test.cjs b/test/e2e-columns.test.cjs new file mode 100644 index 0000000..ddfa8f5 --- /dev/null +++ b/test/e2e-columns.test.cjs @@ -0,0 +1,144 @@ +// 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); +}); diff --git a/test/e2e-conflict.test.cjs b/test/e2e-conflict.test.cjs new file mode 100644 index 0000000..c319c3e --- /dev/null +++ b/test/e2e-conflict.test.cjs @@ -0,0 +1,74 @@ +// End-to-end check of edits made outside the app: they are never silently overwritten. +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-conflict-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +fs.writeFileSync(path.join(vault, 'Note.md'), 'Original line.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Note', bounds: { width: 1200, height: 800 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const note = path.join(vault, 'Note.md'); + const versions = () => { + const dir = path.join(vault, '.notes', 'history', 'Note'); + return fs.existsSync(dir) ? fs.readdirSync(dir).map((f) => fs.readFileSync(path.join(dir, f), 'utf8')) : []; + }; + const toastText = () => js(`const t = document.querySelector('.toast'); return t && t.classList.contains('show') ? t.textContent : ''`); + const typeAtEnd = async (text) => { + await js(`const c = document.querySelector('.block .c'); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); const s = getSelection(); s.removeAllRanges(); s.addRange(r)`); + await wc.insertText(text); + }; + + try { + await sleep(1800); + + // an ordinary edit: no fuss + await typeAtEnd(' One.'); + await sleep(1500); + check('an ordinary edit saves with no warning', /Original line\. One\./.test(fs.readFileSync(note, 'utf8')) && !/outside Ink/.test(await toastText()), JSON.stringify(await toastText())); + + // changed on disk while this window has unsaved edits + await typeAtEnd(' Mine.'); + fs.writeFileSync(note, 'Written by another editor.\n'); // within the save delay + await sleep(2200); + const now = fs.readFileSync(note, 'utf8'); + check('your edit is saved', /Mine\./.test(now), JSON.stringify(now)); + check('...the other version is kept in version history', versions().some((v) => /Written by another editor\./.test(v)), JSON.stringify(versions().map((v) => v.slice(0, 40)))); + check('...and you are told', /outside Ink/.test(await toastText()) && /Show versions/.test(await toastText()), JSON.stringify(await toastText())); + await js(`document.querySelector('.toast .toast-btn').click()`); + await sleep(700); + check('"Show versions" opens the history', !!(await js(`return document.querySelector('[aria-label="Version history"], .panel')`))); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`); + await sleep(400); + + // changed on disk with nothing unsaved: the window just follows + fs.writeFileSync(note, 'Fresh from elsewhere.\n'); + await sleep(1800); + check('with nothing unsaved the page follows the file', /Fresh from elsewhere/.test(await js(`return document.querySelector('.block .c')?.textContent || ''`))); + await typeAtEnd(' Then me.'); + await sleep(1500); + check('...and editing on from there raises no warning', /Fresh from elsewhere\. Then me\./.test(fs.readFileSync(note, 'utf8')) && !/outside Ink/.test(await toastText().then((t) => (/Updated from disk/.test(t) ? '' : t))), JSON.stringify(await toastText())); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nconflict e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-contextmenu.test.cjs b/test/e2e-contextmenu.test.cjs new file mode 100644 index 0000000..c1a9059 --- /dev/null +++ b/test/e2e-contextmenu.test.cjs @@ -0,0 +1,105 @@ +// End-to-end check of the page context menu: the "Add inside" submenu, the delete confirmation, and the sidebar "+" menu. +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-ctx-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +fs.writeFileSync(path.join(vault, 'Alpha.md'), 'Content of Alpha\n'); +fs.writeFileSync(path.join(vault, 'Beta.md'), 'Content of Beta\n'); + +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Alpha', bounds: { width: 1280, height: 860 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const menuOpen = () => js(`return !!document.querySelector('.ctx')`); + const subItems = () => js(`return [...document.querySelectorAll('.ctx .sub .mi')].map((b) => b.textContent.trim())`); + const rightClick = (id) => js(`document.querySelector('.row[data-id="${id}"]').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 200, clientY: 200 }))`); + + try { + await sleep(2000); + + await rightClick('Beta'); + await sleep(300); + check('the page menu opens', await menuOpen()); + check('the four "Add … inside" items are one "Add inside" item', await js(`const t = [...document.querySelectorAll('.ctx > .mi, .ctx > .mi-sub > .mi')].map((b) => b.textContent.trim()); return t.includes('Add inside') && !t.some((x) => /^Add (page|board|database|canvas) inside$/.test(x))`)); + check('the side menu is closed at first', (await subItems()).length === 0); + + await js(`document.querySelector('.ctx .mi-sub').dispatchEvent(new PointerEvent('pointerenter'))`); + await sleep(200); + check('hovering opens the side menu with the four kinds', JSON.stringify(await subItems()) === JSON.stringify(['Page', 'Board', 'Database', 'Canvas']), JSON.stringify(await subItems())); + + await js(`document.querySelector('.ctx .mi-sub').dispatchEvent(new PointerEvent('pointerleave'))`); + await sleep(40); + check('...and it stays open for a moment after the pointer leaves', (await subItems()).length === 4); + await js(`document.querySelector('.ctx .mi-sub')?.dispatchEvent(new PointerEvent('pointerenter'))`); + await sleep(300); + check('...and stays if the pointer comes back', (await subItems()).length === 4); + + await js(`document.querySelector('.ctx .mi-sub').dispatchEvent(new PointerEvent('pointerleave'))`); + await sleep(400); + check('...and closes once it has really left', (await subItems()).length === 0); + + // Delete asks twice, and forgets the first press. + await js(`document.querySelector('.ctx .mi.danger').click()`); + await sleep(100); + check('Delete asks for a second click', await js(`return document.querySelector('.ctx .mi.danger').classList.contains('armed')`)); + await sleep(3400); + check('...and forgets it after a few seconds', await js(`return !document.querySelector('.ctx .mi.danger').classList.contains('armed')`)); + + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`); + await sleep(200); + check('Escape closes the menu', !(await menuOpen())); + + // Without hover (touch): a click opens and closes the side menu. + await rightClick('Beta'); + await sleep(300); + await js(`document.querySelector('.ctx .mi-sub > .mi').click()`); + await sleep(150); + check('a click opens the side menu', (await subItems()).length === 4); + await js(`document.querySelector('.ctx .mi-sub > .mi').click()`); + await sleep(150); + check('...and a second click closes it', (await subItems()).length === 0); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`); + await sleep(150); + + // The page's icon and its breadcrumb open the page menu too + await js(`document.querySelector('.p-icon').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 300, clientY: 200 }))`); + await sleep(200); + check('right-clicking the page icon opens the page menu', await menuOpen()); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`); + await sleep(150); + await js(`document.querySelector('.crumb').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 300, clientY: 40 }))`); + await sleep(200); + check('right-clicking a breadcrumb opens the page menu', await menuOpen()); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`); + await sleep(150); + + // The "+" on a sidebar row + await js(`document.querySelector('.row[data-id="Beta"] .row-add').click()`); + await sleep(300); + check('the "+" opens the kind menu', JSON.stringify(await js(`return [...document.querySelectorAll('.ctx .mi')].map((b) => b.textContent.trim())`)) === JSON.stringify(['Page', 'Board', 'Database', 'Canvas'])); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`); + } catch (err) { + check('harness', false, err.stack); + } + + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ncontext menu e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-cover.test.cjs b/test/e2e-cover.test.cjs new file mode 100644 index 0000000..5dbf2f9 --- /dev/null +++ b/test/e2e-cover.test.cjs @@ -0,0 +1,143 @@ +// End-to-end check of picture covers: upload from the file dialog, drop onto the cover, remove, and moving with the page. +const { app, BrowserWindow, dialog } = require('electron'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'); +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-cover-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(path.join(vault, 'Folder'), { recursive: true }); +fs.writeFileSync(path.join(vault, 'Note.md'), 'Some text.\n'); +fs.writeFileSync(path.join(vault, 'Folder', 'Home.md'), 'Home.\n'); +fs.mkdirSync(path.join(vault, 'Gal')); +fs.writeFileSync(path.join(vault, 'Gal.md'), '---\nink-database: basic\n---\n\n%% ink-database\n{"properties":[],"views":[{"id":"v1","name":"Gallery","type":"gallery"}]}\n%%\n'); +fs.writeFileSync(path.join(vault, 'Gal', 'Item.md'), '---\ncover: first.png\n---\n'); +fs.writeFileSync(path.join(vault, 'Gal', 'first.png'), PNG); +const outside = path.join(tmp, 'banner.png'); +fs.writeFileSync(outside, PNG); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Note', bounds: { width: 1300, height: 800 } })); +dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [outside] }); +// web pictures: no real network (and the address check is told these hosts are public) +const lp = require('../electron/linkpreview.cjs'); +const asked = []; +lp.hooks.lookup = async () => [{ address: '93.184.216.34' }]; +lp.hooks.fetch = async (url) => { + asked.push(url); + if (/\/page$/.test(url)) return new Response('', { headers: { 'content-type': 'text/html' } }); + return new Response(PNG, { headers: { 'content-type': 'image/png' } }); +}; +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 centerOf = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + const click = async (sel) => { + const c = await centerOf(sel); + if (!c) throw new Error('not found: ' + sel); + wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + await sleep(450); + }; + const fm = (f) => fs.readFileSync(path.join(vault, f), 'utf8'); + const loaded = () => js(`const i = document.querySelector('.cover .cover-img'); return !!i && i.complete && i.naturalWidth > 0`); + + try { + await sleep(1800); + check('a page starts with a gradient cover', await js(`return !document.querySelector('.cover .cover-img') && !document.querySelector('.cover.photo')`)); + + await click(`document.querySelector('.cover-btn')`); + check('the cover picker offers an upload', !!(await js(`return [...document.querySelectorAll('.cover-picker .c-up')].find((b) => b.textContent.includes('Upload'))`))); + await click(`[...document.querySelectorAll('.cover-picker .c-up')].find((b) => b.textContent.includes('Upload'))`); + await sleep(900); + check('uploading copies the picture next to the page', fs.existsSync(path.join(vault, 'banner.png'))); + check('...and the page\'s front matter points at it', /cover: banner\.png/.test(fm('Note.md')), JSON.stringify(fm('Note.md'))); + check('...and it shows as the cover', await loaded() && await js(`return document.querySelector('.cover').classList.contains('photo')`)); + + // a dropped picture + await js(` + const dt = new DataTransfer(); + const bytes = Uint8Array.from(atob(${JSON.stringify(PNG.toString('base64'))}), (c) => c.charCodeAt(0)); + dt.items.add(new File([bytes], 'dropped.png', { type: 'image/png' })); + const el = document.querySelector('.cover'); + el.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt })); + el.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })); + `); + await sleep(1200); + check('dropping a picture on the cover replaces it', /cover: dropped\.png/.test(fm('Note.md')) && fs.existsSync(path.join(vault, 'dropped.png')), fm('Note.md')); + + // it goes with the page + await js(`await window.vault.flush?.()`); + await sleep(800); + const moved = await js(`return await window.vault.move('Note', 'Folder')`); + await sleep(800); + check('moving the page moves its cover picture too', moved === 'Folder/Note' && fs.existsSync(path.join(vault, 'Folder', 'dropped.png')) && !fs.existsSync(path.join(vault, 'dropped.png')), String(moved)); + check('...and the front matter still names it', /cover: dropped\.png/.test(fm('Folder/Note.md'))); + + // back to a gradient + await js(`document.querySelector('.row[data-id="Folder/Note"], .row[data-id="Folder"]')?.click()`); + await sleep(600); + await js(`await new Promise((r) => setTimeout(r, 200))`); + await js(`const row = document.querySelector('.row[data-id="Folder/Note"]'); if (row) row.click();`); + await sleep(1000); + await click(`document.querySelector('.cover-btn')`); + await click(`[...document.querySelectorAll('.cover-picker .c-up')].find((b) => b.textContent.includes('Remove'))`); + await sleep(1000); + check('"Remove the image" goes back to a gradient', !/cover:/.test(fm('Folder/Note.md')) && !(await js(`return !!document.querySelector('.cover .cover-img')`)), fm('Folder/Note.md')); + + // a picture from a web address + await js(`document.querySelector('.row[data-id="Folder/Note"]').click()`); + await sleep(900); + await click(`document.querySelector('.cover-btn')`); + await click(`[...document.querySelectorAll('.cover-picker .c-up')].find((b) => b.textContent.includes('link'))`); + check('the picker offers "From a link"', !!(await js(`return document.querySelector('.cover-picker .c-url')`))); + const typeUrl = (v) => js(`const i = document.querySelector('.cover-picker .c-url'); i.value = ${JSON.stringify(v)}; i.dispatchEvent(new Event('input', { bubbles: true }))`); + await typeUrl('http://127.0.0.1/secret.png'); + await click(`document.querySelector('.cover-picker .c-go')`); + await sleep(600); + check('an address on this machine is refused, with a message', /public web address/.test(await js(`return document.querySelector('.cover-picker .c-problem')?.textContent || ''`))); + check('...without anything being fetched', asked.length === 0, JSON.stringify(asked)); + await typeUrl('https://example.com/blog/page'); + await click(`document.querySelector('.cover-picker .c-go')`); + await sleep(600); + check('an address that is not a picture says so', /not a picture/.test(await js(`return document.querySelector('.cover-picker .c-problem')?.textContent || ''`))); + await typeUrl('https://example.com/img/lipstick.png?w=800'); + await click(`document.querySelector('.cover-picker .c-go')`); + await sleep(1200); + check('a picture address is downloaded next to the page', fs.existsSync(path.join(vault, 'Folder', 'lipstick.png')), JSON.stringify(fs.readdirSync(path.join(vault, 'Folder')))); + check('...and becomes the cover', /cover: lipstick\.png/.test(fm('Folder/Note.md')) && await loaded()); + check('...and the picker closes', !(await js(`return !!document.querySelector('.cover-picker')`))); + + // a changed cover shows in the database gallery at once, not after a restart + await js(`document.querySelector('.row[data-id="Gal"]').click()`); + await sleep(1200); + const src = () => js(`return document.querySelector('.db-gcover img')?.getAttribute('src')`); + check('a gallery card shows the record\'s cover picture', /src=first\.png/.test(await src()), await src()); + await js(`document.querySelector('.db-gcard').click()`); + await sleep(1000); + await click(`document.querySelector('.cover-btn')`); + await click(`[...document.querySelectorAll('.cover-picker .c-up')].find((b) => b.textContent.includes('Upload'))`); + await sleep(1200); + await js(`document.querySelector('.crumb').click()`); + await sleep(1200); + check('...and shows the new one right after it is changed', /src=banner\.png/.test(await src()), await src()); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ncover e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-daily.test.cjs b/test/e2e-daily.test.cjs new file mode 100644 index 0000000..d4feffd --- /dev/null +++ b/test/e2e-daily.test.cjs @@ -0,0 +1,100 @@ +// End-to-end check of where "Today" puts the daily note. Run with `bun run test:e2e`. +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-daily-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(path.join(vault, 'Private', 'Daily'), { recursive: true }); +fs.writeFileSync(path.join(vault, 'Private.md'), 'Private stuff\n'); +fs.writeFileSync(path.join(vault, 'Private', 'Daily.md'), 'My journal\n'); +fs.writeFileSync(path.join(vault, 'Private', 'Daily', '2020-01-01.md'), 'An old day\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Private', theme: 'dark', bounds: { width: 1100, height: 760 } })); +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); +}; +const z = (n) => String(n).padStart(2, '0'); +const d = new Date(); +const TODAY = `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`; + +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 m = (type, x, y) => wc.sendInputEvent({ type, x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1 }); + const box = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return { cx: r.x + r.width / 2, cy: r.y + r.height / 2 }`); + const click = async (x, y) => { m('mouseMove', x, y); m('mouseDown', x, y); m('mouseUp', x, y); await sleep(300); }; + const type = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(30); } await sleep(250); }; + const key = async (keyCode) => { wc.sendInputEvent({ type: 'keyDown', keyCode }); wc.sendInputEvent({ type: 'keyUp', keyCode }); await sleep(250); }; + const saved = () => JSON.parse(fs.readFileSync(path.join(tmp, 'settings.json'), 'utf8')); + const today = async () => { + await js(`[...document.querySelectorAll('.sidebar .qa')].find((b) => b.textContent.includes('Today')).click()`); + await sleep(1500); + }; + + try { + await sleep(2200); + await today(); + check('"Today" uses the Daily folder where it already is, not a new one at the top', fs.existsSync(path.join(vault, 'Private', 'Daily', `${TODAY}.md`)) && !fs.existsSync(path.join(vault, 'Daily')) && !fs.existsSync(path.join(vault, 'Daily.md'))); + check('...and opens it', (await js(`return document.querySelector('.tree .row.active')?.dataset.id`)) === `Private/Daily/${TODAY}`); + + // choose another folder in Settings + await js(`[...document.querySelectorAll('.sidebar button')].find((b) => b.getAttribute('aria-label') === 'Settings').click()`); + await sleep(500); + check('Settings shows the folder it found', (await js(`return document.querySelector('input[aria-label="Daily notes folder"]')?.placeholder`)) === 'Private/Daily'); + const field = await box(`document.querySelector('input[aria-label="Daily notes folder"]')`); + await click(field.cx, field.cy); + check('clicking the field lists the folders, with "find automatically" first', await js(`const l = [...document.querySelectorAll('.fp-list .mi')].map((b) => b.textContent.trim()); return l[0].startsWith('Find automatically') && l.some((t) => t.startsWith('Daily')) && l.some((t) => t.startsWith('Private'))`)); + await type('dai'); + check('typing narrows it, best matches first', await js(`const l = [...document.querySelectorAll('.fp-list .mi')].map((b) => b.querySelector('.mi-label').textContent.trim()); return l.length >= 2 && l[0] === 'Daily' && !l.includes('Private') && l[l.length - 1].startsWith('Use')`)); + await key('Escape'); + check('Escape closes the list and keeps the dialog', !(await js(`return !!document.querySelector('.fp-list')`)) && !!(await js(`return !!document.querySelector('.settings')`))); + await click(field.cx, field.cy); + await js(`const i = document.querySelector('input[aria-label="Daily notes folder"]'); i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true }));`); + await type(' Journal / Days '); + check('a folder that does not exist yet can be used as typed, tidied', await js(`const l = [...document.querySelectorAll('.fp-list .mi')]; return l[l.length - 1].textContent.includes('Use “Journal/Days”')`)); + await key('Return'); + await sleep(600); + check('Enter keeps it', (await js(`return document.querySelector('input[aria-label="Daily notes folder"]').value`)) === 'Journal/Days'); + const vkey = Object.keys(saved().vaults || {})[0]; + check('...and remembers it with the notes folder', saved().vaults[vkey].dailyFolder === 'Journal/Days', JSON.stringify(saved().vaults)); + // picking an existing folder from the list + await click(field.cx, field.cy); + await type('priv'); + const row = await box(`[...document.querySelectorAll('.fp-list .mi')].find((b) => b.querySelector('.mi-label').textContent.trim() === 'Private')`); + await click(row.cx, row.cy); + await sleep(500); + check('clicking a folder in the list chooses it', saved().vaults[vkey].dailyFolder === 'Private' && !(await js(`return !!document.querySelector('.fp-list')`))); + await click(field.cx, field.cy); + await type('Journal/Days'); + await key('Return'); + await sleep(500); + await js(`document.querySelector('.settings [aria-label="Close"]').click()`); + await sleep(400); + fs.rmSync(path.join(vault, 'Private', 'Daily', `${TODAY}.md`)); + await today(); + check('"Today" now makes the note in the chosen folder', fs.existsSync(path.join(vault, 'Journal', 'Days', `${TODAY}.md`)) && !fs.existsSync(path.join(vault, 'Private', 'Daily', `${TODAY}.md`))); + check('...and nothing is made at the top', !fs.existsSync(path.join(vault, 'Daily'))); + + // back to automatic + await js(`[...document.querySelectorAll('.sidebar button')].find((b) => b.getAttribute('aria-label') === 'Settings').click()`); + await sleep(500); + await js(`document.querySelector('.set-link').click()`); + await sleep(500); + check('"Find it automatically" goes back', (await js(`return document.querySelector('input[aria-label="Daily notes folder"]').value`)) === '' && saved().vaults[vkey].dailyFolder === null); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ndaily e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-database-extras.test.cjs b/test/e2e-database-extras.test.cjs new file mode 100644 index 0000000..f81998c --- /dev/null +++ b/test/e2e-database-extras.test.cjs @@ -0,0 +1,197 @@ +// End-to-end check of databases inside pages (embeds), CSV import and export, searching by property, links and +// relations in the graph, and what happens to a record's properties when it leaves its database. +const { app, BrowserWindow, dialog } = require('electron'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-dbx-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(path.join(vault, 'Tasks'), { recursive: true }); +const schema = { + properties: [ + { key: 'status', name: 'Status', type: 'select', options: ['Todo', 'Doing', 'Done'] }, + { key: 'points', name: 'Points', type: 'number' }, + { key: 'done', name: 'Done', type: 'checkbox' }, + { key: 'owner', name: 'Owner', type: 'people' }, + { key: 'after', name: 'After', type: 'relation' }, + { key: 'spec', name: 'Spec', type: 'text' } + ], + views: [ + { id: 'v1', name: 'Table', type: 'table', filters: [], hidden: [], sort: null }, + { id: 'v2', name: 'Board', type: 'board', groupBy: 'status', filters: [], hidden: [], sort: null } + ] +}; +const tasksMd = `---\nink-database: basic\nicon: table\n---\n\n%% ink-database\n${JSON.stringify(schema, null, 2)}\n%%\n`; +fs.writeFileSync(path.join(vault, 'Tasks.md'), tasksMd); +fs.writeFileSync(path.join(vault, 'Tasks', 'Write docs.md'), '---\nstatus: Doing\npoints: 5\nowner: [Ana, Ben]\nspec: see [[Spec]]\n---\n\nNotes.\n'); +fs.writeFileSync(path.join(vault, 'Tasks', 'Ship it.md'), '---\nstatus: Todo\npoints: 8\ndone: false\nafter: [Write docs]\n---\n'); +fs.writeFileSync(path.join(vault, 'Tasks', 'Plan.md'), '---\nstatus: Done\npoints: 2\n---\n'); +fs.writeFileSync(path.join(vault, 'Spec.md'), 'The spec.\n'); +fs.writeFileSync(path.join(vault, 'Hub.md'), 'Before.\n\n![[Tasks#Board|filter=status:is:Doing]]\n\nAfter.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Hub', bounds: { width: 1400, height: 900 } })); +const exported = path.join(tmp, 'out.csv'); +dialog.showSaveDialog = async () => ({ canceled: false, filePath: exported }); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(350); }; + const centerOf = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + const click = async (sel) => { + const c = await centerOf(sel); + if (!c) throw new Error('not found: ' + sel); + wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + await sleep(450); + }; + const fm = (f) => fs.readFileSync(path.join(vault, f), 'utf8'); + const openPage = (id) => js(`document.querySelector('.row[data-id="${id}"]').click()`).then(() => sleep(900)); + const embedTab = (name) => click(`[...document.querySelectorAll('.emb.live .db-tab')].find(b => b.textContent.trim() === '${name}')`); + const search = (q) => js(`return (await window.vault.search(${JSON.stringify(q)})).map(r => r.id).sort()`); + const runCommand = async (text) => { + await key('k', ['control']); + await sleep(300); + await js(`const i = document.querySelector('.overlay input'); i.value = ${JSON.stringify(text)}; i.dispatchEvent(new Event('input', { bubbles: true }))`); + await sleep(300); + await key('Return'); + await sleep(900); + }; + + try { + await sleep(1800); + + // ---- a database inside a page ---- + check('![[Tasks#Board|…]] shows the database live inside the page', await js(`return !!document.querySelector('.emb.live .db')`)); + check('...with its saved views as tabs, and the one named after # active', (await js(`return [...document.querySelectorAll('.emb.live .db-tab:not(.add)')].map(b => b.textContent.trim() + (b.classList.contains('on') ? '*' : '')).join()`)) === 'Table,Board*'); + check('...showing only what the embed\'s own filter lets through', (await js(`return [...document.querySelectorAll('.emb.live .db-card-title')].map(c => c.textContent.trim()).join()`)) === 'Write docs'); + check('...and no controls that would change the database itself', await js(`return !document.querySelector('.emb.live .db-tab.add') && !document.querySelector('.emb.live .db-resize')`)); + + await embedTab('Table'); + await sleep(900); + check('picking another tab in the embed is saved into the page', /!\[\[Tasks#Board\|[^\]]*view=Table[^\]]*filter=status:is:Doing/.test(fm('Hub.md')), JSON.stringify(fm('Hub.md'))); + check('...and the embed keeps its filter on the other view', (await js(`return document.querySelectorAll('.emb.live .dbt tbody tr[data-rec]').length`)) === 1); + + await js(`const i = document.querySelector('.emb.live .dbt tbody tr[data-rec] input[type=number]'); i.value = '9'; i.dispatchEvent(new Event('change', { bubbles: true }))`); + await sleep(900); + check('editing a value in the embed writes the record', /points: 9/.test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md')); + check('the database page itself was left alone', fm('Tasks.md') === tasksMd); + + await click(`[...document.querySelectorAll('.emb.live .dbt th .db-th')].find(b => b.textContent.includes('Points'))`); + await sleep(900); + check('clicking a column in the embed sorts it, for this embed only', /sort=points:asc/.test(fm('Hub.md')) && fm('Tasks.md') === tasksMd, fm('Hub.md')); + + await click(`document.querySelector('.emb.live .emb-title')`); + await sleep(900); + check('the embed\'s title opens the database', (await js(`return document.querySelector('.title')?.textContent`)) === 'Tasks'); + + // ---- search by property ---- + check('a property value is found as a plain word', JSON.stringify(await search('Ana')) === '["Tasks/Write docs"]', JSON.stringify(await search('Ana'))); + check('status:Doing finds the records with that value', JSON.stringify(await search('status:doing')) === '["Tasks/Write docs"]'); + check('points:>=5 compares numbers', JSON.stringify(await search('points:>=5')) === '["Tasks/Ship it","Tasks/Write docs"]'); + check('owner:!ana lists the records without that value', JSON.stringify(await search('owner:!ana')) === '["Tasks/Plan","Tasks/Ship it"]', JSON.stringify(await search('owner:!ana'))); + check('a changed value is searchable at once', await (async () => { + await js(`await window.vault.write('Tasks/Plan', { status: 'Doing', points: '2' }, '')`); + return JSON.stringify(await search('status:doing')) === '["Tasks/Plan","Tasks/Write docs"]'; + })()); + await js(`await window.vault.write('Tasks/Plan', { status: 'Done', points: '2' }, '')`); + await key('k', ['control']); + await sleep(300); + await js(`const i = document.querySelector('.overlay input'); i.value = 'status:todo'; i.dispatchEvent(new Event('input', { bubbles: true }))`); + await sleep(500); + check('the search palette lists them, with the values that matched', (await js(`return document.querySelector('.overlay')?.textContent`)).includes('Ship it')); + await key('Escape'); + + // ---- graph and backlinks ---- + const g = await js(`return await window.vault.graph()`); + check('a [[link]] in a property value is an edge in the graph', g.edges.some(([a, b]) => a === 'Tasks/Write docs' && b === 'Spec')); + check('a relation property is an edge too', g.edges.some(([a, b]) => a === 'Tasks/Ship it' && b === 'Tasks/Write docs')); + const back = await js(`return (await window.vault.backlinks('Spec')).map(b => b.id)`); + check('and the page it names lists the record under backlinks', back.includes('Tasks/Write docs'), JSON.stringify(back)); + + // ---- CSV export ---- + await openPage('Tasks'); + await runCommand('Export this database as CSV'); + const csv = fs.existsSync(exported) ? fs.readFileSync(exported, 'utf8') : ''; + check('Export writes a Name column and every property', csv.split('\r\n')[0] === 'Name,Status,Points,Done,Owner,After,Spec', JSON.stringify(csv.split('\r\n')[0])); + check('...with a row per record', csv.includes('Write docs,Doing,9,false,"Ana, Ben",,see [[Spec]]') && csv.includes('Ship it,Todo,8,false,,Write docs,'), JSON.stringify(csv)); + + // ---- CSV import ---- + const people = 'Name,Team,Joined,Salary,Remote,Skills\r\nAda,Core,2024-01-15,"$5,200",yes,"go, sql"\r\nGrace,Core,2024-03-01,"$6,100",no,sql\r\nLin,Docs,3/9/2025,"$4,800",yes,"writing, sql"\r\nMo,Docs,,,no,\r\n'; + await js(` + const orig = HTMLInputElement.prototype.click; + HTMLInputElement.prototype.click = function () { + if (this.type !== 'file') return orig.call(this); + const dt = new DataTransfer(); + dt.items.add(new File([window.__csv], 'People.csv', { type: 'text/csv' })); + this.files = dt.files; + this.onchange && this.onchange(); + }; + `); + await js(`window.__csv = ${JSON.stringify(people)}`); + await runCommand('Import a CSV file as a new database'); + check('importing a CSV opens a dialog listing its columns', (await js(`return document.querySelectorAll('.csv-col').length`)) === 6); + const kinds = await js(`return [...document.querySelectorAll('.csv-col')].map(c => c.querySelector('.sm-val')?.textContent + ':' + (c.querySelector('.csv-type')?.textContent || c.querySelector('select, input.csv-pname')?.value || '')).join('|')`); + check('...with Name, and types guessed for the rest', /^Name:/.test(kinds) && /Number|Date|Checkbox|Multi-select|Select/.test(await js(`return document.querySelector('.csv-cols').textContent + [...document.querySelectorAll('.csv-col .fp.sm .sm-val')].map(e => e.textContent).join()`)), kinds); + await click(`[...document.querySelectorAll('.csv .btn.primary')][0]`); + await sleep(1500); + const dbFile = fs.readdirSync(vault).find((f) => /^People\.md$/.test(f)); + check('Import creates a database page', !!dbFile && /ink-database: basic/.test(fm('People.md'))); + const recs = fs.existsSync(path.join(vault, 'People')) ? fs.readdirSync(path.join(vault, 'People')).sort() : []; + check('...with a record per row', recs.join() === 'Ada.md,Grace.md,Lin.md,Mo.md', recs.join()); + const ada = recs.includes('Ada.md') ? fm('People/Ada.md') : ''; + check('...values stored by type', /joined: 2024-01-15/.test(ada) && /salary: 5200/.test(ada) && /remote: true/.test(ada) && /skills: \[go, sql\]/.test(ada), JSON.stringify(ada)); + check('...a US-style date is read as month/day', recs.includes('Lin.md') && /joined: 2025-03-09/.test(fm('People/Lin.md'))); + const sch = JSON.parse(fm('People.md').slice(fm('People.md').indexOf('{'), fm('People.md').lastIndexOf('}') + 1)); + check('...and the schema has the guessed types, plus a calendar', sch.properties.map((p) => p.type).join() === 'select,date,number,checkbox,multiselect' && sch.views.map((v) => v.type).join() === 'table,calendar', JSON.stringify(sch.properties.map((p) => p.type)) + sch.views.map((v) => v.type)); + check('...and it opens showing the records', (await js(`return document.querySelectorAll('.dbt tbody tr[data-rec]').length`)) === 4); + + // into an existing database: columns matching properties go to them + await openPage('Tasks'); + await js(`window.__csv = 'Title,Status,Points,Effort\\r\\nDeploy,Doing,3,high\\r\\nRelease,Blocked,13,low\\r\\n'`); + await runCommand('Import a CSV file into this database'); + const map = await js(`return [...document.querySelectorAll('.csv-col')].map(c => c.querySelector('.sm-val')?.textContent).join()`); + check('into an existing database, columns line up with its properties by name', map === 'Name,Status,Points,New property', map); + await click(`[...document.querySelectorAll('.csv .btn.primary')][0]`); + await sleep(1800); + const deploy = fs.existsSync(path.join(vault, 'Tasks', 'Deploy.md')) ? fm('Tasks/Deploy.md') : ''; + check('...and the records land in it', /status: Doing/.test(deploy) && /points: 3/.test(deploy) && /effort: high/.test(deploy), JSON.stringify(deploy)); + const sch2 = JSON.parse(fm('Tasks.md').slice(fm('Tasks.md').indexOf('{'), fm('Tasks.md').lastIndexOf('}') + 1)); + check('...a new option is added to the select', sch2.properties[0].options.includes('Blocked') && sch2.properties.some((p) => p.key === 'effort')); + check('...and nothing else in the schema changed', sch2.views.length === 2 && sch2.properties.length === 7); + + // ---- leaving a database ---- + await openPage('Tasks'); + await js(`document.querySelector('[data-rec="Tasks/Ship it"] .db-ico').click()`); + await sleep(900); + await click(`document.querySelector('[aria-label="Page options"]')`); + await click(`[...document.querySelectorAll('.menu .mi')].find(m => m.textContent.includes('Move out of Tasks'))`); + await sleep(1200); + check('moving a record out of its database moves the file', fs.existsSync(path.join(vault, 'Ship it.md')) && !fs.existsSync(path.join(vault, 'Tasks', 'Ship it.md'))); + const toastText = await js(`return document.querySelector('.toast')?.textContent`); + check('...and says that its properties stayed in the file', /propert/.test(toastText) && /stayed in the file/.test(toastText), toastText); + check('...the values are still there until you say', /status: Todo/.test(fm('Ship it.md'))); + await click(`document.querySelector('.toast .toast-btn')`); + await sleep(1200); + const after = fm('Ship it.md'); + check('"Remove" takes this database\'s properties off the record', !/status:|points:|done:|after:/.test(after), JSON.stringify(after)); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ndatabase extras e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-database-formulas.test.cjs b/test/e2e-database-formulas.test.cjs new file mode 100644 index 0000000..d85d0a0 --- /dev/null +++ b/test/e2e-database-formulas.test.cjs @@ -0,0 +1,137 @@ +// End-to-end check of formula properties and the totals row of a database table. +const { app, BrowserWindow, dialog } = require('electron'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-formulas-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(path.join(vault, 'Shelf'), { recursive: true }); +const day = (n) => { const d = new Date(Date.now() + n * 864e5); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; +const schema = { + properties: [ + { key: 'price', name: 'Price', type: 'number', format: 'euro' }, + { key: 'qty', name: 'Qty', type: 'number' }, + { key: 'total', name: 'Total', type: 'formula', expression: 'prop("Price") * prop("Qty")', format: 'euro' }, + { key: 'expiry', name: 'Expiry', type: 'date' }, + { key: 'left', name: 'Days left', type: 'formula', expression: 'if(prop("Expiry"), dateBetween(prop("Expiry"), today(), "days"), "")' } + ], + views: [{ id: 'v1', name: 'Table', type: 'table', filters: [], hidden: [], sort: null, widths: {}, calc: {} }] +}; +const shelfMd = `---\nink-database: basic\nicon: table\n---\n\n%% ink-database\n${JSON.stringify(schema, null, 2)}\n%%\n`; +fs.writeFileSync(path.join(vault, 'Shelf.md'), shelfMd); +fs.writeFileSync(path.join(vault, 'Shelf', 'Serum.md'), `---\nprice: 10\nqty: 3\nexpiry: ${day(12)}\n---\n`); +fs.writeFileSync(path.join(vault, 'Shelf', 'Toner.md'), `---\nprice: 2.5\nqty: 4\n---\n`); +fs.writeFileSync(path.join(vault, 'Shelf', 'Mask.md'), '---\nprice: 7\n---\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Shelf', bounds: { width: 1500, height: 900 } })); +const exported = path.join(tmp, 'out.csv'); +dialog.showSaveDialog = async () => ({ canceled: false, filePath: exported }); +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 centerOf = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + const click = async (sel) => { + const c = await centerOf(sel); + if (!c) throw new Error('not found: ' + sel); + wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + await sleep(450); + }; + const fm = (f) => fs.readFileSync(path.join(vault, f), 'utf8'); + const schemaOf = () => { const t = fm('Shelf.md'); return JSON.parse(t.slice(t.indexOf('{'), t.lastIndexOf('}') + 1)); }; + const cell = (rec, key) => js(`const th = [...document.querySelectorAll('.dbt thead th')]; const i = th.findIndex((t) => t.textContent.includes(${JSON.stringify(key)})); const td = document.querySelector('[data-rec="Shelf/${rec}"]').children[i]; return td.textContent.trim()`); + const header = (name) => `[...document.querySelectorAll('.dbt thead .db-th')].find((b) => b.textContent.includes('${name}'))`; + + try { + await sleep(1800); + check('a formula is computed for every record', (await cell('Serum', 'Total')) === '€30.00' && (await cell('Toner', 'Total')) === '€10.00', `${await cell('Serum', 'Total')} ${await cell('Toner', 'Total')}`); + check('...an empty value counts as 0', (await cell('Mask', 'Total')) === '€0.00', await cell('Mask', 'Total')); + check('...and a date formula counts the days', (await cell('Serum', 'Days left')) === '12', await cell('Serum', 'Days left')); + check('...and is empty where its if() says so', (await cell('Toner', 'Days left')) === '', JSON.stringify(await cell('Toner', 'Days left'))); + check('a formula is never written into the record', !/total|left/.test(fm('Shelf/Serum.md')), fm('Shelf/Serum.md')); + + // it follows edits + await js(`const i = document.querySelector('[data-rec="Shelf/Mask"] .db-numfmt'); i.click(); await new Promise((r) => setTimeout(r, 200)); const n = document.querySelector('[data-rec="Shelf/Mask"] input[type=number]'); n.value = '5'; n.dispatchEvent(new Event('change', { bubbles: true })); n.blur()`); + await js(`const q = [...document.querySelectorAll('[data-rec="Shelf/Mask"] input[type=number]')].pop(); q.value = '2'; q.dispatchEvent(new Event('change', { bubbles: true }))`); + await sleep(900); + check('changing a value updates the formulas at once', (await cell('Mask', 'Total')) === '€10.00', await cell('Mask', 'Total')); + + // totals row + await click(`[...document.querySelectorAll('.db-calcrow .db-calc')][3]`); // Total column: select, name, price, qty, total + await click(`[...document.querySelectorAll('.db-menu .mi')].find((m) => m.textContent.trim() === 'Sum')`); + await sleep(900); + const sumText = await js(`return document.querySelectorAll('.db-calcrow .db-calc')[3]?.textContent || ''`); + check('the totals row sums a formula column in its format', /Sum/i.test(sumText) && sumText.includes('€50.00'), sumText); + check('...and the view keeps it', schemaOf().views[0].calc?.total === 'sum', JSON.stringify(schemaOf().views[0].calc)); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`); + + // the editor + await click(header('Total')); + check('a formula property\'s menu has an editor with a live result', !!(await js(`return document.querySelector('.db-formula-in')`)) && /€10\.00/.test(await js(`return document.querySelector('.db-formula-preview')?.textContent || ''`)), await js(`return document.querySelector('.db-formula-preview')?.textContent`)); + await js(`const t = document.querySelector('.db-formula-in'); t.value = 'prop("Price") * 100'; t.dispatchEvent(new Event('input', { bubbles: true }))`); + await sleep(200); + check('...which previews as you type', /€500\.00/.test(await js(`return document.querySelector('.db-formula-preview')?.textContent || ''`)), await js(`return document.querySelector('.db-formula-preview')?.textContent`)); + await js(`document.querySelector('.db-formula-in').dispatchEvent(new Event('change', { bubbles: true }))`); + await sleep(900); + check('the new formula is used and saved in the schema', schemaOf().properties.find((p) => p.key === 'total').expression === 'prop("Price") * 100' && (await cell('Serum', 'Total')) === '€1,000.00', await cell('Serum', 'Total')); + + // a mistake shows as an error, not as nonsense + await js(`const t = document.querySelector('.db-formula-in'); t.value = 'prop("Price") * '; t.dispatchEvent(new Event('input', { bubbles: true }))`); + await sleep(200); + check('a broken formula says what is wrong while typing', /ends too soon/.test(await js(`return document.querySelector('.db-formula-preview')?.textContent || ''`))); + await js(`document.querySelector('.db-formula-in').dispatchEvent(new Event('change', { bubbles: true }))`); + await sleep(900); + check('...and shows Error in the cells', (await cell('Serum', 'Total')) === 'Error'); + await js(`const t = document.querySelector('.db-formula-in'); t.value = 'prop("Price") * prop("Qty")'; t.dispatchEvent(new Event('input', { bubbles: true })); t.dispatchEvent(new Event('change', { bubbles: true }))`); + await sleep(900); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`); + + // a new formula property + await js(`document.querySelector('.db-th.add').scrollIntoView({ inline: 'center' })`); + await sleep(300); + await click(`document.querySelector('.db-th.add')`); + await click(`[...document.querySelectorAll('.db-menu .mi')].find((m) => m.textContent.trim() === 'Formula')`); + await sleep(600); + check('adding a Formula property opens its editor', !!(await js(`return document.querySelector('.db-formula-in')`))); + await js(`const t = document.querySelector('.db-formula-in'); t.value = 'concat("Qty: ", prop("Qty"))'; t.dispatchEvent(new Event('input', { bubbles: true })); t.dispatchEvent(new Event('change', { bubbles: true }))`); + await sleep(900); + check('...and it works at once', (await cell('Serum', 'Formula')) === 'Qty: 3', await cell('Serum', 'Formula')); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`); + + // a record's own page + await js(`document.querySelector('[data-rec="Shelf/Serum"] .db-ico').click()`); + await sleep(1000); + check('a record\'s page shows its formulas, read-only', await js(`const row = [...document.querySelectorAll('.prop')].find((p) => p.textContent.includes('Total')); return !!row && row.textContent.includes('€30.00') && !row.querySelector('input')`)); + await js(`document.querySelector('.crumb').click()`); + await sleep(900); + + // export + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }))`); + await sleep(300); + await js(`const i = document.querySelector('.overlay input'); i.value = 'Export this database as CSV'; i.dispatchEvent(new Event('input', { bubbles: true }))`); + await sleep(300); + await js(`document.querySelector('.overlay input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))`); + await sleep(1200); + const csv = fs.existsSync(exported) ? fs.readFileSync(exported, 'utf8') : ''; + check('the CSV export has the computed values', /Serum,10,3,30,/.test(csv.replace(/"/g, '')) || /Serum,10,3,30/.test(csv), JSON.stringify(csv.split('\r\n').slice(0, 3))); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nformulas e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-database.test.cjs b/test/e2e-database.test.cjs new file mode 100644 index 0000000..907dde3 --- /dev/null +++ b/test/e2e-database.test.cjs @@ -0,0 +1,365 @@ +// End-to-end check of databases: records are sub-pages, values live in their front matter, views share them. +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-database-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(path.join(vault, 'Tasks'), { recursive: true }); +const now = new Date(); +const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; +const schema = { + properties: [ + { key: 'status', name: 'Status', type: 'select', options: ['Todo', 'Doing', 'Done'] }, + { key: 'due', name: 'Due', type: 'date' }, + { key: 'points', name: 'Points', type: 'number' }, + { key: 'done', name: 'Done', type: 'checkbox' }, + { key: 'owner', name: 'Owner', type: 'people' }, + { key: 'labels', name: 'Labels', type: 'multiselect', options: ['ui', 'api'] }, + { key: 'files', name: 'Files', type: 'files' }, + { key: 'after', name: 'After', type: 'relation' } + ], + views: [ + { id: 'v1', name: 'Table', type: 'table', filters: [], hidden: [], sort: null }, + { id: 'v2', name: 'Board', type: 'board', groupBy: 'status', filters: [], hidden: [], sort: null }, + { id: 'v3', name: 'Calendar', type: 'calendar', dateProp: 'due', filters: [], hidden: [], sort: null }, + { id: 'v4', name: 'Gallery', type: 'gallery', filters: [], hidden: [], sort: null }, + { id: 'v5', name: 'Timeline', type: 'timeline', dateProp: 'due', filters: [], hidden: [], sort: null }, + { id: 'v6', name: 'List', type: 'list', filters: [], hidden: [], sort: { key: 'points', dir: 'desc' } } + ] +}; +fs.writeFileSync(path.join(vault, 'Tasks.md'), `---\nink-database: basic\nicon: table\n---\n\n%% ink-database\n${JSON.stringify(schema, null, 2)}\n%%\n`); +fs.writeFileSync(path.join(vault, 'Tasks', 'Write docs.md'), `---\nstatus: Doing\ndue: ${ym}-10\npoints: 5\nowner: [Ana, Ben]\nlabels: [ui]\n---\n\nSome notes.\n`); +fs.writeFileSync(path.join(vault, 'Tasks', 'Ship it.md'), `---\nstatus: Todo\ndue: ${ym}-20\npoints: 8\ndone: false\nafter: [Write docs]\n---\n`); +fs.writeFileSync(path.join(vault, 'Tasks', 'Plan.md'), `---\npoints: 2\n---\n`); +fs.writeFileSync(path.join(vault, 'Other.md'), 'Other.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Tasks', 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 centerOf = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + const click = async (sel) => { + const c = await centerOf(sel); + if (!c) throw new Error('not found: ' + sel); + wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + await sleep(450); + }; + const tab = (name) => click(`[...document.querySelectorAll('.db-tab')].find(b => b.textContent.trim() === '${name}')`); + const fm = (f) => fs.readFileSync(path.join(vault, f), 'utf8'); + const rows = () => js(`return [...document.querySelectorAll('.dbt tbody tr[data-rec]')].map(r => r.querySelector('.db-title').value)`); + const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(350); }; + const statusOf = () => schemaOf().properties.find((p) => p.key === 'status'); + const schemaOf = () => { const t = fm('Tasks.md'); return JSON.parse(t.slice(t.indexOf('{'), t.lastIndexOf('}') + 1)); }; + const pointer = (type, sel, x, y) => js(`const e = ${sel}; (e || window).dispatchEvent(new PointerEvent('${type}', { bubbles: true, button: 0, clientX: ${x}, clientY: ${y} }))`); + const drag = (fromSel, toSel) => js(` + const dt = new DataTransfer(); + const from = ${fromSel}, to = ${toSel}; + from.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt })); + to.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt })); + to.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })); + from.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer: dt })); + `); + + try { + await sleep(1800); + + // ---- table ---- + check('the page opens as a database with its views as tabs', (await js(`return [...document.querySelectorAll('.db-tab:not(.add)')].map(b => b.textContent.trim())`)).join() === 'Table,Board,Calendar,Gallery,Timeline,List'); + check('records are the sub-pages, one row each', JSON.stringify(await rows()) === '["Plan","Ship it","Write docs"]', JSON.stringify(await rows())); + check('records are not listed under the database in the sidebar', await js(`return !document.querySelector('.row[data-id="Tasks/Plan"]')`)); + check('a row shows typed values', await js(`const r = document.querySelector('[data-rec="Tasks/Write docs"]'); return r.textContent.includes('Doing') && r.textContent.includes('Ana') && r.querySelector('input[type=number]').value === '5'`)); + + await click(`document.querySelector('[data-rec="Tasks/Ship it"] td:not(.db-selcol) input[type=checkbox]').parentElement`); + await sleep(700); + check('ticking a checkbox writes it to the record', /done: true/.test(fm('Tasks/Ship it.md')), JSON.stringify(fm('Tasks/Ship it.md'))); + check('...leaving its other values and keys alone', /status: Todo/.test(fm('Tasks/Ship it.md')) && /points: 8/.test(fm('Tasks/Ship it.md'))); + + await js(`const i = document.querySelector('[data-rec="Tasks/Plan"] input[type=number]'); i.value = '13'; i.dispatchEvent(new Event('change', { bubbles: true }))`); + await sleep(700); + check('a number is saved as a number', /points: 13/.test(fm('Tasks/Plan.md'))); + + await click(`document.querySelector('[data-rec="Tasks/Plan"] .db-sel')`); + await click(`[...document.querySelectorAll('.db-pop .mi')].find(m => m.textContent.includes('Done'))`); + await sleep(700); + check('a select option is picked from the list', /status: Done/.test(fm('Tasks/Plan.md')), JSON.stringify(fm('Tasks/Plan.md'))); + + await click(`document.querySelector('[data-rec="Tasks/Plan"] .db-sel')`); + await js(`const i = document.querySelector('.db-pop .db-find'); i.value = 'Blocked'; i.dispatchEvent(new Event('input', { bubbles: true }))`); + await sleep(100); + await js(`document.querySelector('.db-pop .db-find').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))`); + await sleep(900); + check('typing a new option creates it', /status: Blocked/.test(fm('Tasks/Plan.md')) && /"Blocked"/.test(fm('Tasks.md')), JSON.stringify(fm('Tasks.md').slice(-400))); + + // ---- sort and filter ---- + await click(`[...document.querySelectorAll('.db-tool')].find(b => b.textContent.includes('Sort'))`); + await click(`[...document.querySelectorAll('.db-sortrow')].find(r => r.textContent.includes('Points')).querySelectorAll('button')[1]`); + check('sort by a property reorders rows', JSON.stringify(await rows()) === '["Plan","Ship it","Write docs"]', JSON.stringify(await rows())); + await click(`document.querySelector('.db-menu') && [...document.querySelectorAll('.db-sortrow')].find(r => r.textContent.includes('Points')).querySelectorAll('button')[0]`); + check('...ascending too', JSON.stringify(await rows()) === '["Write docs","Ship it","Plan"]', JSON.stringify(await rows())); + await sleep(800); + check('the view\'s sort is saved in the database page', /"key": "points"/.test(fm('Tasks.md').split('"id": "v1"')[1].split('"id": "v2"')[0])); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`); + await sleep(200); + check('the sort menu closes with Escape', await js(`return !document.querySelector('.db-menu')`)); + + // ---- undo of schema changes ---- + await js(`document.activeElement?.blur?.()`); + await key('z', ['control']); + await sleep(600); + const sortAfterUndo = schemaOf().views[0].sort; + check('Ctrl+Z undoes the last schema change', sortAfterUndo && sortAfterUndo.dir === 'desc', JSON.stringify(sortAfterUndo)); + await key('z', ['control', 'shift']); + await sleep(600); + check('Ctrl+Shift+Z redoes it', schemaOf().views[0].sort.dir === 'asc'); + + // ---- option colours ---- + await click(`[...document.querySelectorAll('.dbt th .db-th')].find(b => b.textContent.includes('Status'))`); + await click(`[...document.querySelectorAll('.db-optrow')].find(r => r.querySelector('input').value === 'Todo').querySelector('.db-dotbtn')`); + await click(`document.querySelector('.db-swatches .cv-swatch[data-color="blue"]')`); + await sleep(700); + check('an option can be given a colour', statusOf().colors?.Todo === 'blue', JSON.stringify(statusOf())); + check('...and shows with it', await js(`return [...document.querySelectorAll('.db-opt')].some(o => o.textContent === 'Todo' && o.style.getPropertyValue('--oc') === '#5b8def')`)); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`); + + // ---- number formats ---- + await click(`[...document.querySelectorAll('.dbt th .db-th')].find(b => b.textContent.includes('Points'))`); + await click(`document.querySelectorAll('.db-menu button.fp.sm')[1]`); + await click(`[...document.querySelectorAll('.sm-list .mi')].find(m => m.textContent.includes('Percent'))`); + await sleep(700); + check('a number property can show as a percent', schemaOf().properties.find((p) => p.key === 'points').format === 'percent' && (await js(`return document.querySelector('[data-rec="Tasks/Ship it"] .db-numfmt')?.textContent.trim()`)) === '8%'); + check('...while the file keeps the plain number', /points: 8/.test(fm('Tasks/Ship it.md'))); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`); + await sleep(200); + await click(`document.querySelector('[data-rec="Tasks/Ship it"] .db-numfmt')`); + check('clicking it edits the number', await js(`return document.activeElement?.type === 'number' && document.activeElement.value === '8'`)); + await js(`document.activeElement.blur()`); + await click(`[...document.querySelectorAll('.dbt th .db-th')].find(b => b.textContent.includes('Points'))`); + await click(`document.querySelectorAll('.db-menu button.fp.sm')[1]`); + await click(`[...document.querySelectorAll('.sm-list .mi')].find(m => m.textContent.includes('Progress bar'))`); + check('...or as a progress bar', !!(await js(`return document.querySelector('[data-rec="Tasks/Ship it"] .db-barline')`))); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`); + + // ---- column width and order ---- + const grip = `document.querySelector('.dbt th:nth-child(5) .db-resize')`; // Points: select, name, status, due, points + const [gx, gy] = await centerOf(grip); + await pointer('pointerdown', grip, gx, gy); + await pointer('pointermove', 'null', gx + 90, gy); + await pointer('pointerup', 'null', gx + 90, gy); + await sleep(700); + check('dragging a column edge resizes it, and the width is saved in the view', schemaOf().views[0].widths?.points === 260, JSON.stringify(schemaOf().views[0].widths)); + check('...the column is that wide', (await js(`return Math.round(document.querySelector('.dbt th:nth-child(5)').getBoundingClientRect().width)`)) === 260); + await drag(`document.querySelector('.dbt th:nth-child(4)')`, `document.querySelector('.dbt th:nth-child(3)')`); + await sleep(700); + check('dragging a column header moves the column', schemaOf().properties.map((p) => p.key).slice(0, 2).join() === 'due,status', schemaOf().properties.map((p) => p.key).join()); + + // ---- keyboard ---- + await js(`document.querySelectorAll('.dbt tbody tr[data-rec]')[0].querySelector('.db-title').focus()`); + await key('Down'); + check('Down moves to the cell below', await js(`return document.activeElement?.closest('tr')?.dataset.rec === document.querySelectorAll('.dbt tbody tr[data-rec]')[1].dataset.rec && document.activeElement.classList.contains('db-title')`)); + await key('Right', ['alt']); + check('Alt+Right moves to the next cell', await js(`return document.activeElement?.closest('td') === document.querySelectorAll('.dbt tbody tr[data-rec]')[1].children[2]`)); + await key('Right', ['alt']); + await key('Up'); + check('Up from a button moves up too', await js(`return document.activeElement?.closest('tr')?.dataset.rec === document.querySelectorAll('.dbt tbody tr[data-rec]')[0].dataset.rec`)); + + // ---- new record ---- + await click(`document.querySelector('.db-new')`); + await sleep(900); + check('New adds a record file inside the database folder', fs.existsSync(path.join(vault, 'Tasks', 'Untitled.md'))); + check('...and its name is ready to type', await js(`return document.activeElement?.classList.contains('db-title')`)); + + // ---- board ---- + await tab('Board'); + const cols = await js(`return [...document.querySelectorAll('.db-col')].map(c => c.querySelector('.db-col-head').textContent.replace(/\\s+/g, ' ').trim())`); + check('a board has a column per option, with counts', cols.join('|').includes('Todo 1') && cols.join('|').includes('Doing 1') && cols.join('|').includes('Done 0'), JSON.stringify(cols)); + await drag( + `[...document.querySelectorAll('.db-card')].find(c => c.textContent.includes('Ship it'))`, + `[...document.querySelectorAll('.db-col')].find(c => c.querySelector('.db-col-head').textContent.includes('Done'))` + ); + await sleep(900); + check('dragging a card to a column sets the property', /status: Done/.test(fm('Tasks/Ship it.md')), JSON.stringify(fm('Tasks/Ship it.md'))); + + // ---- board: groups and cards like a Kanban board's ---- + const colHead = (name) => `[...document.querySelectorAll('.db-col')].find(c => c.querySelector('.db-col-head .db-opt, .db-col-head .db-col-title').textContent.trim() === '${name}').querySelector('.db-col-head')`; + const clickIn = async (sel) => { + await js(`const e = ${sel}; e.scrollIntoView({ inline: 'center', block: 'nearest' })`); + await sleep(300); + await click(sel); + }; + const typeInto = async (value) => { + await js(`const i = document.activeElement; i.value = ${JSON.stringify(value)}; i.dispatchEvent(new Event('input', { bubbles: true }))`); + await key('Return'); + await sleep(900); + }; + await js(`document.querySelector('.kaddcol').scrollIntoView({ inline: 'center' })`); + await sleep(300); + await click(`document.querySelector('.kaddcol')`); + check('"Add a group" makes a new option and asks for its name', await js(`return document.activeElement?.classList.contains('kh-input') && document.activeElement.value === 'New group'`)); + await typeInto('Review'); + check('...which is saved as an option of the property', statusOf().options.includes('Review'), JSON.stringify(statusOf().options)); + + await clickIn(`${colHead('Doing')}.querySelector('.kh-title')`); + await typeInto('In progress'); + check('renaming a group renames the value on its records', /status: In progress/.test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md')); + check('...and in the property\'s options', statusOf().options.includes('In progress') && !statusOf().options.includes('Doing')); + + await clickIn(`${colHead('In progress')}.querySelector('[aria-label="Group options"]')`); + await click(`document.querySelector('.db-menu .cv-swatch[data-color="purple"]')`); + await sleep(700); + check('a group\'s colour is chosen from its menu', statusOf().colors?.['In progress'] === 'purple', JSON.stringify(statusOf().colors)); + check('...and the header pill shows it', await js(`return [...document.querySelectorAll('.db-col-head .db-opt')].some(o => o.textContent === 'In progress' && o.style.getPropertyValue('--oc') === '#9a6de0')`)); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`); + + await drag(`${colHead('Review')}`, `[...document.querySelectorAll('.db-col')].find(c => c.querySelector('.db-col-head .db-opt')?.textContent === 'Todo')`); + await sleep(900); + check('dragging a group header reorders the options', statusOf().options[0] === 'Review', JSON.stringify(statusOf().options)); + + await clickIn(`[...document.querySelectorAll('.db-card')].find(c => c.textContent.includes('Write docs')).querySelector('.kmore')`); + await click(`[...document.querySelectorAll('.db-menu .mi')].find(m => m.textContent.trim() === 'Done')`); + await sleep(900); + check('a card\'s menu moves it to another group', /status: Done/.test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md')); + + await clickIn(`${colHead('Blocked')}.querySelector('[aria-label="Group options"]')`); + await click(`[...document.querySelectorAll('.db-menu .mi')].find(m => m.textContent.includes('Delete group'))`); + check('deleting a group asks to be confirmed', await js(`return document.querySelector('.db-menu .mi.armed')?.textContent.includes('Click again')`)); + await click(`document.querySelector('.db-menu .mi.armed')`); + await sleep(900); + check('...then drops the option and clears the value from its records', !statusOf().options.includes('Blocked') && !/status:/.test(fm('Tasks/Plan.md')), fm('Tasks/Plan.md')); + + // ---- calendar ---- + await tab('Calendar'); + const evs = await js(`return [...document.querySelectorAll('.db-ev')].map(e => e.textContent)`); + check('a calendar shows records on their date', evs.includes('Write docs') && evs.includes('Ship it'), JSON.stringify(evs)); + await drag( + `[...document.querySelectorAll('.db-ev')].find(e => e.textContent === 'Write docs')`, + `[...document.querySelectorAll('.db-day')].find(d => d.getAttribute('aria-label') && !d.classList.contains('out') && d.querySelector('.db-day-head span').textContent === '15')` + ); + await sleep(900); + check('dragging a record to another day changes its date', new RegExp(`due: ${ym}-15`).test(fm('Tasks/Write docs.md')), JSON.stringify(fm('Tasks/Write docs.md'))); + await click(`[...document.querySelectorAll('.db-day')].find(d => !d.classList.contains('out') && d.querySelector('.db-day-head span').textContent === '3').querySelector('.db-day-add')`); + await sleep(900); + const dated = fs.readdirSync(path.join(vault, 'Tasks')).filter((f) => new RegExp(`due: ${ym}-03`).test(fm('Tasks/' + f))); + check('the + on a day makes a record dated that day', dated.length === 1, JSON.stringify(dated)); + + // ---- gallery, timeline, list ---- + await tab('Gallery'); + check('a gallery shows a card per record', (await js(`return document.querySelectorAll('.db-gcard:not(.db-gnew)').length`)) === 5); + await tab('Timeline'); + check('a timeline draws a bar per dated record', (await js(`return document.querySelectorAll('.db-tl-bar').length`)) === 3); + check('...and lists the undated ones', (await js(`return document.querySelector('.db-undated')?.textContent`)).includes('Plan')); + await tab('List'); + const order = await js(`return [...document.querySelectorAll('.db-lname')].map(e => e.textContent)`); + check('a list shows a row per record, in the view\'s order', JSON.stringify(order) === '["Plan","Ship it","Write docs","Untitled","Untitled 2"]', JSON.stringify(order)); + + // ---- calendar: week view and span ---- + await tab('Calendar'); + await click(`[...document.querySelectorAll('.db-seg button')].find(b => b.textContent === 'Week')`); + check('the week view shows seven days', (await js(`return document.querySelectorAll('.db-day').length`)) === 7); + await sleep(800); + check('...and remembers the choice in the view', schemaOf().views[2].range === 'week'); + await click(`[...document.querySelectorAll('.db-seg button')].find(b => b.textContent === 'Month')`); + const gripSel = `[...document.querySelectorAll('.db-evwrap')].find(w => w.textContent.includes('Write docs')).querySelector('.db-grip')`; + const [sx, sy] = await centerOf(gripSel); + const [tx, ty] = await centerOf(`[...document.querySelectorAll('.db-day')].find(d => d.dataset.iso === '${ym}-18')`); + await pointer('pointerdown', gripSel, sx, sy); + await pointer('pointermove', 'null', tx, ty); + await pointer('pointerup', 'null', tx, ty); + await sleep(900); + check('dragging the end of a record in the calendar sets its end date', new RegExp(`end: ${ym}-18`).test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md')); + check('...adding an End property when there was none', schemaOf().properties.some((p) => p.key === 'end' && p.type === 'date') && schemaOf().views[2].endProp === 'end'); + check('the record now shows on each day of the span', (await js(`return [...document.querySelectorAll('.db-ev')].filter(e => e.textContent === 'Write docs').length`)) === 4); + + // ---- timeline: edges and dependencies ---- + await tab('Timeline'); + check('a dependency is drawn as an arrow', (await js(`return document.querySelectorAll('.db-tl-arrows > path').length`)) === 1); + await click(`document.querySelector('.db-tools button.fp.sm[aria-label="End date"]')`); + await click(`[...document.querySelectorAll('.sm-list .mi')].find(m => m.textContent.includes('End: End'))`); + await sleep(700); + const edge = `[...document.querySelectorAll('.db-tl-bar')].find(b => b.title.startsWith('Write docs')).querySelector('.db-tl-grip.r')`; + const [ex, ey] = await centerOf(edge); + await pointer('pointerdown', edge, ex, ey); + await pointer('pointermove', 'null', ex + 50, ey); // 24px a day: two days + await pointer('pointerup', 'null', ex + 50, ey); + await sleep(900); + check('dragging a bar\'s right edge changes the end date', new RegExp(`end: ${ym}-20`).test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md')); + const lead = `[...document.querySelectorAll('.db-tl-bar')].find(b => b.title.startsWith('Write docs')).querySelector('.db-tl-grip.l')`; + const [lx, ly] = await centerOf(lead); + await pointer('pointerdown', lead, lx, ly); + await pointer('pointermove', 'null', lx + 26, ly); + await pointer('pointerup', 'null', lx + 26, ly); + await sleep(900); + check('...and the left edge changes the start', new RegExp(`due: ${ym}-16`).test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md')); + + // ---- bulk selection ---- + await tab('Table'); + await click(`document.querySelectorAll('.dbt tbody tr[data-rec]')[0].querySelector('.db-selcol .dbx')`); + await js(`document.querySelectorAll('.dbt tbody tr[data-rec]')[2].querySelector('.db-selcol input').dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }))`); + await sleep(300); + check('shift-click selects a range of rows', (await js(`return document.querySelector('.db-bulk strong')?.textContent`)) === '3 selected'); + await click(`document.querySelector('.db-bulk button.fp.sm')`); + await click(`[...document.querySelectorAll('.sm-list .mi')].find(m => m.textContent.includes('Set Done'))`); + await click(`[...document.querySelectorAll('.db-bulk .btn')].find(b => b.textContent.trim() === 'Apply')`); + await sleep(900); + const doneAll = ['Write docs', 'Ship it', 'Plan'].filter((n) => /done: true/.test(fm(`Tasks/${n}.md`))); + check('a property can be set on every selected record at once', doneAll.length === 3, JSON.stringify(doneAll)); + await click(`[...document.querySelectorAll('.db-bulk .btn')].find(b => b.textContent.includes('Delete'))`); + await sleep(900); + check('Delete moves the selected records to the trash in one step', fs.readdirSync(path.join(vault, 'Tasks')).filter((f) => f.endsWith('.md')).length === 2, JSON.stringify(fs.readdirSync(path.join(vault, 'Tasks')))); + await click(`[...document.querySelectorAll('.toast button')].find(b => b.textContent.includes('Undo'))`); + await sleep(1200); + check('...and one Undo brings them all back', fs.readdirSync(path.join(vault, 'Tasks')).filter((f) => f.endsWith('.md')).length === 5, JSON.stringify(fs.readdirSync(path.join(vault, 'Tasks')))); + + await tab('List'); + + // ---- record page ---- + await click(`document.querySelector('.db-lrow')`); + await sleep(900); + check('opening a record shows the database\'s properties with their editors', await js(`return !![...document.querySelectorAll('.prop .pk')].find(p => p.textContent.includes('Status')) && !!document.querySelector('.prop .db-sel')`)); + await click(`document.querySelector('.prop .db-sel')`); + const place = await js(`const t = document.querySelector('.prop .db-sel').getBoundingClientRect(); const p = document.querySelector('.db-pop')?.getBoundingClientRect(); return p ? { dx: Math.abs(p.left - t.left), dy: p.top - t.bottom, inside: p.right <= innerWidth && p.bottom <= innerHeight } : null`); + check('an option list opened on a record\'s page sits right under its field', place && place.dx < 30 && place.dy >= 0 && place.dy < 40 && place.inside, JSON.stringify(place)); + await js(`document.querySelector('.db-pop .db-find')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`); + await sleep(300); + await js(`const p = [...document.querySelectorAll('.prop')].find(p => p.textContent.includes('Points')); p.querySelector('.db-numfmt')?.click(); await new Promise(r => setTimeout(r, 200)); const i = p.querySelector('input'); i.value = '21'; i.dispatchEvent(new Event('change', { bubbles: true }))`); + await sleep(1400); + check('editing one there writes the record\'s front matter', fs.readdirSync(path.join(vault, 'Tasks')).some((f) => /points: 21/.test(fm('Tasks/' + f)))); + + // ---- renaming a record updates what depends on it ---- + await js(`document.querySelector('.crumb')?.click()`); + await sleep(800); + await tab('Table'); + await js(`const i = document.querySelector('[data-rec="Tasks/Write docs"] .db-title'); i.value = 'Write guide'; i.dispatchEvent(new Event('change', { bubbles: true }))`); + await sleep(1500); + check('renaming a record re-points the relations that name it', /after: \[Write guide\]/.test(fm('Tasks/Ship it.md')) && fs.existsSync(path.join(vault, 'Tasks', 'Write guide.md')), fm('Tasks/Ship it.md')); + + // ---- creating a database ---- + await js(`document.querySelector('.crumb')?.click()`); + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', ctrlKey: true }))`); + await sleep(500); + await click(`document.querySelector('.tpl-card[data-database]')`); + await sleep(1000); + const made = fs.readdirSync(vault).filter((f) => /^Database/.test(f)); + check('"Database" in the new page dialog makes a database with a Table and a Calendar (a board is added by hand)', made.length === 1 && /ink-database: basic/.test(fm(made[0])) && (await js(`return document.querySelectorAll('.db-tab:not(.add)').length`)) === 2, JSON.stringify(made)); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ndatabase e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-embeds.test.cjs b/test/e2e-embeds.test.cjs new file mode 100644 index 0000000..58b5bc0 --- /dev/null +++ b/test/e2e-embeds.test.cjs @@ -0,0 +1,159 @@ +// End-to-end check of heading links ([[Page#Heading]]) and embeds (![[Page]]). Run with `bun run test:e2e`. +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-embeds-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +const filler = (n, tag) => Array.from({ length: n }, (_, i) => `${tag} paragraph ${i + 1}, long enough to take some room on the page and make it scroll.`).join('\n\n'); + +fs.writeFileSync(path.join(vault, 'Roadmap.md'), [ + '# Roadmap', 'Intro line', '## Q3 goals', '- ship graph\n- ship embeds', filler(14, 'q3'), '### Risks', 'few risks', '## Q4 goals', '- rest and plan', filler(14, 'q4'), +].join('\n\n') + '\n'); +fs.writeFileSync(path.join(vault, 'Hub.md'), [ + 'Links: [[Roadmap#Q4 goals]] and [[#Local part]] and [[Roadmap#Nope]].', + '![[Roadmap#Q3 goals]]', '![[photo.png]]', filler(16, 'hub'), '## Local part', 'the local text', filler(8, 'end'), +].join('\n\n') + '\n'); +fs.writeFileSync(path.join(vault, 'States.md'), ['![[Nowhere]]', '![[States]]', '![[Roadmap#Missing]]', '![[Tasks]]', '![[Roadmap]]'].join('\n\n') + '\n'); +fs.writeFileSync(path.join(vault, 'Tasks.md'), '---\nkanban-plugin: basic\n---\n\n## To do\n\n- [ ] One\n'); + +app.setPath('userData', tmp); +const settingsFile = path.join(tmp, 'settings.json'); +fs.writeFileSync(settingsFile, JSON.stringify({ vault, lastPage: 'Hub', 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 }); 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(350); }; + const click = async (sel) => { + const c = await js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + if (!c) throw new Error('not found: ' + sel); + wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + await sleep(500); + }; + const read = (f) => fs.readFileSync(path.join(vault, f + '.md'), 'utf8'); + const title = () => js(`return document.querySelector('.title')?.textContent`); + const chip = (text) => `[...document.querySelectorAll('.blocks .wl')].find(c => c.textContent === ${JSON.stringify(text)})`; + const scrollTop = () => js(`return Math.round(document.querySelector('.main .scroller').scrollTop)`); + const headingOffset = (text) => js(`const sc = document.querySelector('.main .scroller'); const h = [...document.querySelectorAll('.blocks > .block')].find(b => /b-h\\d/.test(b.className) && b.textContent.trim() === ${JSON.stringify(text)}); return h ? Math.round(h.getBoundingClientRect().top - sc.getBoundingClientRect().top) : null`); + const embeds = () => js(`return [...document.querySelectorAll('.blocks .emb')].map(e => ({ title: e.querySelector('.emb-title').textContent.trim(), text: e.querySelector('.emb-body').textContent, long: e.querySelector('.emb-body').classList.contains('long') }))`); + const caretAtEnd = () => js(`const cs = [...document.querySelectorAll('.blocks .c')].filter(c => c.isContentEditable); const c = cs.at(-1); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); + + try { + await sleep(2200); + + // ---- an embed of a section ---- + let list = await embeds(); + check('embed shows the section under its heading', list[0] && list[0].title === 'Roadmap › Q3 goals' && list[0].text.includes('ship graph') && list[0].text.includes('Risks') && list[0].text.includes('few risks'), JSON.stringify(list[0]?.title)); + check('...but not the next section', !list[0].text.includes('rest and plan') && !list[0].text.includes('q4 paragraph')); + check('a long embed is folded, with a button', list[0].long && (await js(`return !document.querySelector('.emb-more').hidden`))); + await js(`document.querySelector('.emb-more').scrollIntoView({ block: 'center' })`); + await sleep(300); + await click(`document.querySelector('.emb-more')`); + check('"Show more" opens it fully', (await js(`return document.querySelector('.emb').classList.contains('open')`)) && (await js(`return document.querySelector('.emb-more').textContent`)) === 'Show less'); + await click(`document.querySelector('.emb-more')`); + await js(`document.querySelector('.main .scroller').scrollTop = 0`); + await sleep(300); + + // ---- an embed's text is read-only ---- + const hubBefore = read('Hub'), roadBefore = read('Roadmap'); + await click(`[...document.querySelectorAll('.emb .c')].find(c => c.textContent.includes('ship graph'))`); + await type('zzz'); + await sleep(900); + check('typing into embedded text changes nothing', read('Hub') === hubBefore && read('Roadmap') === roadBefore && !(await js(`return document.querySelector('.emb').textContent.includes('zzz')`))); + check('no formatting toolbar over embedded text', await js(`return document.querySelector('.pop.fmt')?.hidden !== false`)); + + // ---- heading links ---- + const labels = await js(`return [...document.querySelectorAll('.blocks .wl')].map(c => c.textContent)`); + check('chips show Page › Heading, or just the heading', labels.includes('Roadmap › Q4 goals') && labels.includes('Local part'), JSON.stringify(labels)); + const before = await scrollTop(); + await click(chip('Local part')); + const flashing = await js(`return !!document.querySelector('.block.flash')`); // the highlight lasts ~1.3 s + await sleep(900); + const off = await headingOffset('Local part'); + check('[[#Heading]] scrolls this page to it', (await title()) === 'Hub' && (await scrollTop()) > before && off !== null && off >= 0 && off < 150, `${before} -> ${await scrollTop()}, heading ${off}px from top`); + check('and flashes it', flashing); + + await js(`document.querySelector('.main .scroller').scrollTop = 0`); // the chip is at the top of the page + await sleep(400); + await click(chip('Roadmap › Q4 goals')); + await sleep(1400); + const q4 = await headingOffset('Q4 goals'); + check('[[Page#Heading]] opens the page at that heading', (await title()) === 'Roadmap' && q4 !== null && q4 >= 0 && q4 < 150, `title ${await title()}, heading ${q4}px from top`); + + await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Hub'))`); + await sleep(900); + await js(`document.querySelector('.main .scroller').scrollTop = 0`); + await sleep(300); + await click(chip('Roadmap › Nope')); + check('a heading that does not exist says so', (await js(`return document.querySelector('.toast.show')?.textContent || ''`)).includes('No heading')); + + // ---- what an embed shows when it can't show a page ---- + await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('States'))`); + await sleep(1200); + const notes = await js(`return [...document.querySelectorAll('.blocks .emb')].map(e => e.querySelector('.emb-body').textContent.slice(0, 60))`); + check('missing / itself / missing heading / board', /no note called/.test(notes[0]) && /itself/.test(notes[1]) && /no heading called/.test(notes[2]) && /board/.test(notes[3]), JSON.stringify(notes)); + check('a whole note embeds in full', notes[4].includes('Roadmap') && notes[4].includes('Intro line')); + + // ---- typing ![[ , [[Page# ---- + await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Hub'))`); + await sleep(1000); + await caretAtEnd(); + await key('Return'); + await type('![[Road'); + check('![[ opens the page picker', (await js(`const s = document.querySelector('.pop.slash'); return !s.hidden && s.textContent.includes('Embed page') && s.textContent.includes('Roadmap')`))); + await key('Return'); + await sleep(900); + check('picking a page inserts an embed block', read('Hub').includes('\n![[Roadmap]]') && (await embeds()).length === 2, JSON.stringify((await embeds()).map((e) => e.title))); + check('a line to keep typing on is left after it', await js(`const last = document.querySelector('.blocks > .block:last-child'); return last.classList.contains('b-p')`)); + + await type('![[Roadmap#Ris'); + check('![[Page#… suggests that page\'s headings', await js(`const s = document.querySelector('.pop.slash'); return !s.hidden && s.textContent.includes('Headings') && s.textContent.includes('Risks')`)); + await key('Return'); + await sleep(900); + check('...and embeds just that heading', read('Hub').includes('![[Roadmap#Risks]]'), JSON.stringify(read('Hub').slice(-80))); + + await type('see [[Roadmap#Q4'); + check('[[Page#… suggests headings too', await js(`const s = document.querySelector('.pop.slash'); return !s.hidden && s.textContent.includes('Q4 goals')`)); + await key('Return'); + await sleep(900); + check('...and makes a heading link', read('Hub').includes('[[Roadmap#Q4 goals]]') && (await js(`return [...document.querySelectorAll('.blocks .wl')].some(c => c.textContent === 'Roadmap › Q4 goals')`))); + + const count = (await embeds()).length; + await key('z', ['control']); + await sleep(500); + check('undo steps back', (await embeds()).length <= count); + + // ---- renaming the page keeps everything pointing at it ---- + await js(`await window.vault.rename('Roadmap', 'Plan')`); + await sleep(1500); + const hub = read('Hub'); + check('rename rewrites embeds and heading links', hub.includes('![[Plan#Q3 goals]]') && hub.includes('[[Plan#Q4 goals]]') && hub.includes('![[Plan]]') && hub.includes('![[Plan#Risks]]') && !/!?\[\[Roadmap[^\]\n]*\]\]/.test(hub), JSON.stringify(hub.split('\n\n').filter((p) => /Plan|photo/.test(p)))); + check('pictures are left alone', hub.includes('![[photo.png]]')); + const back = await js(`return (await window.vault.backlinks('Plan')).map(b => b.id).sort()`); + check('embeds count as backlinks', back.includes('Hub') && back.includes('States'), JSON.stringify(back)); + const g = await js(`return (await window.vault.graph()).edges.filter(e => e[1] === 'Plan').map(e => e[0]).sort()`); + check('embeds appear in the graph', g.includes('Hub') && g.includes('States'), JSON.stringify(g)); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nembeds e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-footnotes.test.cjs b/test/e2e-footnotes.test.cjs new file mode 100644 index 0000000..a8a8013 --- /dev/null +++ b/test/e2e-footnotes.test.cjs @@ -0,0 +1,172 @@ +// End-to-end check of ==highlight== and footnotes. Run with `bun run test:e2e`. +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-footnotes-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +const filler = (n) => Array.from({ length: n }, (_, i) => `Filler paragraph ${i + 1} that takes up a line or two of the page.`).join('\n\n'); +const NOTES = '[^2]: Second note.\n[^src]: The source,\n on two lines.\n[^lonely]: Nobody points here.'; +fs.writeFileSync(path.join(vault, 'Essay.md'), [ + 'Intro with a ==key idea== and a claim[^src] plus an aside^[said in passing].', + 'Second claim[^2] and the first again[^src].', + filler(18), + NOTES, +].join('\n\n') + '\n'); + +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Essay', theme: 'dark', bounds: { width: 1280, height: 860 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(250); }; + const type = async (t) => { for (const ch of t) wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(350); }; + const point = (sel) => js(`const e = ${sel}; if (!e) return null; e.scrollIntoView({ block: 'nearest' }); const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + const click = async (sel) => { + const c = await point(sel); + if (!c) throw new Error('not found: ' + sel); + wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + await sleep(500); + }; + const hover = async (sel) => { const c = await point(sel); wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); await sleep(250); }; + const read = () => fs.readFileSync(path.join(vault, 'Essay.md'), 'utf8'); + const refs = () => js(`return [...document.querySelectorAll('.blocks sup.fn')].map(s => s.textContent + (s.classList.contains('missing') ? '?' : ''))`); + const notes = () => js(`return [...document.querySelectorAll('.blocks .block.b-footnote')].map(b => b.querySelector('.fn-num').textContent + (b.classList.contains('orphan') ? '(orphan)' : '') + ':' + b.querySelector('.c').textContent)`); + const offsetOf = (sel) => js(`const sc = document.querySelector('.main .scroller'); const e = ${sel}; return e ? Math.round(e.getBoundingClientRect().top - sc.getBoundingClientRect().top) : null`); + const fnBlock = (id) => `document.querySelector('.blocks .block.b-footnote[data-fid="${id}"]')`; + const select = (word) => js(`const c = [...document.querySelectorAll('.blocks .c')].find(x => x.textContent.includes('${word}')); c.focus(); const w = document.createTreeWalker(c, NodeFilter.SHOW_TEXT); let t; while ((t = w.nextNode()) && !t.data.includes('${word}')); const a = t.data.indexOf('${word}'); const r = document.createRange(); r.setStart(t, a); r.setEnd(t, a + ${word.length}); getSelection().removeAllRanges(); getSelection().addRange(r);`); + const caretEndOf = (sel) => js(`const c = ${sel}; c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); + + try { + await sleep(2000); + check('==text== shows highlighted', (await js(`return document.querySelector('.blocks mark')?.textContent`)) === 'key idea'); + check('references are numbered in reading order', JSON.stringify(await refs()) === '["1","2","3","1"]', JSON.stringify(await refs())); + check('footnote texts carry their number; unreferenced ones keep their id', JSON.stringify(await notes()) === JSON.stringify(['3:Second note.', '1:The source,\non two lines.', 'lonely(orphan):Nobody points here.']), JSON.stringify(await notes())); + + await hover(`document.querySelector('.blocks sup.fn[data-fn="src"]')`); + check('hovering a reference shows its text', (await js(`const t = document.querySelector('.fn-tip'); return !t.hidden && t.textContent`)) === '1The source,\non two lines.', JSON.stringify(await js(`return document.querySelector('.fn-tip').textContent`))); + await hover(`document.querySelector('.blocks sup.fn.inline')`); + check('...and an inline note its text', (await js(`return document.querySelector('.fn-tip').textContent`)) === '2said in passing'); + + await click(`document.querySelector('.blocks sup.fn[data-fn="src"]')`); + await sleep(700); + const at = await offsetOf(fnBlock('src')); + const flashed = await js(`return ${fnBlock('src')}.classList.contains('flash')`); + // it sits near the end of the page, so it may not reach the very top; it has to be in view and flashing + check('clicking a reference scrolls to its text', at !== null && at >= 0 && at < 700 && flashed, `${at}px from the top, flash ${flashed}`); + await click(`${fnBlock('src')}.querySelector('.fn-num')`); + await sleep(800); + const back = await offsetOf(`[...document.querySelectorAll('.blocks > .block')].find(b => b.textContent.startsWith('Intro'))`); + check('clicking its number goes back to the reference', back !== null && back >= -10 && back < 140, `${back}px from the top`); + + // ---- highlight from the toolbar and the shortcut ---- + await js(`document.querySelector('.main .scroller').scrollTop = 0`); + await sleep(200); + await select('Second claim'); + await sleep(300); + await click(`document.querySelector('.pop.fmt [data-f="mark"]')`); + await sleep(900); + check('toolbar highlights the selection', read().includes('==Second claim==[^2]'), JSON.stringify(read().split('\n')[2])); + await select('Second claim'); + await sleep(200); + check('the button shows it is highlighted', await js(`return document.querySelector('.pop.fmt [data-f="mark"]').classList.contains('on')`)); + await key('h', ['control', 'shift']); + await sleep(900); + check('Ctrl+Shift+H takes it off again', read().includes('\nSecond claim[^2]'), JSON.stringify(read().split('\n')[2])); + await select('Intro'); + await key('h', ['control', 'shift']); + await sleep(900); + check('Ctrl+Shift+H highlights', read().startsWith('==Intro== with a ==key idea=='), JSON.stringify(read().split('\n')[0])); + + // ---- typing references ---- + await caretEndOf(`[...document.querySelectorAll('.blocks .c')].find(x => x.textContent.startsWith('Second claim'))`); + await type(' new point[^new]'); + check('typing [^id] makes a reference', JSON.stringify(await refs()) === '["1","2","3","1","4?"]', JSON.stringify(await refs())); + await click(`document.querySelector('.blocks sup.fn[data-fn="new"]')`); + await sleep(500); + check('clicking a reference with no text starts one', await js(`return document.activeElement?.closest('.block')?.dataset.fid === 'new'`)); + await type('Fresh note'); + await sleep(900); + check('...saved after the other footnotes', read().includes('[^lonely]: Nobody points here.\n[^new]: Fresh note\n'), JSON.stringify(read().slice(-120))); + check('the reference is no longer missing', JSON.stringify(await refs()) === '["1","2","3","1","4"]', JSON.stringify(await refs())); + + // ---- /footnote ---- + await caretEndOf(`[...document.querySelectorAll('.blocks .c')].find(x => x.textContent.startsWith('Intro'))`); + await sleep(900); // focusing the top line scrolls the page back up; scrolling closes the / menu, so let it settle + await type(' Point /footn'); + await key('Return'); + await sleep(600); + check('/footnote adds a reference with the next free number', read().includes('Point[^1]'), JSON.stringify(read().split('\n')[0])); + check('...and puts you in its new text', await js(`return document.activeElement?.closest('.block')?.dataset.fid === '1'`)); + await type('Auto note'); + await sleep(900); + check('...which is saved', read().includes('[^1]: Auto note')); + check('numbers follow reading order again', (await refs()).join(',') === '1,2,3,4,1,5', (await refs()).join(',')); + await key('z', ['control']); + await key('z', ['control']); + await sleep(900); + check('undo removes it', !read().includes('[^1]'), JSON.stringify(read().split('\n')[0])); + + // ---- "[^x]: " opens a footnote text, ^[...] is an inline note ---- + await js(`document.querySelector('.tail').click()`); + await sleep(300); + await type('[^x]: Typed text'); + await sleep(900); + check('typing "[^x]: " at the start of a line makes a footnote text', read().includes('[^x]: Typed text'), JSON.stringify(read().slice(-60))); + await key('Return'); + await type('Quick^[an inline one] note'); + await sleep(900); + check('typing ^[...] makes an inline note, with one space after it', read().includes('Quick^[an inline one] note\n'), JSON.stringify(read().slice(-60))); + // ---- writing flow: footnote texts stay at the bottom, Enter goes back to the text ---- + await js(`document.querySelector('.tail').click()`); + await sleep(300); + check('clicking below the page adds a line above the footnotes', await js(`const all = [...document.querySelectorAll('.blocks > .block')]; const i = all.findIndex(b => b.contains(document.activeElement)); return i >= 0 && all.slice(i + 1).length > 0 && all.slice(i + 1).every(b => b.classList.contains('b-footnote'))`)); + await type('/footnote'); + await key('Return'); + await sleep(500); + const fid = await js(`return document.activeElement?.closest('.block')?.dataset.fid`); + await type('Written note'); + await key('Return'); + check('Enter at the end of a footnote text goes back after its reference', await js(`const c = document.activeElement; const s = getSelection(); const r = s.getRangeAt(0); const pre = document.createRange(); pre.selectNodeContents(c); pre.setEnd(r.startContainer, r.startOffset); const frag = pre.cloneContents(); let last = frag.lastChild; while (last && last.nodeType === 3 && !last.data) last = last.previousSibling; return c.closest('.block').classList.contains('b-p') && !!last && last.nodeName === 'SUP' && last.dataset.fn === ${JSON.stringify(fid)}`), `footnote ${fid}`); + await type('. Then more'); + await sleep(900); + check('...where typing carries on, and the reference stays a reference', read().includes(`[^${fid}]. Then more`) && !read().includes(`1. Then more`), JSON.stringify(read().split('\n\n').find((x) => x.includes('Then more')))); + check('a line starting with a reference is not turned into a list', await js(`return !!document.querySelector('.blocks .b-p sup.fn[data-fn="${fid}"]')`)); + + // ---- the ordinary line-start shortcuts still work ---- + await js(`document.querySelector('.tail').click()`); + await sleep(300); + await type('1. First item'); + check('"1. " still starts a numbered list', await js(`return document.activeElement?.closest('.block')?.classList.contains('b-number') && document.activeElement.textContent === 'First item'`)); + await key('Return'); await key('Return'); + await type('# Title'); + check('"# " still makes a heading', await js(`return document.activeElement?.closest('.block')?.classList.contains('b-h1') && document.activeElement.textContent === 'Title'`)); + await key('Return'); + await type('---'); + await sleep(300); + check('"---" still makes a divider', await js(`return [...document.querySelectorAll('.blocks > .block')].some(b => b.classList.contains('b-divider'))`)); + + check('existing footnote lines kept as written', read().includes('[^2]: Second note.\n[^src]: The source,\n on two lines.\n[^lonely]: Nobody points here.')); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nfootnotes e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-graph.test.cjs b/test/e2e-graph.test.cjs index 760f501..c9c6a7a 100644 --- a/test/e2e-graph.test.cjs +++ b/test/e2e-graph.test.cjs @@ -32,7 +32,7 @@ app.whenReady().then(async () => { try { await sleep(1500); // Open Graph View - await js(`document.querySelector('.topbar [aria-label="Graph view"]').click()`); + await js(`[...document.querySelectorAll('.sidebar .qa')].find((b) => b.textContent.includes('Graph view')).click()`); await sleep(1000); // Open settings panel diff --git a/test/e2e-headings.test.cjs b/test/e2e-headings.test.cjs new file mode 100644 index 0000000..6aebc8e --- /dev/null +++ b/test/e2e-headings.test.cjs @@ -0,0 +1,134 @@ +// End-to-end check: renaming a heading updates [[Page#Heading]] links and embeds. Run with `bun run test:e2e`. +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +// Record every call of the rename IPC, to see WHEN renames happen and how often. +const calls = []; +const handle = ipcMain.handle.bind(ipcMain); +ipcMain.handle = (channel, fn) => handle(channel, (...a) => { if (channel === 'vault:renameHeading') calls.push(a.slice(1)); return fn(...a); }); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-headings-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(path.join(vault, 'Archive'), { recursive: true }); +const filler = (n) => Array.from({ length: n }, (_, i) => `Paragraph ${i + 1} of filler text.`).join('\n\n'); +fs.writeFileSync(path.join(vault, 'Plan.md'), ['# Plan', '## Goals', 'goal text', '## Risks', 'See [[#Risks]] and [[Plan#Goals|my goals]].', '## Extra', filler(3)].join('\n\n') + '\n'); +fs.writeFileSync(path.join(vault, 'Archive', 'Plan.md'), '## Goals\n\nold plan\n'); +fs.writeFileSync(path.join(vault, 'Notes.md'), 'Read [[Plan#Goals]] and [[Plan#Risks|the risks]].\n\n![[Plan#Goals]]\n\nAlso [[Plan#Risks]].\n'); +fs.writeFileSync(path.join(vault, 'Journal.md'), 'Old: [[Archive/Plan#Goals]] and [[Plan]].\n'); +fs.writeFileSync(path.join(vault, 'Dup.md'), '## Topic\n\nfirst\n\n## Topic\n\nsecond\n'); +fs.writeFileSync(path.join(vault, 'Links.md'), 'Go to [[Dup#Topic]].\n'); +fs.writeFileSync(path.join(vault, 'Tasks.md'), '---\nkanban-plugin: basic\n---\n\n## To do\n\n- [ ] see [[Plan#Goals]]\n'); + +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Plan', 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 }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(250); }; + const type = async (t, gap = 70) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(gap); } }; + const read = (f) => fs.readFileSync(path.join(vault, f + '.md'), 'utf8'); + const caretAtEndOf = (text) => js(`const c = [...document.querySelectorAll('.blocks .c')].find(x => x.closest('.block').className.match(/b-h\\d/) && x.textContent === ${JSON.stringify(text)}); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); + const selectAllIn = (text) => js(`const c = [...document.querySelectorAll('.blocks .c')].find(x => x.closest('.block').className.match(/b-h\\d/) && x.textContent === ${JSON.stringify(text)}); c.focus(); const r = document.createRange(); r.selectNodeContents(c); getSelection().removeAllRanges(); getSelection().addRange(r);`); + const headings = () => js(`return [...document.querySelectorAll('.blocks > .block')].filter(b => /b-h\\d/.test(b.className)).map(b => b.textContent.trim())`); + // by id: collapsed folders keep their children in the page, and "Archive/Plan" is also called "Plan" + const openPage = async (id) => { await js(`document.querySelector('.tree .row[data-id=${JSON.stringify(id)}]').click()`); await sleep(900); }; + const toastText = () => js(`return document.querySelector('.toast.show')?.textContent || ''`); + const chips = () => js(`return [...document.querySelectorAll('.blocks .wl')].map(c => c.dataset.ref + '=' + c.textContent)`); + + try { + await sleep(2200); + + // ---- renaming a heading: one update, after you stop typing ---- + await caretAtEndOf('Goals'); + await type(' and more'); + check('nothing is rewritten while you are still typing', calls.length === 0 && read('Notes').includes('[[Plan#Goals]]'), JSON.stringify(calls)); + await sleep(3200); + check('one update after a pause, with the old and new name', calls.length === 1 && calls[0][0] === 'Plan' && calls[0][1] === 'Goals' && calls[0][2] === 'Goals and more', JSON.stringify(calls)); + const notes = read('Notes'); + check('links and embeds in other notes follow the heading, keeping aliases', notes === 'Read [[Plan#Goals and more]] and [[Plan#Risks|the risks]].\n\n![[Plan#Goals and more]]\n\nAlso [[Plan#Risks]].\n', JSON.stringify(notes)); + check('a same-named heading of another page is left alone', read('Journal') === 'Old: [[Archive/Plan#Goals]] and [[Plan]].\n'); + const ref = await chips(); + check('the open page\'s own links follow too, and keep their custom label', ref.includes('Plan#Goals and more=my goals') && ref.includes('#Risks=Risks'), JSON.stringify(ref)); + await sleep(900); + check('...and are saved', read('Plan').includes('[[Plan#Goals and more|my goals]]') && read('Plan').includes('[[#Risks]]'), JSON.stringify(read('Plan').split('\n\n')[4])); + check('a link inside a board card follows too', read('Tasks').includes('[[Plan#Goals and more]]'), JSON.stringify(read('Tasks').slice(-40))); + check('a toast says what was updated', /Updated 4 links to “Goals and more” in 3 notes/.test(await toastText()), await toastText()); + + // ---- undo takes the links back ---- + await key('z', ['control']); + await sleep(3400); + check('Ctrl+Z on the heading restores the links', (await headings()).includes('Goals') && read('Notes').includes('[[Plan#Goals]]') && read('Notes').includes('![[Plan#Goals]]'), JSON.stringify(await headings())); + check('...including the open page\'s own', (await chips()).includes('Plan#Goals=my goals'), JSON.stringify(await chips())); + + // ---- leaving the page applies a pending rename straight away ---- + calls.length = 0; + await caretAtEndOf('Goals'); + await type('!'); + await openPage('Notes'); + check('switching pages applies it without waiting', calls.length === 1 && read('Notes').includes('[[Plan#Goals!]]'), JSON.stringify(calls)); + await openPage('Plan'); + + // ---- things that are not renames ---- + calls.length = 0; + await selectAllIn('Risks'); + await type('RISKS'); + await sleep(3000); + check('a change of case alone does not touch the links', calls.length === 0 && read('Notes').includes('[[Plan#Risks|the risks]]'), JSON.stringify(calls)); + + await caretAtEndOf('Extra'); + await key('Return'); + await type('## Fresh'); + await sleep(3000); + check('a new heading is not a rename', calls.length === 0, JSON.stringify(calls)); + check('...but is tracked afterwards: renaming it later is one', await (async () => { await caretAtEndOf('Fresh'); await type(' ideas'); await sleep(3200); return calls.length === 1 && calls[0][1] === 'Fresh' && calls[0][2] === 'Fresh ideas'; })(), JSON.stringify(calls)); + + calls.length = 0; + await selectAllIn('Fresh ideas'); + await key('Backspace'); + await sleep(3000); + check('emptying a heading is not a rename', calls.length === 0, JSON.stringify(calls)); + + // ---- unsafe characters ---- + await caretAtEndOf('Goals!'); + for (let i = 0; i < 6; i++) await key('Backspace'); + await type('C# [tips]'); + await sleep(3400); + check('characters that would break a link are cleaned in the links', read('Notes').includes('[[Plan#C tips]]') && read('Notes').includes('![[Plan#C tips]]') && calls.at(-1)?.[2] === 'C# [tips]', JSON.stringify(read('Notes').split('\n')[0])); + await openPage('Notes'); + await js(`document.querySelector('.blocks .wl').click()`); + await sleep(1400); + check('...and such a link still finds the heading', (await js(`return document.querySelector('.title')?.textContent`)) === 'Plan' && !(await toastText()).includes('No heading'), await toastText()); + await js(`document.querySelector('.main .scroller').scrollTop = 0`); + + // ---- duplicate names: links follow the first heading only ---- + await openPage('Dup'); + calls.length = 0; + await js(`const hs = [...document.querySelectorAll('.blocks .c')].filter(c => c.closest('.block').className.match(/b-h\\d/)); const c = hs[1]; c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); + await type(' two'); + await sleep(3200); + check('renaming the SECOND heading of a name leaves the links alone', calls.length === 0 && read('Links') === 'Go to [[Dup#Topic]].\n', JSON.stringify(calls)); + await js(`const hs = [...document.querySelectorAll('.blocks .c')].filter(c => c.closest('.block').className.match(/b-h\\d/)); const c = hs[0]; c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); + await type(' one'); + await sleep(3200); + check('renaming the FIRST one updates them', read('Links') === 'Go to [[Dup#Topic one]].\n', JSON.stringify(read('Links'))); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nheadings e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-kanban-cards.test.cjs b/test/e2e-kanban-cards.test.cjs new file mode 100644 index 0000000..5351760 --- /dev/null +++ b/test/e2e-kanban-cards.test.cjs @@ -0,0 +1,146 @@ +// End-to-end check that a Kanban card is shown with the editor's own Markdown (lists, headings, code, math, images…). +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-kcards-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +const BOARD = `## To do + +- [ ] Plain card with **bold**, *italic*, ==mark== and [[Spec]] +- [ ] Release checklist + - first sub item + - second sub item + 1. numbered one + > a quoted line +- [ ] # Heading in a card + with a paragraph below + \`\`\`js + const a = 1; + \`\`\` +- [x] Done one with \`code\` and math $x^2$ + +## Doing + +- [ ] Card with image + ![](missing.png) +`; +fs.writeFileSync(path.join(vault, 'Spec.md'), 'The spec.\n'); +fs.writeFileSync(path.join(vault, 'Plan.md'), '---\nkanban-plugin: basic\n---\n\n' + BOARD); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Plan', bounds: { width: 1300, 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 count = (sel) => js(`return document.querySelectorAll(${JSON.stringify(sel)}).length`); + const card = (i) => `document.querySelectorAll('.kcard')[${i}]`; + + try { + await sleep(2000); + check('inline formatting is the editor\'s', await js(`const t = ${card(0)}.querySelector('.kc-text'); return !!t.querySelector('b') && !!t.querySelector('i') && !!t.querySelector('mark') && !!t.querySelector('.wl')`)); + check('lists inside a card are real lists', (await js(`return ${card(1)}.querySelectorAll('.b-bullet').length`)) === 2 && (await js(`return ${card(1)}.querySelectorAll('.b-number').length`)) === 1); + check('a quote is a quote', (await js(`return ${card(1)}.querySelectorAll('.b-quote').length`)) === 1); + check('a heading is a heading', (await js(`return ${card(2)}.querySelectorAll('.b-h1').length`)) === 1); + check('a code block is highlighted', (await js(`return ${card(2)}.querySelectorAll('.code pre.c span').length`)) > 0); + check('inline math is typeset', (await js(`return ${card(3)}.querySelectorAll('.katex').length`)) === 1); + check('a picture that isn\'t there says so', (await count('.kcard .img.broken, .kcard .img .img-miss')) > 0); + + await js(`${card(0)}.querySelector('.wl').click()`); + await sleep(900); + check('a [[link]] in a card still opens the page', (await js(`return document.querySelector('.title')?.textContent`)) === 'Spec'); + await js(`document.querySelector('.row[data-id="Plan"]').click()`); + await sleep(1000); + + await js(`${card(1)}.querySelector('.kc-text').click()`); + await sleep(400); + const raw = await js(`return document.querySelector('.kedit')?.value`); + check('clicking a card edits its Markdown as written', /^Release checklist\n- first sub item\n- second sub item\n1\. numbered one\n> a quoted line$/.test(raw || ''), JSON.stringify(raw)); + await js(`document.querySelector('.kedit').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`); + await sleep(600); + check('...and leaving it unchanged leaves the file as it was', fs.readFileSync(path.join(vault, 'Plan.md'), 'utf8').endsWith(BOARD)); + + // Test card auto-closing and slash menu + await js(`document.querySelector('.kadd').click()`); + await sleep(400); + check('new card textarea focused', await js(`return document.activeElement?.classList.contains('kedit')`)); + + const sendKey = (key, opts = {}) => js(` + const el = document.querySelector('.kedit'); + el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true, cancelable: true, ...${JSON.stringify(opts)} })); + `); + const insertChar = (char) => js(` + const el = document.querySelector('.kedit'); + const ev = new KeyboardEvent('keydown', { key: ${JSON.stringify(char)}, bubbles: true, cancelable: true }); + const notPrevented = el.dispatchEvent(ev); + if (notPrevented) { + const start = el.selectionStart, end = el.selectionEnd; + el.setRangeText(${JSON.stringify(char)}, start, end, 'end'); + } + el.dispatchEvent(new Event('input', { bubbles: true })); + `); + + // Type '*' then '*' + await insertChar('*'); + await insertChar('*'); + const valStars = await js(`return [document.querySelector('.kedit').value, document.querySelector('.kedit').selectionStart, document.querySelector('.kedit').selectionEnd]`); + check('typing ** auto-closes to **** with caret in middle', valStars[0] === '****' && valStars[1] === 2 && valStars[2] === 2, JSON.stringify(valStars)); + + // Backspace unpairing in **** + await sendKey('Backspace'); + const valUnpaired = await js(`return document.querySelector('.kedit').value`); + check('backspace inside **** unpairs both', valUnpaired === '', JSON.stringify(valUnpaired)); + + // Type '(' + await insertChar('('); + const valParen = await js(`return [document.querySelector('.kedit').value, document.querySelector('.kedit').selectionStart]`); + check('typing ( auto-closes to () with caret in middle', valParen[0] === '()' && valParen[1] === 1, JSON.stringify(valParen)); + + // Step over closer ')' + await insertChar(')'); + const valStepOver = await js(`return [document.querySelector('.kedit').value, document.querySelector('.kedit').selectionStart]`); + check('typing ) steps over existing closer', valStepOver[0] === '()' && valStepOver[1] === 2, JSON.stringify(valStepOver)); + + // Test / slash menu + await js(`const el = document.querySelector('.kedit'); el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true }));`); + await insertChar('/'); + await sleep(300); + const slashOpen = await js(`return !document.querySelector('.kslash')?.hidden && document.querySelector('.kslash')?.querySelectorAll('.si').length > 0`); + check('typing / opens Kanban slash menu', !!slashOpen); + + // Filter slash menu with 'c' + await insertChar('c'); + await sleep(300); + const slashMatch = await js(`return document.querySelector('.kslash .si.on b')?.textContent`); + check('typing c filters slash commands to Callout', slashMatch === 'Callout', JSON.stringify(slashMatch)); + + // Press Enter to apply Callout + await sendKey('Enter'); + await sleep(300); + const afterSlash = await js(`return document.querySelector('.kedit')?.value`); + check('Enter applies slash command to card', afterSlash === '> [!NOTE]\n> ', JSON.stringify(afterSlash)); + + // Cancel edit + await sendKey('Escape'); + await sleep(400); + + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nkanban cards e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-kanban-colors.test.cjs b/test/e2e-kanban-colors.test.cjs new file mode 100644 index 0000000..dee29d6 --- /dev/null +++ b/test/e2e-kanban-colors.test.cjs @@ -0,0 +1,84 @@ +// End-to-end check of column colours on Kanban boards (Markdown boards, not databases). +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-kcolors-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +const BOARD = '## To do\n\n- [ ] One\n\n## Doing\n\n- [ ] Two\n\n## Done\n\n- [x] Three\n'; +fs.writeFileSync(path.join(vault, 'Plan.md'), '---\nkanban-plugin: basic\n---\n\n' + BOARD); +fs.writeFileSync(path.join(vault, 'Other.md'), 'Other.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Plan', bounds: { width: 1300, height: 800 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const centerOf = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + const click = async (sel) => { + const c = await centerOf(sel); + if (!c) throw new Error('not found: ' + sel); + wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + await sleep(450); + }; + const file = () => fs.readFileSync(path.join(vault, 'Plan.md'), 'utf8'); + const pill = (name) => js(`const h = [...document.querySelectorAll('.khead')].find((x) => x.querySelector('.kh-title')?.textContent.trim() === ${JSON.stringify(name)}); return h ? h.querySelector('.db-opt').style.getPropertyValue('--oc') : null`); + const menuFor = async (name) => { + await js(`const h = [...document.querySelectorAll('.khead')].find((x) => x.querySelector('.kh-title')?.textContent.trim() === ${JSON.stringify(name)}); h.querySelector('[aria-label="Column options"]').click()`); + await sleep(300); + }; + + try { + await sleep(1800); + check('column titles are pills', (await js(`return document.querySelectorAll('.khead .db-opt').length`)) === 3); + const auto = await pill('Doing'); + check('...with a colour from their name until you pick one', /^#[0-9a-f]{6}$/i.test(auto), auto); + + await menuFor('Doing'); + await click(`document.querySelector('.kmenu .cv-swatch[data-color="teal"]')`); + await sleep(1000); + check('a colour is picked from the column menu', (await pill('Doing')) === '#3aa6c4', await pill('Doing')); + check('...kept in the page\'s front matter', /ink-colors: .*Doing.*teal/.test(file()), file().slice(0, 120)); + check('...and the board text itself is untouched', file().endsWith(BOARD), JSON.stringify(file().slice(-60))); + check('...and not listed as a property of the page', !(await js(`return [...document.querySelectorAll('.custom-pk')].some((i) => i.value === 'ink-colors')`))); + + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`); + await js(`document.querySelector('.row[data-id="Other"]').click()`); + await sleep(900); + await js(`document.querySelector('.row[data-id="Plan"]').click()`); + await sleep(1200); + check('the colour is there when you come back', (await pill('Doing')) === '#3aa6c4', await pill('Doing')); + + // rename keeps it + await js(`const h = [...document.querySelectorAll('.khead')].find((x) => x.querySelector('.kh-title')?.textContent.trim() === 'Doing'); h.querySelector('.kh-title').click()`); + await sleep(300); + await js(`const i = document.querySelector('.kh-input'); i.value = 'In progress'; i.dispatchEvent(new Event('input', { bubbles: true })); i.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))`); + await sleep(1000); + check('renaming a column keeps its colour', (await pill('In progress')) === '#3aa6c4' && /In progress.*teal/.test(file()) && !/"Doing"/.test(file()), file().slice(0, 140)); + + await menuFor('In progress'); + await click(`document.querySelector('.kmenu .cv-swatch[data-color="auto"]')`); + await sleep(900); + check('"Automatic" takes the colour away again', !/ink-colors/.test(file()), file().slice(0, 100)); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nkanban colours e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-math.test.cjs b/test/e2e-math.test.cjs new file mode 100644 index 0000000..9e6138a --- /dev/null +++ b/test/e2e-math.test.cjs @@ -0,0 +1,98 @@ +// End-to-end check of math ($$) and mermaid blocks: they fill the row, are styled, and their preview follows what +// you type. Run with `bun run test:e2e`. +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-math-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +fs.writeFileSync(path.join(vault, 'Page.md'), 'Intro text.\n\n$$\n3+3=12\n$$\n\n```js\nlet a = 1\n```\n\n```mermaid\ngraph TD\nA[Start] --> B[End]\n```\n\nOutro.\n'); +app.setPath('userData', tmp); +const settingsFile = path.join(tmp, 'settings.json'); +fs.writeFileSync(settingsFile, JSON.stringify({ vault, lastPage: 'Page', 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 type = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(40); } await sleep(200); }; + const read = () => fs.readFileSync(path.join(vault, 'Page.md'), 'utf8'); + const caretEnd = (sel) => js(`const c = ${sel}; c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); + const wait = async (fn, ms = 6000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await fn()) return true; await sleep(150); } return false; }; + const widths = () => js(`const w = (s) => Math.round(document.querySelector(s).getBoundingClientRect().width); return { math: w('.math'), mermaid: w('.mermaid-wrap'), code: w('.block .code'), page: w('.blocks') }`); + const mathPre = `document.querySelector('.math pre')`; + const mermaidPre = `document.querySelector('.mermaid-wrap pre')`; + + try { + await sleep(2200); + + // ---- layout and style ---- + const w = await widths(); + check('math and mermaid fill the row like a code block', w.math === w.code && w.mermaid === w.code && w.code > 400, JSON.stringify(w)); + const st = await js(`const cs = (s) => getComputedStyle(document.querySelector(s)); return { bg: cs('.math').backgroundColor, ring: cs('.math').boxShadow, font: cs('.math pre').fontFamily, mermaidBg: cs('.mermaid-wrap').backgroundColor }`); + check('they have a background and a border', st.bg !== 'rgba(0, 0, 0, 0)' && st.mermaidBg !== 'rgba(0, 0, 0, 0)' && st.ring !== 'none', JSON.stringify(st)); + check('the source is in the monospace font', /mono|monospace/i.test(st.font), st.font); + const label = await js(`const m = document.querySelector('.math').getBoundingClientRect(); const l = document.querySelector('.math .lang').getBoundingClientRect(); return { fromLeft: Math.round(l.left - m.left), inside: l.right <= m.right && l.left >= m.left }`); + check('the label sits at the left, inside the block', label.fromLeft < 30 && label.inside, JSON.stringify(label)); + const mlabel = await js(`const m = document.querySelector('.mermaid-wrap').getBoundingClientRect(); const l = document.querySelector('.mermaid-wrap .lang').getBoundingClientRect(); return l.left >= m.left && l.right <= m.right`); + check('...and the mermaid label is not cut off', mlabel); + check('math and mermaid source has no code colouring (code blocks still do)', (await js(`return !document.querySelector('.math pre span') && !document.querySelector('.mermaid-wrap pre span') && !!document.querySelector('.block .code pre span')`))); + + // ---- previews ---- + check('the formula is rendered', await js(`return !!document.querySelector('.math-preview .katex') && document.querySelector('.math-preview').textContent.includes('12')`)); + check('the diagram is rendered', await wait(() => js(`return !!document.querySelector('.mermaid-preview svg')`))); + const darkSvg = await js(`return document.querySelector('.mermaid-preview svg style')?.textContent || ''`); + check('in the dark theme it uses mermaid\'s dark colours', /fill:#ccc/i.test(darkSvg), darkSvg.match(/fill:#[0-9a-f]{3,6}/gi)?.slice(0, 3).join(' ')); + + // ---- the preview follows the source ---- + await caretEnd(mathPre); + await type('+4'); + check('typing in the formula updates its preview', await wait(() => js(`return document.querySelector('.math-preview').textContent.includes('4')`)), await js(`return document.querySelector('.math-preview').textContent`)); + await js(`${mathPre}.focus(); document.execCommand('selectAll')`); + await type('x^2'); + await sleep(400); + const after = await js(`return document.querySelector('.math-preview').textContent`); + check('...and replacing it shows only the new formula', after.includes('x') && !after.includes('12') && !after.includes('+4'), JSON.stringify(after)); + await sleep(900); + check('the source is saved as $$ … $$', read().includes('$$\nx^2\n$$'), JSON.stringify(read().split('\n').slice(0, 6))); + + // ---- mermaid: errors, recovery, empty ---- + await js(`${mermaidPre}.focus(); document.execCommand('selectAll')`); + await type('this is not a diagram'); + check('an invalid diagram shows its error, not an empty box', await wait(() => js(`return !!document.querySelector('.mermaid-preview .preview-error')`)), await js(`return document.querySelector('.mermaid-preview').textContent.slice(0, 80)`)); + check('...in the error colour', await js(`const e = document.querySelector('.mermaid-preview .preview-error'); return !!e && getComputedStyle(e).color !== getComputedStyle(document.body).color`)); + check('no stray mermaid elements are left in the page', await js(`return document.querySelectorAll('body > [id^="dmermaid"]').length === 0`)); + await js(`${mermaidPre}.focus(); document.execCommand('selectAll')`); + await type('graph LR; X-->Y'); + check('fixing it brings the diagram back', await wait(() => js(`return !!document.querySelector('.mermaid-preview svg') && !document.querySelector('.mermaid-preview .preview-error')`))); + await js(`${mermaidPre}.focus(); document.execCommand('selectAll'); document.execCommand('delete')`); + await sleep(900); + check('an empty block shows a hint', await js(`return document.querySelector('.mermaid-preview').textContent.includes('appears here')`)); + + // ---- light theme ---- + await js(`document.documentElement.dataset.theme = 'light'`); + await caretEnd(mermaidPre); + await type('graph TD; P-->Q'); + await wait(() => js(`return !!document.querySelector('.mermaid-preview svg')`)); + const light = await js(`return { bg: getComputedStyle(document.querySelector('.math')).backgroundColor, svg: document.querySelector('.mermaid-preview svg style')?.textContent || '' }`); + check('light theme: block background follows the theme', light.bg !== st.bg, `${st.bg} -> ${light.bg}`); + check('light theme: a new diagram is drawn with mermaid\'s light colours', /fill:#333/i.test(light.svg) && !/fill:#ccc/i.test(light.svg), light.svg.match(/fill:#[0-9a-f]{3,6}/gi)?.slice(0, 3).join(' ')); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nmath e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-navigation.test.cjs b/test/e2e-navigation.test.cjs new file mode 100644 index 0000000..8effd1b --- /dev/null +++ b/test/e2e-navigation.test.cjs @@ -0,0 +1,105 @@ +// End-to-end check of Back and Forward navigation history, UI buttons, and mouse buttons 3 & 4. +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-nav-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +fs.writeFileSync(path.join(vault, 'Alpha.md'), 'Content of Alpha\n'); +fs.writeFileSync(path.join(vault, 'Beta.md'), 'Content of Beta\n'); +fs.writeFileSync(path.join(vault, 'Gamma.md'), 'Content of Gamma\n'); + +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Alpha', bounds: { width: 1280, height: 860 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + + try { + await sleep(2000); + const title = () => js(`return document.querySelector('.title')?.textContent?.trim()`); + const backDisabled = () => js(`return !!document.querySelector('.nav-btn[aria-label="Back"]')?.disabled`); + const forwardDisabled = () => js(`return !!document.querySelector('.nav-btn[aria-label="Forward"]')?.disabled`); + + check('starts on Alpha', (await title()) === 'Alpha', await title()); + check('back is disabled initially', (await backDisabled()) === true); + check('forward is disabled initially', (await forwardDisabled()) === true); + + // Navigate to Beta + await js(`document.querySelector('.row[data-id="Beta"]')?.click()`); + await sleep(600); + check('navigates to Beta', (await title()) === 'Beta', await title()); + check('back is now enabled', (await backDisabled()) === false); + check('forward remains disabled', (await forwardDisabled()) === true); + + // Navigate to Gamma + await js(`document.querySelector('.row[data-id="Gamma"]')?.click()`); + await sleep(600); + check('navigates to Gamma', (await title()) === 'Gamma', await title()); + + // Click Topbar Back button + await js(`document.querySelector('.nav-btn[aria-label="Back"]')?.click()`); + await sleep(600); + check('topbar Back button goes to Beta', (await title()) === 'Beta', await title()); + check('forward is now enabled', (await forwardDisabled()) === false); + + // Mouse button 3 (Back) + await js(`window.dispatchEvent(new MouseEvent('mouseup', { button: 3, bubbles: true, cancelable: true }))`); + await sleep(600); + check('mouse Back button (button 3) goes to Alpha', (await title()) === 'Alpha', await title()); + check('back is now disabled at start of history', (await backDisabled()) === true); + check('forward is enabled', (await forwardDisabled()) === false); + + // Mouse button 4 (Forward) + await js(`window.dispatchEvent(new MouseEvent('mouseup', { button: 4, bubbles: true, cancelable: true }))`); + await sleep(600); + check('mouse Forward button (button 4) goes to Beta', (await title()) === 'Beta', await title()); + + // Topbar Forward button + await js(`document.querySelector('.nav-btn[aria-label="Forward"]')?.click()`); + await sleep(600); + check('topbar Forward button goes to Gamma', (await title()) === 'Gamma', await title()); + check('forward is disabled at end of history', (await forwardDisabled()) === true); + + // Keyboard Alt+ArrowLeft + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', altKey: true, bubbles: true }))`); + await sleep(600); + check('Alt+ArrowLeft goes back to Beta', (await title()) === 'Beta', await title()); + + // Keyboard Alt+ArrowRight + await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', altKey: true, bubbles: true }))`); + await sleep(600); + check('Alt+ArrowRight goes forward to Gamma', (await title()) === 'Gamma', await title()); + + // Electron app-command browser-backward + win.emit('app-command', { preventDefault() {} }, 'browser-backward'); + await sleep(600); + check('Electron app-command browser-backward goes to Beta', (await title()) === 'Beta', await title()); + + // Electron app-command browser-forward + win.emit('app-command', { preventDefault() {} }, 'browser-forward'); + await sleep(600); + check('Electron app-command browser-forward goes to Gamma', (await title()) === 'Gamma', await title()); + + } catch (err) { + check('harness', false, err.stack); + } + + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nnavigation e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-outline.test.cjs b/test/e2e-outline.test.cjs new file mode 100644 index 0000000..c322750 --- /dev/null +++ b/test/e2e-outline.test.cjs @@ -0,0 +1,122 @@ +// End-to-end check of the outline panel. Run with `bun run test:e2e` (needs a display or --ozone-platform=headless). +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-outline-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); + +const filler = (n) => Array.from({ length: n }, (_, i) => `Paragraph ${i + 1} with a few words so the page gets tall enough to scroll.`).join('\n\n'); +fs.writeFileSync(path.join(vault, 'Long.md'), [ + '# Intro', filler(14), '## Setup', filler(14), '### Install', filler(14), '## Usage', filler(14), '# Appendix', filler(14), 'Last line', +].join('\n\n') + '\n'); +fs.writeFileSync(path.join(vault, 'Plain.md'), 'Just text, no headings.\n'); +fs.writeFileSync(path.join(vault, 'Tasks.md'), '---\nkanban-plugin: basic\n---\n\n## To do\n\n- [ ] One\n\n\n## Done\n'); + +app.setPath('userData', tmp); +const settingsFile = path.join(tmp, 'settings.json'); +fs.writeFileSync(settingsFile, JSON.stringify({ vault, lastPage: 'Long', theme: 'dark', bounds: { width: 1280, height: 860 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const key = async (keyCode, modifiers = []) => { + wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); + wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); + await sleep(200); + }; + const type = async (t) => { for (const ch of t) wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(200); }; + const click = async (sel) => { + const c = await js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + if (!c) throw new Error('not found: ' + sel); + wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + await sleep(350); + }; + const items = () => js(`return [...document.querySelectorAll('.ol-item')].map(b => ({ t: b.textContent.trim(), lv: b.style.getPropertyValue('--lv'), on: b.classList.contains('on') }))`); + const outlineBtn = `document.querySelector('.topbar [aria-label="Outline"]')`; + const scrollTop = () => js(`return Math.round(document.querySelector('.main .scroller').scrollTop)`); + const headingTop = (text) => js(`const sc = document.querySelector('.main .scroller'); const h = [...document.querySelectorAll('.blocks .block')].find(b => /^b-h/.test(b.className.match(/b-\\w+/)[0]) && b.textContent.trim() === ${JSON.stringify(text)}); return h ? Math.round(h.getBoundingClientRect().top - sc.getBoundingClientRect().top) : null`); + // Settings reach the disk ~200 ms after a change. + const saved = async () => { await sleep(500); return JSON.parse(fs.readFileSync(settingsFile, 'utf8')); }; + + try { + await sleep(1800); + check('closed by default', !(await js(`return !!document.querySelector('.outline')`))); + // (a layout wrapper once reused the class of the property values and silently greyed all page text) + check('page text has the normal text colour', await js(`return getComputedStyle(document.querySelector('.blocks .c')).color === getComputedStyle(document.body).color`)); + + await click(outlineBtn); + const list = await items(); + check('lists the headings in order', JSON.stringify(list.map((i) => i.t)) === '["Intro","Setup","Install","Usage","Appendix"]', JSON.stringify(list.map((i) => i.t))); + check('indents by level', JSON.stringify(list.map((i) => i.lv)) === '["0","1","2","1","0"]', JSON.stringify(list.map((i) => i.lv))); + check('count shown', (await js(`return document.querySelector('.ol-head em')?.textContent`)) === '5'); + check('remembered in settings', (await saved()).outline === true); + + await click(`[...document.querySelectorAll('.ol-item')].find(b => b.textContent.trim() === 'Usage')`); + check('landing flash on the heading', await js(`return !!document.querySelector('.block.flash')`)); + await sleep(900); + const top = await headingTop('Usage'); + check('click scrolls to the heading', top !== null && top > 0 && top < 120, `heading ${top}px below the top`); + check('clicked entry is highlighted', (await items()).filter((i) => i.on).map((i) => i.t).join() === 'Usage'); + + await js(`document.querySelector('.main .scroller').scrollTop = 0`); + await sleep(500); + check('nothing highlighted above the first heading', (await items()).every((i) => !i.on), JSON.stringify((await items()).filter((i) => i.on))); + const setupAt = await js(`const sc = document.querySelector('.main .scroller'); const h = [...document.querySelectorAll('.blocks .block')].find(b => b.textContent.trim() === 'Setup'); return Math.round(h.getBoundingClientRect().top - sc.getBoundingClientRect().top + sc.scrollTop)`); + await js(`document.querySelector('.main .scroller').scrollTop = ${setupAt + 200}`); + await sleep(500); + check('highlight follows scrolling', (await items()).filter((i) => i.on).map((i) => i.t).join() === 'Setup', JSON.stringify(await items())); + + // typing: a new heading appears, its text updates live, deleting it removes it + await js(`const c = [...document.querySelectorAll('.blocks .c')].at(-1); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); + await key('Return'); + await type('## Extra'); + check('typing "## " adds an entry', (await items()).map((i) => i.t).join() === 'Intro,Setup,Install,Usage,Appendix,Extra', (await items()).map((i) => i.t).join()); + await type(' stuff'); + check('entry text follows typing', (await items()).at(-1).t === 'Extra stuff', (await items()).at(-1).t); + await key('z', ['control']); + await key('z', ['control']); + await key('z', ['control']); + await sleep(300); + check('undo removes it', (await items()).length === 5, (await items()).map((i) => i.t).join()); + + // pages without headings, and boards + await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Plain'))`); + await sleep(500); + check('page without headings: hint', (await js(`return document.querySelector('.ol-empty')?.textContent || ''`)).includes('No headings yet')); + await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Tasks'))`); + await sleep(600); + check('boards: no panel, button disabled', !(await js(`return !!document.querySelector('.outline')`)) && (await js(`return ${outlineBtn}.disabled`))); + await click(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Long'))`); + await sleep(600); + check('back on a page: panel is back', (await items()).length === 5); + + // shortcut and close button + await key('o', ['control', 'shift']); + check('Ctrl+Shift+O closes', !(await js(`return !!document.querySelector('.outline')`)) && (await saved()).outline === false); + await key('o', ['control', 'shift']); + check('Ctrl+Shift+O opens', await js(`return !!document.querySelector('.outline')`)); + await click(`document.querySelector('.outline [aria-label="Close outline"]')`); + check('close button', !(await js(`return !!document.querySelector('.outline')`))); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\noutline e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-selection.test.cjs b/test/e2e-selection.test.cjs new file mode 100644 index 0000000..dd07242 --- /dev/null +++ b/test/e2e-selection.test.cjs @@ -0,0 +1,151 @@ +// End-to-end check of mouse selection across blocks: a drag may start in one block and end in another. +const { app, BrowserWindow } = require('electron'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-selection-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +fs.writeFileSync(path.join(vault, 'Note.md'), 'First paragraph of text here.\n\nSecond paragraph with more words.\n\n- a bullet item\n\nFourth paragraph at the end.\n'); +fs.writeFileSync(path.join(vault, 'Keys.md'), 'First paragraph of text here.\n\nSecond paragraph with more words.\n\n- a bullet item\n\nFourth paragraph at the end.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Note', bounds: { width: 1200, height: 800 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(350); }; + const fields = () => js(`return [...document.querySelectorAll('.block .c')].map(c => { const r = c.getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y + r.height / 2), w: Math.round(r.width), text: c.textContent }; })`); + const drag = async (from, to, steps = 12) => { + wc.sendInputEvent({ type: 'mouseMove', x: from[0], y: from[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: from[0], y: from[1], button: 'left', clickCount: 1 }); + for (let i = 1; i <= steps; i++) { + wc.sendInputEvent({ type: 'mouseMove', x: Math.round(from[0] + ((to[0] - from[0]) * i) / steps), y: Math.round(from[1] + ((to[1] - from[1]) * i) / steps), button: 'left' }); + await sleep(25); + } + wc.sendInputEvent({ type: 'mouseUp', x: to[0], y: to[1], button: 'left', clickCount: 1 }); + await sleep(300); + }; + const state = () => js(`const s = getSelection(); const r = s.rangeCount ? s.getRangeAt(0) : null; return { blocks: r ? [...document.querySelectorAll('.block')].filter((b) => r.intersectsNode(b)).length : 0, text: r ? r.toString() : '', collapsed: s.isCollapsed, cls: document.body.className }`); + const texts = async () => (await fields()).map((f) => f.text); + + try { + await sleep(1800); + let f = await fields(); + + // inside one block: still the browser's own selection + await drag([f[0].x + 30, f[0].y], [f[0].x + 120, f[0].y + 1]); + let s = await state(); + check('a drag inside one block selects text there', s.blocks === 1 && s.text.length > 3, JSON.stringify(s.text)); + + // from the first block down into the last + await drag([f[0].x + 40, f[0].y], [f[3].x + 90, f[3].y]); + s = await state(); + check('a drag from one block into another selects across them', s.blocks === 4 && !s.collapsed, JSON.stringify(s)); + check('...from the first character it began at to where it ended', s.text.startsWith('paragraph') || s.text.startsWith(' paragraph') || s.text.startsWith('t paragraph') || /^[a-z ]+paragraph/.test(s.text.slice(0, 12)), JSON.stringify(s.text.slice(0, 30))); + check('...including the text of the blocks between', s.text.includes('Second paragraph with more words.') && s.text.includes('a bullet item')); + check('...and the browser gets its own selection back afterwards', !s.cls.includes('xsel')); + + // typing replaces it, like any selection over several blocks + await key('Delete'); + await sleep(400); + const after = await texts(); + check('Delete removes it and joins what is left of the first and last blocks', after.length === 1 && /^Fir.*end\.$/.test(after[0]), JSON.stringify(after)); + await key('z', ['control']); + await sleep(500); + check('...and undo brings it all back', (await texts()).length === 4); + + // upwards, from the last block to the second + f = await fields(); + await drag([f[3].x + 60, f[3].y], [f[1].x + 50, f[1].y]); + s = await state(); + check('a drag upwards works too', s.blocks === 3, JSON.stringify(s)); + + // a click afterwards puts the caret back and drops the selection + await drag([f[2].x + 10, f[2].y], [f[2].x + 10, f[2].y], 1); + s = await state(); + check('clicking again clears it', s.collapsed); + + // a double click still selects a word + wc.sendInputEvent({ type: 'mouseDown', x: f[1].x + 20, y: f[1].y, button: 'left', clickCount: 2 }); + wc.sendInputEvent({ type: 'mouseUp', x: f[1].x + 20, y: f[1].y, button: 'left', clickCount: 2 }); + await sleep(300); + s = await state(); + check('double-click still selects a word', s.blocks === 1 && /^\w+$/.test(s.text.trim()), JSON.stringify(s.text)); + + // ---- the keyboard (on a fresh page) ---- + await js(`document.querySelector('.row[data-id="Keys"]').click()`); + await sleep(1500); + f = await fields(); + const click = async (x, y, shift = false) => { + wc.sendInputEvent({ type: 'mouseMove', x, y }); + wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1, modifiers: shift ? ['shift'] : [] }); + wc.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1, modifiers: shift ? ['shift'] : [] }); + await sleep(300); + }; + await click(f[0].x + 2, f[0].y); + await key('Down', ['shift']); + s = await state(); + check('Shift+Down selects to the end of the line', s.blocks === 1 && s.text === 'First paragraph of text here.', JSON.stringify(s)); + await key('Down', ['shift']); + s = await state(); + check('...and once more carries it into the next block', s.blocks === 2 && s.text.startsWith('First paragraph of text here.'), JSON.stringify(s.text)); + await key('Down', ['shift']); + s = await state(); + check('...where it goes on (to the end of that block)', s.blocks === 2 && s.text === 'First paragraph of text here.Second paragraph with more words.', JSON.stringify(s.text)); + await key('Up', ['shift']); + await key('Up', ['shift']); + s = await state(); + check('...and back', s.blocks === 1, JSON.stringify(s)); + await key('Down', ['shift']); + await key('Down', ['shift']); + await key('Up', ['shift']); + s = await state(); + check('Shift+Up takes it back', s.blocks === 1, JSON.stringify(s)); + await key('End', ['shift', 'control']); + s = await state(); + check('Ctrl+Shift+End selects to the end of the page', s.blocks === 4 && s.text.endsWith('at the end.'), JSON.stringify(s.text.slice(-20))); + await key('Home', ['shift', 'control']); + s = await state(); + check('...and Ctrl+Shift+Home from there back to the start', s.blocks === 1 && s.collapsed === true || s.text === '', JSON.stringify(s)); + + await click(f[1].x + 2, f[1].y); + await key('Left', ['shift']); + s = await state(); + check('Shift+Left at the start of a block reaches back into the one before', s.blocks === 2, JSON.stringify(s)); + await key('Left', ['shift', 'control']); + s = await state(); + check('Ctrl+Shift+Left extends by words', s.blocks === 2 && s.text.length > 3, JSON.stringify(s.text)); + + await click(f[0].x + 20, f[0].y); + await key('Right', ['shift', 'control']); + s = await state(); + check('Ctrl+Shift+Right selects a word', s.blocks === 1 && /^\w+$/.test(s.text.trim()), JSON.stringify(s.text)); + + await click(f[0].x + 20, f[0].y); + await click(f[3].x + 40, f[3].y, true); + s = await state(); + check('Shift+click extends the selection across blocks', s.blocks === 4 && !s.collapsed, JSON.stringify(s)); + await key('Backspace'); + await sleep(300); + const left = await texts(); + check('...and Backspace removes it', left.length === 1, JSON.stringify(left)); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nselection e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-settings.test.cjs b/test/e2e-settings.test.cjs new file mode 100644 index 0000000..199bce9 --- /dev/null +++ b/test/e2e-settings.test.cjs @@ -0,0 +1,204 @@ +// End-to-end check of the Settings dialog and what the settings do. Run with `bun run test:e2e settings`. +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-settings-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +fs.writeFileSync(path.join(vault, 'Page.md'), '---\ntags: [alpha]\n---\nHello.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ + vault, lastPage: 'Page', bounds: { width: 1280, height: 860 }, + startPage: 'today', + prefs: { dateFormat: 'DD-MM-YYYY', dailyTemplate: '## {{weekday}}\n\n- [ ] first' } +})); +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); +}; +const saved = () => { try { return JSON.parse(fs.readFileSync(path.join(tmp, 'settings.json'), 'utf8')); } catch { return {}; } }; + +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 typeKeys = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'keyDown', keyCode: ch }); wc.sendInputEvent({ type: 'char', keyCode: ch }); wc.sendInputEvent({ type: 'keyUp', keyCode: ch }); await sleep(70); } await sleep(200); }; + const openSettings = async () => { await js(`document.querySelector('.sidebar [aria-label="Settings"]').click()`); await sleep(400); }; + const closeSettings = async () => { await key('Escape'); await sleep(350); }; + const tab = async (label) => { await js(`[...document.querySelectorAll('.set-nav button')].find((b) => b.textContent.trim() === ${JSON.stringify(label)}).click()`); await sleep(250); }; + const clickText = (text) => js(`const b = [...document.querySelectorAll('.set-body button')].find((b) => b.textContent.trim() === ${JSON.stringify(text)}); if (!b) throw new Error('no button ' + ${JSON.stringify(text)}); b.click()`); + const clickLabel = (label) => js(`const b = document.querySelector('.set-body [aria-label=${JSON.stringify(label)}]'); if (!b) throw new Error('no control ' + ${JSON.stringify(label)}); b.click()`); + const rootHas = (cls) => js(`return document.documentElement.classList.contains(${JSON.stringify(cls)})`); + const lastText = () => js(`const cs = [...document.querySelectorAll('.blocks .block .c')]; return cs[cs.length - 1].textContent`); + const newLine = async () => { await js(`document.querySelector('.tail').click()`); await sleep(300); }; + + try { + await sleep(2500); + + // ---- launch: open on launch = today's note, named in the chosen format, from the template ---- + const files = () => fs.readdirSync(path.join(vault, 'Daily')).filter((f) => f.endsWith('.md')); + const d = new Date(), z = (n) => String(n).padStart(2, '0'); + const want = `${z(d.getDate())}-${z(d.getMonth() + 1)}-${d.getFullYear()}.md`; + check('"Open on launch: today" made a note named with the chosen date format', fs.existsSync(path.join(vault, 'Daily')) && files().includes(want), JSON.stringify(fs.existsSync(path.join(vault, 'Daily')) ? files() : 'no Daily folder')); + const body = fs.existsSync(path.join(vault, 'Daily', want)) ? fs.readFileSync(path.join(vault, 'Daily', want), 'utf8') : ''; + check('...and it starts with the template, weekday filled in', /## \w+/.test(body) && !body.includes('{{') && body.includes('- [ ] first'), JSON.stringify(body.slice(-60))); + check('...and it is the page that is open', (await js(`return document.querySelector('.title')?.textContent`)) === want.slice(0, -3), await js(`return document.querySelector('.title')?.textContent`)); + // back to a plain page for the editing checks + await js(`[...document.querySelectorAll('.sidebar .row')].find((r) => r.textContent.trim().startsWith('Page'))?.click()`); + await sleep(500); + + // ---- the dialog ---- + await openSettings(); + const tabs = await js(`return [...document.querySelectorAll('.set-nav button')].map((b) => b.textContent.trim())`); + check('Settings has its six sections', tabs.join('|') === 'Appearance|Editor|Notes|Graph & canvas|General|Shortcuts', tabs.join('|')); + + // appearance + await clickText('Compact'); + await sleep(200); + check('density: Compact sets the sidebar row height', (await js(`return document.documentElement.dataset.density`)) === 'compact' && (await js(`return getComputedStyle(document.querySelector('.sidebar .row')).height`)) === '27px', await js(`return getComputedStyle(document.querySelector('.sidebar .row')).height`)); + await clickText('Mono'); + check('interface font: Mono', (await js(`return getComputedStyle(document.body).fontFamily`)).includes('JetBrains'), await js(`return getComputedStyle(document.body).fontFamily`)); + await js(`const r = document.querySelector('[aria-label="Sidebar width"]'); r.value = '320'; r.dispatchEvent(new Event('input', { bubbles: true }))`); + await sleep(700); + check('sidebar width changes the layout', (await js(`return Math.round(document.querySelector('.sidebar').getBoundingClientRect().width)`)) === 320, await js(`return Math.round(document.querySelector('.sidebar').getBoundingClientRect().width)`)); + await clickLabel('Cover image'); + await clickLabel('Page icon'); + await sleep(200); + check('hide cover and icon', (await rootHas('hide-cover')) && (await rootHas('hide-icon')) && (await js(`return getComputedStyle(document.querySelector('.cover')).display`)) === 'none' && (await js(`return getComputedStyle(document.querySelector('.p-icon')).display`)) === 'none'); + await clickLabel('Linked from'); + check('hide backlinks', await rootHas('hide-backlinks')); + await clickLabel('Cover image'); + await clickLabel('Page icon'); + await clickLabel('Linked from'); + + // editor: switches + await tab('Editor'); + await clickText('120%'.replace('120', '100')); // the reset chip is disabled at 100%: a no-op click + await clickLabel('Wrap long code lines'); + check('wrap code lines', await rootHas('code-wrap')); + await clickLabel('Wrap long code lines'); + await clickLabel('Typewriter mode'); + check('typewriter mode adds room below the page', (await rootHas('typewriter')) && parseFloat(await js(`return getComputedStyle(document.querySelector('.page')).paddingBottom`)) > 200); + await clickLabel('Typewriter mode'); + // slash menu: hide Quote + await js(`[...document.querySelectorAll('.chips .chip-t')].find((b) => b.textContent.trim() === 'Quote').click()`); + await sleep(200); + check('slash menu chip turns off', (await js(`return [...document.querySelectorAll('.chips .chip-t')].find((b) => b.textContent.trim() === 'Quote').getAttribute('aria-pressed')`)) === 'false'); + + // persisted + await sleep(500); + const p = saved().prefs || {}; + check('settings are written to settings.json', p.density === 'compact' && p.uiFont === 'mono' && p.sidebarWidth === 320 && Array.isArray(p.hiddenKinds) && p.hiddenKinds.includes('quote'), JSON.stringify(p).slice(0, 200)); + + // ---- typing behaviors ---- + await closeSettings(); + await newLine(); + await typeKeys('/quote'); + const slashHasQuote = await js(`const s = document.querySelector('.pop.slash'); return !s.hidden && [...s.querySelectorAll('.si b')].some((b) => b.textContent === 'Quote')`); + check('a hidden block is not in the slash menu', !slashHasQuote); + await key('Escape'); + await newLine(); + await typeKeys('('); + check('brackets close themselves by default', (await lastText()) === '()', await lastText()); + await openSettings(); await tab('Editor'); + await clickLabel('Close brackets and quotes'); + await closeSettings(); + await newLine(); + await typeKeys('('); + check('...and not when switched off', (await lastText()) === '(', await lastText()); + await openSettings(); await tab('Editor'); + await clickLabel('Smart typography'); + await closeSettings(); + await newLine(); + await typeKeys('He said "hi" and it\'s ok -- fine...'); + const smart = await lastText(); + check('smart typography curls quotes and makes dashes and ellipses', smart === 'He said “hi” and it’s ok — fine…', JSON.stringify(smart)); + await newLine(); + await typeKeys('---'); + check('...but --- is still the divider shortcut', !!(await js(`return document.querySelector('.blocks .b-divider')`))); + await newLine(); + await typeKeys('# '); + check('markdown shortcuts: "# " makes a heading by default', !!(await js(`return document.querySelector('.blocks .b-h1')`))); + await openSettings(); await tab('Editor'); + await clickLabel('Markdown shortcuts'); + await closeSettings(); + await newLine(); + await typeKeys('## x'); + check('...and does not when switched off', (await lastText()) === '## x' && (await js(`return document.querySelectorAll('.blocks .b-h2').length`)) === 0, await lastText()); + + // ---- notes, canvas, general ---- + await openSettings(); await tab('Notes'); + const rows = await js(`return document.querySelector('.set-body').textContent`); + check('Notes section shows daily, trash and history settings', /Note name/.test(rows) && /Empty the trash after/.test(rows) && /Versions kept per page/.test(rows) && /Week starts on/.test(rows)); + await clickText('Sunday'); + await sleep(300); + check('week start is saved', (saved().prefs || {}).weekStart === 'sunday'); + await js(`const i = document.querySelector('[aria-label="Days in trash"]'); i.value = '9999'; i.dispatchEvent(new Event('change', { bubbles: true }))`); + await sleep(300); + check('a number outside its range is clamped', (await js(`return document.querySelector('[aria-label="Days in trash"]').value`)) === '365' && (saved().prefs || {}).trashDays === 365); + + await tab('Graph & canvas'); + await clickLabel('Tags as nodes'); + await clickLabel('Snap to grid'); + await sleep(500); + check('graph and canvas settings are saved', saved().graph?.tags === true && (saved().prefs || {}).snapToGrid === false, JSON.stringify(saved().graph)); + + await tab('General'); + // Under a headless display the window system may ignore the request, so first see whether it can be honoured at all. + win.setAlwaysOnTop(true); const honoured = win.isAlwaysOnTop(); win.setAlwaysOnTop(false); + await clickLabel('Always on top'); + await sleep(300); + check('always on top reaches the window', honoured ? win.isAlwaysOnTop() === true : (saved().prefs || {}).alwaysOnTop === true, honoured ? '' : 'headless: only the saved flag was checked'); + await clickLabel('Always on top'); + await sleep(300); + check('...and turns off again', win.isAlwaysOnTop() === false); + await clickText('Back up now'); + await sleep(2500); + const dir = path.join(tmp, 'backups'); + const zips = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.zip')) : []; + check('Back up now writes a zip', zips.length === 1 && fs.statSync(path.join(dir, zips[0])).size > 50, JSON.stringify(zips)); + check('...and remembers when', saved().lastBackup > 0); + + // dictionary + await tab('Editor'); + await js(`const i = document.querySelector('[aria-label="Add a word"]'); i.value = 'zxqvword'; i.dispatchEvent(new Event('input', { bubbles: true }))`); + await sleep(100); + await js(`document.querySelector('.set-body form button[type=submit]').click()`); + await sleep(600); + check('a word can be added to the dictionary', !!(await js(`return [...document.querySelectorAll('.words .word')].some((w) => w.textContent.includes('zxqvword'))`))); + await js(`document.querySelector('.words .word button').click()`); + await sleep(400); + check('...and removed', !(await js(`return [...document.querySelectorAll('.words .word')].some((w) => w.textContent.includes('zxqvword'))`))); + + // shortcuts reference + await tab('Shortcuts'); + check('shortcut reference lists the command palette', /Command palette/.test(await js(`return document.querySelector('.set-body').textContent`))); + + // reset + await tab('General'); + await js(`window.confirm = () => true`); + await clickText('Reset…'); + await sleep(600); + const after = saved(); + check('Reset puts settings back', after.prefs?.density === 'default' && after.prefs?.uiFont === 'inter' && after.prefs?.weekStart === 'monday' && after.startPage === 'last' && after.palette === 'default', JSON.stringify(after.prefs).slice(0, 160)); + check('...and the page follows', (await js(`return document.documentElement.dataset.density`)) === 'default' && !(await rootHas('hide-cover'))); + + // ask before deleting: cancel keeps the page + await clickLabel('Close').catch(() => {}); + await closeSettings(); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nsettings e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-slash.test.cjs b/test/e2e-slash.test.cjs new file mode 100644 index 0000000..9553931 --- /dev/null +++ b/test/e2e-slash.test.cjs @@ -0,0 +1,270 @@ +// End-to-end check of the / block menu: what's highlighted is what Enter makes. Run with `bun run test:e2e`. +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-slash-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +fs.writeFileSync(path.join(vault, 'Page.md'), 'Hello.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Page', bounds: { width: 1280, height: 860 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const key = async (keyCode) => { wc.sendInputEvent({ type: 'keyDown', keyCode }); wc.sendInputEvent({ type: 'keyUp', keyCode }); await sleep(250); }; + const type = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(60); } await sleep(200); }; + const highlighted = () => js(`const s = document.querySelector('.pop.slash'); return s.hidden ? null : s.querySelector('.si.on b')?.textContent`); + const lastBlock = () => js(`return document.querySelector('.blocks > .block:last-of-type').className.match(/b-(\\w+)/)[1]`); + const newLine = async () => { await js(`document.querySelector('.tail').click()`); await sleep(300); }; + const rowCenter = (label) => js(`const r = [...document.querySelectorAll('.pop.slash .si')].find(x => x.querySelector('b').textContent === ${JSON.stringify(label)}).getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + + try { + await sleep(1800); + await js(`window.__pt = null; document.addEventListener('mousemove', (e) => { if (e.isTrusted) window.__pt = [e.clientX, e.clientY]; }, true)`); + + await newLine(); + await type('/c'); + check('"/c" highlights the best match', (await highlighted()) === 'Callout', await highlighted()); + await key('Return'); + check('...and Enter makes it', (await lastBlock()) === 'callout', await lastBlock()); + + await newLine(); + await type('/'); + const top = await highlighted(); // the kinds picked last come first in the list + await key('Down'); await key('Down'); await key('Down'); + const moved = await highlighted(); + await type('c'); + check('a new query starts at its best match again', moved !== top && (await highlighted()) === 'Callout', `${top} -> ${moved} -> ${await highlighted()}`); + await key('Escape'); + + // a still mouse: the menu opens under it; a "move" at the same spot must not change the pick + await newLine(); + wc.sendInputEvent({ type: 'mouseMove', x: 900, y: 700 }); + await sleep(200); + await type('/'); + const first = await highlighted(); + const row = await rowCenter('Heading 1'); + await js(`const pt = window.__pt; const row = [...document.querySelectorAll('.pop.slash .si')].find(x => x.querySelector('b').textContent === 'Heading 1'); row.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: pt[0], clientY: pt[1] }))`); + await sleep(150); + check('a "move" without moving does not change the pick', (await highlighted()) === first, await highlighted()); + wc.sendInputEvent({ type: 'mouseMove', x: row[0], y: row[1] }); + await sleep(200); + check('a real hover still picks the row', (await highlighted()) === 'Heading 1', await highlighted()); + await key('Escape'); + + await newLine(); + await type('/callout'); + await key('Return'); + check('"/callout" + Enter makes a callout', (await lastBlock()) === 'callout' && !(await js(`return !!document.querySelector('.blocks sup.fn')`)), await lastBlock()); + await newLine(); + await type('/footnote'); + await key('Return'); + await sleep(300); + check('"/footnote" + Enter makes a footnote', !!(await js(`return document.querySelector('.blocks sup.fn') && document.querySelector('.blocks .b-footnote')`))); + + // ---- typing pairs ---- + const typeKeys = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'keyDown', keyCode: ch }); wc.sendInputEvent({ type: 'char', keyCode: ch }); wc.sendInputEvent({ type: 'keyUp', keyCode: ch }); await sleep(70); } await sleep(150); }; + // (footnotes stay at the bottom, so "the line being typed in" is the focused one, not the last one) + const line = () => js(`return document.activeElement.closest('.c')?.textContent ?? null`); + const caret = () => js(`const c = document.activeElement.closest('.c'); const s = getSelection(); const r = document.createRange(); r.selectNodeContents(c); r.setEnd(s.focusNode, s.focusOffset); return r.toString().length`); + const fresh = async () => { await newLine(); await sleep(100); }; + await fresh(); + await typeKeys('**'); + check('typing ** gives **** with the caret in the middle', (await line()) === '****' && (await caret()) === 2, `${await line()} @${await caret()}`); + await typeKeys('bold'); + await typeKeys('**'); + check('...and typing the closing ** steps over them (the source stays shown while the caret is at it)', (await line()) === '**bold**' && (await caret()) === 8, `${JSON.stringify(await line())} @${await caret()}`); + await fresh(); + await typeKeys('('); + check('typing ( gives () with the caret inside', (await line()) === '()' && (await caret()) === 1); + await typeKeys('hi)'); + check('...and the ) steps over the closing one', (await line()) === '(hi)' && (await caret()) === 4, `${await line()} @${await caret()}`); + await fresh(); + await typeKeys('('); + await key('Backspace'); + check('Backspace in an empty pair removes both', (await line()) === ''); + await typeKeys('**'); + await key('Backspace'); + check('...also for ****', (await line()) === ''); + await typeKeys('word'); + await key('Home'); + await js(`const c = document.activeElement.closest('.c'); const r = document.createRange(); r.selectNodeContents(c); getSelection().removeAllRanges(); getSelection().addRange(r)`); + await typeKeys('('); + check('typing ( over a selection wraps it', (await line()) === '(word)', await line()); + await fresh(); + await typeKeys('2*3'); + check('a single * is left alone (2*3)', (await line()) === '2*3', await line()); + await fresh(); + await fresh(); + await typeKeys("don't"); + check('an apostrophe is just an apostrophe', (await line()) === "don't"); + await fresh(); + await typeKeys('[['); + await sleep(300); + check('[[ pairs and opens the page picker', (await line()) === '[[]]' && !!(await js(`const s = document.querySelector('.pop.slash'); return s && !s.hidden`))); + await key('Return'); + await sleep(700); + check('...and choosing a page leaves a link with no stray ]]', await js(`const c = document.activeElement.closest('.c') || document.querySelector('.blocks .wl')?.closest('.c'); return !!c && !!c.querySelector('.wl') && !c.textContent.includes(']]') && !c.textContent.includes('[[')`), await line()); + await fresh(); + await typeKeys('```'); + await sleep(200); + check('``` still makes a code block (no stray backticks)', await js(`const b = document.activeElement.closest('.block'); return b.classList.contains('b-code') && b.querySelector('.c').textContent === ''`)); + await typeKeys('f{'); + check('in a code block, { pairs too', (await js(`return document.activeElement.closest('.c').textContent`)) === 'f{}'); + + // ---- live preview: formatting shows its marks while the caret is at it ---- + const html = () => js(`return document.activeElement.closest('.c').innerHTML.replace(/ /g, ' ')`); + const shown = () => js(`const sp = document.activeElement.closest('.c').querySelector('.md-src'); return sp ? { fmt: sp.dataset.fmt, text: sp.textContent, weight: getComputedStyle(sp.childNodes[1] ? sp : sp).fontWeight } : null`); + await fresh(); + await typeKeys('**'); + await typeKeys('h'); + let sh = await shown(); + check('typing inside ** ** shows **h** styled bold right away', sh && sh.fmt === 'b' && sh.text === '**h**' && +sh.weight >= 600 && (await caret()) === 3, JSON.stringify(sh)); + check('...with the marks dimmed', await js(`const m = document.activeElement.closest('.c').querySelector('.md-mk'); return !!m && getComputedStyle(m).color !== getComputedStyle(m.parentNode).color`)); + await typeKeys('ello**'); + check('...the closing ** steps over the marks', (await line()) === '**hello**' && (await caret()) === 9, `${await line()} @${await caret()}`); + await typeKeys('x'); + check('typing on after it is plain, and the marks hide', (await html()) === 'hellox', await html()); + await fresh(); + await typeKeys('see ~~old~~ and ==key== and `code` and *it* '); + const h = await html(); + check('~~strike~~, ==highlight==, `code` and *italic* work the same way', /old<\/s>/.test(h) && /key<\/mark>/.test(h) && /code<\/code>/.test(h) && /it<\/i>/.test(h) && !h.includes('md-src'), h); + await fresh(); + await typeKeys('a**b**'); + check('after a letter it is left as typed (a**b**)', (await line()) === 'a**b**', await line()); + await fresh(); + await typeKeys('** no **'); + check('a mark with a space just inside is left alone', !(await html()).includes('md-src') && !(await html()).includes(''), await html()); + await fresh(); + await typeKeys('2*3'); + check('a lone * stays a star (2*3)', (await line()) === '2*3'); + + // moving the caret through existing formatting + await fresh(); + await typeKeys('one *it* two'); + const caretAt = (n) => js(`const c = document.activeElement.closest('.c'); c.focus(); const w = document.createTreeWalker(c, NodeFilter.SHOW_TEXT); let t, rest = ${n}; while ((t = w.nextNode())) { if (rest <= t.length) { const r = document.createRange(); r.setStart(t, rest); r.collapse(true); getSelection().removeAllRanges(); getSelection().addRange(r); return; } rest -= t.length; }`); + check('away from it, italic is just italic', (await html()) === 'one it two', await html()); + await key('Home'); + for (let i = 0; i < 5; i++) await key('Right'); + await sleep(150); + check('arriving at it with the arrow keys shows its marks', (await line()) === 'one *it* two' && !!(await shown()), await line()); + for (let i = 0; i < 4; i++) await key('Right'); + await sleep(150); + check('...and moving on hides them again', (await html()) === 'one it two', await html()); + await key('Left'); await key('Left'); await key('Left'); + await sleep(150); + check('coming back from the right shows them again', (await line()) === 'one *it* two', await line()); + await caretAt(5); // right after the opening * + await sleep(150); + await key('Backspace'); + await sleep(150); + await key('End'); + await sleep(150); + check('deleting the opening * makes it plain text', (await html()) === 'one it* two', await html()); + + // modifier keys on their own don't make the marks jump away + await fresh(); + await typeKeys('one *it* two'); + await key('Home'); + for (let i = 0; i < 5; i++) await key('Right'); + await sleep(150); + check('(the marks are shown at the italic)', (await line()) === 'one *it* two', await line()); + // (the piece used to turn back into formatting and re-show itself at once: a flicker, so count DOM removals) + await js(`window.__flicker = 0; const c = document.activeElement.closest('.c'); window.__mo = new MutationObserver((ms) => { for (const m of ms) for (const n of m.removedNodes) if (n.classList && n.classList.contains('md-src')) window.__flicker++; }); window.__mo.observe(c, { childList: true, subtree: true });`); + for (const modKey of ['Control', 'Alt', 'Meta', 'Shift']) { + wc.sendInputEvent({ type: 'keyDown', keyCode: modKey }); + wc.sendInputEvent({ type: 'keyUp', keyCode: modKey }); + await sleep(150); + } + const flicker = await js(`window.__mo.disconnect(); return window.__flicker`); + check('pressing Ctrl, Alt, Win/Meta or Shift alone leaves the marks alone (no flicker)', flicker === 0 && (await line()) === 'one *it* two' && !!(await shown()), `${flicker} removals; ${await line()}`); + + // ---- columns ---- + const colText = () => js(`return [...document.querySelectorAll('.blocks .cols')].pop() ? [...[...document.querySelectorAll('.blocks .cols')].pop().querySelectorAll(':scope > .col')].map((c) => [...c.querySelectorAll('.block .c')].map((x) => x.textContent)) : null`); + const focusIn = (col, idx = 0, atStart = false) => js(`const cols = [...document.querySelectorAll('.blocks .cols')].pop(); const c = cols.querySelectorAll(':scope > .col')[${col}].querySelectorAll('.block .c')[${idx}]; c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(${atStart}); const s = getSelection(); s.removeAllRanges(); s.addRange(r);`); + await fresh(); + await typeKeys('/columns'); + await sleep(200); + await key('Return'); + await sleep(300); + let cols = await js(`const w = [...document.querySelectorAll('.blocks .cols')].pop(); return w ? { n: w.querySelectorAll(':scope > .col').length, side: (() => { const [a, b] = [...w.querySelectorAll(':scope > .col')].map((c) => c.getBoundingClientRect()); return b.left > a.right - 1 && Math.abs(a.top - b.top) < 2; })(), focusIn: !!document.activeElement.closest('.cols > .col[data-i="0"]') } : null`); + check('"/columns" makes two columns side by side, caret in the first', cols && cols.n === 2 && cols.side && cols.focusIn, JSON.stringify(cols)); + await typeKeys('left'); + await key('Return'); + await typeKeys('more'); + await focusIn(1); + await typeKeys('right'); + check('typing goes into each column (Enter stays in its column)', JSON.stringify(await colText()) === JSON.stringify([['left', 'more'], ['right']]), JSON.stringify(await colText())); + await focusIn(1, 0, true); + await key('Backspace'); + check('Backspace at the start of a column does not pull it into the other column', JSON.stringify(await colText()) === JSON.stringify([['left', 'more'], ['right']]), JSON.stringify(await colText())); + // it is saved as fenced columns, in the file + await sleep(1600); + const saved = fs.readFileSync(path.join(vault, 'Page.md'), 'utf8'); + check('the file has the columns as ::: fences', saved.includes(':::: columns\n::: column\nleft\n\nmore\n:::\n::: column\nright\n:::\n::::'), JSON.stringify(saved.slice(-160))); + // the toolbar: another column, then back to ordinary blocks + await js(`[...document.querySelectorAll('.blocks .cols')].pop().querySelector('.cols-add').click()`); + await sleep(300); + check('the toolbar adds a column', (await colText()).length === 3); + await js(`[...document.querySelectorAll('.blocks .cols')].pop().querySelector('.cols-undo').click()`); + await sleep(300); + check('...and turns them back into ordinary blocks, with their text', (await colText()) === null && (await js(`return [...document.querySelectorAll('.blocks > .block .c')].map((x) => x.textContent).join('|').includes('left|more|right')`))); + wc.sendInputEvent({ type: 'keyDown', keyCode: 'z', modifiers: ['control'] }); + wc.sendInputEvent({ type: 'keyUp', keyCode: 'z', modifiers: ['control'] }); + await sleep(400); + check('Ctrl+Z brings the columns back', (await colText()) && (await colText()).length === 3, JSON.stringify(await colText())); + + // ---- an emptied line forgets its formatting ---- + await fresh(); + await typeKeys('**abc'); + for (let i = 0; i < 4; i++) await key('Backspace'); + await typeKeys('x'); + check('after deleting a bold word and its marks with Backspace, new text is plain', (await html()) === 'x', JSON.stringify(await html())); + await fresh(); + await typeKeys('word'); + const selectLine = () => js(`const c = document.activeElement.closest('.c'); const r = document.createRange(); r.selectNodeContents(c); getSelection().removeAllRanges(); getSelection().addRange(r)`); + await selectLine(); + wc.sendInputEvent({ type: 'keyDown', keyCode: 'b', modifiers: ['control'] }); + wc.sendInputEvent({ type: 'keyUp', keyCode: 'b', modifiers: ['control'] }); + await sleep(300); + check('(Ctrl+B text is bold)', (await html()).includes(''), await html()); + await selectLine(); + await key('Delete'); + await typeKeys('new'); + check('after selecting a bold line and deleting it, new text is plain', (await html()) === 'new', JSON.stringify(await html())); + + // an unchecked to-do is never struck through (it used to show a line when a spelling squiggle sat on the word) + await newLine(); + await type('/to-do'); + await key('Return'); + await sleep(200); + await type('dsfds'); + await key('Return'); + await key('Backspace'); + await key('Backspace'); + await sleep(300); + check('an unchecked to-do has no strike-through', (await js(`const c = document.querySelector('.b-todo:not(.done) .c'); return c && getComputedStyle(c).textDecorationLine`)) === 'none'); + await js(`document.querySelector('.b-todo .check').click()`); + await sleep(400); + check('...and a checked one does', (await js(`const c = document.querySelector('.b-todo.done .c'); return c && getComputedStyle(c).textDecorationLine`)) === 'line-through'); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nslash menu e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-tags.test.cjs b/test/e2e-tags.test.cjs new file mode 100644 index 0000000..17337e7 --- /dev/null +++ b/test/e2e-tags.test.cjs @@ -0,0 +1,265 @@ +// End-to-end check of the sidebar's tag list with many tags. Run with `bun run test:e2e`. +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-tags-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +const TAGS = ['programming', 'project', 'study', 'asm', 'ads', 'asdf', 'dfgf', 'dsfsd', 'fdghfdfhg', 'fgjgf', 'gsdfgd', 'ink', 'travel', 'recipes']; +TAGS.forEach((t, i) => fs.writeFileSync(path.join(vault, `Page ${i + 1}.md`), `---\ntags: [${t}]\n---\n\nText ${i + 1}\n`)); +fs.writeFileSync(path.join(vault, 'Page 1b.md'), '---\ntags: [project]\n---\n\nSecond project page\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Page 1', theme: 'dark', bounds: { width: 1100, height: 760 } })); +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 m = (type, x, y, extra = {}) => wc.sendInputEvent({ type, x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1, ...extra }); + 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 click = async (x, y) => { m('mouseMove', x, y); m('mouseDown', x, y); m('mouseUp', x, y); await sleep(250); }; + const type = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(30); } await sleep(200); }; + const key = async (keyCode) => { wc.sendInputEvent({ type: 'keyDown', keyCode }); wc.sendInputEvent({ type: 'keyUp', keyCode }); await sleep(250); }; + const chips = () => js(`return [...document.querySelectorAll('.sidebar .tags .tag')].map((t) => t.textContent.replace(/\\d+$/, '').replace('#', '').trim())`); + const saved = () => JSON.parse(fs.readFileSync(path.join(tmp, 'settings.json'), 'utf8')); + + try { + await sleep(2200); + const all = await chips(); + check('all the tags are there to begin with', all.length === TAGS.length && all.includes('programming'), JSON.stringify(all)); + const list = await box(`document.querySelector('.sidebar .tags')`); + check('the tag list is a short scrolling box, not as tall as its content', list.h <= 120 && (await js(`const t = document.querySelector('.sidebar .tags'); return t.scrollHeight > t.clientHeight`)), JSON.stringify(list)); + const tree = await box(`document.querySelector('.sidebar .tree')`); + check('so the page tree keeps most of the sidebar', tree.h > 150, String(tree.h)); + check('the header shows how many tags there are', (await js(`return document.querySelector('.sec-toggle em')?.textContent`)) === String(TAGS.length - 0 > 0 ? new Set(TAGS).size : 0)); + check('with many tags there is a box to find one', !!(await box(`document.querySelector('.sidebar .tag-find input')`))); + + const input = await box(`document.querySelector('.sidebar .tag-find input')`); + await click(input.cx, input.cy); + await type('pro'); + check('typing narrows the list to matching tags', (await chips()).sort().join() === 'programming,project', JSON.stringify(await chips())); + await type('x'); + check('no match says so', (await js(`return document.querySelector('.sidebar .tags .muted')?.textContent`)) === 'No tag matches'); + await key('Escape'); + check('Escape clears the search and shows them all again', (await chips()).length === TAGS.length); + + // colours: right-click a tag + const rclickAt = async (x, y) => { m('mouseMove', x, y); m('mouseDown', x, y, { button: 'right' }); m('mouseUp', x, y, { button: 'right' }); await sleep(350); }; + const chipFor = async (name) => { + await js(`const t = [...document.querySelectorAll('.sidebar .tags .tag')].find((t) => t.textContent.replace(/\\d+$/, '').replace('#', '').trim() === ${JSON.stringify(name)}); t && t.scrollIntoView({ block: 'nearest' })`); + await sleep(150); + return box(`[...document.querySelectorAll('.sidebar .tags .tag')].find((t) => t.textContent.replace(/\\d+$/, '').replace('#', '').trim() === ${JSON.stringify(name)})`); + }; + const colorOf = (name) => js(`const t = [...document.querySelectorAll('.sidebar .tags .tag')].find((t) => t.textContent.replace(/\\d+$/, '').replace('#', '').trim() === ${JSON.stringify(name)}); return t && { tinted: t.classList.contains('tinted'), tc: t.style.getPropertyValue('--tc') }`); + check('tags have no colour to begin with', (await colorOf('asm')).tinted === false); + await key('Escape'); + const asm = await chipFor('asm'); + await rclickAt(asm.cx, asm.cy); + const swatches = await js(`return [...document.querySelectorAll('.ctx .tag-swatches [data-color]')].map((b) => b.dataset.color)`); + check('right-clicking a tag offers colours (and none), above Rename', swatches.join() === 'red,orange,yellow,green,teal,blue,purple,pink,none' && (await js(`return document.querySelector('.ctx .mi')?.textContent.trim()`)) === 'Rename tag', swatches.join()); + const green = await box(`document.querySelector('.ctx [data-color="green"]')`); + await click(green.cx, green.cy); + const asmColor = await colorOf('asm'); + check('choosing one tints the tag', asmColor.tinted && asmColor.tc === '#44ad6e', JSON.stringify(asmColor)); + await sleep(500); + const vkey = Object.keys(saved().vaults || {})[0]; + check('...and the colour is remembered with the notes folder', saved().vaults[vkey].tagColors.asm === 'green', JSON.stringify(saved().vaults)); + // the same colour shows on the page's own tag + await js(`document.querySelector('.tree .row[data-id="Page 4"]').click()`); + await sleep(900); + check('...and on that tag where a page shows it', await js(`const c = [...document.querySelectorAll('.tagv .chip')].find((c) => c.textContent.trim() === 'asm'); return !!c && c.classList.contains('tinted') && c.style.getPropertyValue('--tc') === '#44ad6e'`)); + // change it and take it off again + const asm2 = await chipFor('asm'); + await rclickAt(asm2.cx, asm2.cy); + const noneSw = await box(`document.querySelector('.ctx [data-color="none"]')`); + check('the current colour is marked in the menu', await js(`return document.querySelector('.ctx [data-color="green"]').classList.contains('on')`)); + await click(noneSw.cx, noneSw.cy); + check('"No colour" takes it off again', (await colorOf('asm')).tinted === false && (await (async () => { await sleep(500); return !('asm' in (saved().vaults[vkey].tagColors || {})); })())); + const pro = await chipFor('project'); + await rclickAt(pro.cx, pro.cy); + const purple = await box(`document.querySelector('.ctx [data-color="purple"]')`); + await click(purple.cx, purple.cy); + await sleep(300); + + // pick one: the tree turns into the tagged pages, the tag stays marked + await click(input.cx, input.cy); + await type('project'); + const chip = await box(`document.querySelector('.sidebar .tags .tag')`); + await click(chip.cx, chip.cy); + check('choosing a tag filters the pages by it', (await js(`return document.querySelectorAll('.sidebar .tree .row.flat').length`)) === 2); + + // fold the list away + const head = await box(`document.querySelector('.sec-toggle')`); + await click(head.cx, head.cy); + await sleep(600); // it slides shut + check('the Tags header folds the list away', (await js(`const w = document.querySelector('.tags-wrap'); return !w.classList.contains('open') && w.offsetHeight === 0 && w.inert`))); + check('...the tag being filtered by stays visible', (await js(`return document.querySelector('.sec-active')?.textContent.includes('project')`))); + check('...in its colour', (await js(`return document.querySelector('.sec-active')?.style.getPropertyValue('--tc')`)) === '#9a6de0'); + check('...and the fold is remembered', await (async () => { await sleep(500); return saved().tagsOpen === false; })()); + const tree2 = await box(`document.querySelector('.sidebar .tree')`); + check('...giving the space to the pages', tree2.h > tree.h); + const active = await box(`document.querySelector('.sec-active')`); + await click(active.cx, active.cy); + check('clicking that tag clears the filter', (await js(`return document.querySelectorAll('.sidebar .tree .row.flat').length`)) === 0 && !(await js(`return !!document.querySelector('.sec-active')`))); + const head2 = await box(`document.querySelector('.sec-toggle')`); + // it slides open rather than jumping: partway through, it is neither shut nor full height + m('mouseMove', head2.cx, head2.cy); m('mouseDown', head2.cx, head2.cy); m('mouseUp', head2.cx, head2.cy); + await sleep(70); + const mid = await js(`return document.querySelector('.tags-wrap').offsetHeight`); + await sleep(700); + const full = await js(`return document.querySelector('.tags-wrap').offsetHeight`); + check('and it unfolds again, sliding open', full > 50 && mid > 0 && mid < full, `${mid} -> ${full}`); + + // the sidebar's quick actions: a list, or a row of icons (a setting) + const qaBoxes = () => js(`return [...document.querySelectorAll('.sidebar .qas .qa')].map((b) => { const r = b.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, text: getComputedStyle(b.querySelector('.qa-t')).display !== 'none' }; })`); + let qa = await qaBoxes(); + check('the shortcuts are a vertical list by default', qa.length === 4 && qa.every((b, i) => i === 0 || b.y > qa[i - 1].y) && qa.every((b) => b.text)); + await js(`[...document.querySelectorAll('.sidebar button')].find((b) => b.getAttribute('aria-label') === 'Settings').click()`); + await sleep(500); + check('Settings has Vertical and Horizontal to choose between', (await js(`return [...document.querySelectorAll('[aria-label="Sidebar shortcuts"] button')].map((b) => b.textContent.trim()).join()`)) === 'Vertical,Horizontal'); + await js(`document.querySelector('[data-layout="horizontal"]').scrollIntoView({ block: 'center' })`); // Settings is long: it sits below the fold + await sleep(200); + const hb = await box(`document.querySelector('[data-layout="horizontal"]')`); + await click(hb.cx, hb.cy); + qa = await qaBoxes(); + check('Horizontal puts them in one row of icons', qa.length === 4 && qa.every((b) => Math.abs(b.y - qa[0].y) < 1) && qa.every((b) => !b.text) && qa.every((b) => b.w > 30), JSON.stringify(qa)); + check('...with names and shortcuts as tooltips', (await js(`return [...document.querySelectorAll('.sidebar .qa')].map((b) => b.title).join()`)).startsWith('Search (Ctrl K),New page')); + await sleep(500); + check('...and the choice is remembered', saved().quickLayout === 'horizontal'); + await js(`document.querySelector('.settings [aria-label="Close"]').click()`); + await sleep(400); + const searchBtn = await box(`document.querySelector('.sidebar .qa[aria-label="Search"]')`); + await click(searchBtn.cx, searchBtn.cy); + check('the icons still work (Search opens the palette)', !!(await js(`return document.querySelector('.pal-top')`))); + await key('Escape'); + await js(`[...document.querySelectorAll('.sidebar button')].find((b) => b.getAttribute('aria-label') === 'Settings').click()`); + await sleep(500); + await js(`document.querySelector('[data-layout="vertical"]').scrollIntoView({ block: 'center' })`); + await sleep(200); + const vb = await box(`document.querySelector('[data-layout="vertical"]')`); + await click(vb.cx, vb.cy); + qa = await qaBoxes(); + check('Vertical brings the list back', qa.every((b, i) => i === 0 || b.y > qa[i - 1].y) && qa.every((b) => b.text)); + + // spellcheck languages: a styled list, not the browser's )', !(await js(`return !!document.querySelector('.settings select')`)) && !!(await js(`return !!document.querySelector('.settings [aria-label="Spellcheck language"].fp')`))); + await js(`document.querySelector('.settings .set-body').scrollTop = 99999`); + await sleep(200); + const lang = await box(`document.querySelector('[aria-label="Spellcheck language"]')`); + await click(lang.cx, lang.cy); + const shownRows = await js(`return [...document.querySelectorAll('.sm-list .mi')].length`); + check('clicking opens a list with a search box and the current language ticked', shownRows > 3 && !!(await js(`return !!document.querySelector('.sm-list .sm-find input')`)) && !!(await js(`return !!document.querySelector('.sm-list .mi[aria-selected="true"]')`)), String(shownRows)); + await type('german'); + const germans = await js(`return [...document.querySelectorAll('.sm-list .mi .mi-label')].map((l) => l.textContent.trim())`); + check('typing narrows it to matching languages', germans.length >= 1 && germans.every((n) => /german/i.test(n)), JSON.stringify(germans)); + await key('Escape'); + check('Escape closes the list and keeps Settings open', !(await js(`return !!document.querySelector('.sm-list')`)) && !!(await js(`return !!document.querySelector('.settings')`))); + await click(lang.cx, lang.cy); + await type('german'); + const g = await box(`document.querySelector('.sm-list .mi')`); + const wanted = await js(`return document.querySelector('.sm-list .mi em')?.textContent`); + await click(g.cx, g.cy); + await sleep(700); + check('choosing one sets the language', saved().spellLangs && saved().spellLangs[0] === wanted && /german/i.test(await js(`return document.querySelector('[aria-label="Spellcheck language"]').textContent`)), JSON.stringify(saved().spellLangs) + ' ' + wanted); + const second = await box(`document.querySelector('[aria-label="Second spellcheck language"]')`); + await click(second.cx, second.cy); + check('the second language can be None, and never the first one', await js(`const l = [...document.querySelectorAll('.sm-list .mi')].map((n) => [n.querySelector('.mi-label').textContent.trim(), n.querySelector('em')?.textContent]); return l[0][0] === 'None' && !l.some(([, code]) => code === ${JSON.stringify(wanted)})`)); + await key('Return'); + await sleep(300); + check('Enter picks the highlighted row', (await js(`return document.querySelector('[aria-label="Second spellcheck language"]').textContent.includes('None')`))); + + // ---- a hidden sidebar floats in while the mouse is at the left edge ---- + const mv = (x, y) => wc.sendInputEvent({ type: 'mouseMove', x, y }); // (no button held) + const side = () => js(`const s = document.querySelector('.sidebar'); const r = s.getBoundingClientRect(); return { op: +getComputedStyle(s).opacity, left: r.left, w: r.width, peek: document.querySelector('.app').classList.contains('peek'), main: document.querySelector('.main').getBoundingClientRect().left }`); + await js(`document.querySelector('.settings [aria-label="Close"]')?.click()`); // (Settings was left open above) + await sleep(500); + await js(`document.querySelector('[aria-label="Hide sidebar"]').click()`); + await sleep(900); + let sd = await side(); + check('hiding the sidebar leaves an invisible edge strip', sd.op === 0 && !!(await box(`document.querySelector('.edge-hot')`)), JSON.stringify(sd)); + const mainLeft = sd.main; + mv(700, 400); await sleep(100); + // touching the strip opens it at once (no waiting) + mv(3, 400); + await sleep(120); + sd = await side(); + check('touching the edge strip opens the sidebar at once', sd.peek, JSON.stringify(sd)); + await sleep(500); + sd = await side(); + check('...fully in, floating over the page, which does not move', sd.op === 1 && Math.abs(sd.w - 268) < 2 && sd.left < 20 && Math.abs(sd.main - mainLeft) < 1, JSON.stringify(sd)); + // brushed the edge and moved on without entering it: it goes again soon (does not hang around) + mv(700, 400); + await sleep(700); + check('brushing the edge and moving on closes it again quickly', !(await side()).peek); + mv(3, 400); await sleep(300); + // slipping a little off the strip and its border is fine + mv(8, 400); await sleep(150); mv(9, 420); await sleep(150); + mv(120, 300); await sleep(300); + mv(285, 420); await sleep(900); + check('slipping off the strip, or just past the sidebar border, does not close it', (await side()).peek); + // leaving and coming back in time cancels the closing + mv(700, 400); await sleep(150); + mv(150, 400); await sleep(700); + check('coming back before it closes keeps it open', (await side()).peek); + mv(500, 400); await sleep(250); + check('(a short grace after leaving: still there)', (await side()).peek); + await sleep(500); + sd = await side(); + check('...then it closes (it does not hang around)', !sd.peek && sd.op < 0.05, JSON.stringify(sd)); + mv(3, 400); await sleep(300); + check('(back in)', (await side()).peek); + // it fades out as well as in (it used to just vanish) + wc.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' }); + wc.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' }); + await sleep(60); + const fading = await js(`const s = document.querySelector('.sidebar'); const cs = getComputedStyle(s); return { op: +cs.opacity, vis: cs.visibility, peek: document.querySelector('.app').classList.contains('peek') }`); + await sleep(500); + sd = await side(); + check('hiding fades it out (partway through it is still visible and see-through)', !fading.peek && fading.vis === 'visible' && fading.op > 0.02 && fading.op < 0.98, JSON.stringify(fading)); + check('Escape closes it, and it stays closed while the mouse is still on the edge', !sd.peek && sd.op === 0 && (await js(`return getComputedStyle(document.querySelector('.sidebar')).visibility`)) === 'hidden'); + // the mouse leaving the window: it does not stay until you click back + mv(700, 400); await sleep(100); + mv(3, 400); await sleep(300); + check('(open again)', (await side()).peek); + wc.sendInputEvent({ type: 'mouseLeave', x: 0, y: 400 }); + await sleep(900); + check('after the mouse leaves the window it stays a moment...', (await side()).peek); + await sleep(1400); + check('...and then closes by itself (about 2s at most)', !(await side()).peek); + mv(700, 400); await sleep(100); + mv(3, 400); await sleep(300); + wc.sendInputEvent({ type: 'mouseLeave', x: 0, y: 400 }); + await sleep(500); + mv(150, 400); await sleep(1500); + check('coming back into the window in time keeps it open', (await side()).peek); + mv(700, 400); await sleep(1000); + // showing the sidebar properly again + mv(700, 400); await sleep(300); + wc.sendInputEvent({ type: 'keyDown', keyCode: '\\', modifiers: ['control'] }); + wc.sendInputEvent({ type: 'keyUp', keyCode: '\\', modifiers: ['control'] }); + await sleep(900); + sd = await side(); + check('Ctrl+\\ brings it back for good, and the strip is gone', sd.op === 1 && !(await js(`return !!document.querySelector('.edge-hot')`)), JSON.stringify(sd)); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ntags e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-tasks.test.cjs b/test/e2e-tasks.test.cjs new file mode 100644 index 0000000..b1338f4 --- /dev/null +++ b/test/e2e-tasks.test.cjs @@ -0,0 +1,78 @@ +// End-to-end check of the tasks list, unlinked mentions, saved searches and quick capture. Run with `bun run e2e tasks`. +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-tasks-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault, { recursive: true }); +fs.writeFileSync(path.join(vault, 'Plan.md'), '# Plan\n\n- [ ] Write the spec #work 📅 2020-01-01\n- [x] Old thing\n'); +fs.writeFileSync(path.join(vault, 'Roadmap.md'), 'The roadmap page\n'); +fs.writeFileSync(path.join(vault, 'Notes.md'), 'Remember the roadmap for next year.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Roadmap', theme: 'dark', bounds: { width: 1100, height: 760 } })); +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); +}; +const read = (f) => fs.readFileSync(path.join(vault, f), 'utf8'); + +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} })()`); + try { + await sleep(2200); + + // unlinked mentions on Roadmap + check('Roadmap lists Notes as an unlinked mention', (await js(`return document.querySelector('.unlinked .sec-toggle')?.textContent.includes('Mentioned without a link')`)) === true); + await js(`document.querySelector('.unlinked .sec-toggle').click()`); + await sleep(300); + await js(`document.querySelector('.unlinked .bl-loose .btn').click()`); + await sleep(1000); + check('"Link it" turns the mention into a link', read('Notes.md').includes('[[Roadmap|roadmap]]'), read('Notes.md')); + + // tasks dialog + await js(`document.querySelector('.sidebar button[aria-label="Tasks"]').click()`); + await sleep(700); + check('the tasks dialog lists the open task', (await js(`return [...document.querySelectorAll('.tk-t')].map((e) => e.textContent)`)).join('|') === 'Write the spec #work'); + check('...as overdue', (await js(`return !!document.querySelector('.tk-due.over')`)) === true); + await js(`document.querySelector('.tk input').click()`); + await sleep(800); + check('ticking a task writes it to the file', read('Plan.md').includes('- [x] Write the spec'), read('Plan.md')); + await js(`document.querySelector('.tasks-panel .btn-icon').click()`); + await sleep(500); + + // saved searches + await js(`document.querySelector('.sidebar button[aria-label="Search"]').click()`); + await sleep(400); + await js(`const i = document.querySelector('.pal-top input'); i.value = 'tag:work'; i.dispatchEvent(new Event('input', { bubbles: true }))`); + await sleep(400); + await js(`[...document.querySelectorAll('.pi')].find((b) => b.textContent.includes('Save this search')).click()`); + await sleep(700); + check('a search can be saved to the sidebar', (await js(`return [...document.querySelectorAll('.sidebar .fav .name')].map((e) => e.textContent).includes('tag:work')`)) === true); + + // quick capture: the window's handler queues it, the page adds it to today's note + const ok = await ipcMain._invokeHandlers.get('capture:save')({}, 'a quick thought', 'daily'); + await sleep(1500); + const d = new Date(); + const z = (n) => String(n).padStart(2, '0'); + const day = `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`; + check('a capture lands in today\'s note', ok === true && fs.existsSync(path.join(vault, 'Daily', day + '.md')) && read(path.join('Daily', day + '.md')).includes('- a quick thought')); + await ipcMain._invokeHandlers.get('capture:save')({}, 'second', 'inbox'); + await sleep(1500); + check('...or in Inbox', fs.existsSync(path.join(vault, 'Inbox.md')) && read('Inbox.md').includes('- second')); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ntasks e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-templates.test.cjs b/test/e2e-templates.test.cjs new file mode 100644 index 0000000..bc4384d --- /dev/null +++ b/test/e2e-templates.test.cjs @@ -0,0 +1,119 @@ +// End-to-end check of the "New page" dialog (blank page or template). Run with `bun run test:e2e`. +const { app, BrowserWindow, shell } = require('electron'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-templates-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +fs.writeFileSync(path.join(vault, 'First.md'), 'First page.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'First', bounds: { width: 1280, height: 860 } })); +const opened = []; +shell.openPath = async (p) => { opened.push(p); return ''; }; // don't open a real file manager +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(350); }; + const centerOf = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + const click = async (sel) => { + const c = await centerOf(sel); + if (!c) throw new Error('not found: ' + sel); + wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + await sleep(500); + }; + const open = async () => { await key('n', ['control']); await sleep(500); }; + const cards = () => js(`return [...document.querySelectorAll('.tpl-card')].map(c => c.querySelector('.tpl-name').textContent)`); + const focused = () => js(`return document.activeElement?.querySelector?.('.tpl-name')?.textContent || null`); + const title = () => js(`return document.querySelector('.title')?.textContent`); + const dialog = () => js(`return !!document.querySelector('.tpl-panel')`); + + try { + await sleep(1800); + + // ---- no templates yet ---- + await open(); + check('Ctrl+N opens the dialog with just "Blank page", "Board", "Database" and "Canvas"', (await dialog()) && JSON.stringify(await cards()) === '["Blank page","Board","Database","Canvas"]', JSON.stringify(await cards())); + check('"Blank page" is focused, so Enter is enough', (await focused()) === 'Blank page'); + check('an empty state explains where templates live', (await js(`return document.querySelector('.tpl-foot').textContent`)).includes('.notes/templates')); + await key('Return'); + await sleep(700); + check('Enter creates a blank page', !(await dialog()) && (await js(`return document.querySelector('.blocks')?.textContent.trim()`)) === '', await title()); + const blankFile = fs.readdirSync(vault).filter((f) => f.endsWith('.md') && f !== 'First.md'); + check('...as a new file', blankFile.length === 1, JSON.stringify(blankFile)); + + // ---- the folder button ---- + await open(); + await click(`[...document.querySelectorAll('.tpl-foot .btn')].find(b => b.textContent.includes('Open folder'))`); + const tdir = path.join(vault, '.notes', 'templates'); + check('"Open folder" makes the templates folder and opens it', fs.existsSync(tdir) && opened[0] === tdir, JSON.stringify(opened)); + await key('Escape'); + check('Escape closes the dialog', !(await dialog())); + + // ---- with templates ---- + fs.writeFileSync(path.join(tdir, 'Meeting notes.md'), '---\nicon: users\ntags: [meeting]\n---\n\n# Meeting notes\n\n## Agenda\n\n- [ ] first item\n\n## Decisions\n'); + fs.writeFileSync(path.join(tdir, 'Journal.md'), '---\nicon: "🌤️"\n---\n\nHow was **today**?\n'); + fs.writeFileSync(path.join(tdir, 'Empty.md'), ''); + await open(); + check('templates show as cards after the blank page', JSON.stringify(await cards()) === '["Blank page","Board","Database","Canvas","Empty","Journal","Meeting notes"]', JSON.stringify(await cards())); + const prev = await js(`return Object.fromEntries([...document.querySelectorAll('.tpl-card')].map(c => [c.querySelector('.tpl-name').textContent, c.querySelector('.tpl-prev').textContent]))`); + check('each card previews its text in plain words', prev['Meeting notes'] === 'Meeting notes · Agenda · first item · Decisions' && prev.Journal === 'How was today?' && prev.Empty === 'An empty template.', JSON.stringify(prev)); + check('and its icon (stroke icon or emoji)', await js(`return !!document.querySelector('.tpl-card[data-name="Meeting notes"] svg') && document.querySelector('.tpl-card[data-name="Journal"] .emo')?.textContent === '🌤️'`)); + + await key('Right'); + await key('Right'); + await key('Right'); + await key('Right'); + check('arrow keys move between cards', (await focused()) === 'Empty', await focused()); + await key('Down'); + await key('Left'); + check('...in both directions', (await focused()) !== null); + await js(`document.querySelector('.tpl-card[data-name="Meeting notes"]').focus()`); + await key('Return'); + await sleep(900); + check('picking a template creates a page from it', (await title()) === 'Meeting notes' && (await js(`return document.querySelector('.blocks').textContent`)).includes('first item'), await title()); + const made = fs.readFileSync(path.join(vault, 'Meeting notes.md'), 'utf8'); + check('...with its tags and text', made.includes('tags: [meeting]') && made.includes('- [ ] first item'), JSON.stringify(made.slice(0, 120))); + + // ---- new templates appear when you come back to the window ---- + await open(); + fs.writeFileSync(path.join(tdir, 'Added later.md'), 'New one\n'); + await js(`window.dispatchEvent(new Event('focus'))`); + await sleep(600); + check('a template added meanwhile appears on refocus', (await cards()).includes('Added later'), JSON.stringify(await cards())); + await click(`document.querySelector('.tpl-card[data-name="Journal"]')`); + await sleep(900); + check('clicking a card creates that page', (await title()) === 'Journal', await title()); + // ---- a board from the same dialog ---- + await open(); + const before = fs.readdirSync(vault).filter((f) => f.endsWith('.md')); + await click(`document.querySelector('.tpl-card[data-board]')`); + await sleep(900); + const madeBoard = fs.readdirSync(vault).filter((f) => f.endsWith('.md') && !before.includes(f)); + check('"Board" makes a board page and opens it', !(await dialog()) && madeBoard.length === 1 && /kanban-plugin/.test(fs.readFileSync(path.join(vault, madeBoard[0]), 'utf8')) && !!(await js(`return document.querySelector('.kanban, .board, .kcol')`)), JSON.stringify(madeBoard)); + await open(); + await click(`document.querySelector('.overlay')`); // the dim area outside the dialog + await sleep(400); + check('clicking outside closes it', !(await dialog())); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ntemplates e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-textmenu.test.cjs b/test/e2e-textmenu.test.cjs new file mode 100644 index 0000000..85d63a8 --- /dev/null +++ b/test/e2e-textmenu.test.cjs @@ -0,0 +1,155 @@ +// End-to-end check of the app's own right-click menu for text. Run with `bun run test:e2e`. +const { app, BrowserWindow, clipboard } = require('electron'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-textmenu-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +fs.writeFileSync(path.join(vault, 'Essay.md'), 'The stack works on the LIFO principle, see [the docs](https://example.com) and [[Other]] for more.\n\nSecond line here.\n'); +fs.writeFileSync(path.join(vault, 'Other.md'), 'Other page.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Essay', bounds: { width: 1280, height: 860 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(250); }; + const read = () => fs.readFileSync(path.join(vault, 'Essay.md'), 'utf8'); + const centerOf = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`); + // Right-click like a mouse would. Returns the labels in the app's menu (null if none opened). + const rightClick = async (x, y) => { + wc.sendInputEvent({ type: 'mouseMove', x, y }); + wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'right', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x, y, button: 'right', clickCount: 1 }); + await sleep(500); + return js(`const m = document.querySelector('.tmenu'); return m ? [...m.querySelectorAll('.mi')].map(b => b.querySelector('.mi-label').textContent + (b.disabled ? ' (off)' : '')) : null`); + }; + const clickRow = async (label) => { + const c = await centerOf(`[...document.querySelectorAll('.tmenu .mi')].find(b => b.querySelector('.mi-label').textContent.startsWith(${JSON.stringify(label)}))`); + if (!c) throw new Error('no menu row ' + label); + wc.sendInputEvent({ type: 'mouseMove', x: c[0], y: c[1] }); + wc.sendInputEvent({ type: 'mouseDown', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: c[0], y: c[1], button: 'left', clickCount: 1 }); + await sleep(500); + }; + const select = (word) => js(`const c = [...document.querySelectorAll('.blocks .c')].find(x => x.textContent.includes('${word}')); c.focus(); const w = document.createTreeWalker(c, NodeFilter.SHOW_TEXT); let t; while ((t = w.nextNode()) && !t.data.includes('${word}')); const a = t.data.indexOf('${word}'); const r = document.createRange(); r.setStart(t, a); r.setEnd(t, a + ${word.length}); getSelection().removeAllRanges(); getSelection().addRange(r); const b = r.getBoundingClientRect(); return [Math.round(b.x + b.width / 2), Math.round(b.y + b.height / 2)]`); + const menuOpen = () => js(`return !!document.querySelector('.tmenu')`); + + try { + await sleep(2000); + + // ---- selected text in a note ---- + let at = await select('LIFO principle'); + await sleep(300); + let rows = await rightClick(...at); + check('right-click on selected text opens the app menu', Array.isArray(rows), JSON.stringify(rows)); + check('with cut / copy / paste / select all', ['Cut', 'Copy', 'Paste', 'Select all'].every((l) => rows.some((r) => r.startsWith(l))), JSON.stringify(rows)); + check('and "Search notes for" the selection', rows.some((r) => r === 'Search notes for “LIFO principle”')); + check('and a row of format buttons', (await js(`return document.querySelectorAll('.tmenu .tm-fmt button').length`)) === 6); + check('the floating toolbar steps aside meanwhile', await js(`return document.querySelector('.pop.fmt').hidden`)); + check('the selection is untouched', (await js(`return getSelection().toString()`)) === 'LIFO principle'); + + await clickRow('Copy'); + check('Copy puts the selection on the clipboard', clipboard.readText() === 'LIFO principle', JSON.stringify(clipboard.readText())); + check('...and closes the menu', !(await menuOpen())); + + at = await select('LIFO principle'); + await rightClick(...at); + const boldBtn = await centerOf(`document.querySelector('.tmenu .tm-fmt [aria-label="Bold"]')`); + wc.sendInputEvent({ type: 'mouseDown', x: boldBtn[0], y: boldBtn[1], button: 'left', clickCount: 1 }); + wc.sendInputEvent({ type: 'mouseUp', x: boldBtn[0], y: boldBtn[1], button: 'left', clickCount: 1 }); + await sleep(900); + check('the Bold button formats the selection', read().includes('**LIFO principle**'), JSON.stringify(read().split('\n')[0])); + + // ---- keyboard ---- + at = await select('Second'); + await rightClick(...at); + await key('Down'); + check('arrow keys move through the menu', await js(`return document.querySelector('.tmenu .mi.act')?.textContent.includes('Cut')`)); + await key('Escape'); + check('Escape closes it, keeping the selection', !(await menuOpen()) && (await js(`return getSelection().toString()`)) === 'Second'); + + // ---- paste and cut ---- + clipboard.writeText('PASTED'); + at = await select('Second'); + await rightClick(...at); + await clickRow('Paste'); + await sleep(900); + check('Paste replaces the selection with the clipboard', read().includes('PASTED line here.'), JSON.stringify(read().split('\n').slice(-2))); + at = await select('PASTED'); + await rightClick(...at); + await clickRow('Cut'); + await sleep(900); + check('Cut removes it and copies it', clipboard.readText() === 'PASTED' && read().includes('\n line here.'), JSON.stringify(read().split('\n').slice(-2))); + + // ---- links ---- + const linkAt = await centerOf(`document.querySelector('.blocks a.xl')`); + rows = await rightClick(...linkAt); + check('right-click on a web link offers link actions', ['Open link', 'Copy link address', 'Edit link…', 'Remove link'].every((l) => rows?.includes(l)), JSON.stringify(rows)); + await clickRow('Copy link address'); + check('Copy link address', clipboard.readText() === 'https://example.com'); + await rightClick(...linkAt); + await clickRow('Remove link'); + await sleep(900); + check('Remove link leaves the text', read().includes('see the docs and') && !read().includes('example.com'), JSON.stringify(read().split('\n')[0])); + + const chipAt = await centerOf(`document.querySelector('.blocks .wl')`); + rows = await rightClick(...chipAt); + check('right-click on a page link offers to open it', rows?.includes('Open page'), JSON.stringify(rows)); + await clickRow('Open page'); + await sleep(700); + check('...which opens it', (await js(`return document.querySelector('.title')?.textContent`)) === 'Other'); + await js(`[...document.querySelectorAll('.tree .row')].find(r => r.textContent.includes('Essay')).click()`); + await sleep(800); + + // ---- other places ---- + const titleAt = await centerOf(`document.querySelector('.title')`); + rows = await rightClick(titleAt[0], titleAt[1]); + check('the page title gets the edit items, without formatting', rows?.includes('Paste') && !(await js(`return !!document.querySelector('.tmenu .tm-fmt')`)), JSON.stringify(rows)); + await key('Escape'); + rows = await rightClick(330, 600); // empty space beside the page + check('empty space opens no menu', rows === null, JSON.stringify(rows)); + + at = await select('stack'); + await rightClick(...at); + await clickRow('Search notes for'); + await sleep(500); + check('"Search notes for" opens search with the text', (await js(`return document.querySelector('.panel input')?.value`)) === 'stack'); + await key('Escape'); + await sleep(300); + + // ---- spelling (needs the dictionary, which Electron downloads on first use) ---- + await js(`const c = [...document.querySelectorAll('.blocks .c')].at(-1); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); + for (const ch of ' recieve ') { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(60); } + await sleep(1500); + const word = await centerOf(`(() => { const c = [...document.querySelectorAll('.blocks .c')].at(-1); const t = [...c.childNodes].find(n => n.nodeType === 3 && n.data.includes('recieve')); const r = document.createRange(); const a = t.data.indexOf('recieve'); r.setStart(t, a); r.setEnd(t, a + 7); return { getBoundingClientRect: () => r.getBoundingClientRect() }; })()`); + rows = await rightClick(...word); + if (rows && rows.some((r) => r.startsWith('Add “recieve”'))) { + check('a misspelled word gets suggestions and "Add to dictionary"', rows.includes('receive'), JSON.stringify(rows)); + await clickRow('receive'); + await sleep(900); + check('picking a suggestion fixes the word', read().includes(' receive') && !read().includes('recieve'), JSON.stringify(read().split('\n').slice(-2))); + } else { + console.log('SKIP spelling: no dictionary available in this run — ' + JSON.stringify(rows)); + await key('Escape'); + } + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ntext menu e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-toggle.test.cjs b/test/e2e-toggle.test.cjs new file mode 100644 index 0000000..4e59013 --- /dev/null +++ b/test/e2e-toggle.test.cjs @@ -0,0 +1,105 @@ +// End-to-end check of a toggle's body: arrows in and out of it, Enter / Shift+Enter, and selecting across title and body. +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-toggle-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(vault); +fs.writeFileSync(path.join(vault, 'Note.md'), 'Before the toggle.\n\n
\nTitle\n\nBody text\n\n
\n\nAfter the toggle.\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Note', bounds: { width: 1200, height: 800 } })); +require('../electron/main.cjs'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const failures = []; +const check = (name, ok, extra = '') => { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`); + if (!ok) failures.push(name); +}; + +app.whenReady().then(async () => { + let win; + for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100); + const wc = win.webContents; + if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r)); + const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`); + const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(250); }; + const type = async (t) => { for (const ch of t) wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(250); }; + // which field has the caret: 'head', 'body', or the text of another block's field + const where = () => js(`const n = getSelection().focusNode; const f = (n?.nodeType === 1 ? n : n?.parentElement)?.closest('.c, .c2'); + if (!f) return null; if (f.closest('.tg')) return f.classList.contains('c2') ? 'body' : 'head'; return f.textContent;`); + const blockTypes = () => js(`return [...document.querySelectorAll('.page .block')].map(b => b.querySelector('.tg') ? 'toggle' : (b.querySelector('.c')?.textContent ?? '?'))`); + const rect = (sel) => js(`const r = document.querySelector('${sel}').getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y + r.height / 2), w: Math.round(r.width), b: Math.round(r.bottom) }`); + + try { + for (let i = 0; i < 60 && !(await js(`return !!document.querySelector('.tg')`)); i++) await sleep(150); + // open the toggle + await js(`document.querySelector('.tg-chev').click()`); + await sleep(500); + + // caret at the end of the first paragraph, then down: title, body, next block + await js(`const c = document.querySelector('.block .c'); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); + await key('Down'); + check('Down from the block above enters the toggle title', (await where()) === 'head', await where()); + await key('End'); + await key('Down'); + check('Down at the end of the title enters the body', (await where()) === 'body', await where()); + await key('End'); + await key('Down'); + check('Down at the end of the body leaves the toggle', (await where()) === 'After the toggle.', await where()); + await key('Home'); + await key('Up'); + check('Up from the block below enters the body', (await where()) === 'body', await where()); + await key('Home'); + await key('Up'); + check('Up at the start of the body goes to the title', (await where()) === 'head', await where()); + + // Shift+Enter: a new line in the body; Enter: a new block after the toggle + await js(`const c = document.querySelector('.c2'); c.focus(); const r = document.createRange(); r.selectNodeContents(c); r.collapse(false); getSelection().removeAllRanges(); getSelection().addRange(r);`); + await key('Enter', ['shift']); + await type('second'); + const body = await js(`return document.querySelector('.c2').innerText`); + check('Shift+Enter makes a new line inside the body', /Body text\s*\nsecond/.test(body), JSON.stringify(body)); + await key('Enter'); + await type('new'); + const types = await blockTypes(); + check('Enter in the body starts a new block after the toggle', JSON.stringify(types) === JSON.stringify(['Before the toggle.', 'toggle', 'new', 'After the toggle.']), JSON.stringify(types)); + check('...with the caret in it', (await where()) === 'new', await where()); + const body2 = await js(`return document.querySelector('.c2').innerText.trim()`); + check('...and the body keeps its text', /^Body text\s*\nsecond$/.test(body2), JSON.stringify(body2)); + + // mouse: drag from the title into the body + const head = await rect('.tg .c'), b2 = await rect('.c2'); + wc.sendInputEvent({ type: 'mouseMove', x: head.x + 3, y: head.y }); + wc.sendInputEvent({ type: 'mouseDown', x: head.x + 3, y: head.y, button: 'left', clickCount: 1 }); + for (let i = 1; i <= 10; i++) { + wc.sendInputEvent({ type: 'mouseMove', x: Math.round(head.x + 3 + (b2.x + 40 - head.x - 3) * i / 10), y: Math.round(head.y + (b2.b - 6 - head.y) * i / 10), button: 'left' }); + await sleep(25); + } + wc.sendInputEvent({ type: 'mouseUp', x: b2.x + 40, y: b2.b - 6, button: 'left', clickCount: 1 }); + await sleep(300); + const picked = await js(`const s = getSelection(); return s.rangeCount ? s.getRangeAt(0).toString() : ''`); + check('Dragging from the title into the body selects both', /itle/.test(picked) && /Body/.test(picked), JSON.stringify(picked)); + + // Shift+Down from the title extends into the body + await js(`const c = document.querySelector('.tg .c'); c.focus(); const r = document.createRange(); r.setStart(c.firstChild, 0); r.collapse(true); getSelection().removeAllRanges(); getSelection().addRange(r);`); + await key('Down', ['shift']); + await key('Down', ['shift']); + const picked2 = await js(`const s = getSelection(); return s.rangeCount ? s.getRangeAt(0).toString() : ''`); + check('Shift+Down from the title extends into the body', /Title/.test(picked2) && /Body/.test(picked2), JSON.stringify(picked2)); + + // Backspace over a title-to-body selection: the title keeps what was before, the body what was after + await js(`const h = document.querySelector('.tg .c'), b = document.querySelector('.c2'); const t = [...b.childNodes].find(n => n.nodeType === 3 && n.textContent.includes('Body')); + getSelection().setBaseAndExtent(h.firstChild, 2, t, 5);`); + await key('Backspace'); + const after = await js(`return [document.querySelector('.tg .c').textContent, document.querySelector('.c2').innerText.trim()]`); + check('Backspace over title + body removes just the selection', after[0] === 'Ti' && /^text\s*\nsecond$/.test(after[1]), JSON.stringify(after)); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ntoggle e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e-workflow.test.cjs b/test/e2e-workflow.test.cjs new file mode 100644 index 0000000..8e7cd47 --- /dev/null +++ b/test/e2e-workflow.test.cjs @@ -0,0 +1,109 @@ +// End-to-end: daily calendar and stepping, tabs, tag manager, typed properties, rich paste, web clip, imports. +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-flow-')); +const vault = path.join(tmp, 'Vault'); +fs.mkdirSync(path.join(vault, 'Daily'), { recursive: true }); +fs.writeFileSync(path.join(vault, 'Daily.md'), ''); +fs.writeFileSync(path.join(vault, 'Daily', '2020-01-01.md'), '---\ncreated: 2020-01-01\n---\n\nfirst\n'); +fs.writeFileSync(path.join(vault, 'Daily', '2020-01-05.md'), '---\ncreated: 2020-01-05\n---\n\nsecond\n'); +fs.writeFileSync(path.join(vault, 'A.md'), '---\ntags: [alpha, beta]\ndone: false\nrating: 4\n---\n\nPage A\n'); +fs.writeFileSync(path.join(vault, 'B.md'), '---\ntags: [beta]\n---\n\nPage B\n'); +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'A', theme: 'dark', bounds: { width: 1200, height: 800 } })); +const lp = require('../electron/linkpreview.cjs'); +lp.hooks.lookup = async () => [{ address: '93.184.216.34' }]; +lp.hooks.fetch = async () => new Response('Hello Web

Hello Web

Some bold text and a link.

  • one
  • two
', { headers: { 'content-type': 'text/html' } }); +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); +}; +const read = (f) => fs.readFileSync(path.join(vault, f), 'utf8'); + +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 click = (sel) => js(`document.querySelector(${JSON.stringify(sel)}).click()`); + const rowClick = (id, mods = '') => js(`document.querySelector('.tree .row[data-id=${JSON.stringify(id)}]').dispatchEvent(new MouseEvent('click', { bubbles: true, ${mods} }))`); + try { + await sleep(2200); + + // properties: typed by value + check('a "true/false" property is a checkbox, a number a number', (await js(`return document.querySelectorAll('.props .db-pv').length`)) === 2); + await js(`[...document.querySelectorAll('.props .chip-add')].find((b) => b.textContent.includes('Add property')).click()`); + await sleep(300); + const chips = await js(`return [...document.querySelectorAll('.type-chip')].map((b) => b.textContent.trim())`); + check('Add property offers types', chips.includes('Date') && chips.includes('Checkbox'), chips.join(',')); + await js(`[...document.querySelectorAll('.type-chip')].find((b) => b.textContent.includes('Date')).click()`); + await sleep(200); + await js(`const i = document.querySelector('.custom-pk[placeholder$="property name"]'); i.value = 'due'; i.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))`); + await sleep(1500); + check('a new Date property is written to the front matter', /due: \d{4}-\d{2}-\d{2}/.test(read('A.md')), read('A.md')); + + // tags: merge and remove + await js(`document.querySelector('.sidebar button[aria-label="Manage tags"]').click()`); + await sleep(500); + check('the tag manager lists tags with counts', (await js(`return [...document.querySelectorAll('.tr .tr-t')].map((e) => e.textContent.replace(/\\s+/g, ' ').trim()).join('|')`)) === '#alpha1 page|#beta2 pages'); + await js(`[...document.querySelectorAll('.tr')].find((r) => r.textContent.includes('#alpha')).querySelector('.btn').click()`); + await sleep(200); + await js(`const i = document.querySelector('.tg-edit'); i.value = 'beta'; i.dispatchEvent(new Event('input', { bubbles: true }))`); + await sleep(200); + await js(`[...document.querySelectorAll('.tr .btn')].find((b) => b.textContent.includes('Merge')).click()`); + await sleep(1200); + check('renaming into an existing tag merges without duplicates', /tags: \[beta\]/.test(read('A.md')), read('A.md').split('\n').slice(0, 5).join(' ')); + await js(`[...document.querySelectorAll('.tr')].find((r) => r.textContent.includes('#beta')).querySelector('.btn.danger').click()`); + await sleep(200); + await js(`[...document.querySelectorAll('.tr')].find((r) => r.textContent.includes('#beta')).querySelector('.btn.danger').click()`); + await sleep(1200); + check('removing a tag takes it off every page', !/tags:/.test(read('A.md')) && !/tags:/.test(read('B.md'))); + await js(`document.querySelector('.tasks-panel .btn-icon').click()`); + await sleep(400); + + // daily: calendar + stepping + await js(`document.querySelector('.tree .row[data-id="Daily"]').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))`); + await sleep(500); + await rowClick('Daily/2020-01-01'); + await sleep(500); + check('a daily note shows the day buttons', (await js(`return !!document.querySelector('.day-nav')`)) === true); + await js(`document.querySelector('.day-nav button[aria-label="Next daily note"]').click()`); + await sleep(700); + check('"next" goes to the next existing daily note', (await js(`return document.querySelector('.tree .row.active')?.dataset.id`)) === 'Daily/2020-01-05'); + await js(`document.querySelector('.day-nav button[aria-label="Pick a day"]').click()`); + await sleep(400); + check('the calendar marks days with notes', (await js(`return document.querySelectorAll('.cal-d.has').length`)) === 2); + await js(`[...document.querySelectorAll('.cal-d')].find((b) => b.textContent.trim() === '3').click()`); + await sleep(1500); + check('picking an empty day creates its note', fs.existsSync(path.join(vault, 'Daily', '2020-01-03.md'))); + + // rich paste + await rowClick('B'); + await sleep(600); + await js(`document.querySelector('.block .c').focus()`); + await js(`const dt = new DataTransfer(); dt.setData('text/html', '

Title

  • one
  • two

a bold link

'); dt.setData('text/plain', 'Title one two a bold link'); + document.querySelector('.block .c').dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }))`); + await sleep(1500); + await sleep(2000); + const b = read('B.md'); + check('pasting web content keeps headings, lists, bold and links', /## Title/.test(b) && /- one/.test(b) && /\*\*bold\*\*/.test(b) && /\[link\]\(https:\/\/x\.test\/?\)/.test(b), b); + + // web clip + await ipcMain._invokeHandlers.get('web:clip')({}, 'https://example.test/post').then(async (page) => { + check('the clipper fetches a page', /Hello Web/.test(page.html)); + }); + } catch (err) { + check('harness', false, err.stack); + } + fs.rmSync(tmp, { recursive: true, force: true }); + console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nworkflow e2e passed!'); + app.exit(failures.length ? 1 : 0); +}); diff --git a/test/e2e.test.cjs b/test/e2e.test.cjs new file mode 100644 index 0000000..ccbdd78 --- /dev/null +++ b/test/e2e.test.cjs @@ -0,0 +1,72 @@ +const { app, BrowserWindow } = require('electron'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); +const assert = require('assert'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-')); +const vaultDir = path.join(tmp, 'Vault'); +fs.mkdirSync(vaultDir); + +app.setPath('userData', tmp); +fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault: vaultDir })); + +require('../electron/main.cjs'); + +app.whenReady().then(async () => { + while (BrowserWindow.getAllWindows().length === 0) { + await new Promise(r => setTimeout(r, 100)); + } + const win = BrowserWindow.getAllWindows()[0]; + + let ok = false; + for (let i = 0; i < 50; i++) { + await new Promise(r => setTimeout(r, 100)); + if (win.isDestroyed()) break; + const hasBtn = await win.webContents.executeJavaScript(`!!document.querySelector('[aria-label="New page"]')`).catch(() => false); + if (hasBtn) { ok = true; break; } + } + + // Click 'New page' + await win.webContents.executeJavaScript(` + document.querySelector('[aria-label="New page"]').click(); + `); + + // Wait for the Template picker + let templateReady = false; + for (let i = 0; i < 50; i++) { + await new Promise(r => setTimeout(r, 100)); + templateReady = await win.webContents.executeJavaScript(`!!document.querySelector('[aria-label="Templates"]')`).catch(() => false); + if (templateReady) break; + } + + assert.strictEqual(templateReady, true, "Template picker didn't open"); + + // Click 'Blank Page' + await win.webContents.executeJavaScript(` + document.querySelector('.tpl-card[data-blank]').click(); + `); + + // Wait for the page to render + for (let i = 0; i < 50; i++) { + await new Promise(r => setTimeout(r, 100)); + const isReady = await win.webContents.executeJavaScript(`!!document.querySelector('.cover-btn')`).catch(() => false); + if (isReady) break; + } + + await win.webContents.executeJavaScript(` + document.querySelector('.cover-btn').click(); + `); + + // Wait for the popup to render + let popupReady = false; + for (let i = 0; i < 50; i++) { + await new Promise(r => setTimeout(r, 100)); + popupReady = await win.webContents.executeJavaScript(`!!document.querySelector('.cover-picker')`).catch(() => false); + if (popupReady) break; + } + + assert.strictEqual(popupReady, true, "Cover picker didn't open"); + console.log('e2e tests passed!'); + app.quit(); +}); diff --git a/test/embed.test.js b/test/embed.test.js new file mode 100644 index 0000000..389a601 --- /dev/null +++ b/test/embed.test.js @@ -0,0 +1,76 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { parseMarkdown, toMarkdown, mdToHtml, htmlToMd } from '../src/lib/markdown.js'; +import { headingOf, linkLabel, normHeading, refPage } from '../src/lib/links.js'; +import { headingsOf, sectionOf, staticHtml } from '../src/lib/embed.js'; + +const require = createRequire(import.meta.url); +const { findLinks, rewriteLinks, backlinks, graph } = require('../electron/links.cjs'); + +test('heading links: label, parts and round trip', () => { + assert.equal(refPage('Plans/Road map#Q3'), 'Plans/Road map'); + assert.equal(headingOf('Road map#Q3 goals'), 'Q3 goals'); + assert.equal(headingOf('Page#One#Two'), 'Two'); + assert.equal(headingOf('Page#^abc123'), ''); + assert.equal(headingOf('Page'), ''); + assert.equal(linkLabel('Plans/Road map#Q3'), 'Road map › Q3'); + assert.equal(linkLabel('#Local'), 'Local'); + assert.equal(linkLabel('Page'), 'Page'); + assert.equal(normHeading(' Q3 Goals '), 'q3 goals'); + const md = 'See [[Road map#Q3]], [[#Here]] and [[Page#Part|the part]].'; + const html = mdToHtml(md); + assert.match(html, /data-ref="Road map#Q3">Road map › Q3Herethe part { + const md = 'Before\n\n![[Roadmap]]\n\n![[Plans/Roadmap#Q3 goals|Goals]]\n\n![[photo.png]]\n\n![[slides.pdf]]\n\nAfter\n'; + const blocks = parseMarkdown(md); + assert.deepEqual(blocks.map((b) => b.type), ['p', 'embed', 'embed', 'image', 'p', 'p']); + assert.equal(blocks[1].ref, 'Roadmap'); + assert.equal(blocks[2].ref, 'Plans/Roadmap#Q3 goals'); + assert.equal(blocks[2].alias, 'Goals'); + assert.equal(blocks[4].html, '![[slides.pdf]]'); // unsupported files stay as text + assert.equal(toMarkdown(blocks), md); +}); + +const DOC = parseMarkdown('Intro\n\n# Top\n\nunder top\n\n## Alpha\n\n- one\n- [x] two\n\n### Deep\n\nlow\n\n## Beta\n\nbeta text\n'); + +test('sections: a heading and everything below it, up to the next heading of the same or a higher level', () => { + assert.deepEqual(headingsOf(DOC).map((h) => `${h.level}:${h.text}`), ['1:Top', '2:Alpha', '3:Deep', '2:Beta']); + assert.deepEqual(sectionOf(DOC, 'alpha').map((b) => b.type), ['h2', 'bullet', 'todo', 'h3', 'p']); + assert.equal(sectionOf(DOC, ' ALPHA ').length, 5); + assert.deepEqual(sectionOf(DOC, 'Beta').map((b) => b.type), ['h2', 'p']); + assert.equal(sectionOf(DOC, 'Top').length, 9); // everything after the intro line + assert.equal(sectionOf(DOC, 'Nope'), null); +}); + +test('embedded content renders read-only, escaped, and with no editor hooks', () => { + const blocks = parseMarkdown('## Part\n\nSome **bold** & \n\n- [x] done\n\n```js\nlet a = 1 < 2\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n![pic](p.png)\n\n![[Other]]\n\n> [!warning]\n> careful\n'); + const html = staticHtml(blocks, { imageUrl: (s) => `media://x/${s}` }); + assert.ok(!html.includes('contenteditable="true"'), 'nothing editable'); + assert.ok(!html.includes('class="block'), 'no editor block wrappers'); + assert.ok(!html.includes('