feat: collaboration temps reel - edition simultanee (#62)
CI / lint (push) Successful in 1m1s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m47s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 1m1s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m47s
CI / build (push) Successful in 1m20s
CI / e2e (push) Successful in 10m36s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
WebSocket /ws/collab/{vault}/{path} (rooms par fichier), relais Yjs/CRDT, awareness (curseurs colores + presence), persistance serveur debounce 2s, auth WS + check_vault_access, reconnexion automatique. Frontend frontend/js/collab.js, backend backend/collab.py. Tests: 17 backend (5 clients simultanes) + 10 frontend. Docs: CHANGELOG, ROADMAP, fiche features/collaboration.md, README FR/EN.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM tests for the collaboration module (ROADMAP #62).
|
||||
*
|
||||
* Covers the pure helpers of frontend/js/collab.js (text diff used to apply
|
||||
* remote Yjs changes, cursor URL building, avatar initials). The WebSocket /
|
||||
* Yjs runtime paths are exercised by the backend pytest suite.
|
||||
*
|
||||
* Usage: node tests/frontend/collab.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// ── JSDOM bootstrap (collab.js imports i18n.js which touches window) ────────
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html><html><body>
|
||||
<div class="user-display-name">Alice</div>
|
||||
<div id="collab-presence" style="display:none"></div>
|
||||
</body></html>`,
|
||||
{ url: "https://example.com/vault", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: w.navigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.WebSocket = w.WebSocket || class {};
|
||||
|
||||
const collab = await import(
|
||||
pathToFileURL(path.join(REPO_ROOT, "frontend", "js", "collab.js")).href
|
||||
);
|
||||
|
||||
const { computeTextDiff, initialOf, buildCollabUrl, formatPeerCount } = collab;
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passCount++;
|
||||
} catch (e) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n── collab.js unit tests (#62) ──\n");
|
||||
|
||||
test("computeTextDiff returns null for identical text", () => {
|
||||
assert.equal(computeTextDiff("abc", "abc"), null);
|
||||
});
|
||||
|
||||
test("computeTextDiff detects an insertion", () => {
|
||||
assert.deepEqual(computeTextDiff("ab", "aXb"), { from: 1, to: 1, insert: "X" });
|
||||
});
|
||||
|
||||
test("computeTextDiff detects a deletion", () => {
|
||||
assert.deepEqual(computeTextDiff("abc", "ac"), { from: 1, to: 2, insert: "" });
|
||||
});
|
||||
|
||||
test("computeTextDiff detects a replacement", () => {
|
||||
assert.deepEqual(computeTextDiff("hello world", "hello there"), {
|
||||
from: 6,
|
||||
to: 11,
|
||||
insert: "there",
|
||||
});
|
||||
});
|
||||
|
||||
test("computeTextDiff handles empty originals", () => {
|
||||
assert.deepEqual(computeTextDiff("", "new"), { from: 0, to: 0, insert: "new" });
|
||||
assert.deepEqual(computeTextDiff("old", ""), { from: 0, to: 3, insert: "" });
|
||||
});
|
||||
|
||||
test("computeTextDiff only replaces the changed region", () => {
|
||||
const oldText = "line1\nline2\nline3";
|
||||
const newText = "line1\nCHANGED\nline3";
|
||||
const diff = computeTextDiff(oldText, newText);
|
||||
assert.equal(diff.from, 6);
|
||||
assert.equal(diff.insert, "CHANGED");
|
||||
assert.equal(oldText.slice(0, diff.from) + diff.insert + oldText.slice(diff.to), newText);
|
||||
});
|
||||
|
||||
test("initialOf returns an uppercase initial", () => {
|
||||
assert.equal(initialOf("alice"), "A");
|
||||
assert.equal(initialOf("Bob"), "B");
|
||||
assert.equal(initialOf(""), "?");
|
||||
assert.equal(initialOf(" "), "?");
|
||||
});
|
||||
|
||||
test("buildCollabUrl encodes vault and path segments", () => {
|
||||
const loc = { protocol: "https:", host: "obsigate.local" };
|
||||
assert.equal(
|
||||
buildCollabUrl(loc, "Mon Vault", "Projets/mon fichier.md"),
|
||||
"wss://obsigate.local/ws/collab/Mon%20Vault/Projets/mon%20fichier.md",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCollabUrl uses ws:// for http origins", () => {
|
||||
const loc = { protocol: "http:", host: "localhost:8080" };
|
||||
assert.equal(
|
||||
buildCollabUrl(loc, "V", "note.md"),
|
||||
"ws://localhost:8080/ws/collab/V/note.md",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatPeerCount is always a non-negative integer string", () => {
|
||||
assert.equal(formatPeerCount(0), "0");
|
||||
assert.equal(formatPeerCount(5), "5");
|
||||
assert.equal(formatPeerCount(-2), "0");
|
||||
assert.equal(formatPeerCount(undefined), "0");
|
||||
});
|
||||
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
@@ -0,0 +1,336 @@
|
||||
# tests/test_collab.py — Tests for real-time collaboration (ROADMAP #62)
|
||||
"""Unit and integration tests for the collaboration WebSocket relay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from backend.collab import (
|
||||
CollabManager,
|
||||
CollabRoom,
|
||||
authenticate_websocket,
|
||||
color_for_index,
|
||||
)
|
||||
|
||||
|
||||
class _StubWebSocket:
|
||||
"""Minimal WebSocket stub for auth unit tests."""
|
||||
|
||||
def __init__(self, query=None, cookies=None):
|
||||
self.query_params = query or {}
|
||||
self.cookies = cookies or {}
|
||||
|
||||
|
||||
def _recv_json(ws) -> dict:
|
||||
return json.loads(ws.receive_text())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_collab_manager():
|
||||
"""Isolate the collaboration singleton between tests."""
|
||||
from backend.collab import collab_manager
|
||||
|
||||
collab_manager._rooms.clear()
|
||||
collab_manager._next_conn_id = 1
|
||||
yield
|
||||
collab_manager._rooms.clear()
|
||||
collab_manager._next_conn_id = 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_color_for_index_is_deterministic_and_cycles():
|
||||
assert color_for_index(0) == color_for_index(10)
|
||||
assert color_for_index(0) != color_for_index(1)
|
||||
assert color_for_index(0).startswith("#")
|
||||
|
||||
|
||||
def test_authenticate_websocket_disabled_returns_anonymous(monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "false")
|
||||
user = authenticate_websocket(_StubWebSocket())
|
||||
assert user is not None
|
||||
assert user["role"] == "admin"
|
||||
assert user["vaults"] == ["*"]
|
||||
|
||||
|
||||
def test_authenticate_websocket_no_token_returns_none(monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "true")
|
||||
assert authenticate_websocket(_StubWebSocket()) is None
|
||||
|
||||
|
||||
def test_authenticate_websocket_invalid_token_returns_none(monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "true")
|
||||
assert authenticate_websocket(_StubWebSocket(cookies={"access_token": "garbage"})) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manager unit tests (no WebSocket transport)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_manager(tmp_path: Path, debounce: float = 0.05) -> CollabManager:
|
||||
return CollabManager(save_debounce=debounce)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_persists_text_after_debounce(tmp_path: Path):
|
||||
target = tmp_path / "note.md"
|
||||
target.write_text("initial", encoding="utf-8")
|
||||
manager = _make_manager(tmp_path)
|
||||
room = CollabRoom(vault="V", path="note.md", file_path=target)
|
||||
|
||||
room.pending_text = "updated content"
|
||||
manager._schedule_save(room)
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "updated content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_flush_writes_pending_text(tmp_path: Path):
|
||||
target = tmp_path / "note.md"
|
||||
target.write_text("initial", encoding="utf-8")
|
||||
manager = _make_manager(tmp_path)
|
||||
room = CollabRoom(vault="V", path="note.md", file_path=target, pending_text="flushed")
|
||||
await manager._flush(room)
|
||||
assert target.read_text(encoding="utf-8") == "flushed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_rejects_oversized_update(tmp_path: Path):
|
||||
target = tmp_path / "note.md"
|
||||
target.write_text("x", encoding="utf-8")
|
||||
manager = _make_manager(tmp_path)
|
||||
room = CollabRoom(vault="V", path="note.md", file_path=target)
|
||||
client = _FakeClient(conn_id=1)
|
||||
|
||||
payload = base64.b64encode(b"a" * 10).decode()
|
||||
# Monkeypatch the max size check by sending an oversized base64 string.
|
||||
import backend.collab as collab_mod
|
||||
|
||||
original = collab_mod.MAX_UPDATE_BYTES
|
||||
try:
|
||||
collab_mod.MAX_UPDATE_BYTES = 4
|
||||
await manager._on_message(room, client, json.dumps({"type": "update", "update": payload}))
|
||||
finally:
|
||||
collab_mod.MAX_UPDATE_BYTES = original
|
||||
|
||||
assert room.updates == []
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self):
|
||||
self.sent: list[dict] = []
|
||||
|
||||
async def send_json(self, message: dict):
|
||||
self.sent.append(message)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, conn_id: int):
|
||||
self.conn_id = conn_id
|
||||
self.websocket = _FakeWebSocket()
|
||||
self.username = "u"
|
||||
self.display_name = "u"
|
||||
self.color = "#000"
|
||||
self.y_client_id = None
|
||||
self.awareness = None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_appends_update_and_broadcasts(tmp_path: Path):
|
||||
target = tmp_path / "note.md"
|
||||
target.write_text("x", encoding="utf-8")
|
||||
manager = _make_manager(tmp_path)
|
||||
room = CollabRoom(vault="V", path="note.md", file_path=target)
|
||||
sender = _FakeClient(1)
|
||||
receiver = _FakeClient(2)
|
||||
room.clients[1] = sender
|
||||
room.clients[2] = receiver
|
||||
|
||||
encoded = base64.b64encode(b"hello-yjs").decode()
|
||||
await manager._on_message(room, sender, json.dumps({"type": "update", "update": encoded}))
|
||||
|
||||
assert room.updates == [b"hello-yjs"]
|
||||
assert room.has_updates is True
|
||||
assert receiver.websocket.sent[-1]["type"] == "update"
|
||||
assert receiver.websocket.sent[-1]["update"] == encoded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_awareness_relayed(tmp_path: Path):
|
||||
target = tmp_path / "note.md"
|
||||
target.write_text("x", encoding="utf-8")
|
||||
manager = _make_manager(tmp_path)
|
||||
room = CollabRoom(vault="V", path="note.md", file_path=target)
|
||||
sender = _FakeClient(1)
|
||||
receiver = _FakeClient(2)
|
||||
room.clients[1] = sender
|
||||
room.clients[2] = receiver
|
||||
|
||||
msg = {"type": "awareness", "clientId": 42, "state": {"cursor": {"anchor": 1, "head": 2}}}
|
||||
await manager._on_message(room, sender, json.dumps(msg))
|
||||
|
||||
assert sender.y_client_id == 42
|
||||
assert receiver.websocket.sent[-1]["type"] == "awareness"
|
||||
assert receiver.websocket.sent[-1]["state"]["cursor"]["head"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests (Starlette TestClient WebSocket)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_websocket_init_and_broadcast(client):
|
||||
with client.websocket_connect("/ws/collab/TestVault/note1.md") as ws1:
|
||||
init1 = _recv_json(ws1)
|
||||
assert init1["type"] == "init"
|
||||
assert init1["connId"] == 1
|
||||
assert init1["seed"] is not None # first client receives the file content
|
||||
assert init1["updates"] == []
|
||||
|
||||
with client.websocket_connect("/ws/collab/TestVault/note1.md") as ws2:
|
||||
init2 = _recv_json(ws2)
|
||||
assert init2["type"] == "init"
|
||||
assert init2["seed"] is None # already seeded
|
||||
# ws1 is notified of the new peer
|
||||
joined = _recv_json(ws1)
|
||||
assert joined["type"] == "peer_joined"
|
||||
assert len(init2["peers"]) == 2
|
||||
|
||||
encoded = base64.b64encode(b"payload").decode()
|
||||
ws1.send_text(json.dumps({"type": "update", "update": encoded}))
|
||||
relayed = _recv_json(ws2)
|
||||
assert relayed["type"] == "update"
|
||||
assert relayed["update"] == encoded
|
||||
|
||||
# ws2 left → ws1 gets peer_left
|
||||
left = _recv_json(ws1)
|
||||
assert left["type"] == "peer_left"
|
||||
|
||||
|
||||
def test_websocket_awareness_relay(client):
|
||||
with client.websocket_connect("/ws/collab/TestVault/note1.md") as ws1:
|
||||
_recv_json(ws1)
|
||||
with client.websocket_connect("/ws/collab/TestVault/note1.md") as ws2:
|
||||
_recv_json(ws2)
|
||||
_recv_json(ws1) # peer_joined
|
||||
|
||||
ws1.send_text(json.dumps({
|
||||
"type": "awareness",
|
||||
"clientId": 7,
|
||||
"state": {"user": {"name": "Alice"}, "cursor": {"anchor": 3, "head": 3}},
|
||||
}))
|
||||
msg = _recv_json(ws2)
|
||||
assert msg["type"] == "awareness"
|
||||
assert msg["clientId"] == 7
|
||||
assert msg["state"]["user"]["name"] == "Alice"
|
||||
|
||||
|
||||
def test_websocket_persists_text(client, test_vault_dir):
|
||||
from backend import collab as collab_mod
|
||||
|
||||
original = collab_mod.collab_manager._save_debounce
|
||||
collab_mod.collab_manager._save_debounce = 0.05
|
||||
try:
|
||||
with client.websocket_connect("/ws/collab/TestVault/note1.md") as ws:
|
||||
_recv_json(ws)
|
||||
ws.send_text(json.dumps({"type": "text", "text": "# Persisted by collab\n"}))
|
||||
deadline = time.time() + 2
|
||||
target = Path(test_vault_dir) / "note1.md"
|
||||
while time.time() < deadline:
|
||||
if target.read_text(encoding="utf-8") == "# Persisted by collab\n":
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert target.read_text(encoding="utf-8") == "# Persisted by collab\n"
|
||||
finally:
|
||||
collab_mod.collab_manager._save_debounce = original
|
||||
|
||||
|
||||
def test_websocket_unknown_file_rejected(client):
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with client.websocket_connect("/ws/collab/TestVault/missing.md") as ws:
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
def test_websocket_unknown_vault_rejected(client):
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with client.websocket_connect("/ws/collab/Nope/note1.md") as ws:
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
def test_websocket_requires_auth(admin_client):
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with admin_client.websocket_connect("/ws/collab/TestVault/note1.md") as ws:
|
||||
ws.receive_text()
|
||||
|
||||
|
||||
def test_websocket_vault_acl_enforced(admin_client):
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
root = Path(get_vault_data("TestVault")["path"])
|
||||
target = root / "acl_test.md"
|
||||
target.write_text("hi", encoding="utf-8")
|
||||
try:
|
||||
_login(admin_client, "normaluser", "normal123") # sets the access_token cookie
|
||||
# normaluser has access to TestVault only.
|
||||
with admin_client.websocket_connect("/ws/collab/TestVault/acl_test.md") as ws:
|
||||
assert _recv_json(ws)["type"] == "init"
|
||||
|
||||
with pytest.raises(WebSocketDisconnect):
|
||||
with admin_client.websocket_connect("/ws/collab/OtherVault/acl_test.md") as ws:
|
||||
ws.receive_text()
|
||||
finally:
|
||||
target.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _login(client: TestClient, username: str, password: str) -> str:
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _recv_until(ws, msg_type: str, limit: int = 20) -> dict:
|
||||
for _ in range(limit):
|
||||
msg = _recv_json(ws)
|
||||
if msg.get("type") == msg_type:
|
||||
return msg
|
||||
raise AssertionError(f"message type '{msg_type}' not received")
|
||||
|
||||
|
||||
def test_websocket_load_five_clients(client):
|
||||
"""5+ utilisateurs simultanés sur le même fichier (ROADMAP #62)."""
|
||||
from backend.collab import collab_manager
|
||||
|
||||
contexts = []
|
||||
try:
|
||||
sessions = []
|
||||
for _ in range(5):
|
||||
ctx = client.websocket_connect("/ws/collab/TestVault/note1.md")
|
||||
ws = ctx.__enter__()
|
||||
contexts.append(ctx)
|
||||
sessions.append(ws)
|
||||
assert _recv_json(ws)["type"] == "init"
|
||||
|
||||
assert collab_manager.room_peer_count("TestVault", "note1.md") == 5
|
||||
|
||||
# The first client broadcasts an update, all 4 others receive it.
|
||||
encoded = base64.b64encode(b"load-test-update").decode()
|
||||
sessions[0].send_text(json.dumps({"type": "update", "update": encoded}))
|
||||
for ws in sessions[1:]:
|
||||
relayed = _recv_until(ws, "update")
|
||||
assert relayed["update"] == encoded
|
||||
finally:
|
||||
for ctx in reversed(contexts):
|
||||
try:
|
||||
ctx.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user