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

198 lines
14 KiB
JavaScript

// End-to-end check of databases inside pages (embeds), CSV import and export, searching by property, links and
// relations in the graph, and what happens to a record's properties when it leaves its database.
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-dbx-'));
const vault = path.join(tmp, 'Vault');
fs.mkdirSync(path.join(vault, 'Tasks'), { recursive: true });
const schema = {
properties: [
{ key: 'status', name: 'Status', type: 'select', options: ['Todo', 'Doing', 'Done'] },
{ key: 'points', name: 'Points', type: 'number' },
{ key: 'done', name: 'Done', type: 'checkbox' },
{ key: 'owner', name: 'Owner', type: 'people' },
{ key: 'after', name: 'After', type: 'relation' },
{ key: 'spec', name: 'Spec', type: 'text' }
],
views: [
{ id: 'v1', name: 'Table', type: 'table', filters: [], hidden: [], sort: null },
{ id: 'v2', name: 'Board', type: 'board', groupBy: 'status', filters: [], hidden: [], sort: null }
]
};
const tasksMd = `---\nink-database: basic\nicon: table\n---\n\n%% ink-database\n${JSON.stringify(schema, null, 2)}\n%%\n`;
fs.writeFileSync(path.join(vault, 'Tasks.md'), tasksMd);
fs.writeFileSync(path.join(vault, 'Tasks', 'Write docs.md'), '---\nstatus: Doing\npoints: 5\nowner: [Ana, Ben]\nspec: see [[Spec]]\n---\n\nNotes.\n');
fs.writeFileSync(path.join(vault, 'Tasks', 'Ship it.md'), '---\nstatus: Todo\npoints: 8\ndone: false\nafter: [Write docs]\n---\n');
fs.writeFileSync(path.join(vault, 'Tasks', 'Plan.md'), '---\nstatus: Done\npoints: 2\n---\n');
fs.writeFileSync(path.join(vault, 'Spec.md'), 'The spec.\n');
fs.writeFileSync(path.join(vault, 'Hub.md'), 'Before.\n\n![[Tasks#Board|filter=status:is:Doing]]\n\nAfter.\n');
app.setPath('userData', tmp);
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Hub', bounds: { width: 1400, 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 key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(350); };
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 openPage = (id) => js(`document.querySelector('.row[data-id="${id}"]').click()`).then(() => sleep(900));
const embedTab = (name) => click(`[...document.querySelectorAll('.emb.live .db-tab')].find(b => b.textContent.trim() === '${name}')`);
const search = (q) => js(`return (await window.vault.search(${JSON.stringify(q)})).map(r => r.id).sort()`);
const runCommand = async (text) => {
await key('k', ['control']);
await sleep(300);
await js(`const i = document.querySelector('.overlay input'); i.value = ${JSON.stringify(text)}; i.dispatchEvent(new Event('input', { bubbles: true }))`);
await sleep(300);
await key('Return');
await sleep(900);
};
try {
await sleep(1800);
// ---- a database inside a page ----
check('![[Tasks#Board|…]] shows the database live inside the page', await js(`return !!document.querySelector('.emb.live .db')`));
check('...with its saved views as tabs, and the one named after # active', (await js(`return [...document.querySelectorAll('.emb.live .db-tab:not(.add)')].map(b => b.textContent.trim() + (b.classList.contains('on') ? '*' : '')).join()`)) === 'Table,Board*');
check('...showing only what the embed\'s own filter lets through', (await js(`return [...document.querySelectorAll('.emb.live .db-card-title')].map(c => c.textContent.trim()).join()`)) === 'Write docs');
check('...and no controls that would change the database itself', await js(`return !document.querySelector('.emb.live .db-tab.add') && !document.querySelector('.emb.live .db-resize')`));
await embedTab('Table');
await sleep(900);
check('picking another tab in the embed is saved into the page', /!\[\[Tasks#Board\|[^\]]*view=Table[^\]]*filter=status:is:Doing/.test(fm('Hub.md')), JSON.stringify(fm('Hub.md')));
check('...and the embed keeps its filter on the other view', (await js(`return document.querySelectorAll('.emb.live .dbt tbody tr[data-rec]').length`)) === 1);
await js(`const i = document.querySelector('.emb.live .dbt tbody tr[data-rec] input[type=number]'); i.value = '9'; i.dispatchEvent(new Event('change', { bubbles: true }))`);
await sleep(900);
check('editing a value in the embed writes the record', /points: 9/.test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md'));
check('the database page itself was left alone', fm('Tasks.md') === tasksMd);
await click(`[...document.querySelectorAll('.emb.live .dbt th .db-th')].find(b => b.textContent.includes('Points'))`);
await sleep(900);
check('clicking a column in the embed sorts it, for this embed only', /sort=points:asc/.test(fm('Hub.md')) && fm('Tasks.md') === tasksMd, fm('Hub.md'));
await click(`document.querySelector('.emb.live .emb-title')`);
await sleep(900);
check('the embed\'s title opens the database', (await js(`return document.querySelector('.title')?.textContent`)) === 'Tasks');
// ---- search by property ----
check('a property value is found as a plain word', JSON.stringify(await search('Ana')) === '["Tasks/Write docs"]', JSON.stringify(await search('Ana')));
check('status:Doing finds the records with that value', JSON.stringify(await search('status:doing')) === '["Tasks/Write docs"]');
check('points:>=5 compares numbers', JSON.stringify(await search('points:>=5')) === '["Tasks/Ship it","Tasks/Write docs"]');
check('owner:!ana lists the records without that value', JSON.stringify(await search('owner:!ana')) === '["Tasks/Plan","Tasks/Ship it"]', JSON.stringify(await search('owner:!ana')));
check('a changed value is searchable at once', await (async () => {
await js(`await window.vault.write('Tasks/Plan', { status: 'Doing', points: '2' }, '')`);
return JSON.stringify(await search('status:doing')) === '["Tasks/Plan","Tasks/Write docs"]';
})());
await js(`await window.vault.write('Tasks/Plan', { status: 'Done', points: '2' }, '')`);
await key('k', ['control']);
await sleep(300);
await js(`const i = document.querySelector('.overlay input'); i.value = 'status:todo'; i.dispatchEvent(new Event('input', { bubbles: true }))`);
await sleep(500);
check('the search palette lists them, with the values that matched', (await js(`return document.querySelector('.overlay')?.textContent`)).includes('Ship it'));
await key('Escape');
// ---- graph and backlinks ----
const g = await js(`return await window.vault.graph()`);
check('a [[link]] in a property value is an edge in the graph', g.edges.some(([a, b]) => a === 'Tasks/Write docs' && b === 'Spec'));
check('a relation property is an edge too', g.edges.some(([a, b]) => a === 'Tasks/Ship it' && b === 'Tasks/Write docs'));
const back = await js(`return (await window.vault.backlinks('Spec')).map(b => b.id)`);
check('and the page it names lists the record under backlinks', back.includes('Tasks/Write docs'), JSON.stringify(back));
// ---- CSV export ----
await openPage('Tasks');
await runCommand('Export this database as CSV');
const csv = fs.existsSync(exported) ? fs.readFileSync(exported, 'utf8') : '';
check('Export writes a Name column and every property', csv.split('\r\n')[0] === 'Name,Status,Points,Done,Owner,After,Spec', JSON.stringify(csv.split('\r\n')[0]));
check('...with a row per record', csv.includes('Write docs,Doing,9,false,"Ana, Ben",,see [[Spec]]') && csv.includes('Ship it,Todo,8,false,,Write docs,'), JSON.stringify(csv));
// ---- CSV import ----
const people = 'Name,Team,Joined,Salary,Remote,Skills\r\nAda,Core,2024-01-15,"$5,200",yes,"go, sql"\r\nGrace,Core,2024-03-01,"$6,100",no,sql\r\nLin,Docs,3/9/2025,"$4,800",yes,"writing, sql"\r\nMo,Docs,,,no,\r\n';
await js(`
const orig = HTMLInputElement.prototype.click;
HTMLInputElement.prototype.click = function () {
if (this.type !== 'file') return orig.call(this);
const dt = new DataTransfer();
dt.items.add(new File([window.__csv], 'People.csv', { type: 'text/csv' }));
this.files = dt.files;
this.onchange && this.onchange();
};
`);
await js(`window.__csv = ${JSON.stringify(people)}`);
await runCommand('Import a CSV file as a new database');
check('importing a CSV opens a dialog listing its columns', (await js(`return document.querySelectorAll('.csv-col').length`)) === 6);
const kinds = await js(`return [...document.querySelectorAll('.csv-col')].map(c => c.querySelector('.sm-val')?.textContent + ':' + (c.querySelector('.csv-type')?.textContent || c.querySelector('select, input.csv-pname')?.value || '')).join('|')`);
check('...with Name, and types guessed for the rest', /^Name:/.test(kinds) && /Number|Date|Checkbox|Multi-select|Select/.test(await js(`return document.querySelector('.csv-cols').textContent + [...document.querySelectorAll('.csv-col .fp.sm .sm-val')].map(e => e.textContent).join()`)), kinds);
await click(`[...document.querySelectorAll('.csv .btn.primary')][0]`);
await sleep(1500);
const dbFile = fs.readdirSync(vault).find((f) => /^People\.md$/.test(f));
check('Import creates a database page', !!dbFile && /ink-database: basic/.test(fm('People.md')));
const recs = fs.existsSync(path.join(vault, 'People')) ? fs.readdirSync(path.join(vault, 'People')).sort() : [];
check('...with a record per row', recs.join() === 'Ada.md,Grace.md,Lin.md,Mo.md', recs.join());
const ada = recs.includes('Ada.md') ? fm('People/Ada.md') : '';
check('...values stored by type', /joined: 2024-01-15/.test(ada) && /salary: 5200/.test(ada) && /remote: true/.test(ada) && /skills: \[go, sql\]/.test(ada), JSON.stringify(ada));
check('...a US-style date is read as month/day', recs.includes('Lin.md') && /joined: 2025-03-09/.test(fm('People/Lin.md')));
const sch = JSON.parse(fm('People.md').slice(fm('People.md').indexOf('{'), fm('People.md').lastIndexOf('}') + 1));
check('...and the schema has the guessed types, plus a calendar', sch.properties.map((p) => p.type).join() === 'select,date,number,checkbox,multiselect' && sch.views.map((v) => v.type).join() === 'table,calendar', JSON.stringify(sch.properties.map((p) => p.type)) + sch.views.map((v) => v.type));
check('...and it opens showing the records', (await js(`return document.querySelectorAll('.dbt tbody tr[data-rec]').length`)) === 4);
// into an existing database: columns matching properties go to them
await openPage('Tasks');
await js(`window.__csv = 'Title,Status,Points,Effort\\r\\nDeploy,Doing,3,high\\r\\nRelease,Blocked,13,low\\r\\n'`);
await runCommand('Import a CSV file into this database');
const map = await js(`return [...document.querySelectorAll('.csv-col')].map(c => c.querySelector('.sm-val')?.textContent).join()`);
check('into an existing database, columns line up with its properties by name', map === 'Name,Status,Points,New property', map);
await click(`[...document.querySelectorAll('.csv .btn.primary')][0]`);
await sleep(1800);
const deploy = fs.existsSync(path.join(vault, 'Tasks', 'Deploy.md')) ? fm('Tasks/Deploy.md') : '';
check('...and the records land in it', /status: Doing/.test(deploy) && /points: 3/.test(deploy) && /effort: high/.test(deploy), JSON.stringify(deploy));
const sch2 = JSON.parse(fm('Tasks.md').slice(fm('Tasks.md').indexOf('{'), fm('Tasks.md').lastIndexOf('}') + 1));
check('...a new option is added to the select', sch2.properties[0].options.includes('Blocked') && sch2.properties.some((p) => p.key === 'effort'));
check('...and nothing else in the schema changed', sch2.views.length === 2 && sch2.properties.length === 7);
// ---- leaving a database ----
await openPage('Tasks');
await js(`document.querySelector('[data-rec="Tasks/Ship it"] .db-ico').click()`);
await sleep(900);
await click(`document.querySelector('[aria-label="Page options"]')`);
await click(`[...document.querySelectorAll('.menu .mi')].find(m => m.textContent.includes('Move out of Tasks'))`);
await sleep(1200);
check('moving a record out of its database moves the file', fs.existsSync(path.join(vault, 'Ship it.md')) && !fs.existsSync(path.join(vault, 'Tasks', 'Ship it.md')));
const toastText = await js(`return document.querySelector('.toast')?.textContent`);
check('...and says that its properties stayed in the file', /propert/.test(toastText) && /stayed in the file/.test(toastText), toastText);
check('...the values are still there until you say', /status: Todo/.test(fm('Ship it.md')));
await click(`document.querySelector('.toast .toast-btn')`);
await sleep(1200);
const after = fm('Ship it.md');
check('"Remove" takes this database\'s properties off the record', !/status:|points:|done:|after:/.test(after), JSON.stringify(after));
} catch (err) {
check('harness', false, err.stack);
}
fs.rmSync(tmp, { recursive: true, force: true });
console.log(failures.length ? `\n${failures.length} check(s) failed` : '\ndatabase extras e2e passed!');
app.exit(failures.length ? 1 : 0);
});