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,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);
|
||||
});
|
||||
Reference in new issue
Block a user