test: unit and end-to-end suites
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
27fafa0bcd
commit
9b00e21c44
49 files changed
+6694
-1
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user