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