feat(notes): push, long polling and fallback detection for note sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
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>
This commit is contained in:
1 parent
aa397c18cd
commit
8b7a53daab
18 files changed
+783
-37
No files matched your search
+5
-1
@@ -32,4 +32,8 @@ Der Eigentümer teilt eine Notiz über den Freigabe-Dialog mit Rolle „Bearbeit
|
||||
|
||||
Sperren, Präsenz, Rückgängig und Konfliktkopien mit zwei Browsern: Schloss und Schreibschutz am fremd bearbeiteten Bullet, Hinweis statt halb ausgeführter Aktion, `Strg+Z`/`Strg+Umschalt+Z`, Tippen direkt nach Enter, Markierung von Konfliktkopien sowie „Übernehmen“ (verweigert bei fremder Sperre, erfolgreich für den Sperrinhaber).
|
||||
|
||||
`collab.e2e.mjs` und `lock.e2e.mjs` dauern wegen des Sperrablaufs je etwa eine Minute. Er läuft nicht in der CI, weil er Browser-Images braucht.
|
||||
## channels.e2e.mjs
|
||||
|
||||
Der Eigentümer tippt mit normaler Verbindung, der Bearbeiter sitzt hinter einem simulierten Proxy. Für jede Situation wird der gewählte Weg und die Zeit bis zur Anzeige gemessen: nichts blockiert (WebSocket), WebSocket abgelehnt (SSE), WebSocket angenommen aber stumm (SSE nach Herzschlag-Ausfall), WebSocket und SSE blockiert sowie SSE hängend (Long-Polling), nur normale Anfragen (Abfrage alle 3 s).
|
||||
|
||||
`collab.e2e.mjs`, `lock.e2e.mjs` und `channels.e2e.mjs` dauern je etwa eine Minute. Die Tests laufen nicht in der CI, weil sie Browser-Images brauchen.
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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);
|
||||
});
|
||||
+1
-1
@@ -43,7 +43,7 @@ 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 }
|
||||
{ timeout: 5000, polling: 100 } // rAF polling stalls in background pages
|
||||
);
|
||||
const el = handle.asElement();
|
||||
await el.click();
|
||||
|
||||
+1
-1
@@ -15,4 +15,4 @@ for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18080/api/v1/health >/dev
|
||||
|
||||
echo "Starte Browser-Tests …"
|
||||
"$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
||||
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs && node lock.e2e.mjs'
|
||||
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs && node lock.e2e.mjs && node channels.e2e.mjs'
|
||||
Reference in new issue
Block a user