test: unit and end-to-end suites

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

No files matched your search

+311
View File
@@ -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(''), '');
});
+101
View File
@@ -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'] });
});
+33
View File
@@ -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');
});
+266
View File
@@ -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' });
});
+95
View File
@@ -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);
});
+145
View File
@@ -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);
});
+682
View File
@@ -0,0 +1,682 @@
// End-to-end check of canvases (".canvas" files, as in Obsidian). Run with `bun run test:e2e`.
const { app, BrowserWindow, clipboard, dialog, nativeImage } = require('electron');
const path = require('path');
const os = require('os');
const fs = require('fs');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-canvas-'));
const vault = path.join(tmp, 'Vault');
fs.mkdirSync(path.join(vault, 'Projects'), { recursive: true });
fs.writeFileSync(path.join(vault, 'Projects', 'Roadmap.md'), '# Roadmap\n\nIntro text.\n\n## Goals\n\nShip the canvas.\n\n## Later\n\nNot shown.\n');
fs.writeFileSync(path.join(vault, 'Notes.md'), 'Open [[Plan.canvas]] to see the plan.\n');
// as Obsidian writes it
const PLAN = `{
\t"nodes":[
\t\t{"id":"aaaaaaaaaaaaaaaa","type":"text","text":"## Idea\\n\\nA **bold** start","x":0,"y":0,"width":260,"height":140},
\t\t{"id":"bbbbbbbbbbbbbbbb","type":"file","file":"Projects/Roadmap.md","subpath":"#Goals","x":400,"y":0,"width":320,"height":240},
\t\t{"id":"cccccccccccccccc","type":"link","url":"https://jsoncanvas.org","x":0,"y":300,"width":260,"height":120}
\t],
\t"edges":[
\t\t{"id":"eeeeeeeeeeeeeeee","fromNode":"aaaaaaaaaaaaaaaa","fromSide":"right","toNode":"bbbbbbbbbbbbbbbb","toSide":"left","label":"leads to"}
\t]
}`;
fs.writeFileSync(path.join(vault, 'Plan.canvas'), PLAN);
fs.writeFileSync(path.join(vault, 'Broken.canvas'), '{ "nodes": [ oops');
// no real network: one address answers with a page, anything else is "not found"
const linkpreview = require('../electron/linkpreview.cjs');
linkpreview.hooks.lookup = async () => [{ address: '93.184.216.34' }];
linkpreview.hooks.fetch = async (url) => (new URL(url).hostname === 'jsoncanvas.org'
? new Response('<html><head><title>fallback</title><meta property="og:title" content="JSON Canvas &amp; friends"><meta name="description" content="An open file format for infinite canvas data."></head></html>', { headers: { 'content-type': 'text/html; charset=utf-8' } })
: new Response('nope', { status: 404 }));
app.setPath('userData', tmp);
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Notes', theme: 'dark', bounds: { width: 1400, height: 900 } }));
require('../electron/main.cjs');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const failures = [];
const check = (name, ok, extra = '') => {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
if (!ok) failures.push(name);
};
app.whenReady().then(async () => {
let win;
for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100);
const wc = win.webContents;
if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(250); };
const type = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(30); } await sleep(150); };
const m = (type, x, y, extra = {}) => wc.sendInputEvent({ type, x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1, ...extra });
const click = async (x, y, mods = []) => { m('mouseMove', x, y); m('mouseDown', x, y, { modifiers: mods }); m('mouseUp', x, y, { modifiers: mods }); await sleep(250); };
const dbl = async (x, y) => { m('mouseMove', x, y); m('mouseDown', x, y); m('mouseUp', x, y); m('mouseDown', x, y, { clickCount: 2 }); m('mouseUp', x, y, { clickCount: 2 }); await sleep(400); };
const drag = async (x1, y1, x2, y2, steps = 12) => {
m('mouseMove', x1, y1); m('mouseDown', x1, y1); await sleep(30);
for (let i = 1; i <= steps; i++) { m('mouseMove', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps); await sleep(20); }
await sleep(60); m('mouseUp', x2, y2); await sleep(400);
};
const box = (sel) => js(`const e = ${sel}; if (!e) return null; const r = e.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height, cx: r.x + r.width / 2, cy: r.y + r.height / 2 }`);
const node = (id) => `document.querySelector('.cv-node[data-id="${id}"]')`;
const file = (f = 'Plan.canvas') => fs.readFileSync(path.join(vault, f), 'utf8');
const canvasOf = (f) => JSON.parse(file(f));
const openRow = async (id) => { await js(`document.querySelector('.tree .row[data-id=${JSON.stringify(id)}]').click()`); await sleep(1000); };
try {
await sleep(2000);
// ---- it shows up and opens ----
check('a .canvas file is listed in the sidebar with the canvas icon, by its name', await js(`const r = document.querySelector('.tree .row[data-id="Plan.canvas"]'); return !!r && r.querySelector('.name').textContent === 'Plan'`));
await js(`document.querySelector('.blocks .wl').click()`);
await sleep(1200);
check('a [[Plan.canvas]] link opens it', !!(await js(`return document.querySelector('.cv')`)));
check('three items, one arrow with its label', (await js(`return document.querySelectorAll('.cv-node').length`)) === 3 && (await js(`return document.querySelectorAll('.cv-line').length`)) === 1 && (await js(`return document.querySelector('.cv-elabel')?.textContent`)) === 'leads to');
check('a text card shows its Markdown', (await js(`return document.querySelector(${JSON.stringify(`.cv-node[data-id="aaaaaaaaaaaaaaaa"] .eb.b-h2`)})?.textContent`)) === 'Idea' && !!(await js(`return ${node('aaaaaaaaaaaaaaaa')}.querySelector('b,strong')`)));
await sleep(500);
const preview = await js(`return ${node('bbbbbbbbbbbbbbbb')}.querySelector('.cv-scroll').textContent`);
check('a note shows its section (#Goals) only', preview.includes('Ship the canvas.') && !preview.includes('Not shown') && !preview.includes('Intro'), JSON.stringify(preview));
await sleep(800);
check('a web link card shows the page title and description', (await js(`return ${node('cccccccccccccccc')}.querySelector('.cv-ftitle').textContent`)) === 'JSON Canvas & friends' &&
(await js(`return ${node('cccccccccccccccc')}.querySelector('.cv-desc')?.textContent`)) === 'An open file format for infinite canvas data.' &&
(await js(`return ${node('cccccccccccccccc')}.querySelector('.cv-url').textContent`)) === 'jsoncanvas.org');
await js(`await window.vault.setSettings({ linkPreviews: false })`);
check('with previews turned off in settings, nothing is fetched', (await js(`return (await window.vault.linkPreview('https://jsoncanvas.org')).off`)) === true);
await js(`await window.vault.setSettings({ linkPreviews: true })`);
// ---- right-click menu ----
const rclick = async (x, y) => { m('mouseMove', x, y); m('mouseDown', x, y, { button: 'right' }); m('mouseUp', x, y, { button: 'right' }); await sleep(350); };
const menuItems = () => js(`return [...document.querySelectorAll('.cv-menu .mi')].map((b) => b.textContent.trim())`);
const menuClick = async (label) => {
const b = await box(`[...document.querySelectorAll('.cv-menu .mi')].find((x) => x.textContent.trim() === ${JSON.stringify(label)})`);
await click(b.cx, b.cy);
};
const text = await box(node('aaaaaaaaaaaaaaaa'));
await rclick(text.cx, text.cy);
const onCard = await menuItems();
check('right-clicking a card opens its menu and selects it', onCard.includes('Edit') && onCard.includes('Duplicate') && onCard.includes('Bring to front') && onCard.includes('Delete') && (await js(`return ${node('aaaaaaaaaaaaaaaa')}.classList.contains('sel')`)));
await key('Escape');
check('Escape closes the menu', !(await js(`return document.querySelector('.cv-menu')`)));
// a point on the arrow (its label, or the line) that nothing else covers
const lbl = await js(`const r = document.querySelector('.cv-elabel[data-edge]').getBoundingClientRect(); for (let x = r.left + 2; x < r.right - 2; x += 3) { const y = r.top + r.height / 2; const h = document.elementFromPoint(x, y); if (h && h.closest('[data-edge]')) return { cx: x, cy: y }; } return null`);
await rclick(lbl.cx, lbl.cy);
const arrowItems = await menuItems();
check('right-clicking an arrow offers its label and direction', arrowItems.includes('Reverse direction') && arrowItems.includes('Edit label'), JSON.stringify(arrowItems));
await menuClick('Reverse direction');
await sleep(700);
const rev = canvasOf().edges.find((e) => e.id === 'eeeeeeeeeeeeeeee');
check('"Reverse direction" swaps the arrow\'s ends in the file', rev && rev.fromNode === 'bbbbbbbbbbbbbbbb' && rev.toNode === 'aaaaaaaaaaaaaaaa' && rev.fromSide === 'left' && rev.toSide === 'right', JSON.stringify(rev));
const link = await box(node('cccccccccccccccc'));
await rclick(link.cx, link.cy);
check('a web link card offers "Open in browser"', (await menuItems()).includes('Open in browser'));
await menuClick('Bring to front');
await sleep(700);
const order = canvasOf().nodes.map((n) => n.id);
check('"Bring to front" moves it to the end of the file', order[order.length - 1] === 'cccccccccccccccc', order.join());
const countBefore = canvasOf().nodes.length;
const vp = await box(`document.querySelector('.cv')`);
await rclick(vp.x + vp.w - 120, vp.y + 120);
check('right-clicking empty space offers canvas actions', (await menuItems()).includes('Add card here') && (await menuItems()).includes('Show everything'));
await menuClick('Add card here');
await sleep(300);
check('"Add card here" starts a card in edit mode', !!(await js(`return document.querySelector('.cv-ed')`)));
await key('Escape');
await sleep(500);
check('...and left empty it goes again', canvasOf().nodes.length === countBefore);
const l2 = await box(node('cccccccccccccccc'));
await rclick(l2.cx, l2.cy);
await menuClick('Delete');
await sleep(700);
check('"Delete" in the menu removes the card from the file', !canvasOf().nodes.some((n) => n.id === 'cccccccccccccccc'));
await key('z', ['control']);
await sleep(600);
check('...and Ctrl+Z brings it back', canvasOf().nodes.some((n) => n.id === 'cccccccccccccccc'));
await key('z', ['control']);
await key('z', ['control']);
await sleep(500);
check('undoing the menu actions restores the file as it was', file() === PLAN);
// ---- align and distribute ----
const freePoint = (id) => js(`const n = document.querySelector('.cv-node[data-id="${id}"]'); const r = n.getBoundingClientRect(); for (let fy = 0.1; fy < 0.9; fy += 0.05) for (let fx = 0.1; fx < 0.9; fx += 0.05) { const x = r.left + r.width * fx, y = r.top + r.height * fy; const h = document.elementFromPoint(x, y); if (h && h.closest('.cv-node')?.dataset.id === '${id}' && !h.closest('.cv-ui,.cv-resize,.cv-dot')) return { cx: x, cy: y }; } return null`);
const place = (id) => { const n = canvasOf().nodes.find((x) => x.id === id); return n; };
const pickCards = async (ids) => {
let first = true;
for (const id of ids) {
const p = await freePoint(id);
await click(p.cx, p.cy, first ? [] : ['shift']);
first = false;
}
};
const trio = ['aaaaaaaaaaaaaaaa', 'bbbbbbbbbbbbbbbb', 'cccccccccccccccc'];
const vpA = await box(`document.querySelector('.cv')`);
const beforeAlign = file();
await pickCards(trio);
const offered = (await js(`return [...document.querySelectorAll('.cv-selbar [data-align]')].map((b) => b.dataset.align)`)).join();
check('with several cards selected the bar offers align and spread', offered === 'left,centerX,right,top,centerY,bottom,spreadX,spreadY', offered);
const minX = Math.min(...trio.map((id) => place(id).x));
let ab = await box(`document.querySelector('.cv-selbar [data-align="left"]')`);
await click(ab.cx, ab.cy);
await sleep(700);
check('"Align left" puts the left edges on the leftmost one', trio.every((id) => place(id).x === minX), JSON.stringify(trio.map((id) => place(id).x)));
await click(vpA.x + vpA.w - 40, vpA.y + 40); // empty space: back to the canvas, selection cleared
await key('z', ['control']);
await sleep(500);
check('...and Ctrl+Z puts them back', file() === beforeAlign);
await pickCards(trio);
ab = await box(`document.querySelector('.cv-selbar [data-align="top"]')`);
await click(ab.cx, ab.cy);
await sleep(700);
const minY = Math.min(...trio.map((id) => beforeY(id)));
function beforeY(id) { return JSON.parse(beforeAlign).nodes.find((n) => n.id === id).y; }
check('"Align top" puts the top edges level', trio.every((id) => place(id).y === minY));
await click(vpA.x + vpA.w - 40, vpA.y + 40);
await key('z', ['control']);
await sleep(500);
await pickCards(trio);
ab = await box(`document.querySelector('.cv-selbar [data-align="spreadX"]')`);
await click(ab.cx, ab.cy);
await sleep(700);
const row = trio.map((id) => place(id)).sort((a, b) => a.x + a.width / 2 - (b.x + b.width / 2));
const g1 = row[1].x - (row[0].x + row[0].width), g2 = row[2].x - (row[1].x + row[1].width);
check('"Space evenly across" makes the gaps equal', Math.abs(g1 - g2) <= 1, `${g1} vs ${g2}`);
await click(vpA.x + vpA.w - 40, vpA.y + 40);
await key('z', ['control']);
await sleep(500);
check('...and it undoes in one step', file() === beforeAlign);
// ---- find in canvas ----
const findState = () => js(`const f = document.querySelector('.cv-find'); return { open: !!f, count: f?.querySelector('.cv-count')?.textContent || '', focused: document.activeElement === f?.querySelector('input'), found: [...document.querySelectorAll('.cv-node.found, .cv-elabel.found')].map((n) => n.dataset.id || 'label'), cur: [...document.querySelectorAll('.found-cur')].map((n) => n.dataset.id || 'label'), dimmed: document.querySelector('.cv')?.classList.contains('finding') }`);
const beforeFind = file();
await key('f', ['control']);
let fs0 = await findState();
check('Ctrl+F opens a find bar with the cursor in it', fs0.open && fs0.focused, JSON.stringify(fs0));
check('the find bar opens with the same pop animation as the page find bar, staying centred', await js(`const f = document.querySelector('.cv-find'); const cs = getComputedStyle(f); const r = f.getBoundingClientRect(); const v = document.querySelector('.cv').getBoundingClientRect(); return cs.animationName === 'pop' && parseFloat(cs.animationDuration) > 0 && Math.abs((r.left + r.width / 2) - (v.left + v.width / 2)) < 3`));
await type('bold');
fs0 = await findState();
check('a card containing the text is lit up, with a count', fs0.found.join() === 'aaaaaaaaaaaaaaaa' && fs0.count === '1 of 1' && fs0.cur.join() === 'aaaaaaaaaaaaaaaa' && fs0.dimmed, JSON.stringify(fs0));
await key('a', ['control']);
await type('canvas');
await sleep(300);
fs0 = await findState();
check('notes (what they show) and links (title) are searched too, in reading order', fs0.found.sort().join() === 'bbbbbbbbbbbbbbbb,cccccccccccccccc' && fs0.count === '1 of 2' && fs0.cur.join() === 'bbbbbbbbbbbbbbbb', JSON.stringify(fs0));
await key('Return');
fs0 = await findState();
check('Enter goes to the next match and brings it to the middle of the screen', fs0.count === '2 of 2' && fs0.cur.join() === 'cccccccccccccccc');
const centreBox = await box(node('cccccccccccccccc'));
const viewBox = await box(`document.querySelector('.cv')`);
check('...', Math.abs(centreBox.cx - viewBox.cx) <= 3 && Math.abs(centreBox.cy - viewBox.cy) <= 3, `${centreBox.cx},${centreBox.cy} vs ${viewBox.cx},${viewBox.cy}`);
await key('Return', ['shift']);
check('Shift+Enter goes back', (await findState()).count === '1 of 2');
await key('a', ['control']);
await type('leads');
await sleep(200);
fs0 = await findState();
check('arrow labels are found too', fs0.found.join() === 'label' && fs0.count === '1 of 1', JSON.stringify(fs0));
await key('a', ['control']);
await type('zzzz');
await sleep(200);
fs0 = await findState();
check('no match says so and does not grey everything out', fs0.count === 'No matches' && fs0.found.length === 0 && !fs0.dimmed, JSON.stringify(fs0));
await key('Escape');
fs0 = await findState();
check('Escape closes the bar and clears the highlights', !fs0.open && fs0.found.length === 0 && !fs0.dimmed);
check('finding never changes the file', file() === beforeFind);
await key('1', ['shift']); // jumping around moved the view: show everything again, as it was on opening
await sleep(300);
// ---- arrow styles ----
const beforeStyles = file();
const arrowPoint = () => js(`const r = document.querySelector('.cv-elabel[data-edge]').getBoundingClientRect(); for (let x = r.left + 2; x < r.right - 2; x += 3) { const y = r.top + r.height / 2; const h = document.elementFromPoint(x, y); if (h && h.closest('[data-edge]')) return { cx: x, cy: y }; } return null`);
const edgeInFile = () => canvasOf().edges.find((e) => e.id === 'eeeeeeeeeeeeeeee');
const styleMenu = async (label) => {
const p = await arrowPoint();
await rclick(p.cx, p.cy);
await menuClick(label);
await sleep(600);
};
await styleMenu('Straight line');
check('"Straight line" is saved on the arrow and drawn without a curve', edgeInFile().style === 'straight' && !/ C/.test(await js(`return document.querySelector('.cv-edge .cv-line').getAttribute('d')`)));
await styleMenu('Right angles');
check('"Right angles" draws square corners', edgeInFile().style === 'orthogonal' && /Q|L/.test(await js(`return document.querySelector('.cv-edge .cv-line').getAttribute('d')`)));
await styleMenu('Arrow at the start');
check('"Arrow at the start" puts a head on that end too', edgeInFile().fromEnd === 'arrow' && (await js(`return document.querySelectorAll('.cv-edge .cv-arrow').length`)) === 2);
await styleMenu('Arrow at the end');
check('"Arrow at the end" turns the end head off', edgeInFile().toEnd === 'none' && (await js(`return document.querySelectorAll('.cv-edge .cv-arrow').length`)) === 1);
check('the menu shows what is on', await (async () => {
const p = await arrowPoint();
await rclick(p.cx, p.cy);
const state = await js(`return [...document.querySelectorAll('.cv-menu [aria-checked="true"]')].map((b) => b.textContent.trim())`);
await key('Escape');
return state.join() === 'Right angles,Arrow at the start';
})());
await styleMenu('Curved line');
check('"Curved line" goes back to the default and leaves no style in the file', edgeInFile().style === undefined);
// the bar's own button opens the same menu
const arrowAt = await arrowPoint();
await click(arrowAt.cx, arrowAt.cy);
const lsb = await box(`document.querySelector('.cv-selbar [data-act="line-style"]')`);
await click(lsb.cx, lsb.cy);
check('the selection bar has a line style button that opens the menu', (await menuItems()).includes('Straight line'));
await key('Escape');
for (let i = 0; i < 5; i++) await key('z', ['control']);
await sleep(500);
check('...and every style change undoes', file() === beforeStyles, '');
// ---- alignment guides ----
const beforeGuides = file();
const gz = (await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`));
const gp = await freePoint('cccccccccccccccc');
// drag the link card so its centre lands within a couple of pixels of the note's centre (which is off the 20-unit grid)
m('mouseMove', gp.cx, gp.cy); m('mouseDown', gp.cx, gp.cy);
for (let i = 1; i <= 12; i++) { m('mouseMove', gp.cx + (428 * gz * i) / 12, gp.cy); await sleep(25); }
await sleep(150);
const lines = await js(`return [...document.querySelectorAll('.cv-guide')].map((g) => ({ w: g.offsetWidth, h: g.offsetHeight }))`);
check('while dragging, a guide line shows where it lines up with another card', lines.length === 1 && lines[0].h > lines[0].w, JSON.stringify(lines));
m('mouseUp', gp.cx + 428 * gz, gp.cy);
await sleep(700);
check('...and the card snaps onto the line (off the grid)', place('cccccccccccccccc').x === 430 && place('cccccccccccccccc').y === 300, JSON.stringify([place('cccccccccccccccc').x, place('cccccccccccccccc').y]));
check('the guide goes when it is let go', !(await js(`return !!document.querySelector('.cv-guide')`)));
await click(vpA.x + vpA.w - 40, vpA.y + 40);
await key('z', ['control']);
await sleep(500);
check('...and it undoes', file() === beforeGuides);
// holding Alt moves freely: no snapping to the line
m('mouseMove', gp.cx, gp.cy); m('mouseDown', gp.cx, gp.cy);
for (let i = 1; i <= 12; i++) { m('mouseMove', gp.cx + (428 * gz * i) / 12, gp.cy, { modifiers: ['alt'] }); await sleep(25); }
await sleep(150);
check('Alt turns the guides off', (await js(`return document.querySelectorAll('.cv-guide').length`)) === 0);
m('mouseUp', gp.cx + 428 * gz, gp.cy, { modifiers: ['alt'] });
await sleep(500);
await click(vpA.x + vpA.w - 40, vpA.y + 40);
await key('z', ['control']);
await sleep(500);
check('...and that undoes too', file() === beforeGuides);
const view = await box(`document.querySelector('.cv')`);
const inView = await js(`const v = document.querySelector('.cv').getBoundingClientRect(); return [...document.querySelectorAll('.cv-node')].every(n => { const r = n.getBoundingClientRect(); return r.left >= v.left && r.right <= v.right && r.top >= v.top && r.bottom <= v.bottom; })`);
check('everything is in view when it opens', inView);
check('no word count or outline button for a canvas', await js(`return !document.querySelector('.topbar .wc')`));
await sleep(900);
check('opening it leaves the file untouched', file() === PLAN);
// ---- a new card ----
const empty = [view.x + view.w - 260, view.y + 120];
await dbl(...empty);
check('double-clicking empty space starts a new card', await js(`return !!document.activeElement?.closest('.cv-ed')`));
await type('Hello canvas');
await click(view.x + view.w - 120, view.y + view.h - 120); // somewhere empty
await sleep(900);
let c = canvasOf();
const made = c.nodes.find((n) => n.text === 'Hello canvas');
check('...and it is saved as a text node', !!made && made.type === 'text' && /^[0-9a-f]{16}$/.test(made.id), JSON.stringify(c.nodes.map((n) => n.text || n.file || n.url)));
check('the file stays in Obsidian\'s layout', file().startsWith('{\n\t"nodes":[\n\t\t{"id":"aaaaaaaaaaaaaaaa"'));
const leftEmpty = (await js(`return document.querySelectorAll('.cv-node').length`));
await dbl(view.x + view.w - 160, view.y + view.h - 200);
await click(view.x + view.w - 120, view.y + view.h - 60);
check('a new card left empty goes away again', (await js(`return document.querySelectorAll('.cv-node').length`)) === leftEmpty);
// ---- the block editor in a card ----
const spot = [view.x + view.w - 300, view.y + view.h - 270];
await dbl(...spot);
const h0 = await js(`return document.querySelector('.cv-card.editing').offsetHeight`);
await type('a few plain words');
check('typing in a new card does not make it taller', (await js(`return document.querySelector('.cv-card.editing').offsetHeight`)) === h0, String(h0));
await key('a', ['control']);
await key('Backspace');
await type('# Title');
await key('Return');
await type('/');
await sleep(200);
check('the slash menu works inside a card', !!(await js(`const s = document.querySelector('.slash'); return !!s && !s.hidden`)));
await key('Escape');
check('Escape closes the slash menu without leaving the card', !!(await js(`return !!document.querySelector('.cv-ed') && document.querySelector('.slash').hidden`)));
await key('Backspace');
await type('- one');
await key('Return');
await type('two');
check('the card is a real block editor: heading and bullets', await js(`const e = document.querySelector('.cv-ed'); return !!e.querySelector('.block.b-h1') && e.querySelectorAll('.block.b-bullet').length === 2`));
await key('Escape');
await sleep(900);
check('...and is saved as Markdown', canvasOf().nodes.some((n) => n.text === '# Title\n\n- one\n- two'), JSON.stringify(canvasOf().nodes.map((n) => n.text)));
check('a finished card shows the rendered heading and list', await js(`const t = [...document.querySelectorAll('.cv-card.cv-text .cv-md')].find((e) => e.textContent.includes('Title')); return !!t && t.textContent.includes('one') && t.textContent.includes('two')`));
const mk = canvasOf().nodes.find((n) => n.text === '# Title\n\n- one\n- two');
const mkBox = await box(node(mk.id));
await click(mkBox.cx, mkBox.cy);
await key('Delete');
await sleep(700);
check('...and can be deleted again', !canvasOf().nodes.some((n) => n.id === mk.id));
// ---- moving ----
const z = await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`);
const card = await box(node(made.id));
await drag(card.cx, card.cy, card.cx - 200 * z, card.cy + 40 * z);
await sleep(900);
c = canvasOf();
const moved = c.nodes.find((n) => n.id === made.id);
check('dragging a card moves it, lined up on the grid', Math.abs(moved.x - (made.x - 200)) <= 20 && Math.abs(moved.y - (made.y + 40)) <= 20 && moved.x % 20 === 0 && moved.y % 20 === 0, `${made.x},${made.y} -> ${moved.x},${moved.y}`);
// ---- an arrow ----
await js(`${node(made.id)}.dispatchEvent(new PointerEvent('pointerover', { bubbles: true }))`);
const dot = await box(`${node(made.id)}.querySelector('.cv-dot[data-side="top"]')`);
const idea = await box(node('cccccccccccccccc'));
await drag(dot.cx, dot.cy, idea.cx, idea.cy + 10, 16);
await sleep(900);
c = canvasOf();
const edge = c.edges.find((e) => e.fromNode === made.id);
check('dragging a side dot onto another card joins them with an arrow', !!edge && edge.toNode === 'cccccccccccccccc' && edge.fromSide === 'top' && ['top', 'bottom', 'left', 'right'].includes(edge.toSide), JSON.stringify(c.edges));
// ---- delete and undo ----
const now = await box(node(made.id));
await click(now.cx, now.cy);
await key('Delete');
await sleep(900);
c = canvasOf();
check('Delete removes the card and its arrows', !c.nodes.some((n) => n.id === made.id) && !c.edges.some((e) => e.fromNode === made.id));
await key('z', ['control']);
await sleep(900);
c = canvasOf();
check('Ctrl+Z brings both back', c.nodes.some((n) => n.id === made.id) && c.edges.some((e) => e.fromNode === made.id));
// ---- colour ----
const ideaBox = await box(node('aaaaaaaaaaaaaaaa'));
await click(ideaBox.cx, ideaBox.cy);
await sleep(200);
const sw = await box(`document.querySelector('.cv-selbar [aria-label="Colour 4"]')`);
await click(sw.cx, sw.cy);
await sleep(900);
check('the selection bar colours a card', canvasOf().nodes.find((n) => n.id === 'aaaaaaaaaaaaaaaa').color === '4' && (await js(`return ${node('aaaaaaaaaaaaaaaa')}.classList.contains('colored')`)));
// ---- box select + group ----
const a = await box(node('aaaaaaaaaaaaaaaa')), l = await box(node('cccccccccccccccc'));
await drag(a.x - 30, a.y - 30, l.x + l.w + 15, l.y + l.h + 15);
const picked = await js(`return [...document.querySelectorAll('.cv-node.sel')].map(n => n.dataset.id).sort()`);
check('dragging over empty space selects with a box', JSON.stringify(picked) === JSON.stringify(['aaaaaaaaaaaaaaaa', 'cccccccccccccccc']), JSON.stringify(picked));
const gb = await box(`document.querySelector('.cv-bar [aria-label="Add a group"]')`);
await click(gb.cx, gb.cy);
await sleep(200);
await js(`document.execCommand('selectAll')`);
await type('Phase 1');
await key('Return');
await sleep(900);
c = canvasOf();
const g = c.nodes.find((n) => n.type === 'group');
check('"Group" puts a named group around the selection', !!g && g.label === 'Phase 1' && c.nodes.indexOf(g) === 0 && ['aaaaaaaaaaaaaaaa', 'cccccccccccccccc'].every((i) => { const n = c.nodes.find((x) => x.id === i); return n.x >= g.x && n.y >= g.y && n.x + n.width <= g.x + g.width && n.y + n.height <= g.y + g.height; }), JSON.stringify(g));
// moving the group moves what's in it
const before = c.nodes.find((n) => n.id === 'cccccccccccccccc');
const label = await box(`document.querySelector('.cv-group .cv-glabel')`);
const z2 = await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`);
await drag(label.cx, label.cy, label.cx + 100 * z2, label.cy);
await sleep(900);
const after = canvasOf().nodes.find((n) => n.id === 'cccccccccccccccc');
check('dragging a group takes its contents along', Math.abs(after.x - before.x - 100) <= 20 && after.y === before.y, `${before.x} -> ${after.x}`);
// ---- folding a group away, and cards joining one ----
const gid = g.id;
const groupFile = () => canvasOf().nodes.find((n) => n.id === gid);
const inGroup = ['aaaaaaaaaaaaaaaa', 'cccccccccccccccc'];
const beforeFold = file();
const toggle = await box(`${node(gid)}.querySelector('.cv-gtoggle')`);
await js(`${node(gid)}.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))`);
await click(toggle.cx, toggle.cy);
await sleep(700);
check('the toggle folds the group: what is in it is hidden, the group becomes a strip', groupFile().collapsed === true && (await js(`return ${inGroup.map((i) => `!${node(i)}`).join(' && ')}`)) && (await js(`return ${node(gid)}.offsetHeight`)) === 44);
check('...and the strip says how much it holds', (await js(`return ${node(gid)}.querySelector('.cv-gcount')?.textContent`)) === '2 items');
const folded = canvasOf().edges;
check('arrows to what is hidden now meet the strip', folded.length > 0 && (await Promise.all(folded.map((e) => js(`return !!document.querySelector('.cv-hit[data-edge="${e.id}"]')`)))).every(Boolean));
await key('f', ['control']);
await type('bold');
await sleep(250);
check('finding something that is folded away lights up the group', (await js(`return ${node(gid)}.classList.contains('found-cur')`)) && (await js(`return document.querySelector('.cv-count').textContent`)) === '1 of 1');
await key('Escape');
const t2 = await box(`${node(gid)}.querySelector('.cv-gtoggle')`);
await click(t2.cx, t2.cy);
await sleep(700);
check('unfolding brings everything back, with nothing extra left in the file', groupFile().collapsed === undefined && (await js(`return ${inGroup.map((i) => `!!${node(i)}`).join(' && ')}`)) && file() === beforeFold);
// a card let go with its middle inside the group becomes part of it
const gNow = groupFile();
const vpJ = await box(`document.querySelector('.cv')`);
await dbl(vpJ.x + vpJ.w - 140, vpJ.y + 90);
await type('Joiner');
await key('Escape');
await sleep(600);
const mine = { id: canvasOf().nodes.find((n) => n.text === 'Joiner')?.id };
const gBox = await box(node(gid));
const zz = await js(`return parseFloat(document.querySelector('.cv-pct').textContent) / 100`);
const hp = await freePoint(mine.id);
const hb = await box(node(mine.id));
// its middle goes to a point just inside the group's right edge, so most of it hangs outside
const want = [gBox.x + gBox.w - 30 * zz, gBox.y + gBox.h / 2];
const shift = [want[0] - hb.cx, want[1] - hb.cy];
const beforeJoin = file();
m('mouseMove', hp.cx, hp.cy); m('mouseDown', hp.cx, hp.cy);
for (let i = 1; i <= 12; i++) { m('mouseMove', hp.cx + (shift[0] * i) / 12, hp.cy + (shift[1] * i) / 12, { modifiers: ['alt'] }); await sleep(25); }
await sleep(150);
check('a card held over a group shows that it will join it', await js(`return ${node(gid)}.classList.contains('target')`));
m('mouseUp', hp.cx + shift[0], hp.cy + shift[1], { modifiers: ['alt'] });
await sleep(800);
const joined = canvasOf().nodes.find((n) => n.id === mine.id), gAfter = groupFile();
check('...and on letting go it is moved fully inside', joined.x >= gAfter.x && joined.y >= gAfter.y && joined.x + joined.width <= gAfter.x + gAfter.width && joined.y + joined.height <= gAfter.y + gAfter.height && gAfter.width === gNow.width, JSON.stringify({ joined, gAfter }));
check('...so the group takes it along when moved', await (async () => {
const lb = await box(`${node(gid)}.querySelector('.cv-glabel')`);
const h0 = canvasOf().nodes.find((n) => n.id === mine.id).x;
await drag(lb.cx, lb.cy, lb.cx + 60 * zz, lb.cy);
await sleep(800);
const moved = canvasOf().nodes.find((n) => n.id === mine.id).x;
await key('z', ['control']);
await sleep(500);
return Math.abs(moved - h0 - 60) <= 20;
})());
check('...and joining is a single undo step', (await (async () => { await click(vpA.x + vpA.w - 40, vpA.y + 40); await key('z', ['control']); await sleep(500); return file(); })()) === beforeJoin);
const jp = await freePoint(mine.id);
await click(jp.cx, jp.cy);
await key('Delete');
await sleep(500);
// ---- card <-> note ----
await dbl(vpJ.x + vpJ.w - 140, vpJ.y + 90);
await type('# Trip ideas');
await key('Return');
await type('Pack light');
await key('Escape');
await sleep(600);
const tripCard = canvasOf().nodes.find((n) => String(n.text || '').startsWith('# Trip ideas'));
const tp = await freePoint(tripCard.id);
await rclick(tp.cx, tp.cy);
const cardItems = await menuItems();
check('a text card offers "Turn into note" (and not "Embed in card")', cardItems.includes('Turn into note') && !cardItems.includes('Embed in card'), JSON.stringify(cardItems));
const sizeBefore = [tripCard.x, tripCard.y];
await menuClick('Turn into note');
await sleep(1500);
const tripFile = path.join(vault, 'Trip ideas.md');
check('"Turn into note" makes a page named after the first line, with the rest as its text', fs.existsSync(tripFile) && /Pack light\n$/.test(fs.readFileSync(tripFile, 'utf8')) && !/# Trip ideas/.test(fs.readFileSync(tripFile, 'utf8').split('---').pop()), fs.existsSync(tripFile) ? fs.readFileSync(tripFile, 'utf8') : 'missing');
const asNote = canvasOf().nodes.find((n) => n.id === tripCard.id);
check('...and the same card becomes a note card in the same place (so its arrows stay)', asNote.type === 'file' && asNote.file === 'Trip ideas.md' && asNote.text === undefined && asNote.x === sizeBefore[0] && asNote.y === sizeBefore[1], JSON.stringify(asNote));
await sleep(600);
check('...which shows the note', (await js(`return ${node(tripCard.id)}.textContent.includes('Pack light') && ${node(tripCard.id)}.textContent.includes('Trip ideas')`)));
const np = await freePoint(tripCard.id);
await rclick(np.cx, np.cy);
const noteItems = await menuItems();
check('a note card offers "Embed in card"', noteItems.includes('Embed in card') && !noteItems.includes('Turn into note'));
await menuClick('Embed in card');
await sleep(900);
const asEmbed = canvasOf().nodes.find((n) => n.id === tripCard.id);
check('"Embed in card" makes a text card holding ![[Note]]', asEmbed.type === 'text' && asEmbed.text === '![[Trip ideas]]' && asEmbed.file === undefined, JSON.stringify(asEmbed));
check('...which shows the note\'s text in place, with a title that opens it', await js(`const c = ${node(tripCard.id)}; const t = c.querySelector('.emb-title'); return !!t && t.textContent.includes('Trip ideas') && c.querySelector('.emb-body').textContent.includes('Pack light')`));
check('...and the page itself is untouched', /Pack light\n$/.test(fs.readFileSync(tripFile, 'utf8')));
const ep = await freePoint(tripCard.id);
await click(ep.cx, ep.cy);
await key('Delete');
await sleep(500);
// ---- view ----
const pct = () => js(`return document.querySelector('.cv-pct').textContent`);
const p0 = await pct();
const mid = [view.x + view.w / 2, view.y + view.h / 2];
wc.sendInputEvent({ type: 'mouseWheel', x: mid[0], y: mid[1], deltaX: 0, deltaY: 240, modifiers: ['control'] });
await sleep(400);
check('Ctrl + scroll zooms', (await pct()) !== p0, `${p0} -> ${await pct()}`);
const t0 = await js(`return document.querySelector('.cv-world').style.transform`);
wc.sendInputEvent({ type: 'mouseWheel', x: view.x + 40, y: view.y + 40, deltaX: 0, deltaY: -240 });
await sleep(400);
const t1 = await js(`return document.querySelector('.cv-world').style.transform`);
check('scrolling moves around', t0 !== t1 && (await pct()) === (await pct()), `${t0} -> ${t1}`);
const fitB = await box(`document.querySelector('.cv-zoom [aria-label="Show everything"]')`);
await click(fitB.cx, fitB.cy);
check('"Show everything" brings it all into view', await js(`const v = document.querySelector('.cv').getBoundingClientRect(); return [...document.querySelectorAll('.cv-node')].every(n => { const r = n.getBoundingClientRect(); return r.left >= v.left - 1 && r.right <= v.right + 1 && r.top >= v.top - 1 && r.bottom <= v.bottom + 1; })`));
// ---- copy and paste ----
const count = canvasOf().nodes.length;
const card2 = await box(node('cccccccccccccccc'));
await click(card2.cx, card2.cy);
await key('c', ['control']);
await sleep(200);
check('Ctrl+C copies the selection as canvas text', clipboard.readText().includes('"url":"https://jsoncanvas.org"'));
await key('v', ['control']);
await sleep(900);
check('Ctrl+V pastes a copy (new id)', canvasOf().nodes.filter((n) => n.url === 'https://jsoncanvas.org').length === 2 && canvasOf().nodes.length === count + 1);
clipboard.writeText('https://example.com/page');
await key('v', ['control']);
await sleep(900);
check('pasting a web address makes a link card', canvasOf().nodes.some((n) => n.type === 'link' && n.url === 'https://example.com/page'));
await sleep(700);
check('a link whose page can\'t be read shows its address', await js(`const c = [...document.querySelectorAll('.cv-link')].find((e) => e.textContent.includes('example.com')); return !!c && c.querySelector('.cv-ftitle').textContent === 'example.com' && c.querySelector('.cv-url').textContent === 'https://example.com/page' && !c.querySelector('.cv-desc')`));
// ---- a note on the canvas opens, and follows a rename ----
const leftAt = await js(`return document.querySelector('.cv-world').style.transform`);
const title = await box(`${node('bbbbbbbbbbbbbbbb')}.querySelector('.cv-ftitle')`);
await click(title.cx, title.cy);
await sleep(1200);
check('clicking a note\'s title opens it (at its heading)', (await js(`return document.querySelector('.title')?.textContent`)) === 'Roadmap');
await js(`await window.vault.rename('Projects/Roadmap', 'Plan 2027')`);
await sleep(600);
const renamed = canvasOf().nodes.find((n) => n.id === 'bbbbbbbbbbbbbbbb');
check('renaming the note updates the canvas', renamed.file === 'Projects/Plan 2027.md' && renamed.subpath === '#Goals', JSON.stringify(renamed));
// ---- search and backlinks ----
check('search finds text on a canvas', (await js(`return (await window.vault.search('Hello canvas')).map(r => r.id)`)).includes('Plan.canvas'));
check('a note on a canvas has the canvas as a backlink', (await js(`return (await window.vault.backlinks('Projects/Plan 2027')).map(b => b.id)`)).includes('Plan.canvas'));
// ---- add a note from the bar (it lands in the middle, on top of things: done last) ----
await openRow('Plan.canvas');
const backAt = await js(`return document.querySelector('.cv-world').style.transform`);
check('coming back, the canvas is where you left it', backAt === leftAt, `${leftAt} / ${backAt}`);
const nb = await box(`document.querySelector('.cv-bar [aria-label="Add a note"]')`);
await click(nb.cx, nb.cy);
await type('Notes');
await key('Return');
await sleep(900);
check('"Add a note" puts a note on the canvas', canvasOf().nodes.some((n) => n.type === 'file' && n.file === 'Notes.md'));
// ---- a page dragged in from the sidebar ----
const notesBefore = canvasOf().nodes.filter((n) => n.file === 'Notes.md').length;
const dragRow = await box(`document.querySelector('.tree .row[data-id="Notes"]')`);
const dropVp = await box(`document.querySelector('.cv')`);
const dropAt = [dropVp.x + dropVp.w - 220, dropVp.y + dropVp.h - 220];
// let go in the sidebar's own space after Escape: nothing is added
m('mouseMove', dragRow.cx, dragRow.cy); m('mouseDown', dragRow.cx, dragRow.cy);
for (let i = 1; i <= 10; i++) { m('mouseMove', dragRow.cx + ((dropAt[0] - dragRow.cx) * i) / 10, dragRow.cy + ((dropAt[1] - dragRow.cy) * i) / 10); await sleep(25); }
await sleep(100);
check('a page held over the canvas lights it up as a drop target', !!(await js(`return document.querySelector('.cv')?.classList.contains('dropping')`)));
await key('Escape');
m('mouseUp', dropAt[0], dropAt[1]);
await sleep(700);
check('Escape cancels the drag: nothing is added', canvasOf().nodes.filter((n) => n.file === 'Notes.md').length === notesBefore);
await drag(dragRow.cx, dragRow.cy, dropAt[0], dropAt[1], 14);
await sleep(700);
const dropped = canvasOf().nodes.filter((n) => n.file === 'Notes.md');
check('dropping a page from the sidebar puts a note card there', dropped.length === notesBefore + 1);
const fresh = dropped[dropped.length - 1];
const fb = await box(node(fresh.id));
check('...centred where it was let go', Math.abs(fb.cx - dropAt[0]) <= 24 && Math.abs(fb.cy - dropAt[1]) <= 24, `${fb.cx},${fb.cy} vs ${dropAt}`);
check('...and the page was not moved or opened', fs.existsSync(path.join(vault, 'Notes.md')) && (await js(`return !document.querySelector('.cv-broken') && !!document.querySelector('.cv')`)));
check('the drop highlight is gone', !(await js(`return document.querySelector('.cv')?.classList.contains('dropping')`)));
// ---- export ----
const exportTo = async (item, ext) => {
const out = path.join(tmp, 'out.' + ext);
fs.rmSync(out, { force: true });
dialog.showSaveDialog = async () => ({ canceled: false, filePath: out });
await js(`document.querySelector('[aria-label="Page options"]').click()`);
await sleep(250);
await js(`[...document.querySelectorAll('.menu .mi')].find((b) => b.textContent.includes(${JSON.stringify(item)})).click()`);
for (let i = 0; i < 60 && !fs.existsSync(out); i++) await sleep(250);
await sleep(300);
return fs.existsSync(out) ? out : null;
};
const beforeExport = file();
const planNodes = canvasOf().nodes;
const spanW = Math.max(...planNodes.map((n) => n.x + n.width)) - Math.min(...planNodes.map((n) => n.x));
const spanH = Math.max(...planNodes.map((n) => n.y + n.height)) - Math.min(...planNodes.map((n) => n.y));
const png = await exportTo('Export as PNG', 'png');
check('Export as PNG writes a picture of the whole canvas', !!png);
if (png) {
const img = nativeImage.createFromPath(png);
const sz = img.getSize();
check('...sized to the items plus a margin (at 2x)', Math.abs(sz.width - (spanW + 96) * 2) <= 4 && Math.abs(sz.height - (spanH + 96) * 2) <= 4, JSON.stringify(sz) + ` expected ~${(spanW + 96) * 2}x${(spanH + 96) * 2}`);
const px = img.toBitmap();
const seen = new Set();
for (let i = 0; i < px.length; i += 4 * 97) seen.add(px.readUInt32LE(i));
check('...and is drawn, not blank', seen.size > 8, String(seen.size));
}
const svgFile = await exportTo('Export as SVG', 'svg');
const svgText = svgFile ? fs.readFileSync(svgFile, 'utf8') : '';
check('Export as SVG writes a vector file with the cards in it', svgText.startsWith('<svg') && svgText.includes('foreignObject') && svgText.includes('Idea') && svgText.includes('leads to') && !/class="cv-dot"/.test(svgText), JSON.stringify({ len: svgText.length, head: svgText.slice(0, 80), idea: svgText.includes('Idea'), lbl: svgText.includes('leads to'), dot: svgText.includes('cv-dot') }));
const pdf = await exportTo('Export as PDF', 'pdf');
check('Export as PDF writes a one-page PDF', !!pdf && fs.readFileSync(pdf).subarray(0, 4).toString() === '%PDF' && (fs.readFileSync(pdf, 'latin1').match(/\/Type\s*\/Page[^s]/g) || []).length === 1);
check('exporting leaves the canvas file alone', file() === beforeExport);
// ---- version history ----
const nowText = file();
const histDir = path.join(vault, '.notes', 'history', 'Plan.canvas');
fs.mkdirSync(histDir, { recursive: true });
fs.writeFileSync(path.join(histDir, `${Date.now() - 3600 * 1000}.md`), PLAN);
await js(`document.querySelector('[aria-label="Page options"]').click()`);
await sleep(250);
await js(`[...document.querySelectorAll('.menu .mi')].find((b) => b.textContent.includes('Version history')).click()`);
await sleep(700);
const rows = await js(`return [...document.querySelectorAll('.panel .tr')].map((r) => r.querySelector('.tr-t').textContent)`);
check('a canvas has a version history that says what each version held', rows.some((t) => t.includes('1 card · 1 note · 1 link · 1 arrow')), JSON.stringify(rows));
await js(`[...document.querySelectorAll('.panel .tr')].filter((r) => r.textContent.includes('1 card · 1 note · 1 link · 1 arrow')).pop().querySelector('.btn').click()`);
await sleep(1500);
check('restoring a version puts that canvas file back exactly', file() === PLAN);
check('...and the open canvas shows it', (await js(`return document.querySelectorAll('.cv-node').length`)) === 3);
const kept = fs.readdirSync(histDir).map((f) => fs.readFileSync(path.join(histDir, f), 'utf8'));
check('...and the canvas as it was before is kept as a version', kept.includes(nowText));
// ---- duplicate, from the sidebar's right-click menu ----
const sidebarDuplicate = async (rowId) => {
const r = await box(`document.querySelector('.tree .row[data-id=${JSON.stringify(rowId)}]')`);
await rclick(r.cx, r.cy);
const items = await js(`return [...document.querySelectorAll('.menu.ctx .mi')].map((b) => b.textContent.trim())`);
const b = await box(`[...document.querySelectorAll('.menu.ctx .mi')].find((x) => x.textContent.trim() === 'Duplicate')`);
await click(b.cx, b.cy);
await sleep(1500);
return items;
};
const menuHad = await sidebarDuplicate('Plan.canvas');
check('the sidebar menu has Duplicate', menuHad.includes('Duplicate'), JSON.stringify(menuHad));
const copyOfPlan = path.join(vault, 'Plan copy.canvas');
check('duplicating a canvas writes an identical file beside it, and opens it', fs.existsSync(copyOfPlan) && fs.readFileSync(copyOfPlan, 'utf8') === file() && !!(await js(`return document.querySelector('.cv') && document.querySelector('.tree .row.active')?.dataset.id === 'Plan copy.canvas'`)));
check('...and it is in the sidebar under its own name', !!(await js(`return document.querySelector('.tree .row[data-id="Plan copy.canvas"] .name')?.textContent === 'Plan copy'`)));
await sidebarDuplicate('Notes');
check('duplicating a page makes "Notes copy" with the same text', fs.existsSync(path.join(vault, 'Notes copy.md')) && fs.readFileSync(path.join(vault, 'Notes copy.md'), 'utf8') === fs.readFileSync(path.join(vault, 'Notes.md'), 'utf8'));
await openRow('Plan.canvas');
// ---- a new canvas ----
await key('n', ['control']);
await sleep(500);
await js(`document.querySelector('.tpl-card[data-canvas]').click()`);
await sleep(1400);
check('Ctrl+N → Canvas makes an empty canvas file and opens it', fs.existsSync(path.join(vault, 'Untitled canvas.canvas')) && file('Untitled canvas.canvas') === '{\n\t"nodes":[],\n\t"edges":[]\n}' && !!(await js(`return document.querySelector('.cv-empty')`)));
// ---- a file it can't read ----
await openRow('Broken.canvas');
check('an unreadable canvas says so', !!(await js(`return document.querySelector('.cv-broken')`)));
await dbl(view.x + 300, view.y + 300);
await sleep(900);
check('...and is never written over', file('Broken.canvas') === '{ "nodes": [ oops');
} catch (err) {
check('harness', false, err.stack);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ncanvas e2e passed!');
app.exit(failures.length ? 1 : 0);
});
+144
View File
@@ -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);
});
+74
View File
@@ -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);
});
+105
View File
@@ -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);
});
+143
View File
@@ -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('<html></html>', { 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);
});
+100
View File
@@ -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);
});
+197
View File
@@ -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);
});
+137
View File
@@ -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);
});
+365
View File
@@ -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);
});
+159
View File
@@ -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);
});
+172
View File
@@ -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);
});
+1 -1
View File
@@ -32,7 +32,7 @@ app.whenReady().then(async () => {
try { try {
await sleep(1500); await sleep(1500);
// Open Graph View // 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); await sleep(1000);
// Open settings panel // Open settings panel
+134
View File
@@ -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);
});
+146
View File
@@ -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);
});
+84
View File
@@ -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);
});
+98
View File
@@ -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);
});
+105
View File
@@ -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);
});
+122
View File
@@ -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);
});
+151
View File
@@ -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);
});
+204
View File
@@ -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);
});
+270
View File
@@ -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(/&nbsp;/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()) === '<b>hello</b>x', 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', /<s>old<\/s>/.test(h) && /<mark>key<\/mark>/.test(h) && /<code>code<\/code>/.test(h) && /<i>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('<b>'), 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 <i>it</i> 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 <i>it</i> 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('<b>'), 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);
});
+265
View File
@@ -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 <select>
await js(`[...document.querySelectorAll('.sidebar button')].find((b) => b.getAttribute('aria-label') === 'Settings').click()`);
await sleep(600);
await js(`[...document.querySelectorAll('.set-nav button')].find((b) => b.textContent.trim() === 'Editor').click()`); // spellcheck lives in the Editor section
await sleep(300);
check('the language fields are styled pickers (no native <select>)', !(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);
});
+78
View File
@@ -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);
});
+119
View File
@@ -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);
});
+155
View File
@@ -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);
});
+105
View File
@@ -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<details>\n<summary>Title</summary>\n\nBody text\n\n</details>\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);
});
+109
View File
@@ -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('<html><head><title>Hello Web</title></head><body><nav>menu</nav><article><h1>Hello Web</h1><p>Some <b>bold</b> text and a <a href="/x">link</a>.</p><ul><li>one</li><li>two</li></ul></article></body></html>', { 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', '<h2>Title</h2><ul><li>one</li><li>two</li></ul><p>a <b>bold</b> <a href="https://x.test">link</a></p>'); 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);
});
+72
View File
@@ -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();
});
+76
View File
@@ -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 › Q3</);
assert.match(html, /data-ref="#Here">Here</);
assert.match(html, /data-ref="Page#Part">the part</);
assert.equal(htmlToMd(html), md);
});
test('![[embeds]] are blocks; pictures and other files are not', () => {
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** & <script>x</script>\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('<script>'), 'escaped');
assert.match(html, /<div class="eb b-h2"><div class="c">Part<\/div><\/div>/);
assert.match(html, /<b>bold<\/b>/);
assert.match(html, /b-todo done/);
assert.match(html, /<div class="ecell">1<\/div>/);
assert.match(html, /src="media:\/\/x\/p\.png"/);
assert.match(html, /emb-nested.*data-ref="Other"/);
assert.match(html, /data-kind="warning"/);
});
test('on disk: embeds count as links, heading parts and aliases survive a rename, files and code are left alone', () => {
const P = (id, body) => ({ id, title: id.split('/').pop(), parent: null, exists: true, body });
const notes = P('Notes', 'See [[Plan#Goals|goals]] and [[#Local]].\n\n![[Plan]]\n\n![[Plan#Q3]]\n![[photo.png]] ![[slides.pdf]]\n```\n![[Plan]]\n```\n');
const before = [P('Plan', '# Goals'), notes];
assert.deepEqual(findLinks(notes.body).map((l) => (l.embed ? '!' : '') + l.ref), ['Plan#Goals', '#Local', '!Plan', '!Plan#Q3']);
const out = rewriteLinks(notes.body, before, [P('Roadmap', ''), notes], (id) => (id === 'Plan' ? 'Roadmap' : null));
assert.equal(out, 'See [[Roadmap#Goals|goals]] and [[#Local]].\n\n![[Roadmap]]\n\n![[Roadmap#Q3]]\n![[photo.png]] ![[slides.pdf]]\n```\n![[Plan]]\n```\n');
assert.deepEqual(backlinks('Plan', before).map((b) => b.id), ['Notes']);
assert.deepEqual(graph(before).edges, [['Notes', 'Plan']]);
assert.deepEqual(graph(before).ghosts, []);
});
+89
View File
@@ -0,0 +1,89 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { evaluateFormula, parseFormula, FormulaError, show } from '../src/lib/formula.js';
const vars = { Price: 12.5, Qty: 4, Name: 'Cleanser', Brand: 'Balea', Opened: '2026-03-01', Expiry: '2026-09-01', Done: true, Tags: ['a', 'b'], Empty: null };
const ctx = { today: '2026-03-31', prop: (n) => { if (!(n in vars)) throw new FormulaError(`Unknown property "${n}"`); return vars[n]; } };
const ev = (s) => evaluateFormula(s, ctx);
test('arithmetic, precedence and properties', () => {
assert.equal(ev('1 + 2 * 3'), 7);
assert.equal(ev('(1 + 2) * 3'), 9);
assert.equal(ev('2 ^ 3 ^ 2'), 512); // right-associative
assert.equal(ev('-2 ^ 2'), -4); // as in maths: the power first
assert.equal(ev('prop("Price") * prop("Qty")'), 50);
assert.equal(ev('{Price} * {Qty}'), 50);
assert.equal(ev('10 % 4'), 2);
assert.equal(ev('1 / 4'), 0.25);
assert.equal(ev('prop("Empty") + 5'), 5); // empty counts as 0
assert.equal(show(ev('0.1 + 0.2')), '0.3');
});
test('comparisons, logic and if', () => {
assert.equal(ev('3 > 2 and 2 >= 2'), true);
assert.equal(ev('1 > 2 or not false'), true);
assert.equal(ev('!(1 == 1)'), false);
assert.equal(ev('prop("Name") == "Cleanser"'), true);
assert.equal(ev('prop("Empty") == ""'), true);
assert.equal(ev('if(prop("Price") > 10, "pricey", "cheap")'), 'pricey');
assert.equal(ev('if(false, 1)'), null);
assert.equal(ev('if(prop("Empty"), "x", "y")'), 'y');
assert.equal(ev('and(true, 1, "x")'), true);
assert.equal(ev('if(true, 1, 1 / 0)'), 1); // the other branch is not evaluated
});
test('text and lists', () => {
assert.equal(ev('concat(prop("Brand"), " – ", prop("Name"))'), 'Balea – Cleanser');
assert.equal(ev('"a" + "b"'), 'ab');
assert.equal(ev('"Total: " + 5'), 'Total: 5');
assert.equal(ev('upper(prop("Name"))'), 'CLEANSER');
assert.equal(ev('length(prop("Tags"))'), 2);
assert.equal(ev('join(prop("Tags"), "/")'), 'a/b');
assert.equal(ev('contains(prop("Tags"), "B")'), true);
assert.equal(ev('contains("Hello", "ell")'), true);
assert.equal(ev('replace("a-b-c", "-", "+")'), 'a+b+c');
});
test('numbers functions', () => {
assert.equal(ev('round(2.567, 1)'), 2.6);
assert.equal(ev('round(2.5)'), 3);
assert.equal(ev('floor(2.9) + ceil(2.1)'), 5);
assert.equal(ev('abs(-3)'), 3);
assert.equal(ev('min(3, 1, 2)'), 1);
assert.equal(ev('max(prop("Tags"), 5)'.replace('prop("Tags"), ', '')), 5);
assert.equal(ev('sum(1, 2, 3)'), 6);
assert.equal(ev('average(2, 4)'), 3);
assert.equal(ev('toNumber("€12.50")'), 12.5);
});
test('dates', () => {
assert.equal(ev('today()'), '2026-03-31');
assert.equal(ev('dateBetween(prop("Expiry"), today(), "days")'), 154);
assert.equal(ev('dateBetween(prop("Expiry"), prop("Opened"), "months")'), 6);
assert.equal(ev('dateBetween(today(), prop("Expiry"), "days")'), -154);
assert.equal(ev('dateAdd(prop("Opened"), 12, "months")'), '2027-03-01');
assert.equal(ev('dateAdd("2026-01-31", 1, "months")'), '2026-02-28');
assert.equal(ev('dateAdd(prop("Opened"), 2, "weeks")'), '2026-03-15');
assert.equal(ev('prop("Expiry") - prop("Opened")'), 184); // date - date = days
assert.equal(ev('prop("Opened") + 30'), '2026-03-31'); // date + number = days later
assert.equal(ev('prop("Expiry") > today()'), true);
assert.equal(ev('year(prop("Opened")) * 100 + month(prop("Opened"))'), 202603);
assert.equal(ev('formatDate(prop("Opened"), "MMM D, YYYY")'), 'Mar 1, 2026');
});
test('mistakes are reported, not thrown as anything else', () => {
const msg = (s) => { try { ev(s); return null; } catch (e) { assert.ok(e instanceof FormulaError, s + ' -> ' + e); return e.message; } };
assert.match(msg('1 +'), /ends too soon/);
assert.match(msg('1 / 0'), /Division by zero/);
assert.match(msg('prop("Nope")'), /Unknown property/);
assert.match(msg('nope(1)'), /no function/);
assert.match(msg('Price * 2'), /not a function or a value/);
assert.match(msg('"abc" * 2'), /can't be used as a number/);
assert.match(msg('(1 + 2'), /Expected “\)”/);
assert.match(msg('1 $ 2'), /Unexpected/);
assert.match(msg('"open'), /not closed/);
assert.match(msg(''), /empty/);
assert.match(msg('dateBetween("x", today(), "days")'), /can't be used as a date/);
assert.match(msg('dateBetween(today(), today(), "decades")'), /Unknown unit/);
assert.throws(() => parseFormula('1 +'), FormulaError);
});
+57
View File
@@ -0,0 +1,57 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { createSimulation, neighbourhood } from '../src/lib/graph.js';
import { nodeColor } from '../src/lib/graphColors.js';
const require = createRequire(import.meta.url);
const { graph } = require('../electron/links.cjs');
test('graph data: one edge per linked pair, ghosts for missing pages, code ignored', () => {
const P = (id, body) => ({ id, title: id.split('/').pop(), parent: id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : null, exists: true, body });
const g = graph([P('A', '[[B]] [[B]] [[Nope]] `[[C]]`'), P('B', '[[A]] [[a]] [[B]]'), P('C', ''), P('X/Y', '[[C]]'), { id: 'X', title: 'X', exists: false, body: '' }]);
assert.deepEqual(g.nodes.map((n) => n.id), ['A', 'B', 'C', 'X/Y']);
assert.deepEqual(g.edges, [['A', 'B'], ['A', '?nope'], ['B', 'A'], ['X/Y', 'C']]);
assert.deepEqual(g.ghosts, [{ id: '?nope', title: 'Nope' }]);
});
test('simulation settles: linked notes close, clusters apart, nothing stacked', () => {
const ids = Array.from({ length: 40 }, (_, i) => 'n' + i);
const edges = [];
for (let i = 1; i < 20; i++) edges.push(['n0', 'n' + i]);
for (let i = 21; i < 36; i++) edges.push(['n20', 'n' + i]);
const sim = createSimulation().setData(ids.map((id) => ({ id })), edges);
let n = 0;
while (sim.running && n < 2000) { sim.tick(); n++; }
assert.ok(n < 600, `settles (${n} ticks)`);
const N = sim.nodes, d = (a, b) => Math.hypot(N[a].x - N[b].x, N[a].y - N[b].y);
const spoke = d(0, 1);
assert.ok(spoke > 40 && spoke < 400, `spoke length ${spoke}`);
assert.ok(d(0, 20) > spoke * 1.5, 'the two hubs push apart');
let closest = Infinity;
for (let i = 0; i < 40; i++) for (let j = i + 1; j < 40; j++) closest = Math.min(closest, d(i, j));
assert.ok(closest > 5, `no stacked nodes (closest ${closest})`);
assert.equal(N[0].degree, 19);
});
test('dragged (fixed) nodes stay put; stacked starts separate; neighbourhood depth', () => {
const sim = createSimulation().setData([{ id: 'a', x: 0, y: 0 }, { id: 'b', x: 0, y: 0 }, { id: 'c', x: 0, y: 0 }], [['a', 'b']]);
sim.nodes[0].fx = 50;
sim.nodes[0].fy = 50;
sim.tick(200);
assert.deepEqual([sim.nodes[0].x, sim.nodes[0].y], [50, 50]);
assert.ok(Math.hypot(sim.nodes[1].x - sim.nodes[2].x, sim.nodes[1].y - sim.nodes[2].y) > 5);
const edges = [['a', 'b'], ['b', 'c'], ['c', 'd'], ['x', 'y']];
assert.deepEqual([...neighbourhood('b', edges, 1)].sort(), ['a', 'b', 'c']);
assert.deepEqual([...neighbourhood('a', edges, 3)].sort(), ['a', 'b', 'c', 'd']);
});
test('graph colours: a coloured tag colours its pages; everything else stays plain', () => {
const colors = { project: 'purple', asm: 'green' };
assert.equal(nodeColor(['project'], colors, '#plain'), '#9a6de0');
assert.equal(nodeColor(['study', 'asm', 'project'], colors, '#plain'), '#44ad6e', 'the first of its tags that has a colour');
assert.equal(nodeColor(['study'], colors, '#plain'), '#plain', 'a tag without a colour does not colour its pages');
assert.equal(nodeColor(['study'], { study: 'not-a-colour' }, '#plain'), '#plain', 'an unknown colour name is ignored');
assert.equal(nodeColor([], colors, '#plain'), '#plain');
assert.equal(nodeColor(undefined, colors, '#plain'), '#plain');
});
+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { parseMarkdown } from '../src/lib/markdown.js';
import { linkHeading, matchesHeading } from '../src/lib/links.js';
import { sectionOf } from '../src/lib/embed.js';
const require = createRequire(import.meta.url);
const { renameHeadingRefs, matchesHeading: matchesOnDisk, linkHeading: linkHeadingOnDisk } = require('../electron/links.cjs');
test('heading text inside a link: unsafe characters become spaces, matching ignores case and spacing', () => {
assert.equal(linkHeading('C# [tips] | ^x'), 'C tips x');
assert.equal(linkHeading(' Plain heading '), 'Plain heading');
assert.equal(linkHeading('# | ^'), '');
assert.ok(matchesHeading('goals', 'Goals'));
assert.ok(matchesHeading('Q3 GOALS', ' q3 goals '));
assert.ok(matchesHeading('C tips', 'C# [tips]'), 'the link-safe spelling of a heading finds it');
assert.ok(matchesHeading('C# [tips]', 'C# [tips]'));
assert.ok(!matchesHeading('', 'Goals') && !matchesHeading('Goal', 'Goals'));
// the disk side follows the same rules
for (const [a, b] of [['goals', 'Goals'], ['C tips', 'C# [tips]'], ['Goal', 'Goals']]) assert.equal(matchesOnDisk(a, b), matchesHeading(a, b));
assert.equal(linkHeadingOnDisk('C# [tips] | ^x'), linkHeading('C# [tips] | ^x'));
// a section can be found by either spelling
const blocks = parseMarkdown('## C# [tips]\n\ntext\n\n## Next\n');
assert.equal(sectionOf(blocks, 'C tips').length, 2);
});
test('renaming a heading re-points [[Page#Heading]] links and embeds, and nothing else', () => {
const P = (id, body) => ({ id, title: id.split('/').pop(), parent: null, exists: true, body });
const pages = [P('Plan', '# Goals'), P('Archive/Plan', '# Goals'), P('Notes', '')];
const body = 'See [[Plan#Goals]], [[plan#GOALS|the goals]], ![[Plan#goals]], [[Plan#Goals#Sub]], [[Plan#Other]], [[Archive/Plan#Goals]], [[#Goals]], [[Plan]], `[[Plan#Goals]]`.';
const r = renameHeadingRefs(body, pages, 'Plan', 'Goals', 'Q3 [goals] | new #1');
assert.equal(r.count, 3);
assert.equal(r.body, 'See [[Plan#Q3 goals new 1]], [[plan#Q3 goals new 1|the goals]], ![[Plan#Q3 goals new 1]], [[Plan#Goals#Sub]], [[Plan#Other]], [[Archive/Plan#Goals]], [[#Goals]], [[Plan]], `[[Plan#Goals]]`.');
assert.equal(renameHeadingRefs('nothing [[Plan#X]]', pages, 'Plan', 'Goals', 'New'), null);
assert.equal(renameHeadingRefs(body, pages, 'Plan', 'Goals', '# | ^'), null, 'a name with nothing usable is not written');
// a link written with the link-safe spelling of the old heading follows too
const unsafe = renameHeadingRefs('[[Plan#C tips]]', [P('Plan', '')], 'Plan', 'C# [tips]', 'New');
assert.equal(unsafe.body, '[[Plan#New]]');
});
+31
View File
@@ -0,0 +1,31 @@
// The icon sets: every icon offered has a drawing, nothing is offered twice, and the drawings are well-formed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PATHS, PICKER_ICONS, PICKER_EMOJI, isEmoji } from '../src/lib/icons.js';
test('every icon in the picker has a drawing, and none is listed twice', () => {
const names = PICKER_ICONS.map(([n]) => n);
assert.deepEqual(names.filter((n) => !PATHS[n]), [], 'missing drawings');
assert.deepEqual(names.filter((n, i) => names.indexOf(n) !== i), [], 'duplicates');
for (const [n, words] of PICKER_ICONS) assert.ok(words && words.trim(), `${n} has search words`);
assert.ok(names.length >= 100, `a good number of icons (${names.length})`);
});
test('every drawing is made of valid path commands, inside the 24px grid', () => {
for (const [name, d] of Object.entries(PATHS)) {
for (const part of d.split('|')) {
assert.match(part, /^[MmLlHhVvCcSsQqTtAaZz0-9eE.,\s-]+$/, `${name}: unexpected characters in "${part}"`);
assert.match(part, /^[Mm]/, `${name}: a path starts with a move`);
const nums = (part.match(/-?\d*\.?\d+/g) || []).map(Number);
assert.ok(nums.length >= 2, `${name} has coordinates`);
}
}
});
test('emoji in the picker are real emoji, listed once, with words to find them by', () => {
const list = PICKER_EMOJI.map(([e]) => e);
assert.ok(list.every((e) => isEmoji(e)), 'all are emoji');
assert.deepEqual(list.filter((e, i) => list.indexOf(e) !== i), [], 'duplicates');
assert.ok(PICKER_EMOJI.every(([, words]) => words.trim()));
assert.ok(list.length >= 200, `plenty of emoji (${list.length})`);
});
+57
View File
@@ -0,0 +1,57 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { fetchImage, nameFor } = require('../electron/imagefetch.cjs');
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
const publicIp = async () => [{ address: '93.184.216.34' }];
const reply = (body, headers = {}, status = 200) => new Response(body, { status, headers });
const msg = async (p) => { try { await p; return null; } catch (e) { return e.message; } };
test('a picture is downloaded with its name and type', async () => {
const seen = [];
const img = await fetchImage('https://example.com/a/My%20Photo.png?size=large', {
lookup: publicIp,
fetch: async (url) => { seen.push(url); return reply(PNG, { 'content-type': 'image/png' }); }
});
assert.equal(img.name, 'My Photo.png');
assert.equal(img.type, 'image/png');
assert.equal(img.bytes.length, PNG.length);
assert.deepEqual(seen, ['https://example.com/a/My%20Photo.png?size=large']);
});
test('names: from the address when it has an image extension, else from the type', () => {
assert.equal(nameFor(new URL('https://x.test/pic.JPG'), 'image/jpeg'), 'pic.JPG');
assert.equal(nameFor(new URL('https://x.test/photo'), 'image/webp'), 'photo.webp');
assert.equal(nameFor(new URL('https://x.test/'), 'image/png'), 'cover.png');
assert.equal(nameFor(new URL('https://x.test/get.php'), 'image/gif'), 'get.gif');
assert.equal(nameFor(new URL('https://x.test/a%2Fb%3F.png'), 'image/png'), 'a-b-.png');
});
test('only public pictures: wrong schemes, private addresses, non-images and errors are refused', async () => {
const ok = { lookup: publicIp, fetch: async () => reply(PNG, { 'content-type': 'image/png' }) };
assert.match(await msg(fetchImage('file:///etc/passwd', ok)), /public web address/);
assert.match(await msg(fetchImage('ftp://example.com/a.png', ok)), /public web address/);
assert.match(await msg(fetchImage('http://127.0.0.1/a.png', ok)), /public web address/);
assert.match(await msg(fetchImage('http://localhost/a.png', ok)), /public web address/);
assert.match(await msg(fetchImage('http://192.168.1.5/a.png', ok)), /public web address/);
assert.match(await msg(fetchImage('https://intranet.test/a.png', { ...ok, lookup: async () => [{ address: '10.0.0.7' }] })), /public web address/);
assert.match(await msg(fetchImage('not a url', ok)), /public web address/);
assert.match(await msg(fetchImage('https://example.com/page', { lookup: publicIp, fetch: async () => reply('<html></html>', { 'content-type': 'text/html' }) })), /not a picture/);
assert.match(await msg(fetchImage('https://example.com/a.png', { lookup: publicIp, fetch: async () => reply('nope', {}, 404) })), /answered 404/);
assert.match(await msg(fetchImage('https://example.com/a.png', { lookup: publicIp, fetch: async () => { throw new Error('boom'); } })), /Could not reach/);
assert.match(await msg(fetchImage('https://example.com/a.png', { lookup: publicIp, fetch: async () => reply('', { 'content-type': 'image/png' }) })), /nothing/);
assert.match(await msg(fetchImage('https://example.com/a.png', { lookup: publicIp, fetch: async () => reply(PNG, { 'content-type': 'image/png', 'content-length': String(60 * 1024 * 1024) }) })), /too big/);
});
test('redirects are followed, and each hop is checked again', async () => {
const good = async (url) => (url.endsWith('/start') ? reply('', { location: 'https://cdn.example.com/real.png' }, 302) : reply(PNG, { 'content-type': 'image/png' }));
const img = await fetchImage('https://example.com/start', { lookup: publicIp, fetch: good });
assert.equal(img.name, 'real.png');
const toPrivate = async () => reply('', { location: 'http://127.0.0.1:8080/secret.png' }, 301);
assert.match(await msg(fetchImage('https://example.com/start', { lookup: publicIp, fetch: toPrivate })), /redirects somewhere/);
const loop = async () => reply('', { location: 'https://example.com/start' }, 302);
assert.match(await msg(fetchImage('https://example.com/start', { lookup: publicIp, fetch: loop })), /Too many redirects/);
});
+72
View File
@@ -0,0 +1,72 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtemp, readFile, writeFile, mkdir, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
const require = createRequire(import.meta.url);
const { importFolder, importZip, stripId } = require('../electron/importer.cjs');
const { zipFolder } = require('../electron/backup.cjs');
const tmp = () => mkdtemp(path.join(tmpdir(), 'ink-import-'));
const ID = '0123456789abcdef0123456789abcdef';
async function notion(root) {
await mkdir(path.join(root, `Projects ${ID}`), { recursive: true });
await writeFile(path.join(root, `Projects ${ID}.md`), `# Projects\n\nSee [Roadmap](Projects%20${ID}/Roadmap%20${ID.replace(/0/g, '1')}.md) and ![shot](Projects%20${ID}/shot.png).\n[Web](https://example.com/a.md)\n`);
await writeFile(path.join(root, `Projects ${ID}`, `Roadmap ${ID.replace(/0/g, '1')}.md`), '# Roadmap\n');
await writeFile(path.join(root, `Projects ${ID}`, 'shot.png'), Buffer.from([1, 2, 3]));
await writeFile(path.join(root, `Projects ${ID}_all.csv`), 'a,b\n');
await mkdir(path.join(root, '.obsidian'));
await writeFile(path.join(root, '.obsidian', 'app.json'), '{}');
}
test('Notion ids are stripped from names', () => {
assert.equal(stripId(`Page ${ID}.md`), 'Page.md');
assert.equal(stripId(`Folder ${ID}`), 'Folder');
assert.equal(stripId('Plain name.md'), 'Plain name.md');
});
test('a Notion export folder is imported into a new folder, with links as [[wiki links]]', async () => {
const src = await tmp(), vault = await tmp();
await notion(src);
await writeFile(path.join(vault, 'Existing.md'), 'keep');
const r = await importFolder(vault, src);
assert.equal(r.notes, 2);
assert.equal(r.files, 1); // the picture; the _all.csv and .obsidian are left out
const folder = path.join(vault, r.folder);
const top = await readFile(path.join(folder, 'Projects.md'), 'utf8');
assert.ok(top.includes(`[[${r.folder}/Projects/Roadmap]]`), top);
assert.ok(top.includes('![shot](Projects/shot.png)'), top);
assert.ok(top.includes('[Web](https://example.com/a.md)'));
assert.ok(existsSync(path.join(folder, 'Projects', 'Roadmap.md')));
assert.ok(existsSync(path.join(folder, 'Projects', 'shot.png')));
assert.ok(!existsSync(path.join(folder, '.obsidian')));
assert.equal(await readFile(path.join(vault, 'Existing.md'), 'utf8'), 'keep');
await rm(src, { recursive: true }); await rm(vault, { recursive: true });
});
test('a zip is imported the same way, ignoring one wrapping folder, and a second import makes a second folder', async () => {
const base = await tmp(), vault = await tmp(), out = await tmp();
const src = path.join(base, 'Export');
await mkdir(src);
await notion(src);
const zip = path.join(out, 'Export.zip');
await zipFolder(base, zip);
const a = await importZip(vault, zip);
const b = await importZip(vault, zip);
assert.equal(a.notes, 2);
assert.notEqual(a.folder, b.folder);
assert.ok(existsSync(path.join(vault, a.folder, 'Projects', 'Roadmap.md')));
for (const d of [base, vault, out]) await rm(d, { recursive: true });
});
test('a zip with .. in a name is not written outside the notes', async () => {
const vault = await tmp();
const { importFiles } = require('../electron/importer.cjs');
await importFiles(vault, 'X', [{ rel: 'ok.md', read: () => Buffer.from('a') }, { rel: '../../evil.md', read: () => Buffer.from('b') }]).catch(() => {});
assert.ok(!existsSync(path.join(vault, '..', 'evil.md')));
await rm(vault, { recursive: true });
});
+48
View File
@@ -0,0 +1,48 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseBoard, boardToMd, boardFromMarkdown, defaultBoard, isBoard } from '../src/lib/kanban.js';
const OBSIDIAN = `## To do
- [ ] Write **intro** with [[Plan]]
second line
- [ ] Two
## Done
**Complete**
- [x] Old
## Empty
%% kanban:settings
\`\`\`
{"kanban-plugin":"basic"}
\`\`\`
%%
`;
test('an Obsidian Kanban board survives a round trip', () => {
const b = parseBoard(OBSIDIAN);
assert.deepEqual(b.columns.map((c) => [c.title, c.cards.length]), [['To do', 2], ['Done', 1], ['Empty', 0]]);
assert.equal(b.columns[0].cards[0].text, 'Write **intro** with [[Plan]]\nsecond line');
assert.equal(b.columns[1].cards[0].done, true);
assert.deepEqual(b.columns[1].extra, ['**Complete**']);
assert.match(b.tail, /^%% kanban:settings/);
assert.equal(boardToMd(b), OBSIDIAN);
});
test('boards are made from pages and from scratch', () => {
assert.equal(isBoard({ 'kanban-plugin': 'basic' }), true);
assert.equal(isBoard({}), false);
assert.equal(boardToMd(defaultBoard()), '## To do\n\n\n## Doing\n\n\n## Done\n');
const b = boardFromMarkdown('Loose idea\n\n# Week\n\n- [x] done thing\n- bullet\n\n---\n\nPara with [[Link]]\n');
assert.deepEqual(b.columns.map((c) => [c.title, c.cards.map((k) => k.text + (k.done ? ' ✓' : ''))]), [
['To do', ['Loose idea']],
['Week', ['done thing ✓', 'bullet', 'Para with [[Link]]']]
]);
assert.equal(boardFromMarkdown('').columns.length, 3);
});
+79
View File
@@ -0,0 +1,79 @@
// Web link previews: reading a page's title and description, and never fetching private addresses.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { parsePreview, fetchPreview, allowed } = require('../electron/linkpreview.cjs');
const page = (head) => `<!doctype html><html><head>${head}</head><body><p>hi</p></body></html>`;
const reply = (body, { status = 200, type = 'text/html; charset=utf-8', headers = {} } = {}) => ({
ok: status >= 200 && status < 300,
status,
headers: new Headers({ 'content-type': type, ...headers }),
body: new Response(body).body
});
const PUBLIC = async () => [{ address: '93.184.216.34' }];
test('parsePreview prefers Open Graph, falls back to <title> and the description meta', () => {
assert.deepEqual(
parsePreview(page('<title>Plain</title><meta property="og:title" content="The &amp; Title"><meta name="description" content="Desc"><meta property="og:description" content="OG &quot;desc&quot;"><meta property="og:site_name" content="Site">')),
{ title: 'The & Title', description: 'OG "desc"', site: 'Site' }
);
assert.deepEqual(parsePreview(page('<title>\n Just a\n title </title><meta name="description" content="D">')), { title: 'Just a title', description: 'D', site: '' });
assert.deepEqual(parsePreview(page("<meta content='single' name='twitter:title'><meta content=bare name=description>")), { title: 'single', description: 'bare', site: '' });
assert.deepEqual(parsePreview('no markup at all'), { title: '', description: '', site: '' });
});
test('parsePreview decodes numeric entities and caps lengths', () => {
assert.equal(parsePreview(page('<title>caf&#233; &#x1F600; &unknown;</title>')).title, 'café 😀 &unknown;');
const long = parsePreview(page(`<meta name="description" content="${'x'.repeat(1000)}">`)).description;
assert.equal(long.length, 400);
assert.ok(long.endsWith('…'));
});
test('only public http(s) addresses are allowed', async () => {
const ok = async (u) => !!(await allowed(u, PUBLIC));
assert.equal(await ok('https://example.com/a?b=1'), true);
for (const u of ['file:///etc/passwd', 'ftp://example.com', 'javascript:alert(1)', 'http://localhost:3000', 'http://app.localhost', 'http://127.0.0.1', 'http://10.1.2.3', 'http://192.168.0.1', 'http://172.20.0.1',
'http://169.254.169.254/latest', 'http://100.64.0.1', 'http://[::1]/', 'http://[fd00::1]/', 'http://[::ffff:127.0.0.1]/', 'http://[::ffff:192.168.1.5]/', 'http://[::127.0.0.1]/', 'http://[::ffff:10.0.0.1]/', 'http://2130706433/', 'http://0x7f.1/', 'http://user:pw@example.com', 'not a url']) {
assert.equal(await ok(u), false, u);
}
// a name that resolves to a private address
assert.equal(!!(await allowed('https://evil.example', async () => [{ address: '10.0.0.5' }])), false);
assert.equal(!!(await allowed('https://evil.example', async () => [{ address: '93.184.216.34' }, { address: '127.0.0.1' }])), false);
assert.equal(!!(await allowed('https://nx.example', async () => { throw new Error('ENOTFOUND'); })), false);
});
test('fetchPreview reads the page, follows redirects, and checks every hop', async () => {
const seen = [];
const fetch = async (url) => {
seen.push(url);
if (url === 'https://a.example/') return reply('', { status: 301, headers: { location: '/next' } });
if (url === 'https://a.example/next') return reply(page('<title>Landed</title><meta name="description" content="here">'));
return reply('', { status: 404 });
};
assert.deepEqual(await fetchPreview('https://a.example/', { fetch, lookup: PUBLIC }), { title: 'Landed', description: 'here', site: '' });
assert.deepEqual(seen, ['https://a.example/', 'https://a.example/next']);
// a public page that bounces to an address on the local network is not followed
const bounce = async (url) => (url === 'https://b.example/' ? reply('', { status: 302, headers: { location: 'http://192.168.1.1/admin' } }) : reply(page('<title>Router</title>')));
const calls = [];
assert.equal(await fetchPreview('https://b.example/', { fetch: async (u) => { calls.push(u); return bounce(u); }, lookup: PUBLIC }), null);
assert.deepEqual(calls, ['https://b.example/']);
});
test('fetchPreview gives null for non-HTML, errors, empty pages and loops', async () => {
const lookup = PUBLIC;
assert.equal(await fetchPreview('https://c.example/', { fetch: async () => reply('{"a":1}', { type: 'application/json' }), lookup }), null);
assert.equal(await fetchPreview('https://c.example/', { fetch: async () => { throw new Error('offline'); }, lookup }), null);
assert.equal(await fetchPreview('https://c.example/', { fetch: async () => reply(page('')), lookup }), null);
assert.equal(await fetchPreview('https://c.example/', { fetch: async () => reply('', { status: 302, headers: { location: '/' } }), lookup }), null);
assert.equal(await fetchPreview('http://127.0.0.1/', { fetch: async () => { throw new Error('must not be called'); }, lookup }), null);
});
test('fetchPreview honours the page encoding', async () => {
const latin = Buffer.from(page('<meta charset="iso-8859-1"><title>Café</title>'), 'latin1');
const r = await fetchPreview('https://d.example/', { fetch: async () => reply(latin, { type: 'text/html' }), lookup: PUBLIC });
assert.equal(r.title, 'Café');
});
+222
View File
@@ -0,0 +1,222 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseMarkdown, toMarkdown, mdToHtml, htmlToMd, toHtmlDocument } from '../src/lib/markdown.js';
const SAMPLE = `Plain paragraph with **bold**, *italic* and \`code\`.
Second line of the same paragraph.
# Heading one
## Heading two
### Heading three
- first bullet
- second bullet
- [ ] open task
- [x] done task
> A quote
> over two lines
> [!note]
> A callout with <kbd>Ctrl</kbd> <kbd>K</kbd>
---
\`\`\`ts
const a = 1 < 2 && "x"
console.log(a)
\`\`\`
<details>
<summary>More</summary>
Hidden text with **bold**.
</details>
$$
x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}
$$
\`\`\`mermaid
graph TD;
A-->B;
\`\`\`
`;
test('markdown survives a round trip unchanged', () => {
const blocks = parseMarkdown(SAMPLE);
assert.equal(toMarkdown(blocks), SAMPLE);
// and a second pass is stable
assert.equal(toMarkdown(parseMarkdown(toMarkdown(blocks))), SAMPLE);
});
test('block types are recognised', () => {
const types = parseMarkdown(SAMPLE).map((b) => b.type);
assert.deepEqual(types, ['p', 'h1', 'h2', 'h3', 'bullet', 'bullet', 'todo', 'todo', 'quote', 'callout', 'divider', 'code', 'toggle', 'math', 'mermaid']);
const code = parseMarkdown(SAMPLE).find((b) => b.type === 'code');
assert.equal(code.lang, 'ts');
assert.equal(code.text, 'const a = 1 < 2 && "x"\nconsole.log(a)');
});
test('inline math converts both ways', () => {
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
});
test('inline math converts both ways', () => {
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
});
test('inline math converts both ways', () => {
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
});
test('inline math converts both ways', () => {
assert.equal(mdToHtml('a $E=mc^2$ b'), 'a <code class="language-math">E=mc^2</code> b');
assert.equal(htmlToMd('a <code class="language-math">E=mc^2</code> b'), 'a $E=mc^2$ b');
});
test('inline formatting converts both ways and escapes HTML', () => {
assert.equal(mdToHtml('a **b** *c* `<d>`'), 'a <b>b</b> <i>c</i> <code>&lt;d&gt;</code>');
assert.equal(htmlToMd('a <b>b</b> <i>c</i> <code>&lt;d&gt;</code>'), 'a **b** *c* `<d>`');
assert.equal(mdToHtml('5 < 6 & 7'), '5 &lt; 6 &amp; 7');
assert.equal(htmlToMd('line<br>next&nbsp;word'), 'line\nnext word');
assert.equal(htmlToMd('<span style="x">plain</span>'), 'plain');
});
test('contenteditable output becomes clean markdown', () => {
const blocks = [
{ type: 'p', html: 'Hello <strong>world</strong>' },
{ type: 'p', html: '' },
{ type: 'todo', html: 'Ship it', checked: true },
{ type: 'bullet', html: 'one' },
{ type: 'h2', html: 'Next' }
];
assert.equal(toMarkdown(blocks), 'Hello **world**\n\n- [x] Ship it\n- one\n\n## Next\n');
});
test('empty and unusual input', () => {
assert.equal(parseMarkdown('').length, 1);
assert.equal(toMarkdown(parseMarkdown('')), '');
const b = parseMarkdown('#### deep heading\n* star bullet\n+ plus bullet');
assert.deepEqual(b.map((x) => x.type), ['h3', 'bullet', 'bullet']);
});
test('page links become chips and come back unchanged', () => {
const md = 'See [[Roadmap]], [[Projects/Plan|the plan]] and `[[not a link]]`.';
const html = mdToHtml(md);
assert.match(html, /<span class="wl" contenteditable="false" data-ref="Roadmap">Roadmap<\/span>/);
assert.match(html, /data-ref="Projects\/Plan">the plan<\/span>/);
assert.match(html, /<code>\[\[not a link\]\]<\/code>/);
assert.equal(htmlToMd(html), md);
assert.equal(htmlToMd(mdToHtml('[[A_b_c]] & [[x<y]]')), '[[A_b_c]] & [[x<y]]');
// a chip whose label was left alone keeps the short form
assert.equal(htmlToMd('<span class="wl missing" contenteditable="false" data-ref="Plan">Plan</span>&nbsp;'), '[[Plan]]');
assert.equal(htmlToMd('<span class="wl" contenteditable="false" data-ref="Plan">Plan</span>&nbsp;and more'), '[[Plan]] and more');
});
test('images are their own block and round trip', () => {
const md = '![](pasted-1.png)\n\n![A cat](my%20cat.jpg)\n\n![[photo.webp]]\n\nText with ![inline](x.png) stays text.\n';
const blocks = parseMarkdown(md);
assert.deepEqual(blocks.map((b) => b.type), ['image', 'image', 'image', 'p']);
assert.equal(blocks[1].src, 'my cat.jpg');
assert.equal(blocks[1].alt, 'A cat');
assert.equal(blocks[2].wiki, true);
assert.equal(toMarkdown(blocks), md);
});
test('numbered lists count per run and strikethrough round trips', () => {
const md = '1. one\n2. two\n\nBreak\n\n1. again\n- bullet\n1. new run\n\n~~gone~~ and **kept**\n';
const blocks = parseMarkdown(md);
assert.deepEqual(blocks.map((b) => b.type), ['number', 'number', 'p', 'number', 'bullet', 'number', 'p']);
assert.equal(toMarkdown(blocks), md);
// numbers in the file are rewritten to count from 1
assert.equal(toMarkdown(parseMarkdown('3. a\n7) b\n')), '1. a\n2. b\n');
assert.equal(htmlToMd('<strike>a</strike> <del>b</del> <s>c</s>'), '~~a~~ ~~b~~ ~~c~~');
assert.equal(mdToHtml('~~x~~'), '<s>x</s>');
});
test('web links and bare URLs round trip; unsafe ones stay text', () => {
const md = 'Read [the **docs**](https://example.com/a_b "Guide") or https://ink.app/x_(y). Mail [me](mailto:a@b.c), [spaced](<https://x.org/a b>).';
const html = mdToHtml(md);
assert.match(html, /<a class="xl" href="https:\/\/example\.com\/a_b" spellcheck="false" data-title="Guide">the <b>docs<\/b><\/a>/);
assert.match(html, /href="https:\/\/ink\.app\/x_\(y\)" spellcheck="false" data-auto="1">https:\/\/ink\.app\/x_\(y\)<\/a>\./);
assert.equal(htmlToMd(html), md);
for (const keep of ['[x](javascript:alert(1))', '[rel](notes.md)', 'plain http:// text']) assert.equal(htmlToMd(mdToHtml(keep)), keep);
assert.ok(!mdToHtml('[x](javascript:alert(1))').includes('<a'));
// a link whose text was edited is written as [text](url)
assert.equal(htmlToMd('<a class="xl" href="https://ink.app" data-auto="1">Ink</a>'), '[Ink](https://ink.app)');
});
test('tables keep their source until edited, then line up', () => {
const md = '|a|b|\n|:-|-:|\n|`x|y`|2|\n| p \\| q | [[Plan]] |\n';
const [t] = parseMarkdown(md);
assert.equal(t.type, 'table');
assert.deepEqual(t.align, ['left', 'right']);
assert.equal(t.rows.length, 3);
assert.equal(t.rows[1][0], '<code>x|y</code>');
assert.equal(t.rows[2][0], 'p | q');
assert.equal(toMarkdown([t]), md);
delete t.src;
t.rows[1][1] = '<b>20</b>';
assert.equal(toMarkdown([t]), '| a | b |\n| :----- | -------: |\n| `x\\|y` | **20** |\n| p \\| q | [[Plan]] |\n');
assert.equal(parseMarkdown('a | b\nno delimiter row')[0].type, 'p');
});
test('==highlight== round trips; == and === in normal text are left alone', () => {
for (const md of ['A ==bright== idea', '==**bold** inside== and ==*it*==', 'if a == b then', 'x === y === z', '`==code==` stays']) {
assert.equal(htmlToMd(mdToHtml(md)), md);
}
assert.equal(mdToHtml('A ==bright== idea'), 'A <mark>bright</mark> idea');
assert.ok(!mdToHtml('if a == b == c').includes('<mark>'));
assert.ok(!mdToHtml('`==code==`').includes('<mark>'));
});
test('footnotes: references, inline notes and footnote texts round trip; export numbers them in reading order', () => {
const md = 'A claim[^src] with an aside^[said in passing].\n\nAnother[^2] and again[^src].\n\n[^2]: Second note.\n[^src]: The source,\n on two lines.\n[^unused]: Nobody points here.\n';
const blocks = parseMarkdown(md);
assert.deepEqual(blocks.map((b) => b.type + (b.fid ? ':' + b.fid : '')), ['p', 'p', 'footnote:2', 'footnote:src', 'footnote:unused']);
assert.match(blocks[0].html, /<sup class="fn" contenteditable="false" data-fn="src">/);
assert.match(blocks[0].html, /data-inline="said in passing"/);
assert.equal(blocks[3].html, 'The source,\non two lines.');
assert.equal(toMarkdown(blocks), md);
const html = toHtmlDocument('t', blocks);
assert.deepEqual([...html.matchAll(/<sup class="fn"[^>]*>(?:<a href="#fn-(\w+)">)?(\d+)/g)].map((m) => m[2] + (m[1] ? ':' + m[1] : '')), ['1:src', '2', '3:2', '1:src']);
assert.match(html, /<p class="footnote" id="fn-2"><sup>3<\/sup> Second note\.<\/p>/);
assert.match(html, /<p class="footnote" id="fn-unused"><sup>unused<\/sup>/);
// "[^1]:" mid-line isn't a footnote text, and ids can't hold spaces
assert.deepEqual(parseMarkdown('Text [^1]: not a definition\n\n[^a b]: neither\n').map((b) => b.type), ['p', 'p']);
});
test('columns: fenced :::: columns round-trip, with any blocks in a column', () => {
const src = 'Before\n\n:::: columns\n::: column\nLeft **bold**\n\n- a\n- b\n:::\n::: column\n## Right\n\ntext\n:::\n::::\n\nAfter\n';
const blocks = parseMarkdown(src);
assert.deepEqual(blocks.map((b) => b.type + (b.col ? `[${b.col.i}]` : '')), ['p', 'p[0]', 'bullet[0]', 'bullet[0]', 'h2[1]', 'p[1]', 'p']);
const g = blocks[1].col.g;
assert.ok(blocks.slice(1, 6).every((b) => b.col.g === g), 'one group');
assert.equal(toMarkdown(blocks), src);
// three columns, one of them empty: it keeps its place
const three = ':::: columns\n::: column\na\n:::\n::: column\n:::\n::: column\nc\n:::\n::::\n';
const b3 = parseMarkdown(three);
assert.deepEqual(b3.map((b) => b.col.i), [0, 1, 2]);
// not closed: just text, nothing lost
const open = ':::: columns\n::: column\nlost?\n:::\n';
assert.ok(toMarkdown(parseMarkdown(open)).includes('lost?'));
// columns inside a code block are code
const code = '```\n:::: columns\n::: column\nx\n:::\n::::\n```\n';
assert.deepEqual(parseMarkdown(code).map((b) => b.type), ['code']);
// two groups in a row stay two groups
const two = ':::: columns\n::: column\na\n:::\n::::\n\n:::: columns\n::: column\nb\n:::\n::::\n';
const gs = new Set(parseMarkdown(two).map((b) => b.col.g));
assert.equal(gs.size, 2);
});
+125
View File
@@ -0,0 +1,125 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import { DEFAULTS, cleanPrefs, wikiToMarkdownLinks } from '../src/lib/prefs.js';
import { formatDate, dailyId, dailyBody } from '../src/lib/daily.js';
import { weekOf, monthGrid, weekdayNames } from '../src/lib/database.js';
const require = createRequire(import.meta.url);
test('cleanPrefs fills defaults and drops values of the wrong kind', () => {
assert.deepEqual(cleanPrefs(undefined), DEFAULTS);
assert.deepEqual(cleanPrefs('nope'), DEFAULTS);
const p = cleanPrefs({ density: 'huge', sidebarWidth: 9999, codeSize: '110', autoPair: 'yes', hiddenKinds: ['quote', 4], trashDays: -3, dateFormat: 'DD/MM' });
assert.equal(p.density, 'default');
assert.equal(p.sidebarWidth, 420);
assert.equal(p.codeSize, 110);
assert.equal(p.autoPair, true);
assert.deepEqual(p.hiddenKinds, ['quote']);
assert.equal(p.trashDays, 0);
assert.equal(p.dateFormat, 'YYYY-MM-DD');
assert.equal(cleanPrefs({ historyKeep: '' }).historyKeep, DEFAULTS.historyKeep);
});
test('daily note names follow the chosen format', () => {
const d = new Date(2026, 0, 5);
assert.equal(formatDate(d), '2026-01-05');
assert.equal(formatDate(d, 'DD-MM-YYYY'), '05-01-2026');
assert.equal(formatDate(d, 'MMMM D, YYYY', 'en-US'), 'January 5, 2026');
assert.equal(formatDate(d, 'YYYY-MM-DD dddd', 'en-US'), '2026-01-05 Monday');
assert.equal(dailyId('Daily', d, 'YYYY.MM.DD'), 'Daily/2026.01.05');
assert.equal(dailyId('Daily', d), 'Daily/2026-01-05');
});
test('the daily template fills in the date and weekday', () => {
const d = new Date(2026, 0, 5);
assert.equal(dailyBody('## {{weekday}} {{ date }}', d, 'DD-MM-YYYY', 'en-US'), '## Monday 05-01-2026');
assert.equal(dailyBody('', d), '');
});
test('calendar weeks can start on Sunday', () => {
assert.equal(weekOf('2026-01-07')[0].iso, '2026-01-05'); // Monday
assert.equal(weekOf('2026-01-07', true)[0].iso, '2026-01-04'); // Sunday
assert.equal(weekOf('2026-01-04', true)[0].iso, '2026-01-04');
assert.deepEqual(weekdayNames(true).slice(0, 2), ['Sun', 'Mon']);
const g = monthGrid(2026, 1, true); // February 2026 starts on a Sunday: a Sunday-first grid has no lead days
assert.equal(g[0][0].iso, '2026-02-01');
assert.equal(g.length, 4);
});
test('Markdown export can write links as Markdown links', () => {
const md = 'See [[My Page]] and [[Other|that one]], ![[photo.png]], [[Page#Some Heading]].\n`[[kept]]`\n```\n[[kept too]]\n```';
const out = wikiToMarkdownLinks(md);
assert.match(out, /\[My Page\]\(My%20Page\.md\)/);
assert.match(out, /\[that one\]\(Other\.md\)/);
assert.match(out, /!\[\]\(photo\.png\)/);
assert.match(out, /\(Page\.md#some-heading\)/);
assert.match(out, /`\[\[kept\]\]`/);
assert.match(out, /\n\[\[kept too\]\]\n/);
});
test('a backup is a readable zip without history and trash', async () => {
const { zipFolder, backup, due, crc32 } = require('../electron/backup.cjs');
assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-bk-'));
const out = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-bk-out-'));
fs.mkdirSync(path.join(root, 'A'), { recursive: true });
fs.mkdirSync(path.join(root, '.notes', 'history', 'A'), { recursive: true });
fs.writeFileSync(path.join(root, 'A.md'), '# Hello\n'.repeat(50));
fs.writeFileSync(path.join(root, 'A', 'Sub é.md'), 'x');
fs.writeFileSync(path.join(root, '.notes', 'history', 'A', '1.md'), 'old');
const file = path.join(out, 'x.zip');
assert.equal(await zipFolder(root, file), 2);
// read it back: end record -> central directory -> entries
const buf = fs.readFileSync(file);
const end = buf.length - 22;
assert.equal(buf.readUInt32LE(end), 0x06054b50);
const n = buf.readUInt16LE(end + 10);
let at = buf.readUInt32LE(end + 16);
const found = {};
for (let i = 0; i < n; i++) {
const method = buf.readUInt16LE(at + 10), crc = buf.readUInt32LE(at + 16), csize = buf.readUInt32LE(at + 20), size = buf.readUInt32LE(at + 24);
const nameLen = buf.readUInt16LE(at + 28), off = buf.readUInt32LE(at + 42);
const name = buf.toString('utf8', at + 46, at + 46 + nameLen);
const dataAt = off + 30 + buf.readUInt16LE(off + 26) + buf.readUInt16LE(off + 28);
const raw = buf.subarray(dataAt, dataAt + csize);
const data = method === 8 ? zlib.inflateRawSync(raw) : raw;
assert.equal(data.length, size);
assert.equal(crc32(data), crc);
found[name] = data.toString();
at += 46 + nameLen;
}
assert.deepEqual(Object.keys(found).sort(), ['A.md', 'A/Sub é.md']);
assert.equal(found['A/Sub é.md'], 'x');
// keeping only the newest few
for (const f of ['2020-01-01 0000', '2020-01-02 0000', '2020-01-03 0000']) fs.writeFileSync(path.join(out, `${path.basename(root)} ${f}.zip`), 'z');
await backup(root, out, 2);
assert.equal(fs.readdirSync(out).filter((f) => f.startsWith(path.basename(root))).length, 2);
assert.equal(due('off', 0), false);
assert.equal(due('daily', 0), true);
assert.equal(due('daily', Date.now() - 3600e3), false);
assert.equal(due('weekly', Date.now() - 3 * 864e5), false);
});
test('history limits can be changed', async () => {
const { Vault, setLimits } = require('../electron/vault.cjs');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-hist-'));
const v = new Vault(dir);
setLimits({ historyKeep: 3, historyGapMin: 1 });
try {
for (let i = 0; i < 6; i++) {
await v.write('P', {}, 'v' + i);
await v.snapshot('P', 'text ' + i, { force: true });
}
assert.equal((await v.listHistory('P')).length, 3);
} finally {
setLimits({ historyKeep: 100, historyGapMin: 5 });
}
});
+45
View File
@@ -0,0 +1,45 @@
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-test-rename-'));
const vault = path.join(tmp, 'Vault');
fs.mkdirSync(vault);
fs.writeFileSync(path.join(vault, 'A.md'), '# Target\n');
fs.writeFileSync(path.join(vault, 'B.md'), 'Go to [[A#Target]]\n');
app.setPath('userData', tmp);
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'A', bounds: { width: 1280, height: 860 } }));
require('../electron/main.cjs');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
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} })()`);
await sleep(1000);
// Change heading in A
await js(`
const el = document.querySelector('.b-h1 .c');
el.textContent = 'New Target';
const evt = new Event('input', { bubbles: true });
el.dispatchEvent(evt);
`);
// Trigger blur to save
await js(`
const el = document.querySelector('.b-h1 .c');
el.blur();
`);
await sleep(3000); // Wait for heading renames to commit (2000ms delay)
// Read B
const b = fs.readFileSync(path.join(vault, 'B.md'), 'utf8');
console.log("B content:", b);
app.exit();
});
+52
View File
@@ -0,0 +1,52 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ACTIONS, comboOf, eventCombo, actionFor, format, valid } from '../src/lib/shortcuts.js';
import { cleanPrefs } from '../src/lib/prefs.js';
import { createSimulation } from '../src/lib/graph.js';
const ev = (key, mods = {}, code = '') => ({ key, code, ctrlKey: !!mods.ctrl, metaKey: !!mods.meta, altKey: !!mods.alt, shiftKey: !!mods.shift });
test('every action has a unique valid default', () => {
const seen = new Set();
for (const a of ACTIONS) {
assert.ok(valid(a.combo), a.id);
assert.ok(!seen.has(a.combo), 'duplicate ' + a.combo);
seen.add(a.combo);
}
});
test('key events become combos; Ctrl and ⌘ are the same Mod', () => {
assert.equal(eventCombo(ev('k', { ctrl: true })), 'Mod+K');
assert.equal(eventCombo(ev('K', { meta: true, shift: true })), 'Mod+Shift+K');
assert.equal(eventCombo(ev('|', { ctrl: true, shift: true }, 'Backslash')), 'Mod+Shift+\\');
assert.equal(eventCombo(ev('k')), '');
assert.equal(eventCombo(ev('Control', { ctrl: true })), '');
});
test('a changed shortcut replaces the default, and only that', () => {
const custom = { tasks: 'Mod+Shift+Y' };
assert.equal(actionFor(ev('y', { ctrl: true, shift: true }), custom), 'tasks');
assert.equal(actionFor(ev('t', { ctrl: true, shift: true }), custom), null);
assert.equal(actionFor(ev('k', { ctrl: true }), custom), 'palette');
assert.equal(comboOf('tasks', custom), 'Mod+Shift+Y');
});
test('combos are shown for the platform', () => {
assert.equal(format('Mod+Shift+D', false), 'Ctrl ⇧ D');
assert.equal(format('Mod+Shift+D', true), '⌘ ⇧ D');
});
test('saved shortcuts are cleaned', () => {
assert.deepEqual(cleanPrefs({ shortcuts: { tasks: 'Mod+Shift+Y', bad: 'K', worse: 5 } }).shortcuts, { tasks: 'Mod+Shift+Y' });
assert.deepEqual(cleanPrefs({ shortcuts: [] }).shortcuts, {});
});
test('tickFor stops at its time budget, but always ticks once', () => {
const nodes = Array.from({ length: 4000 }, (_, i) => ({ id: 'n' + i }));
const sim = createSimulation().setData(nodes, []);
const a0 = sim.alpha;
const t = performance.now();
sim.tickFor(10000, 30);
assert.ok(performance.now() - t < 500);
assert.ok(sim.alpha < a0);
});
+48
View File
@@ -0,0 +1,48 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { findTasks, setTask } = require('../electron/tasks.cjs');
const links = require('../electron/links.cjs');
const page = (id, body, extra = {}) => ({ id, title: id.split('/').pop(), exists: true, parent: null, tags: [], props: {}, body, ...extra });
test('findTasks lists checklist items with due dates and tags, skipping code, boards and canvases', () => {
const pages = [
page('Plan', '# Plan\n- [ ] Write spec #work 📅 2026-03-01\n - [x] Draft\n```\n- [ ] not a task\n```\n- plain\n', { tags: ['proj'] }),
page('Board', '## Todo\n- [ ] card', { props: { 'kanban-plugin': 'basic' } }),
page('Map.canvas', '- [ ] x', { kind: 'canvas' })
];
const t = findTasks(pages);
assert.equal(t.length, 2);
assert.deepEqual({ ...t[0], tags: [...t[0].tags].sort() }, { id: 'Plan', title: 'Plan', icon: null, line: 1, done: false, text: 'Write spec #work 📅 2026-03-01', due: '2026-03-01', tags: ['proj', 'work'], depth: 0 });
assert.equal(t[1].done, true);
assert.equal(t[1].depth, 1);
});
test('setTask ticks the line, and refuses when the line changed', () => {
const body = 'a\n- [ ] one\n- [x] two\n';
assert.equal(setTask(body, 1, 'one', true), 'a\n- [x] one\n- [x] two\n');
assert.equal(setTask(body, 2, 'two', false), 'a\n- [ ] one\n- [ ] two\n');
assert.equal(setTask(body, 1, 'other', true), null);
assert.equal(setTask(body, 0, 'a', true), null);
});
test('unlinked finds plain mentions and ignores links, code and URLs', () => {
const pages = [
page('Roadmap', 'Plan the Roadmap'),
page('A', 'We should read the roadmap soon.'),
page('B', 'Already [[Roadmap]] linked, roadmap again.'),
page('C', '`roadmap` and https://x.test/roadmap and [roadmap](y)'),
page('D', 'roadmaps are not it')
];
assert.deepEqual(links.unlinked('Roadmap', pages).map((p) => p.id), ['A']);
});
test('linkFirstMention links only the first mention and keeps what was written', () => {
const pages = [page('Roadmap', ''), page('A', '')];
assert.equal(links.linkFirstMention('see the roadmap, the Roadmap', 'Roadmap', pages), 'see the [[Roadmap|roadmap]], the Roadmap');
assert.equal(links.linkFirstMention('Roadmap here', 'Roadmap', pages), '[[Roadmap]] here');
assert.equal(links.linkFirstMention('nothing', 'Roadmap', pages), null);
});
+206
View File
@@ -256,3 +256,209 @@ test('version history: throttled copies, restore keeps the current text, follows
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
test('version history works for canvases: the file text comes back exactly, and it follows a rename', async () => {
const root = await tmp();
try {
const v = new Vault(root);
const id = await v.createCanvas(null, 'Map');
const first = '{\n\t"nodes":[\n\t\t{"id":"aaaaaaaaaaaaaaaa","type":"text","text":"---\\nnot front matter","x":0,"y":0,"width":250,"height":60}\n\t],\n\t"edges":[]\n}';
// making the canvas kept its empty state as the first version; age that so the next save makes a new one
const dir = path.join(root, '.notes', 'history', id);
const [t0] = await v.listHistory(id);
await writeFile(path.join(dir, `${t0 - 10 * 60 * 1000}.md`), await readFile(path.join(dir, `${t0}.md`), 'utf8'));
await rm(path.join(dir, `${t0}.md`));
await v.write(id, {}, first);
const [ts] = await v.listHistory(id);
assert.equal((await v.readHistory(id, ts)).body, first, 'the file text comes back as saved, not read as front matter');
const second = '{\n\t"nodes":[],\n\t"edges":[]\n}';
await v.write(id, {}, second.replace('[]', '[ ]')); // within the gap: no version of its own
await v.restoreHistory(id, ts);
assert.equal(await readFile(v.abs(id), 'utf8'), first, 'written back byte for byte, without front matter added');
const kept = await v.listHistory(id);
assert.ok((await Promise.all(kept.map((t) => v.readHistory(id, t)))).some((x) => x.body === second.replace('[]', '[ ]')), 'the canvas before the restore was kept');
const next = await v.rename(id, 'Roadmap');
assert.equal(next, 'Roadmap.canvas');
assert.deepEqual(await v.listHistory(next), kept);
assert.deepEqual(await v.listHistory(id), []);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test('duplicate: a page (with its sub-pages), a board and a canvas, next to the original', async () => {
const root = await tmp();
try {
const v = new Vault(root);
const id = await v.create(null, 'Plan', { icon: 'star' }, 'Hello\n');
const child = await v.create(id, 'Child', {}, 'inside\n');
const pic = await v.saveImage(id, 'pic.png', Buffer.from([1, 2, 3]));
const copy = await v.duplicate(id);
assert.equal(copy, 'Plan copy');
assert.deepEqual(await readFile(v.abs(copy), 'utf8'), await readFile(v.abs(id), 'utf8'), 'the file is copied as it is');
assert.equal((await v.read(`${copy}/Child`)).body, 'inside\n', 'sub-pages come along');
assert.ok(v.imagePath(copy, pic) && (await readFile(v.imagePath(copy, pic))).length === 3, 'pictures (kept beside the page) are still found by the copy');
// the copy is its own page
await v.write(copy, {}, 'changed\n');
assert.equal((await v.read(id)).body, 'Hello\n');
assert.equal((await v.read(child)).body, 'inside\n');
// again: a free name
assert.equal(await v.duplicate(id), 'Plan copy 2');
// the copy of a copy
assert.equal(await v.duplicate(copy), 'Plan copy copy');
// inside a parent: stays there
assert.equal(await v.duplicate(child), `${id}/Child copy`);
// a canvas keeps its text exactly
const cv = await v.createCanvas(null, 'Map');
const text = '{\n\t"nodes":[\n\t\t{"id":"aaaaaaaaaaaaaaaa","type":"text","text":"hi","x":0,"y":0,"width":250,"height":60}\n\t],\n\t"edges":[]\n}';
await v.write(cv, {}, text);
const cvCopy = await v.duplicate(cv);
assert.equal(cvCopy, 'Map copy.canvas');
assert.equal(await readFile(v.abs(cvCopy), 'utf8'), text);
assert.equal((await v.listHistory(cvCopy)).length, 1, 'the copy starts its own history');
// nothing to copy
await assert.rejects(v.duplicate('Nope'), /no file to copy/);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test('a database record keeps its property values in front matter, and the list reports them', async () => {
const root = await tmp();
const v = new Vault(root);
const db = await v.create(null, 'Tasks', { 'ink-database': 'basic' }, '%% ink-database\n{}\n%%\n');
const rec = await v.create(db, 'Write', { status: 'Doing', due: '2026-03-01', owner: ['Ana', 'Ben'], done: 'true', 'due-date': '2026-04-02' });
const pages = await v.list();
const d = pages.find((p) => p.id === db), r = pages.find((p) => p.id === rec);
assert.equal(d.db, true);
assert.equal(r.db, undefined);
assert.deepEqual(r.props, { status: 'Doing', due: '2026-03-01', owner: ['Ana', 'Ben'], done: 'true', 'due-date': '2026-04-02' });
assert.equal(r.parent, 'Tasks');
assert.equal(r.tags.length, 0); // page keys are not properties
// a value change keeps every other key and the body
const before = await v.read(rec);
await v.write(rec, { ...before.meta, status: 'Done' }, 'Notes\n');
const after = (await v.list()).find((p) => p.id === rec);
assert.equal(after.props.status, 'Done');
assert.deepEqual(after.props.owner, ['Ana', 'Ben']);
assert.equal((await v.read(rec)).body, 'Notes\n');
await rm(root, { recursive: true, force: true });
});
test('property values are found as words and by key:value, with the same operators as tag: and in:', async () => {
const dir = await tmp();
const db = openDb(path.join(dir, 'i.sqlite'));
db.upsert({ id: 'T/Write docs', title: 'Write docs', tags: ['work'], body: 'Some text', props: { status: 'Doing', points: '5', owner: ['Ana', 'Ben'], due: '2026-03-01' } });
db.upsert({ id: 'T/Ship it', title: 'Ship it', tags: [], body: 'Release', props: { status: 'In progress', points: '12', owner: ['Ben'] } });
db.upsert({ id: 'T/Plan', title: 'Plan', tags: [], body: 'Roadmap', props: {} });
const ids = (q) => db.search(q).map((r) => r.id).sort();
assert.deepEqual(ids('Ana'), ['T/Write docs']); // a value, as a plain word
assert.deepEqual(ids('status:doing'), ['T/Write docs']);
assert.deepEqual(ids('status:do'), ['T/Write docs']); // like tag:, a prefix matches
assert.deepEqual(ids('status:"In progress"'), ['T/Ship it']);
assert.deepEqual(ids('owner:ben'), ['T/Ship it', 'T/Write docs']);
assert.deepEqual(ids('owner:!ana'), ['T/Ship it']);
assert.deepEqual(ids('points:>5'), ['T/Ship it']);
assert.deepEqual(ids('points:<=5'), ['T/Write docs']);
assert.deepEqual(ids('has:due'), ['T/Write docs']);
assert.deepEqual(ids('owner:ben release'), ['T/Ship it']); // words narrow it too
assert.deepEqual(ids('tag:work status:doing'), ['T/Write docs']);
assert.deepEqual(ids('status:done'), []);
assert.deepEqual(ids('nokey:value'), []); // not a property: searched as words, which match nothing
assert.match(db.search('status:doing')[0].snip, /status: doing/);
// a changed value replaces the old one; a removed page leaves no values behind
db.upsert({ id: 'T/Write docs', title: 'Write docs', tags: [], body: '', props: { status: 'Done' } });
assert.deepEqual(ids('status:doing'), []);
assert.deepEqual(ids('status:done'), ['T/Write docs']);
db.remove('T');
assert.deepEqual(ids('status:done'), []);
db.close();
await rm(dir, { recursive: true, force: true });
});
test('an index made before properties were indexed is started again', async () => {
const dir = await tmp();
const file = path.join(dir, 'old.sqlite');
const { DatabaseSync } = require('node:sqlite');
const old = new DatabaseSync(file);
old.exec("CREATE VIRTUAL TABLE notes USING fts5(id UNINDEXED, title, tags, body, tokenize = 'unicode61 remove_diacritics 2')");
old.exec("INSERT INTO notes (id, title, tags, body) VALUES ('A', 'Old', '', 'old words')");
old.close();
const db = openDb(file);
assert.deepEqual(db.search('old'), []); // emptied; the app rebuilds it on launch
db.rebuild([{ id: 'A', title: 'Old', tags: [], body: 'old words', exists: true, props: { status: 'Done' } }]);
assert.deepEqual(db.search('status:done').map((r) => r.id), ['A']);
db.close();
await rm(dir, { recursive: true, force: true });
});
test('links in property values and relation properties are links', () => {
const links = require('../electron/links.cjs');
const schema = '%% ink-database\n{"properties":[{"key":"after","name":"After","type":"relation"},{"key":"ref","name":"Ref","type":"text"}],"views":[]}\n%%\n';
const pages = [
{ id: 'Tasks', title: 'Tasks', exists: true, parent: null, body: schema, props: {} },
{ id: 'Tasks/A', title: 'A', exists: true, parent: 'Tasks', body: '', props: { after: ['B'], ref: 'see [[Spec]]' } },
{ id: 'Tasks/B', title: 'B', exists: true, parent: 'Tasks', body: '', props: {} },
{ id: 'Spec', title: 'Spec', exists: true, parent: null, body: '', props: {} }
];
const g = links.graph(pages);
assert.ok(g.edges.some(([a, b]) => a === 'Tasks/A' && b === 'Spec'), 'link in a property value');
assert.ok(g.edges.some(([a, b]) => a === 'Tasks/A' && b === 'Tasks/B'), 'relation');
assert.deepEqual(links.backlinks('Spec', pages).map((b) => b.id), ['Tasks/A']);
assert.deepEqual(links.backlinks('Tasks/B', pages).map((b) => [b.id, b.snip]), [['Tasks/A', 'After']]);
const after = pages.map((p) => (p.id === 'Spec' ? { ...p, id: 'Specs', title: 'Specs' } : p));
const patch = links.rewriteProps(pages[1].props, pages, after, (id) => (id === 'Spec' ? 'Specs' : null));
assert.deepEqual(patch, { ref: 'see [[Specs]]' });
assert.equal(links.rewriteProps({ after: ['B'] }, pages, after, () => null), null);
});
test('the watcher reports changes made outside, also after the app has saved and moved things', async () => {
const root = await tmp();
const v = new Vault(root);
await v.create(null, 'A', {}, 'a\n');
await mkdir(path.join(root, 'Sub'));
await writeFile(path.join(root, 'Sub', 'Inner.md'), 'inner\n');
const seen = [];
const stop = v.watch((ids) => seen.push(...ids));
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const expectId = async (id, label) => {
for (let i = 0; i < 30 && !seen.includes(id); i++) await sleep(100);
assert.ok(seen.includes(id), `${label}: expected ${id}, saw ${JSON.stringify(seen)}`);
seen.length = 0;
};
await sleep(300);
await writeFile(path.join(root, 'Outside.md'), 'x');
await expectId('Outside', 'a new file');
await writeFile(path.join(root, 'Sub', 'Inner.md'), 'changed\n');
await expectId('Sub/Inner', 'a file in a folder');
// the app saves (which makes .notes/history): outside changes must still come through
await sleep(1700); // past the "just written by us" window
await v.write('A', {}, 'mine\n');
await sleep(1700);
seen.length = 0;
await writeFile(path.join(root, 'Outside.md'), 'again');
await expectId('Outside', 'after the app saved');
// a folder made later is watched too
await mkdir(path.join(root, 'Later'));
await sleep(500);
await writeFile(path.join(root, 'Later', 'New.md'), 'n');
await expectId('Later/New', 'a folder created after the watch began');
// a renamed folder reports under its new name
await rm(path.join(root, 'Sub', 'Inner.md'));
await sleep(300);
seen.length = 0;
await (await import('node:fs/promises')).rename(path.join(root, 'Later'), path.join(root, 'Moved'));
await sleep(900);
seen.length = 0;
await writeFile(path.join(root, 'Moved', 'New.md'), 'edited');
await expectId('Moved/New', 'a renamed folder');
// own writes and dot folders stay quiet
seen.length = 0;
await sleep(1700);
await v.write('A', {}, 'again\n');
await sleep(900);
assert.deepEqual(seen, [], 'our own write is not reported');
stop();
await rm(root, { recursive: true, force: true });
});