Files
ink-note-app/test/e2e-settings.test.cjs
T
2026-10-15 11:57:09 +02:00

205 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});