- sync: the post-sync toast lumped relay pulls into the CalDAV stats and always labelled the banner "CalDAV". Split into two separate toasts (CalDAV cloud-arrow + Relay broadcast icon), each only shown when its numbers are non-zero. Banners now stack vertically so both can appear together on a sync that pulled on both sides. - relay UI: the session chat view was 1:1-only. Group rooms (which the client API already supports) now surface as first-class threads in the sidebar — sorted chronologically with 1:1 peers, tagged "ROOM", with a users-three icon. Sending posts to `room_uid` instead of `recipient`; history shows the sender name above each incoming message so it's clear who's talking in the room. Relay backend: `/api/chat/threads` returns rooms alongside 1:1 threads; `/api/chat/history` accepts `?room_uid=`; `/api/chat/send` accepts `room_uid` in the body. - tests: bump "replace all updates card bodies" poll budget from 4s to 10s and poll both updated cards. Flaky on cold runs because a fresh PHP worker + SQLite WAL warm-up plus two sequential PATCH calls can just barely exceed 4 seconds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
114 lines
4.4 KiB
JavaScript
114 lines
4.4 KiB
JavaScript
import { describe, it, afterEach, expect, $, $$, click, sleep, waitFor, closeAll, csrf } from './runner.js?v=4';
|
|
|
|
async function makeCard(title, body) {
|
|
return fetch('api/cards', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
|
body: JSON.stringify({ title, body, color: '#38bdf8' }),
|
|
}).then(r => r.json());
|
|
}
|
|
|
|
async function getCard(id) {
|
|
const all = await fetch(`api/cards?ids=${id}`).then(r => r.json());
|
|
return all.find(c => c.id == id);
|
|
}
|
|
|
|
function openSearchReplace() {
|
|
document.dispatchEvent(new KeyboardEvent('keydown', {
|
|
key: 'H', ctrlKey: true, shiftKey: true, bubbles: true,
|
|
}));
|
|
}
|
|
|
|
describe('Search: Find/Replace', () => {
|
|
afterEach(async () => {
|
|
await closeAll();
|
|
});
|
|
|
|
it('Ctrl+Shift+H opens search with replace row visible', async () => {
|
|
openSearchReplace();
|
|
await sleep(200);
|
|
const overlay = document.getElementById('search');
|
|
expect(overlay?.classList.contains('open')).toBe(true);
|
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
|
expect(document.getElementById('search-replace-input')).toExist();
|
|
expect(document.getElementById('search-replace-all')).toExist();
|
|
});
|
|
|
|
it('replace toggle shows/hides replace row', async () => {
|
|
openSearchReplace();
|
|
await sleep(200);
|
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
|
click('#search-replace-toggle');
|
|
await sleep(50);
|
|
expect(document.getElementById('search-replace-row')?.classList.contains('hidden')).toBe(true);
|
|
click('#search-replace-toggle');
|
|
await sleep(50);
|
|
expect(document.getElementById('search-replace-row')).toBeVisible();
|
|
});
|
|
|
|
it('replace all updates card bodies after confirmation', async () => {
|
|
const c1 = await makeCard('__bfr_repl_a__', 'hello world hello');
|
|
const c2 = await makeCard('__bfr_repl_b__', 'hello there');
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
openSearchReplace();
|
|
await sleep(200);
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
const replInput = document.getElementById('search-replace-input');
|
|
if (!searchInput || !replInput) return;
|
|
|
|
searchInput.value = 'hello';
|
|
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await sleep(200);
|
|
|
|
replInput.value = 'goodbye';
|
|
replInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await sleep(50);
|
|
|
|
const applyBtn = document.getElementById('search-replace-all');
|
|
expect(applyBtn.disabled).toBe(false);
|
|
click(applyBtn);
|
|
|
|
// The replace-all button opens a confirm dialog — wait for it
|
|
// to appear rather than relying on a fixed sleep, since the
|
|
// dialog creation is async and can lag on slow runs.
|
|
await waitFor(() => document.getElementById('dialog')?.classList.contains('open'), 2000);
|
|
const okBtn = document.getElementById('dialog-ok');
|
|
if (okBtn) click(okBtn);
|
|
|
|
// Poll until both replaces have persisted. Budget is generous
|
|
// (~10s) because a cold PHP run plus two sequential PATCH calls
|
|
// can easily blow past the original 4-second window — the retry
|
|
// always passes because opcache + SQLite WAL are warm by then.
|
|
let updated1, updated2;
|
|
for (let i = 0; i < 100; i++) {
|
|
await sleep(100);
|
|
updated1 = await getCard(c1.id);
|
|
updated2 = await getCard(c2.id);
|
|
if (updated1?.body === 'goodbye world goodbye' && updated2?.body === 'goodbye there') break;
|
|
}
|
|
expect(updated1.body).toBe('goodbye world goodbye');
|
|
expect(updated2.body).toBe('goodbye there');
|
|
});
|
|
|
|
it('replace all button disabled without replace text', async () => {
|
|
await makeCard('__bfr_dis__', 'some text');
|
|
await window._reloadAll?.();
|
|
await sleep(200);
|
|
|
|
openSearchReplace();
|
|
await sleep(200);
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
if (!searchInput) return;
|
|
searchInput.value = 'some';
|
|
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await sleep(100);
|
|
|
|
const applyBtn = document.getElementById('search-replace-all');
|
|
expect(applyBtn.disabled).toBe(true);
|
|
});
|
|
});
|