- Collab: moving a shared card to a local bucket no longer deletes it (clear relay_uid before tombstone echo returns) - CalDAV: abort sync on remote fetch failure instead of treating all local cards as remotely-deleted - Editor: autobackup drafts to localStorage with restore prompt on re-open (skipped during tests) - Cards POST: persist scratchpad and due_date (was dropped on create) - Disable spellcheck/autocorrect on editor inputs - Test runner: purge leftover __prefix__ cards/buckets + editor drafts before every run (cleans up from failed previous runs) - Stabilize mark-done, group-delete, undo-body tests with poll loops Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
import { describe, it, afterEach, expect, $, $$, click, type, rightClick, pressKey, sleep, closeAll, csrf } from './runner.js?v=4';
|
|
import { state } from '../../js/modules/state.js';
|
|
|
|
function ctrlZ() {
|
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'z', ctrlKey: true, bubbles: true }));
|
|
}
|
|
|
|
/**
|
|
* Poll `state.cards` until the card with the given id satisfies the
|
|
* predicate. Lets a test wait for an async click handler's _reloadAll
|
|
* to land in the in-memory state before triggering the next user
|
|
* action that reads from it. Use this in place of fixed `sleep(300)`
|
|
* waits between chained actions — fixed sleeps race against handler
|
|
* speed when the run executes faster than the original tuning.
|
|
*/
|
|
async function waitForStateCard(id, predicate, timeout = 2000) {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeout) {
|
|
const card = state.cards.find(c => c.id == id);
|
|
if (card && predicate(card)) return card;
|
|
await sleep(30);
|
|
}
|
|
throw new Error(`waitForStateCard: card ${id} did not satisfy predicate within ${timeout}ms`);
|
|
}
|
|
|
|
describe('Undo: Color Change', () => {
|
|
afterEach(() => closeAll());
|
|
|
|
it('Ctrl+Z reverts color via context menu', async () => {
|
|
const res = await fetch('api/cards', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ title: '__test_undo_color__', body: '', color: '#38bdf8' }),
|
|
}).then(r => r.json());
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
if (!cardEl) return;
|
|
rightClick(cardEl);
|
|
await sleep(150);
|
|
click($$('#ctx-items [data-action="color"]')[0]);
|
|
await sleep(100);
|
|
click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]'));
|
|
await sleep(300);
|
|
|
|
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.color).toBe('#fb7185');
|
|
|
|
ctrlZ();
|
|
await sleep(400);
|
|
|
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.color).toBe('#38bdf8');
|
|
});
|
|
});
|
|
|
|
describe('Undo: Editor Save', () => {
|
|
afterEach(() => closeAll());
|
|
|
|
it('Ctrl+Z reverts title change', async () => {
|
|
const res = await fetch('api/cards', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ title: '__test_undo_title_orig__', body: 'original body', color: '#38bdf8' }),
|
|
}).then(r => r.json());
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
// Open editor via context menu
|
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
if (!cardEl) return;
|
|
cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
|
|
await sleep(150);
|
|
click(document.querySelector('#ctx-items [data-action="edit"]'));
|
|
await sleep(300);
|
|
|
|
// Change title
|
|
type('#inp-title', '__test_undo_title_changed__');
|
|
click('#modal-save-close');
|
|
await sleep(400);
|
|
|
|
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.title).toBe('__test_undo_title_changed__');
|
|
|
|
// Undo
|
|
ctrlZ();
|
|
await sleep(400);
|
|
|
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.title).toBe('__test_undo_title_orig__');
|
|
});
|
|
|
|
it('Ctrl+Z reverts body change', async () => {
|
|
const res = await fetch('api/cards', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ title: '__test_undo_body__', body: 'before edit', color: '#38bdf8' }),
|
|
}).then(r => r.json());
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
if (!cardEl) return;
|
|
cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
|
|
await sleep(150);
|
|
click(document.querySelector('#ctx-items [data-action="edit"]'));
|
|
await sleep(300);
|
|
|
|
const bodyEl = $('#editor-body');
|
|
bodyEl.value = 'after edit';
|
|
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
click('#modal-save-close');
|
|
|
|
let card;
|
|
for (let i = 0; i < 40; i++) {
|
|
await sleep(100);
|
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
if ((card?.body || '').includes('after edit')) break;
|
|
}
|
|
expect(card.body).toContain('after edit');
|
|
|
|
ctrlZ();
|
|
|
|
for (let i = 0; i < 40; i++) {
|
|
await sleep(100);
|
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
if ((card?.body || '').includes('before edit')) break;
|
|
}
|
|
expect(card.body).toContain('before edit');
|
|
});
|
|
|
|
it('Ctrl+Z reverts due_date change', async () => {
|
|
const res = await fetch('api/cards', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ title: '__test_undo_due__', body: '', color: '#38bdf8' }),
|
|
}).then(r => r.json());
|
|
// Set a due date
|
|
await fetch(`api/cards/${res.id}`, {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ due_date: '2099-06-15' }),
|
|
});
|
|
await window._reloadAll?.();
|
|
// Make sure state.cards has the new due_date before opening the
|
|
// editor — the editor's snapshot for the undo entry reads from
|
|
// state.cards.find() and would otherwise capture due_date=null.
|
|
await waitForStateCard(res.id, c => c.due_date === '2099-06-15');
|
|
|
|
// Edit and change due date via editor
|
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
if (!cardEl) return;
|
|
cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
|
|
await sleep(150);
|
|
click(document.querySelector('#ctx-items [data-action="edit"]'));
|
|
await sleep(300);
|
|
|
|
// Change due date by directly clearing it
|
|
const dueInput = $('#inp-due-value');
|
|
if (dueInput) dueInput.value = '';
|
|
click('#modal-save-close');
|
|
await sleep(400);
|
|
|
|
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.due_date).toBe(null);
|
|
|
|
// Undo should restore the due date
|
|
ctrlZ();
|
|
await sleep(400);
|
|
|
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.due_date).toBe('2099-06-15');
|
|
});
|
|
});
|
|
|
|
describe('Undo: Card Delete', () => {
|
|
afterEach(() => closeAll());
|
|
|
|
it('Ctrl+Z after delete recreates card with body and color', async () => {
|
|
const res = await fetch('api/cards', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ title: '__test_undo_del__', body: 'restore me #testtag', color: '#fb7185' }),
|
|
}).then(r => r.json());
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
// Delete via context menu
|
|
// First disable confirm-delete
|
|
const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete');
|
|
localStorage.setItem('hyprcrd-confirm-delete', '0');
|
|
|
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
if (!cardEl) return;
|
|
cardEl.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 100, clientY: 100 }));
|
|
await sleep(150);
|
|
click(document.querySelector('#ctx-items [data-action="delete"]'));
|
|
await sleep(400);
|
|
|
|
// Verify deleted
|
|
let cards = await fetch('api/cards').then(r => r.json());
|
|
expect(cards.some(c => c.id == res.id)).toBe(false);
|
|
|
|
// Undo
|
|
ctrlZ();
|
|
await sleep(500);
|
|
|
|
// Card should be recreated (with new ID)
|
|
cards = await fetch('api/cards').then(r => r.json());
|
|
const restored = cards.find(c => c.title === '__test_undo_del__');
|
|
expect(restored).toExist();
|
|
expect(restored.body).toContain('restore me');
|
|
expect(restored.color).toBe('#fb7185');
|
|
|
|
// Restore confirm setting
|
|
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
|
else localStorage.removeItem('hyprcrd-confirm-delete');
|
|
});
|
|
|
|
it('Ctrl+Z after delete preserves parent_id', async () => {
|
|
const parent = await fetch('api/cards', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ title: '__test_undo_del_parent__', body: '', color: '#38bdf8' }),
|
|
}).then(r => r.json());
|
|
const child = await fetch('api/cards', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ title: '__test_undo_del_child__', body: '', color: '#38bdf8', parent_id: parent.id }),
|
|
}).then(r => r.json());
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
const prevConfirm = localStorage.getItem('hyprcrd-confirm-delete');
|
|
localStorage.setItem('hyprcrd-confirm-delete', '0');
|
|
|
|
// Delete the child (it may not be directly visible, delete via API + push undo manually)
|
|
// Simpler: delete via API and test if undo restores parent_id
|
|
// Actually we need to use the context menu to get the undo pushed
|
|
// The child might be inside a group view — let's just test via the undo module directly
|
|
|
|
// Import undo and test directly
|
|
const { pushCardDelete, snapshotCard, undo, canUndo } = await import('../../js/modules/undo.js');
|
|
const snap = { ...child, parent_id: parent.id };
|
|
pushCardDelete('test-del', snap);
|
|
await fetch(`api/cards/${child.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf() } });
|
|
|
|
// Undo
|
|
await undo();
|
|
await sleep(300);
|
|
|
|
const cards = await fetch('api/cards').then(r => r.json());
|
|
const restored = cards.find(c => c.title === '__test_undo_del_child__');
|
|
expect(restored).toExist();
|
|
expect(restored.parent_id).toBe(parent.id);
|
|
|
|
if (prevConfirm !== null) localStorage.setItem('hyprcrd-confirm-delete', prevConfirm);
|
|
else localStorage.removeItem('hyprcrd-confirm-delete');
|
|
});
|
|
});
|
|
|
|
describe('Undo: Bucket Change', () => {
|
|
afterEach(() => closeAll());
|
|
|
|
it('Ctrl+Z reverts bucket assignment via context menu', async () => {
|
|
const bucket = await fetch('api/buckets', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ name: '__test_undo_bucket__' }),
|
|
}).then(r => r.json());
|
|
|
|
const res = await fetch('api/cards', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ title: '__test_undo_bkt__', body: '', color: '#38bdf8' }),
|
|
}).then(r => r.json());
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
if (!cardEl) return;
|
|
|
|
// Original: no bucket
|
|
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.bucket_id).toBe(null);
|
|
|
|
// Assign bucket via context menu
|
|
rightClick(cardEl);
|
|
await sleep(150);
|
|
click($$('#ctx-items [data-action="bucket"]')[0]);
|
|
await sleep(100);
|
|
const bktBtn = $$('#ctx-buckets [data-ctx-bucket]').find(b => b.dataset.ctxBucket == bucket.id);
|
|
if (bktBtn) click(bktBtn);
|
|
await sleep(300);
|
|
|
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.bucket_id).toBe(bucket.id);
|
|
|
|
// Undo
|
|
ctrlZ();
|
|
await sleep(400);
|
|
|
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.bucket_id).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('Undo: Multiple Undos', () => {
|
|
afterEach(() => closeAll());
|
|
|
|
it('multiple Ctrl+Z reverts in stack order', async () => {
|
|
const res = await fetch('api/cards', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ title: '__test_undo_multi__', body: '', color: '#38bdf8' }),
|
|
}).then(r => r.json());
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
const cardEl = document.querySelector(`[data-id="${res.id}"]`);
|
|
if (!cardEl) return;
|
|
|
|
// Change 1: color to pink
|
|
rightClick(cardEl);
|
|
await sleep(150);
|
|
click($$('#ctx-items [data-action="color"]')[0]);
|
|
await sleep(100);
|
|
click(document.querySelector('#ctx-color-grid [data-ctx-color="#fb7185"]'));
|
|
// Wait for state.cards to reflect the new color before doing the
|
|
// *second* rightClick — otherwise the second action's contextmenu
|
|
// handler reads the stale card (#38bdf8) from state.cards and
|
|
// pushes the wrong undo snapshot, collapsing both undo entries
|
|
// onto the original color.
|
|
await waitForStateCard(res.id, c => c.color === '#fb7185');
|
|
|
|
// Change 2: color to green
|
|
const cardEl2 = document.querySelector(`[data-id="${res.id}"]`);
|
|
rightClick(cardEl2);
|
|
await sleep(150);
|
|
click($$('#ctx-items [data-action="color"]')[0]);
|
|
await sleep(100);
|
|
click(document.querySelector('#ctx-color-grid [data-ctx-color="#4ade80"]'));
|
|
await waitForStateCard(res.id, c => c.color === '#4ade80');
|
|
|
|
let card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.color).toBe('#4ade80');
|
|
|
|
// Undo 1: back to pink
|
|
ctrlZ();
|
|
await sleep(400);
|
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.color).toBe('#fb7185');
|
|
|
|
// Undo 2: back to original blue
|
|
ctrlZ();
|
|
await sleep(400);
|
|
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
|
expect(card.color).toBe('#38bdf8');
|
|
});
|
|
});
|
|
|
|
describe('Undo: No-Op When Empty', () => {
|
|
it('Ctrl+Z with empty stack does nothing', async () => {
|
|
const { canUndo } = await import('../../js/modules/undo.js');
|
|
// Clear stack by undoing everything
|
|
while (canUndo()) {
|
|
ctrlZ();
|
|
await sleep(200);
|
|
}
|
|
// Now Ctrl+Z should be harmless
|
|
const cardsBefore = await fetch('api/cards').then(r => r.json());
|
|
ctrlZ();
|
|
await sleep(200);
|
|
const cardsAfter = await fetch('api/cards').then(r => r.json());
|
|
expect(cardsAfter.length).toBe(cardsBefore.length);
|
|
});
|
|
});
|