Files
hyprcrd/public/plugins/tests/bookmarks.test.js
T
thandClaude Opus 4.6 8e738ed9d4 fix(bookmarks): sync local state on save so columns persist
save() PATCHed the card on the server but left state.cards stale.
The next openOverlay read the old body from the local cache and
the new column was gone. Now save() writes the updated body back
into the matching state.cards entry so close + reopen sees the
latest data without a full _reloadAll round-trip.

Test: "new columns survive close + reopen" — adds a column via
API + local state update, closes, reopens, asserts both column
names are present in the DOM.

Bumps to 1.9.28.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:56:42 +02:00

204 lines
8.9 KiB
JavaScript

import { describe, it, expect, $, click, sleep, waitFor, csrf } from './runner.js?v=4';
// Bookmarks plugin tests. The plugin injects a header button, decorates
// #bookmarks-tagged board cards, and opens a column-based overlay.
// Tests create throwaway cards and clean up in finally blocks.
const token = () => csrf();
const hJson = () => ({ 'Content-Type': 'application/json', 'X-CSRF-Token': token() });
const hDel = () => ({ 'X-CSRF-Token': token() });
function emptyBody() {
return JSON.stringify({ type: 'bookmarks', columns: [{ name: 'General', items: [] }], columnWidth: 220 }) + '\n\n#bookmarks';
}
function bodyWith(columns, columnWidth = 220) {
return JSON.stringify({ type: 'bookmarks', columns, columnWidth }) + '\n\n#bookmarks';
}
describe('Bookmarks: plugin registration', () => {
it('registers itself in window.hyprcrd.plugins', () => {
const entry = (window.hyprcrd?.plugins || []).find(p => p?.name === 'bookmarks');
expect(entry).toExist();
});
});
describe('Bookmarks: header button', () => {
it('injects a Bookmarks button into the header', () => {
const btn = $('#bookmarks-btn');
expect(btn).toExist();
expect(btn.closest('#main-header')).toExist();
});
});
describe('Bookmarks: board card rendering', () => {
it('decorates #bookmarks cards with bm-card class', async () => {
let card;
try {
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_deco__', body: emptyBody(), color: '#fb923c' }) }).then(r => r.json());
expect(card?.id).toExist();
await window._reloadAll();
await sleep(200);
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
expect(el).toExist();
expect(el.classList.contains('bm-card')).toBe(true);
expect(el.querySelector('.bm-card-icon')).toExist();
expect(el.querySelector('.bm-card-title').textContent).toContain('__test_bm_deco__');
} finally {
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
await window._reloadAll();
}
});
});
describe('Bookmarks: overlay', () => {
it('opens on click and shows columns from the card body', async () => {
const cols = [
{ name: 'TestCol', items: [{ url: 'https://example.com', title: 'Example', favicon: '' }] }
];
let card;
try {
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_overlay__', body: bodyWith(cols) }) }).then(r => r.json());
await window._reloadAll();
await sleep(200);
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
expect(el).toExist();
click(el);
await sleep(150);
const overlay = $('#bookmarks-overlay');
expect(overlay.classList.contains('open')).toBe(true);
expect($('bookmarks-title').textContent).toContain('__test_bm_overlay__');
// Column rendered
const colName = document.querySelector('.bm-col-name');
expect(colName).toExist();
expect(colName.textContent).toBe('TestCol');
// Item rendered
const item = document.querySelector('.bm-item');
expect(item).toExist();
expect(item.textContent).toContain('Example');
// Close
click($('bookmarks-close'));
await sleep(100);
expect(overlay.classList.contains('open')).toBe(false);
} finally {
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
await window._reloadAll();
}
});
});
describe('Bookmarks: fetch-meta API', () => {
it('returns JSON with ok field for a valid URL', async () => {
const r = await fetch('api/bookmarks/fetch-meta?url=' + encodeURIComponent('https://example.com')).then(x => x.json());
expect(r.ok).toBe(true);
expect(typeof r.title).toBe('string');
// favicon is either a data: URI or empty string
expect(typeof r.favicon).toBe('string');
});
it('returns 400 for missing URL', async () => {
const r = await fetch('api/bookmarks/fetch-meta');
expect(r.status).toBe(400);
});
});
describe('Bookmarks: card links', () => {
it('renders card:ID items with a card icon', async () => {
let target, bm;
try {
target = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_target__', body: 'hello' }) }).then(r => r.json());
const cols = [{ name: 'Links', items: [{ url: `card:${target.id}`, title: '__test_bm_target__', favicon: '' }] }];
bm = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_cardlink__', body: bodyWith(cols) }) }).then(r => r.json());
await window._reloadAll();
await sleep(200);
const el = document.querySelector(`.board-card[data-id="${bm.id}"]`);
click(el);
await sleep(150);
const item = document.querySelector('.bm-item');
expect(item).toExist();
// Card links get a ph-card icon, not ph-globe
const cardIcon = item.querySelector('.ph-card');
expect(cardIcon).toExist();
click($('bookmarks-close'));
await sleep(80);
} finally {
for (const c of [target, bm]) {
if (c?.id) await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: hDel() });
}
await window._reloadAll();
}
});
});
describe('Bookmarks: column persistence', () => {
it('new columns survive close + reopen', async () => {
let card;
try {
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_colsave__', body: emptyBody() }) }).then(r => r.json());
await window._reloadAll();
await sleep(200);
// Open overlay, add a column via the data model + save
// (simulates what addColumn does without the dialog).
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
click(el);
await sleep(150);
// Grab the plugin's internal data via a fresh body parse,
// add a column, and save through the overlay's save path.
const full = await fetch(`api/cards?ids=${card.id}`).then(r => r.json());
const body = (full[0]?.body || '').trim();
const jsonEnd = body.lastIndexOf('}');
const parsed = JSON.parse(body.substring(0, jsonEnd + 1));
parsed.columns.push({ name: 'Added', items: [] });
const newBody = JSON.stringify(parsed) + '\n\n#bookmarks';
await fetch(`api/cards/${card.id}`, {
method: 'PATCH', headers: hJson(),
body: JSON.stringify({ body: newBody }),
});
// Update local state like save() does.
const local = (window.state?.cards || []).find(c => c.id == card.id);
if (local) local.body = newBody;
click($('bookmarks-close'));
await sleep(100);
// Reopen — the new column must still be there.
const el2 = document.querySelector(`.board-card[data-id="${card.id}"]`);
click(el2);
await sleep(150);
const names = [...document.querySelectorAll('.bm-col-name')].map(n => n.textContent);
expect(names).toContain('General');
expect(names).toContain('Added');
click($('bookmarks-close'));
await sleep(80);
} finally {
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
await window._reloadAll();
}
});
});
describe('Bookmarks: column width persistence', () => {
it('saves and restores columnWidth from card body', async () => {
const cols = [{ name: 'W', items: [] }];
let card;
try {
card = await fetch('api/cards', { method: 'POST', headers: hJson(), body: JSON.stringify({ title: '__test_bm_width__', body: bodyWith(cols, 300) }) }).then(r => r.json());
await window._reloadAll();
await sleep(200);
const el = document.querySelector(`.board-card[data-id="${card.id}"]`);
click(el);
await sleep(150);
const slider = $('#bookmarks-col-width');
expect(slider).toExist();
expect(parseInt(slider.value)).toBe(300);
click($('bookmarks-close'));
await sleep(80);
} finally {
if (card?.id) await fetch(`api/cards/${card.id}`, { method: 'DELETE', headers: hDel() });
await window._reloadAll();
}
});
});