Files
MindSift/e2e/channels.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

120 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// How fast changes arrive behind different (simulated) proxies. The admin edits with a normal connection;
// the editor's browser blocks WebSocket, SSE and/or long polling. Needs a server in demo mode.
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(100);
}
return null;
}
const editorValues = (page) =>
page.$$eval('textarea', (els) => els.filter((e) => !e.placeholder?.startsWith('Titel')).map((e) => e.value));
const channelOf = (page) => page.getAttribute('[data-sync-channel]', 'data-sync-channel');
async function openDoc(browser, token, title, block = {}) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
// A blocking proxy refuses the connection (policy violation); a clean close would just mean "reconnect"
if (block.ws === 'reject') await ctx.routeWebSocket(/\/api\/v1\/ws/, (ws) => ws.close({ code: 1008, reason: 'blocked by proxy' }));
if (block.ws === 'silent') await ctx.routeWebSocket(/\/api\/v1\/ws/, () => {}); // accepted, nothing forwarded
if (block.sse === 'abort') await ctx.route('**/api/v1/events**', (r) => r.abort());
if (block.sse === 'hang') await ctx.route('**/api/v1/events**', () => {}); // never answered
if (block.longpoll) await ctx.route('**/changes**', (r) => r.abort());
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 };
}
async function typeAtEnd(page, text) {
await page.bringToFront();
// Place the cursor via script: Playwright's actionability checks wait for animation frames, which
// pages in the background do not get while the other browser window is in front
const ok = await page.evaluate(() => {
const el = Array.from(document.querySelectorAll('textarea')).find((e) => e.value.startsWith('Live-Test'));
if (!el) return false;
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
return true;
});
if (!ok) throw new Error('Live-Test field not found');
await page.keyboard.type(text);
}
const TITLE = `Kanal-Test ${Date.now()}`;
const MODES = [
{ name: 'nichts blockiert', block: {}, channel: 'ws', maxMs: 2500 },
{ name: 'WebSocket-Handshake abgelehnt', block: { ws: 'reject' }, channel: 'sse', maxMs: 2500 },
{ name: 'WebSocket angenommen, leitet nichts weiter', block: { ws: 'silent' }, channel: 'sse', maxMs: 2500, switchMs: 60000 },
{ name: 'WebSocket und SSE blockiert', block: { ws: 'reject', sse: 'abort' }, channel: 'longpoll', maxMs: 2500 },
{ name: 'SSE-Verbindung hängt', block: { ws: 'reject', sse: 'hang' }, channel: 'longpoll', maxMs: 2500 },
{ name: 'nur normale Anfragen möglich', block: { ws: 'reject', sse: 'abort', longpoll: true }, channel: 'interval', maxMs: 6000, switchMs: 15000 },
];
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 doc = await api(admin, 'POST', '/api/v1/documents', {
title: TITLE,
blocks: [{ id: `ch_${Date.now()}`, type: 'bullet', content: 'Live-Test', 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);
for (const [i, mode] of MODES.entries()) {
const B = await openDoc(browser, user, TITLE, mode.block);
const channel = await waitFor(async () => ((await channelOf(B.page)) === mode.channel ? mode.channel : null), mode.switchMs || 10000);
check(channel === mode.channel, `${mode.name}: Weg ${(await channelOf(B.page)) || '?'} (erwartet ${mode.channel})`);
const marker = ` r${i}`;
const start = Date.now();
await typeAtEnd(A.page, marker);
const arrived = await waitFor(async () => (await editorValues(B.page)).some((v) => v.endsWith(marker)), mode.maxMs + 4000);
const ms = Date.now() - start;
check(!!arrived && ms <= mode.maxMs, `${mode.name}: Änderung kam nach ${arrived ? ms : '–'} ms an (Ziel ≤ ${mode.maxMs} ms)`);
await B.ctx.close();
}
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);
});