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