test: unit and end-to-end suites
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
27fafa0bcd
commit
9b00e21c44
49 files changed
+6694
-1
No files matched your search
@@ -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(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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'] });
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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' });
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 & 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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|

|
||||||
|
`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// End-to-end check of the / block menu: what's highlighted is what Enter makes. Run with `bun run test:e2e`.
|
||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ink-e2e-slash-'));
|
||||||
|
const vault = path.join(tmp, 'Vault');
|
||||||
|
fs.mkdirSync(vault);
|
||||||
|
fs.writeFileSync(path.join(vault, 'Page.md'), 'Hello.\n');
|
||||||
|
app.setPath('userData', tmp);
|
||||||
|
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Page', bounds: { width: 1280, height: 860 } }));
|
||||||
|
require('../electron/main.cjs');
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const failures = [];
|
||||||
|
const check = (name, ok, extra = '') => {
|
||||||
|
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
|
||||||
|
if (!ok) failures.push(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
let win;
|
||||||
|
for (let i = 0; i < 80 && !(win = BrowserWindow.getAllWindows()[0]); i++) await sleep(100);
|
||||||
|
const wc = win.webContents;
|
||||||
|
if (wc.isLoading()) await new Promise((r) => wc.once('did-finish-load', r));
|
||||||
|
const js = (code) => wc.executeJavaScript(`(async () => { ${code} })()`);
|
||||||
|
const key = async (keyCode) => { wc.sendInputEvent({ type: 'keyDown', keyCode }); wc.sendInputEvent({ type: 'keyUp', keyCode }); await sleep(250); };
|
||||||
|
const type = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'char', keyCode: ch }); await sleep(60); } await sleep(200); };
|
||||||
|
const highlighted = () => js(`const s = document.querySelector('.pop.slash'); return s.hidden ? null : s.querySelector('.si.on b')?.textContent`);
|
||||||
|
const lastBlock = () => js(`return document.querySelector('.blocks > .block:last-of-type').className.match(/b-(\\w+)/)[1]`);
|
||||||
|
const newLine = async () => { await js(`document.querySelector('.tail').click()`); await sleep(300); };
|
||||||
|
const rowCenter = (label) => js(`const r = [...document.querySelectorAll('.pop.slash .si')].find(x => x.querySelector('b').textContent === ${JSON.stringify(label)}).getBoundingClientRect(); return [Math.round(r.x + r.width / 2), Math.round(r.y + r.height / 2)]`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sleep(1800);
|
||||||
|
await js(`window.__pt = null; document.addEventListener('mousemove', (e) => { if (e.isTrusted) window.__pt = [e.clientX, e.clientY]; }, true)`);
|
||||||
|
|
||||||
|
await newLine();
|
||||||
|
await type('/c');
|
||||||
|
check('"/c" highlights the best match', (await highlighted()) === 'Callout', await highlighted());
|
||||||
|
await key('Return');
|
||||||
|
check('...and Enter makes it', (await lastBlock()) === 'callout', await lastBlock());
|
||||||
|
|
||||||
|
await newLine();
|
||||||
|
await type('/');
|
||||||
|
const top = await highlighted(); // the kinds picked last come first in the list
|
||||||
|
await key('Down'); await key('Down'); await key('Down');
|
||||||
|
const moved = await highlighted();
|
||||||
|
await type('c');
|
||||||
|
check('a new query starts at its best match again', moved !== top && (await highlighted()) === 'Callout', `${top} -> ${moved} -> ${await highlighted()}`);
|
||||||
|
await key('Escape');
|
||||||
|
|
||||||
|
// a still mouse: the menu opens under it; a "move" at the same spot must not change the pick
|
||||||
|
await newLine();
|
||||||
|
wc.sendInputEvent({ type: 'mouseMove', x: 900, y: 700 });
|
||||||
|
await sleep(200);
|
||||||
|
await type('/');
|
||||||
|
const first = await highlighted();
|
||||||
|
const row = await rowCenter('Heading 1');
|
||||||
|
await js(`const pt = window.__pt; const row = [...document.querySelectorAll('.pop.slash .si')].find(x => x.querySelector('b').textContent === 'Heading 1'); row.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: pt[0], clientY: pt[1] }))`);
|
||||||
|
await sleep(150);
|
||||||
|
check('a "move" without moving does not change the pick', (await highlighted()) === first, await highlighted());
|
||||||
|
wc.sendInputEvent({ type: 'mouseMove', x: row[0], y: row[1] });
|
||||||
|
await sleep(200);
|
||||||
|
check('a real hover still picks the row', (await highlighted()) === 'Heading 1', await highlighted());
|
||||||
|
await key('Escape');
|
||||||
|
|
||||||
|
await newLine();
|
||||||
|
await type('/callout');
|
||||||
|
await key('Return');
|
||||||
|
check('"/callout" + Enter makes a callout', (await lastBlock()) === 'callout' && !(await js(`return !!document.querySelector('.blocks sup.fn')`)), await lastBlock());
|
||||||
|
await newLine();
|
||||||
|
await type('/footnote');
|
||||||
|
await key('Return');
|
||||||
|
await sleep(300);
|
||||||
|
check('"/footnote" + Enter makes a footnote', !!(await js(`return document.querySelector('.blocks sup.fn') && document.querySelector('.blocks .b-footnote')`)));
|
||||||
|
|
||||||
|
// ---- typing pairs ----
|
||||||
|
const typeKeys = async (t) => { for (const ch of t) { wc.sendInputEvent({ type: 'keyDown', keyCode: ch }); wc.sendInputEvent({ type: 'char', keyCode: ch }); wc.sendInputEvent({ type: 'keyUp', keyCode: ch }); await sleep(70); } await sleep(150); };
|
||||||
|
// (footnotes stay at the bottom, so "the line being typed in" is the focused one, not the last one)
|
||||||
|
const line = () => js(`return document.activeElement.closest('.c')?.textContent ?? null`);
|
||||||
|
const caret = () => js(`const c = document.activeElement.closest('.c'); const s = getSelection(); const r = document.createRange(); r.selectNodeContents(c); r.setEnd(s.focusNode, s.focusOffset); return r.toString().length`);
|
||||||
|
const fresh = async () => { await newLine(); await sleep(100); };
|
||||||
|
await fresh();
|
||||||
|
await typeKeys('**');
|
||||||
|
check('typing ** gives **** with the caret in the middle', (await line()) === '****' && (await caret()) === 2, `${await line()} @${await caret()}`);
|
||||||
|
await typeKeys('bold');
|
||||||
|
await typeKeys('**');
|
||||||
|
check('...and typing the closing ** steps over them (the source stays shown while the caret is at it)', (await line()) === '**bold**' && (await caret()) === 8, `${JSON.stringify(await line())} @${await caret()}`);
|
||||||
|
await fresh();
|
||||||
|
await typeKeys('(');
|
||||||
|
check('typing ( gives () with the caret inside', (await line()) === '()' && (await caret()) === 1);
|
||||||
|
await typeKeys('hi)');
|
||||||
|
check('...and the ) steps over the closing one', (await line()) === '(hi)' && (await caret()) === 4, `${await line()} @${await caret()}`);
|
||||||
|
await fresh();
|
||||||
|
await typeKeys('(');
|
||||||
|
await key('Backspace');
|
||||||
|
check('Backspace in an empty pair removes both', (await line()) === '');
|
||||||
|
await typeKeys('**');
|
||||||
|
await key('Backspace');
|
||||||
|
check('...also for ****', (await line()) === '');
|
||||||
|
await typeKeys('word');
|
||||||
|
await key('Home');
|
||||||
|
await js(`const c = document.activeElement.closest('.c'); const r = document.createRange(); r.selectNodeContents(c); getSelection().removeAllRanges(); getSelection().addRange(r)`);
|
||||||
|
await typeKeys('(');
|
||||||
|
check('typing ( over a selection wraps it', (await line()) === '(word)', await line());
|
||||||
|
await fresh();
|
||||||
|
await typeKeys('2*3');
|
||||||
|
check('a single * is left alone (2*3)', (await line()) === '2*3', await line());
|
||||||
|
await fresh();
|
||||||
|
await fresh();
|
||||||
|
await typeKeys("don't");
|
||||||
|
check('an apostrophe is just an apostrophe', (await line()) === "don't");
|
||||||
|
await fresh();
|
||||||
|
await typeKeys('[[');
|
||||||
|
await sleep(300);
|
||||||
|
check('[[ pairs and opens the page picker', (await line()) === '[[]]' && !!(await js(`const s = document.querySelector('.pop.slash'); return s && !s.hidden`)));
|
||||||
|
await key('Return');
|
||||||
|
await sleep(700);
|
||||||
|
check('...and choosing a page leaves a link with no stray ]]', await js(`const c = document.activeElement.closest('.c') || document.querySelector('.blocks .wl')?.closest('.c'); return !!c && !!c.querySelector('.wl') && !c.textContent.includes(']]') && !c.textContent.includes('[[')`), await line());
|
||||||
|
await fresh();
|
||||||
|
await typeKeys('```');
|
||||||
|
await sleep(200);
|
||||||
|
check('``` still makes a code block (no stray backticks)', await js(`const b = document.activeElement.closest('.block'); return b.classList.contains('b-code') && b.querySelector('.c').textContent === ''`));
|
||||||
|
await typeKeys('f{');
|
||||||
|
check('in a code block, { pairs too', (await js(`return document.activeElement.closest('.c').textContent`)) === 'f{}');
|
||||||
|
|
||||||
|
// ---- live preview: formatting shows its marks while the caret is at it ----
|
||||||
|
const html = () => js(`return document.activeElement.closest('.c').innerHTML.replace(/ /g, ' ')`);
|
||||||
|
const shown = () => js(`const sp = document.activeElement.closest('.c').querySelector('.md-src'); return sp ? { fmt: sp.dataset.fmt, text: sp.textContent, weight: getComputedStyle(sp.childNodes[1] ? sp : sp).fontWeight } : null`);
|
||||||
|
await fresh();
|
||||||
|
await typeKeys('**');
|
||||||
|
await typeKeys('h');
|
||||||
|
let sh = await shown();
|
||||||
|
check('typing inside ** ** shows **h** styled bold right away', sh && sh.fmt === 'b' && sh.text === '**h**' && +sh.weight >= 600 && (await caret()) === 3, JSON.stringify(sh));
|
||||||
|
check('...with the marks dimmed', await js(`const m = document.activeElement.closest('.c').querySelector('.md-mk'); return !!m && getComputedStyle(m).color !== getComputedStyle(m.parentNode).color`));
|
||||||
|
await typeKeys('ello**');
|
||||||
|
check('...the closing ** steps over the marks', (await line()) === '**hello**' && (await caret()) === 9, `${await line()} @${await caret()}`);
|
||||||
|
await typeKeys('x');
|
||||||
|
check('typing on after it is plain, and the marks hide', (await html()) === '<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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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\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, []);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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]]');
|
||||||
|
});
|
||||||
@@ -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})`);
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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 .\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(''), 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 });
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 & Title"><meta name="description" content="Desc"><meta property="og:description" content="OG "desc""><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é 😀 &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é');
|
||||||
|
});
|
||||||
@@ -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><d></code>');
|
||||||
|
assert.equal(htmlToMd('a <b>b</b> <i>c</i> <code><d></code>'), 'a **b** *c* `<d>`');
|
||||||
|
assert.equal(mdToHtml('5 < 6 & 7'), '5 < 6 & 7');
|
||||||
|
assert.equal(htmlToMd('line<br>next 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> '), '[[Plan]]');
|
||||||
|
assert.equal(htmlToMd('<span class="wl" contenteditable="false" data-ref="Plan">Plan</span> and more'), '[[Plan]] and more');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('images are their own block and round trip', () => {
|
||||||
|
const md = '\n\n\n\n![[photo.webp]]\n\nText with  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);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
Reference in new issue
Block a user