Files
MindSift/e2e/lock.e2e.mjs
T
sandrodachandClaude Opus 5.5 8b7a53daab
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
feat(notes): push, long polling and fallback detection for note sync
Phase 4 of the collaboration plan:
- The sync engine listens for DOC_SEQ in the note's realtime room and syncs
  right away. Without WebSocket/SSE it long-polls /docs/{id}/changes (25 s);
  after three failures it falls back to polling every 3 s and retries long
  polling when the browser comes back online.
- With push or long polling the engine only syncs every 7 s while holding
  leases (renewal), otherwise every 30 s as a safety net. Focusing a block
  announces its lease immediately (otherwise it could take up to 30 s).
- Realtime connection: SSE counts as connected only after its first event;
  a stream that stays silent for 5 s (buffering proxy) switches SSE off for
  the tab. App-level PING/PONG detects WebSockets that are accepted but
  forward nothing and switches to SSE after 35 s.
- The editor shows the active channel for shared notes.
- Tests: engine channel tests (push, heartbeat, lease on focus, long
  polling, interval fallback, switching), realtime fallback tests with fake
  WebSocket/EventSource, Go tests for PONG and DOC_SEQ delivery, and
  e2e/channels.e2e.mjs measuring latency behind six simulated proxy setups
  (0.2-0.3 s on all live channels, ~3 s with plain polling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:10:29 +01:00

171 lines
9.1 KiB
JavaScript

// Locks, presence, undo and conflict copies with two real browsers. Needs a server in demo mode
// (accounts admin/admin and user/user); see e2e/README.md.
import { chromium } from 'playwright';
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let failures = 0;
const check = (cond, msg) => {
console.log(`${cond ? 'OK ' : 'FAIL'} ${msg}`);
if (!cond) failures++;
};
async function api(token, method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text}`);
return text ? JSON.parse(text) : null;
}
async function waitFor(fn, ms) {
const end = Date.now() + ms;
while (Date.now() < end) {
try {
const v = await fn();
if (v) return v;
} catch {}
await sleep(250);
}
return null;
}
const serverBlocks = async (token, docId) =>
(await api(token, 'GET', `/api/v1/docs/${docId}/snapshot`)).blocks.filter((b) => !b.deleted);
const editorValues = (page) =>
page.$$eval('textarea', (els) => els.filter((e) => !e.placeholder?.startsWith('Titel')).map((e) => e.value));
// Puts the cursor at the end of the text field showing `text` (works whether or not it is focused already)
async function focusField(page, text) {
const handle = await page.waitForFunction(
(t) => Array.from(document.querySelectorAll('textarea')).find((e) => e.value === t) || null,
text,
{ timeout: 5000, polling: 100 } // rAF polling stalls in background pages
);
const el = handle.asElement();
await el.click();
await page.keyboard.press('End');
}
async function openDoc(browser, token, title) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token);
const page = await ctx.newPage();
await page.goto(BASE);
await page.getByRole('button', { name: /Workspace/ }).first().click();
await page.getByText(title, { exact: true }).first().click();
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 1, null, { timeout: 10000 });
return { ctx, page };
}
const TITLE = `Sperr-Test ${Date.now()}`;
const id = (n) => `lk_${n}_${Date.now()}`;
const main = async () => {
const admin = (await api(null, 'POST', '/api/v1/auth/login', { login: 'admin', password: 'admin' })).token;
const user = (await api(null, 'POST', '/api/v1/auth/login', { login: 'user', password: 'user' })).token;
const dir = await api(admin, 'GET', '/api/v1/users/directory');
const userId = dir.find((u) => u.username === 'user' || u.email?.startsWith('user')).id;
const ids = { a: id('a'), b: id('b') };
const doc = await api(admin, 'POST', '/api/v1/documents', {
title: TITLE,
blocks: [
{ id: ids.a, type: 'bullet', content: 'Admin schreibt hier', level: 0 },
{ id: ids.b, type: 'bullet', content: 'Darunter', level: 0 },
],
});
await api(admin, 'POST', `/api/v1/documents/${doc.id}/shares`, { userId, permission: 'write' });
const browser = await chromium.launch();
const A = await openDoc(browser, admin, TITLE);
const B = await openDoc(browser, user, TITLE);
// Presence
const presence = await waitFor(async () => B.page.getByTitle('Hat diese Notiz gerade geöffnet').innerText(), 8000);
check(!!presence && /Admin/i.test(presence), `Bearbeiter sieht, wer noch in der Notiz ist: ${presence}`);
// Admin clicks into the first bullet -> user sees the lock
await A.page.getByText('Admin schreibt hier', { exact: true }).first().click();
const lockBadge = await waitFor(async () => (await B.page.getByTitle(/bearbeitet diesen Punkt gerade/).count()) > 0, 10000);
check(!!lockBadge, 'Bearbeiter sieht das Schloss am gesperrten Bullet');
const readOnly = await B.page.$$eval('textarea', (els) => els.find((e) => e.value === 'Admin schreibt hier')?.readOnly);
check(readOnly === true, 'Gesperrter Bullet ist beim Bearbeiter schreibgeschützt');
// User tries to merge "Darunter" into the locked bullet
await B.page.getByText('Darunter', { exact: true }).first().click();
await B.page.keyboard.press('Home');
await B.page.keyboard.press('Backspace');
const notice = await waitFor(async () => B.page.getByRole('status').innerText(), 3000);
check(!!notice && /bearbeitet diesen Punkt gerade/.test(notice), `Hinweis statt halber Aktion: ${notice}`);
await sleep(1500);
const afterMerge = (await serverBlocks(admin, doc.id)).map((b) => b.content).sort();
check(JSON.stringify(afterMerge) === JSON.stringify(['Admin schreibt hier', 'Darunter']), `Nichts wurde teilweise ausgeführt: ${JSON.stringify(afterMerge)}`);
// Undo on the admin side
await focusField(A.page, 'Admin schreibt hier');
await A.page.keyboard.type(' und noch mehr');
await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier und noch mehr'), 5000);
await A.page.keyboard.press('Control+z');
const undone = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier'), 5000);
check(!!undone, 'Strg+Z nimmt den getippten Text zurück (auch auf dem Server)');
const fieldUndone = await waitFor(async () => (await editorValues(A.page)).includes('Admin schreibt hier'), 3000);
check(!!fieldUndone, 'Das fokussierte Feld zeigt den zurückgenommenen Text');
await A.page.keyboard.press('Control+Shift+z');
const redone = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier und noch mehr'), 5000);
check(!!redone, 'Strg+Umschalt+Z stellt ihn wieder her');
// Typing right after Enter lands in the new bullet (no pause)
await A.page.keyboard.press('End');
await A.page.keyboard.press('Enter');
await A.page.keyboard.type('Direkt nach Enter');
const newBullet = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Direkt nach Enter'), 5000);
check(!!newBullet, 'Sofort nach Enter Getipptes landet im neuen Bullet');
// Conflict copy: two writes on the same base version via the API
const before = (await serverBlocks(admin, doc.id)).find((b) => b.id === ids.b);
await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'api-1', lastSeq: 0, ops: [{ opId: `${ids.b}-x`, kind: 'set_content', blockId: ids.b, baseVersion: before.version, content: 'Gewinner-Text' }] });
await api(user, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'api-2', lastSeq: 0, ops: [{ opId: `${ids.b}-y`, kind: 'set_content', blockId: ids.b, baseVersion: before.version, content: 'Verlierer-Text' }] });
const conflictShown = await waitFor(async () => (await A.page.getByText(/Version von/).count()) > 0, 8000);
check(!!conflictShown, 'Konfliktkopie wird markiert angezeigt');
// The user's cursor is still in the original bullet (it holds the lock), so the admin may not overwrite it
await A.page.getByRole('button', { name: 'Übernehmen' }).first().click();
const refused = await waitFor(async () => A.page.getByRole('status').innerText(), 3000);
check(!!refused && /bearbeitet/.test(refused), `Übernehmen in einen fremd gesperrten Punkt wird verweigert: ${refused}`);
// The user holding the original can accept once the copy's author (here a separate API client that never
// renews its lease) no longer holds the copy, i.e. after at most 20 s
await waitFor(async () => (await B.page.getByText(/Version von/).count()) > 0, 8000);
const copyId = (await serverBlocks(admin, doc.id)).find((b) => b.props?.conflictOf)?.id;
const copyFree = await waitFor(async () => {
const probe = await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'probe', lastSeq: 0 });
return !probe.leases.others.some((l) => l.blockId === copyId);
}, 25000);
check(!!copyFree, 'Sperre der Konfliktkopie läuft ab');
await sleep(3500); // the user's browser learns about it with its next sync
// Both API writes became conflict copies (the user's cursor locked the original); accept the first one
const copiesBefore = (await serverBlocks(admin, doc.id)).filter((b) => b.props?.conflictOf === ids.b);
const firstCopyText = await B.page.$$eval(
'textarea',
(els, texts) => els.map((e) => e.value).find((v) => texts.includes(v)),
copiesBefore.map((c) => c.content)
);
await B.page.getByRole('button', { name: 'Übernehmen' }).first().click();
const accepted = await waitFor(async () => {
const blocks = await serverBlocks(admin, doc.id);
const copiesNow = blocks.filter((b) => b.props?.conflictOf === ids.b);
return blocks.find((b) => b.id === ids.b)?.content === firstCopyText && copiesNow.length === copiesBefore.length - 1;
}, 6000);
check(!!accepted, `„Übernehmen“ ersetzt den ursprünglichen Text durch „${firstCopyText}“ und entfernt diese Kopie`);
await browser.close();
console.log(failures ? `${failures} FEHLGESCHLAGEN` : 'ALLE PRÜFUNGEN BESTANDEN');
process.exit(failures ? 1 : 0);
};
main().catch((e) => {
console.error(e);
process.exit(2);
});