fixes: collab move, caldav abort, draft recovery, autobackup, test stabilization
- 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>
This commit is contained in:
1 parent
7e2b130434
commit
3cca14a3c6
8 files changed
+178
-23
No files matched your search
@@ -411,9 +411,16 @@ function collabSyncCards(PDO $db): array {
|
||||
$stats['moved']++;
|
||||
|
||||
if ($mv['card_missing'] || empty($mv['new_bucket_uid'])) {
|
||||
// Card is gone or moved out of collab entirely — forget about it
|
||||
// Card is gone or moved out of collab entirely — forget about it.
|
||||
// Critical: also clear the card's relay_uid so the incoming
|
||||
// tombstone (echoed back from the relay on next pull) doesn't
|
||||
// find this card and delete it locally too.
|
||||
$db->prepare('DELETE FROM collab_card_state WHERE card_id = :id')
|
||||
->execute(['id' => $mv['card_id']]);
|
||||
if (!$mv['card_missing']) {
|
||||
$db->prepare('UPDATE cards SET relay_uid = NULL WHERE id = :id')
|
||||
->execute(['id' => $mv['card_id']]);
|
||||
}
|
||||
} else {
|
||||
// Card moved to a different collab bucket. Rewrite state so the
|
||||
// push phase below picks it up under the new bucket_uid and also
|
||||
|
||||
@@ -157,14 +157,26 @@ describe('Card: Context Menu Actions', () => {
|
||||
expect(doneBtn.textContent.toLowerCase()).toContain('mark done');
|
||||
|
||||
click(doneBtn);
|
||||
await sleep(300);
|
||||
let updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
||||
// Poll until #done is persisted to the DB
|
||||
let updated;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(100);
|
||||
updated = await fetch(`api/cards?ids=${res.id}`).then(r => r.json());
|
||||
if ((updated[0]?.tags || '').includes('done')) break;
|
||||
}
|
||||
expect((updated[0].body || '').toLowerCase()).toContain('#done');
|
||||
expect(updated[0].tags || '').toContain('done');
|
||||
|
||||
// Wait for the board to re-render with the updated card data
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const card = window.state?.cards?.find(c => c.id == res.id);
|
||||
if (card && (card.tags || '').includes('done')) break;
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
// Re-open menu — the entry should now flip to "Unmark done"
|
||||
rightClick(document.querySelector(`[data-id="${res.id}"]`));
|
||||
await sleep(150);
|
||||
await sleep(200);
|
||||
const doneBtn2 = $$('#ctx-items [data-action="due-done"]')[0];
|
||||
expect(doneBtn2.textContent.toLowerCase()).toContain('unmark');
|
||||
|
||||
@@ -331,11 +343,16 @@ describe('Card: Group Delete', () => {
|
||||
// Confirm the delete
|
||||
const okBtn = document.getElementById('dialog-ok');
|
||||
if (okBtn) click(okBtn);
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// All three cards should be gone
|
||||
const remaining = await fetch('api/cards').then(r => r.json());
|
||||
// Poll until all three cards are gone (group delete = N serial DELETEs)
|
||||
let remaining;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await sleep(100);
|
||||
remaining = await fetch('api/cards').then(r => r.json());
|
||||
const stillThere = remaining.some(c => c.id === parent.id || c.id === child1.id || c.id === child2.id);
|
||||
if (!stillThere) break;
|
||||
}
|
||||
expect(remaining.some(c => c.id === parent.id)).toBe(false);
|
||||
expect(remaining.some(c => c.id === child1.id)).toBe(false);
|
||||
expect(remaining.some(c => c.id === child2.id)).toBe(false);
|
||||
|
||||
@@ -201,6 +201,37 @@ async function createBackup() {
|
||||
backupBlob = await res.blob();
|
||||
}
|
||||
|
||||
// Purge leftover test artifacts from previous failed runs.
|
||||
// Tests use `__prefix__` naming; anything matching gets deleted before
|
||||
// the baseline backup so it doesn't get restored after the run either.
|
||||
async function purgeTestLeftovers() {
|
||||
const token = () => document.cookie.match(/csrf=([^;]+)/)?.[1] || getCsrf();
|
||||
let purged = { cards: 0, buckets: 0 };
|
||||
try {
|
||||
const cards = await fetch('api/cards').then(r => r.json());
|
||||
const testCards = cards.filter(c => /^__.*__/.test(c.title || ''));
|
||||
for (const c of testCards) {
|
||||
await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } });
|
||||
purged.cards++;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const buckets = await fetch('api/buckets').then(r => r.json());
|
||||
const testBuckets = buckets.filter(b => /^__.*__/.test(b.name || ''));
|
||||
for (const b of testBuckets) {
|
||||
await fetch(`api/buckets/${b.id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': token() } });
|
||||
purged.buckets++;
|
||||
}
|
||||
} catch {}
|
||||
// Also clear any leftover editor drafts so the restore prompt doesn't fire
|
||||
try {
|
||||
for (const k of Object.keys(localStorage)) {
|
||||
if (k.startsWith('hyprcrd-editor-draft')) localStorage.removeItem(k);
|
||||
}
|
||||
} catch {}
|
||||
return purged;
|
||||
}
|
||||
|
||||
async function restoreBackup() {
|
||||
if (!backupBlob) return;
|
||||
const form = new FormData();
|
||||
@@ -398,10 +429,18 @@ async function run() {
|
||||
const output = panel.querySelector('#test-output');
|
||||
const summary = panel.querySelector('#test-summary');
|
||||
|
||||
// 1. Create backup
|
||||
output.innerHTML = '<div style="color:#64748b;">creating backup...</div>';
|
||||
// 1. Purge leftover test artifacts from any previous failed run
|
||||
output.innerHTML = '<div style="color:#64748b;">cleaning up previous test artifacts...</div>';
|
||||
summary.textContent = '';
|
||||
await sleep(50);
|
||||
const purged = await purgeTestLeftovers();
|
||||
if (purged.cards || purged.buckets) {
|
||||
output.innerHTML += `<div style="color:#fbbf24;">purged ${purged.cards} leftover card${purged.cards !== 1 ? 's' : ''}, ${purged.buckets} bucket${purged.buckets !== 1 ? 's' : ''}</div>`;
|
||||
}
|
||||
|
||||
// 2. Create backup (clean baseline)
|
||||
output.innerHTML += '<div style="color:#64748b;">creating backup...</div>';
|
||||
await sleep(50);
|
||||
try {
|
||||
await createBackup();
|
||||
} catch (e) {
|
||||
|
||||
@@ -108,15 +108,22 @@ describe('Undo: Editor Save', () => {
|
||||
bodyEl.value = 'after edit';
|
||||
bodyEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
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);
|
||||
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();
|
||||
await sleep(400);
|
||||
|
||||
card = (await fetch(`api/cards?ids=${res.id}`).then(r => r.json())).find(c => c.id == res.id);
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user