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