test: unit and end-to-end suites

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent 27fafa0bcd
commit 9b00e21c44
49 files changed
+6694 -1

No files matched your search

+365
View File
@@ -0,0 +1,365 @@
// End-to-end check of databases: records are sub-pages, values live in their front matter, views share them.
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-database-'));
const vault = path.join(tmp, 'Vault');
fs.mkdirSync(path.join(vault, 'Tasks'), { recursive: true });
const now = new Date();
const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const schema = {
properties: [
{ key: 'status', name: 'Status', type: 'select', options: ['Todo', 'Doing', 'Done'] },
{ key: 'due', name: 'Due', type: 'date' },
{ key: 'points', name: 'Points', type: 'number' },
{ key: 'done', name: 'Done', type: 'checkbox' },
{ key: 'owner', name: 'Owner', type: 'people' },
{ key: 'labels', name: 'Labels', type: 'multiselect', options: ['ui', 'api'] },
{ key: 'files', name: 'Files', type: 'files' },
{ key: 'after', name: 'After', type: 'relation' }
],
views: [
{ id: 'v1', name: 'Table', type: 'table', filters: [], hidden: [], sort: null },
{ id: 'v2', name: 'Board', type: 'board', groupBy: 'status', filters: [], hidden: [], sort: null },
{ id: 'v3', name: 'Calendar', type: 'calendar', dateProp: 'due', filters: [], hidden: [], sort: null },
{ id: 'v4', name: 'Gallery', type: 'gallery', filters: [], hidden: [], sort: null },
{ id: 'v5', name: 'Timeline', type: 'timeline', dateProp: 'due', filters: [], hidden: [], sort: null },
{ id: 'v6', name: 'List', type: 'list', filters: [], hidden: [], sort: { key: 'points', dir: 'desc' } }
]
};
fs.writeFileSync(path.join(vault, 'Tasks.md'), `---\nink-database: basic\nicon: table\n---\n\n%% ink-database\n${JSON.stringify(schema, null, 2)}\n%%\n`);
fs.writeFileSync(path.join(vault, 'Tasks', 'Write docs.md'), `---\nstatus: Doing\ndue: ${ym}-10\npoints: 5\nowner: [Ana, Ben]\nlabels: [ui]\n---\n\nSome notes.\n`);
fs.writeFileSync(path.join(vault, 'Tasks', 'Ship it.md'), `---\nstatus: Todo\ndue: ${ym}-20\npoints: 8\ndone: false\nafter: [Write docs]\n---\n`);
fs.writeFileSync(path.join(vault, 'Tasks', 'Plan.md'), `---\npoints: 2\n---\n`);
fs.writeFileSync(path.join(vault, 'Other.md'), 'Other.\n');
app.setPath('userData', tmp);
fs.writeFileSync(path.join(tmp, 'settings.json'), JSON.stringify({ vault, lastPage: 'Tasks', bounds: { width: 1400, height: 900 } }));
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 tab = (name) => click(`[...document.querySelectorAll('.db-tab')].find(b => b.textContent.trim() === '${name}')`);
const fm = (f) => fs.readFileSync(path.join(vault, f), 'utf8');
const rows = () => js(`return [...document.querySelectorAll('.dbt tbody tr[data-rec]')].map(r => r.querySelector('.db-title').value)`);
const key = async (keyCode, modifiers = []) => { wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await sleep(350); };
const statusOf = () => schemaOf().properties.find((p) => p.key === 'status');
const schemaOf = () => { const t = fm('Tasks.md'); return JSON.parse(t.slice(t.indexOf('{'), t.lastIndexOf('}') + 1)); };
const pointer = (type, sel, x, y) => js(`const e = ${sel}; (e || window).dispatchEvent(new PointerEvent('${type}', { bubbles: true, button: 0, clientX: ${x}, clientY: ${y} }))`);
const drag = (fromSel, toSel) => js(`
const dt = new DataTransfer();
const from = ${fromSel}, to = ${toSel};
from.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer: dt }));
to.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }));
to.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }));
from.dispatchEvent(new DragEvent('dragend', { bubbles: true, dataTransfer: dt }));
`);
try {
await sleep(1800);
// ---- table ----
check('the page opens as a database with its views as tabs', (await js(`return [...document.querySelectorAll('.db-tab:not(.add)')].map(b => b.textContent.trim())`)).join() === 'Table,Board,Calendar,Gallery,Timeline,List');
check('records are the sub-pages, one row each', JSON.stringify(await rows()) === '["Plan","Ship it","Write docs"]', JSON.stringify(await rows()));
check('records are not listed under the database in the sidebar', await js(`return !document.querySelector('.row[data-id="Tasks/Plan"]')`));
check('a row shows typed values', await js(`const r = document.querySelector('[data-rec="Tasks/Write docs"]'); return r.textContent.includes('Doing') && r.textContent.includes('Ana') && r.querySelector('input[type=number]').value === '5'`));
await click(`document.querySelector('[data-rec="Tasks/Ship it"] td:not(.db-selcol) input[type=checkbox]').parentElement`);
await sleep(700);
check('ticking a checkbox writes it to the record', /done: true/.test(fm('Tasks/Ship it.md')), JSON.stringify(fm('Tasks/Ship it.md')));
check('...leaving its other values and keys alone', /status: Todo/.test(fm('Tasks/Ship it.md')) && /points: 8/.test(fm('Tasks/Ship it.md')));
await js(`const i = document.querySelector('[data-rec="Tasks/Plan"] input[type=number]'); i.value = '13'; i.dispatchEvent(new Event('change', { bubbles: true }))`);
await sleep(700);
check('a number is saved as a number', /points: 13/.test(fm('Tasks/Plan.md')));
await click(`document.querySelector('[data-rec="Tasks/Plan"] .db-sel')`);
await click(`[...document.querySelectorAll('.db-pop .mi')].find(m => m.textContent.includes('Done'))`);
await sleep(700);
check('a select option is picked from the list', /status: Done/.test(fm('Tasks/Plan.md')), JSON.stringify(fm('Tasks/Plan.md')));
await click(`document.querySelector('[data-rec="Tasks/Plan"] .db-sel')`);
await js(`const i = document.querySelector('.db-pop .db-find'); i.value = 'Blocked'; i.dispatchEvent(new Event('input', { bubbles: true }))`);
await sleep(100);
await js(`document.querySelector('.db-pop .db-find').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))`);
await sleep(900);
check('typing a new option creates it', /status: Blocked/.test(fm('Tasks/Plan.md')) && /"Blocked"/.test(fm('Tasks.md')), JSON.stringify(fm('Tasks.md').slice(-400)));
// ---- sort and filter ----
await click(`[...document.querySelectorAll('.db-tool')].find(b => b.textContent.includes('Sort'))`);
await click(`[...document.querySelectorAll('.db-sortrow')].find(r => r.textContent.includes('Points')).querySelectorAll('button')[1]`);
check('sort by a property reorders rows', JSON.stringify(await rows()) === '["Plan","Ship it","Write docs"]', JSON.stringify(await rows()));
await click(`document.querySelector('.db-menu') && [...document.querySelectorAll('.db-sortrow')].find(r => r.textContent.includes('Points')).querySelectorAll('button')[0]`);
check('...ascending too', JSON.stringify(await rows()) === '["Write docs","Ship it","Plan"]', JSON.stringify(await rows()));
await sleep(800);
check('the view\'s sort is saved in the database page', /"key": "points"/.test(fm('Tasks.md').split('"id": "v1"')[1].split('"id": "v2"')[0]));
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`);
await sleep(200);
check('the sort menu closes with Escape', await js(`return !document.querySelector('.db-menu')`));
// ---- undo of schema changes ----
await js(`document.activeElement?.blur?.()`);
await key('z', ['control']);
await sleep(600);
const sortAfterUndo = schemaOf().views[0].sort;
check('Ctrl+Z undoes the last schema change', sortAfterUndo && sortAfterUndo.dir === 'desc', JSON.stringify(sortAfterUndo));
await key('z', ['control', 'shift']);
await sleep(600);
check('Ctrl+Shift+Z redoes it', schemaOf().views[0].sort.dir === 'asc');
// ---- option colours ----
await click(`[...document.querySelectorAll('.dbt th .db-th')].find(b => b.textContent.includes('Status'))`);
await click(`[...document.querySelectorAll('.db-optrow')].find(r => r.querySelector('input').value === 'Todo').querySelector('.db-dotbtn')`);
await click(`document.querySelector('.db-swatches .cv-swatch[data-color="blue"]')`);
await sleep(700);
check('an option can be given a colour', statusOf().colors?.Todo === 'blue', JSON.stringify(statusOf()));
check('...and shows with it', await js(`return [...document.querySelectorAll('.db-opt')].some(o => o.textContent === 'Todo' && o.style.getPropertyValue('--oc') === '#5b8def')`));
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`);
// ---- number formats ----
await click(`[...document.querySelectorAll('.dbt th .db-th')].find(b => b.textContent.includes('Points'))`);
await click(`document.querySelectorAll('.db-menu button.fp.sm')[1]`);
await click(`[...document.querySelectorAll('.sm-list .mi')].find(m => m.textContent.includes('Percent'))`);
await sleep(700);
check('a number property can show as a percent', schemaOf().properties.find((p) => p.key === 'points').format === 'percent' && (await js(`return document.querySelector('[data-rec="Tasks/Ship it"] .db-numfmt')?.textContent.trim()`)) === '8%');
check('...while the file keeps the plain number', /points: 8/.test(fm('Tasks/Ship it.md')));
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`);
await sleep(200);
await click(`document.querySelector('[data-rec="Tasks/Ship it"] .db-numfmt')`);
check('clicking it edits the number', await js(`return document.activeElement?.type === 'number' && document.activeElement.value === '8'`));
await js(`document.activeElement.blur()`);
await click(`[...document.querySelectorAll('.dbt th .db-th')].find(b => b.textContent.includes('Points'))`);
await click(`document.querySelectorAll('.db-menu button.fp.sm')[1]`);
await click(`[...document.querySelectorAll('.sm-list .mi')].find(m => m.textContent.includes('Progress bar'))`);
check('...or as a progress bar', !!(await js(`return document.querySelector('[data-rec="Tasks/Ship it"] .db-barline')`)));
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`);
// ---- column width and order ----
const grip = `document.querySelector('.dbt th:nth-child(5) .db-resize')`; // Points: select, name, status, due, points
const [gx, gy] = await centerOf(grip);
await pointer('pointerdown', grip, gx, gy);
await pointer('pointermove', 'null', gx + 90, gy);
await pointer('pointerup', 'null', gx + 90, gy);
await sleep(700);
check('dragging a column edge resizes it, and the width is saved in the view', schemaOf().views[0].widths?.points === 260, JSON.stringify(schemaOf().views[0].widths));
check('...the column is that wide', (await js(`return Math.round(document.querySelector('.dbt th:nth-child(5)').getBoundingClientRect().width)`)) === 260);
await drag(`document.querySelector('.dbt th:nth-child(4)')`, `document.querySelector('.dbt th:nth-child(3)')`);
await sleep(700);
check('dragging a column header moves the column', schemaOf().properties.map((p) => p.key).slice(0, 2).join() === 'due,status', schemaOf().properties.map((p) => p.key).join());
// ---- keyboard ----
await js(`document.querySelectorAll('.dbt tbody tr[data-rec]')[0].querySelector('.db-title').focus()`);
await key('Down');
check('Down moves to the cell below', await js(`return document.activeElement?.closest('tr')?.dataset.rec === document.querySelectorAll('.dbt tbody tr[data-rec]')[1].dataset.rec && document.activeElement.classList.contains('db-title')`));
await key('Right', ['alt']);
check('Alt+Right moves to the next cell', await js(`return document.activeElement?.closest('td') === document.querySelectorAll('.dbt tbody tr[data-rec]')[1].children[2]`));
await key('Right', ['alt']);
await key('Up');
check('Up from a button moves up too', await js(`return document.activeElement?.closest('tr')?.dataset.rec === document.querySelectorAll('.dbt tbody tr[data-rec]')[0].dataset.rec`));
// ---- new record ----
await click(`document.querySelector('.db-new')`);
await sleep(900);
check('New adds a record file inside the database folder', fs.existsSync(path.join(vault, 'Tasks', 'Untitled.md')));
check('...and its name is ready to type', await js(`return document.activeElement?.classList.contains('db-title')`));
// ---- board ----
await tab('Board');
const cols = await js(`return [...document.querySelectorAll('.db-col')].map(c => c.querySelector('.db-col-head').textContent.replace(/\\s+/g, ' ').trim())`);
check('a board has a column per option, with counts', cols.join('|').includes('Todo 1') && cols.join('|').includes('Doing 1') && cols.join('|').includes('Done 0'), JSON.stringify(cols));
await drag(
`[...document.querySelectorAll('.db-card')].find(c => c.textContent.includes('Ship it'))`,
`[...document.querySelectorAll('.db-col')].find(c => c.querySelector('.db-col-head').textContent.includes('Done'))`
);
await sleep(900);
check('dragging a card to a column sets the property', /status: Done/.test(fm('Tasks/Ship it.md')), JSON.stringify(fm('Tasks/Ship it.md')));
// ---- board: groups and cards like a Kanban board's ----
const colHead = (name) => `[...document.querySelectorAll('.db-col')].find(c => c.querySelector('.db-col-head .db-opt, .db-col-head .db-col-title').textContent.trim() === '${name}').querySelector('.db-col-head')`;
const clickIn = async (sel) => {
await js(`const e = ${sel}; e.scrollIntoView({ inline: 'center', block: 'nearest' })`);
await sleep(300);
await click(sel);
};
const typeInto = async (value) => {
await js(`const i = document.activeElement; i.value = ${JSON.stringify(value)}; i.dispatchEvent(new Event('input', { bubbles: true }))`);
await key('Return');
await sleep(900);
};
await js(`document.querySelector('.kaddcol').scrollIntoView({ inline: 'center' })`);
await sleep(300);
await click(`document.querySelector('.kaddcol')`);
check('"Add a group" makes a new option and asks for its name', await js(`return document.activeElement?.classList.contains('kh-input') && document.activeElement.value === 'New group'`));
await typeInto('Review');
check('...which is saved as an option of the property', statusOf().options.includes('Review'), JSON.stringify(statusOf().options));
await clickIn(`${colHead('Doing')}.querySelector('.kh-title')`);
await typeInto('In progress');
check('renaming a group renames the value on its records', /status: In progress/.test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md'));
check('...and in the property\'s options', statusOf().options.includes('In progress') && !statusOf().options.includes('Doing'));
await clickIn(`${colHead('In progress')}.querySelector('[aria-label="Group options"]')`);
await click(`document.querySelector('.db-menu .cv-swatch[data-color="purple"]')`);
await sleep(700);
check('a group\'s colour is chosen from its menu', statusOf().colors?.['In progress'] === 'purple', JSON.stringify(statusOf().colors));
check('...and the header pill shows it', await js(`return [...document.querySelectorAll('.db-col-head .db-opt')].some(o => o.textContent === 'In progress' && o.style.getPropertyValue('--oc') === '#9a6de0')`));
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))`);
await drag(`${colHead('Review')}`, `[...document.querySelectorAll('.db-col')].find(c => c.querySelector('.db-col-head .db-opt')?.textContent === 'Todo')`);
await sleep(900);
check('dragging a group header reorders the options', statusOf().options[0] === 'Review', JSON.stringify(statusOf().options));
await clickIn(`[...document.querySelectorAll('.db-card')].find(c => c.textContent.includes('Write docs')).querySelector('.kmore')`);
await click(`[...document.querySelectorAll('.db-menu .mi')].find(m => m.textContent.trim() === 'Done')`);
await sleep(900);
check('a card\'s menu moves it to another group', /status: Done/.test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md'));
await clickIn(`${colHead('Blocked')}.querySelector('[aria-label="Group options"]')`);
await click(`[...document.querySelectorAll('.db-menu .mi')].find(m => m.textContent.includes('Delete group'))`);
check('deleting a group asks to be confirmed', await js(`return document.querySelector('.db-menu .mi.armed')?.textContent.includes('Click again')`));
await click(`document.querySelector('.db-menu .mi.armed')`);
await sleep(900);
check('...then drops the option and clears the value from its records', !statusOf().options.includes('Blocked') && !/status:/.test(fm('Tasks/Plan.md')), fm('Tasks/Plan.md'));
// ---- calendar ----
await tab('Calendar');
const evs = await js(`return [...document.querySelectorAll('.db-ev')].map(e => e.textContent)`);
check('a calendar shows records on their date', evs.includes('Write docs') && evs.includes('Ship it'), JSON.stringify(evs));
await drag(
`[...document.querySelectorAll('.db-ev')].find(e => e.textContent === 'Write docs')`,
`[...document.querySelectorAll('.db-day')].find(d => d.getAttribute('aria-label') && !d.classList.contains('out') && d.querySelector('.db-day-head span').textContent === '15')`
);
await sleep(900);
check('dragging a record to another day changes its date', new RegExp(`due: ${ym}-15`).test(fm('Tasks/Write docs.md')), JSON.stringify(fm('Tasks/Write docs.md')));
await click(`[...document.querySelectorAll('.db-day')].find(d => !d.classList.contains('out') && d.querySelector('.db-day-head span').textContent === '3').querySelector('.db-day-add')`);
await sleep(900);
const dated = fs.readdirSync(path.join(vault, 'Tasks')).filter((f) => new RegExp(`due: ${ym}-03`).test(fm('Tasks/' + f)));
check('the + on a day makes a record dated that day', dated.length === 1, JSON.stringify(dated));
// ---- gallery, timeline, list ----
await tab('Gallery');
check('a gallery shows a card per record', (await js(`return document.querySelectorAll('.db-gcard:not(.db-gnew)').length`)) === 5);
await tab('Timeline');
check('a timeline draws a bar per dated record', (await js(`return document.querySelectorAll('.db-tl-bar').length`)) === 3);
check('...and lists the undated ones', (await js(`return document.querySelector('.db-undated')?.textContent`)).includes('Plan'));
await tab('List');
const order = await js(`return [...document.querySelectorAll('.db-lname')].map(e => e.textContent)`);
check('a list shows a row per record, in the view\'s order', JSON.stringify(order) === '["Plan","Ship it","Write docs","Untitled","Untitled 2"]', JSON.stringify(order));
// ---- calendar: week view and span ----
await tab('Calendar');
await click(`[...document.querySelectorAll('.db-seg button')].find(b => b.textContent === 'Week')`);
check('the week view shows seven days', (await js(`return document.querySelectorAll('.db-day').length`)) === 7);
await sleep(800);
check('...and remembers the choice in the view', schemaOf().views[2].range === 'week');
await click(`[...document.querySelectorAll('.db-seg button')].find(b => b.textContent === 'Month')`);
const gripSel = `[...document.querySelectorAll('.db-evwrap')].find(w => w.textContent.includes('Write docs')).querySelector('.db-grip')`;
const [sx, sy] = await centerOf(gripSel);
const [tx, ty] = await centerOf(`[...document.querySelectorAll('.db-day')].find(d => d.dataset.iso === '${ym}-18')`);
await pointer('pointerdown', gripSel, sx, sy);
await pointer('pointermove', 'null', tx, ty);
await pointer('pointerup', 'null', tx, ty);
await sleep(900);
check('dragging the end of a record in the calendar sets its end date', new RegExp(`end: ${ym}-18`).test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md'));
check('...adding an End property when there was none', schemaOf().properties.some((p) => p.key === 'end' && p.type === 'date') && schemaOf().views[2].endProp === 'end');
check('the record now shows on each day of the span', (await js(`return [...document.querySelectorAll('.db-ev')].filter(e => e.textContent === 'Write docs').length`)) === 4);
// ---- timeline: edges and dependencies ----
await tab('Timeline');
check('a dependency is drawn as an arrow', (await js(`return document.querySelectorAll('.db-tl-arrows > path').length`)) === 1);
await click(`document.querySelector('.db-tools button.fp.sm[aria-label="End date"]')`);
await click(`[...document.querySelectorAll('.sm-list .mi')].find(m => m.textContent.includes('End: End'))`);
await sleep(700);
const edge = `[...document.querySelectorAll('.db-tl-bar')].find(b => b.title.startsWith('Write docs')).querySelector('.db-tl-grip.r')`;
const [ex, ey] = await centerOf(edge);
await pointer('pointerdown', edge, ex, ey);
await pointer('pointermove', 'null', ex + 50, ey); // 24px a day: two days
await pointer('pointerup', 'null', ex + 50, ey);
await sleep(900);
check('dragging a bar\'s right edge changes the end date', new RegExp(`end: ${ym}-20`).test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md'));
const lead = `[...document.querySelectorAll('.db-tl-bar')].find(b => b.title.startsWith('Write docs')).querySelector('.db-tl-grip.l')`;
const [lx, ly] = await centerOf(lead);
await pointer('pointerdown', lead, lx, ly);
await pointer('pointermove', 'null', lx + 26, ly);
await pointer('pointerup', 'null', lx + 26, ly);
await sleep(900);
check('...and the left edge changes the start', new RegExp(`due: ${ym}-16`).test(fm('Tasks/Write docs.md')), fm('Tasks/Write docs.md'));
// ---- bulk selection ----
await tab('Table');
await click(`document.querySelectorAll('.dbt tbody tr[data-rec]')[0].querySelector('.db-selcol .dbx')`);
await js(`document.querySelectorAll('.dbt tbody tr[data-rec]')[2].querySelector('.db-selcol input').dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }))`);
await sleep(300);
check('shift-click selects a range of rows', (await js(`return document.querySelector('.db-bulk strong')?.textContent`)) === '3 selected');
await click(`document.querySelector('.db-bulk button.fp.sm')`);
await click(`[...document.querySelectorAll('.sm-list .mi')].find(m => m.textContent.includes('Set Done'))`);
await click(`[...document.querySelectorAll('.db-bulk .btn')].find(b => b.textContent.trim() === 'Apply')`);
await sleep(900);
const doneAll = ['Write docs', 'Ship it', 'Plan'].filter((n) => /done: true/.test(fm(`Tasks/${n}.md`)));
check('a property can be set on every selected record at once', doneAll.length === 3, JSON.stringify(doneAll));
await click(`[...document.querySelectorAll('.db-bulk .btn')].find(b => b.textContent.includes('Delete'))`);
await sleep(900);
check('Delete moves the selected records to the trash in one step', fs.readdirSync(path.join(vault, 'Tasks')).filter((f) => f.endsWith('.md')).length === 2, JSON.stringify(fs.readdirSync(path.join(vault, 'Tasks'))));
await click(`[...document.querySelectorAll('.toast button')].find(b => b.textContent.includes('Undo'))`);
await sleep(1200);
check('...and one Undo brings them all back', fs.readdirSync(path.join(vault, 'Tasks')).filter((f) => f.endsWith('.md')).length === 5, JSON.stringify(fs.readdirSync(path.join(vault, 'Tasks'))));
await tab('List');
// ---- record page ----
await click(`document.querySelector('.db-lrow')`);
await sleep(900);
check('opening a record shows the database\'s properties with their editors', await js(`return !![...document.querySelectorAll('.prop .pk')].find(p => p.textContent.includes('Status')) && !!document.querySelector('.prop .db-sel')`));
await click(`document.querySelector('.prop .db-sel')`);
const place = await js(`const t = document.querySelector('.prop .db-sel').getBoundingClientRect(); const p = document.querySelector('.db-pop')?.getBoundingClientRect(); return p ? { dx: Math.abs(p.left - t.left), dy: p.top - t.bottom, inside: p.right <= innerWidth && p.bottom <= innerHeight } : null`);
check('an option list opened on a record\'s page sits right under its field', place && place.dx < 30 && place.dy >= 0 && place.dy < 40 && place.inside, JSON.stringify(place));
await js(`document.querySelector('.db-pop .db-find')?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`);
await sleep(300);
await js(`const p = [...document.querySelectorAll('.prop')].find(p => p.textContent.includes('Points')); p.querySelector('.db-numfmt')?.click(); await new Promise(r => setTimeout(r, 200)); const i = p.querySelector('input'); i.value = '21'; i.dispatchEvent(new Event('change', { bubbles: true }))`);
await sleep(1400);
check('editing one there writes the record\'s front matter', fs.readdirSync(path.join(vault, 'Tasks')).some((f) => /points: 21/.test(fm('Tasks/' + f))));
// ---- renaming a record updates what depends on it ----
await js(`document.querySelector('.crumb')?.click()`);
await sleep(800);
await tab('Table');
await js(`const i = document.querySelector('[data-rec="Tasks/Write docs"] .db-title'); i.value = 'Write guide'; i.dispatchEvent(new Event('change', { bubbles: true }))`);
await sleep(1500);
check('renaming a record re-points the relations that name it', /after: \[Write guide\]/.test(fm('Tasks/Ship it.md')) && fs.existsSync(path.join(vault, 'Tasks', 'Write guide.md')), fm('Tasks/Ship it.md'));
// ---- creating a database ----
await js(`document.querySelector('.crumb')?.click()`);
await js(`window.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', ctrlKey: true }))`);
await sleep(500);
await click(`document.querySelector('.tpl-card[data-database]')`);
await sleep(1000);
const made = fs.readdirSync(vault).filter((f) => /^Database/.test(f));
check('"Database" in the new page dialog makes a database with a Table and a Calendar (a board is added by hand)', made.length === 1 && /ink-database: basic/.test(fm(made[0])) && (await js(`return document.querySelectorAll('.db-tab:not(.add)').length`)) === 2, JSON.stringify(made));
} 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 e2e passed!');
app.exit(failures.length ? 1 : 0);
});