// 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); });