205 lines
13 KiB
JavaScript
205 lines
13 KiB
JavaScript
// 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);
|
||
});
|