Files
figcraft/crates/collab-net/src/session_tests.rs
T
BFlatandClaude Opus 5.5 4d7a9187ff M9: live sessions: host from the app, invite links, live cursors and presence (#104)
* M9: real-time collaboration protocol (design)

docs/collab-protocol.md: WebSocket sessions with a server-ordered,
per-property last-writer-wins document sync, client-scoped ids,
fractional positions for children, ephemeral presence (cursors,
selections, follow), per-user undo, hosting from the app first and a
self-hosted server next, and the implementation plan. ROADMAP gains M9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: collaboration core: ids, change sets, server ordering, convergence

Step 1 of docs/collab-protocol.md, no networking:

- Client-scoped node ids: Document::set_client_id makes alloc_id hand out
  client << 32 | counter; single-user files allocate as before; the id
  counter is no longer part of document equality.
- crates/collab (layer 2): the synced object/property model (every saved
  Node field, plus document-level styles, comments, variables, images,
  dev snapshots, library data), Change batches, an O(changed) diff, strict
  and lenient apply, fractional sibling positions, snapshots.
- Server (ordering, validation, rejects with fixes) and Client (optimistic
  view, replay under remote batches, author-side repair of derived data)
  as pure state machines; binary (version byte + postcard, JSON values) and
  JSON message forms, fuzzed decoders.
- Engine: normalize as the recompute hook, collab.diff.
- Tests: round trips over random real commands, an N-client convergence
  fuzz test over delayed in-order links, junk batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: fmt after merge

* M9: live sessions: host from the app, invite links, live cursors and presence

Step 2 of docs/collab-protocol.md, networking and presence half:

- crates/collab-net (layer 6): WebSocket transport (sync tungstenite, a
  reader and a writer thread per connection, no async runtime), the
  session host the app embeds (runs figcraft_collab::Server as the
  authority, carries its messages), invite links, presence throttling
  (~30 Hz, latest wins), limits (sockets, hello/idle timeouts, message
  size, rate, slow clients), keep-alive, clean close, reconnect with
  backoff; a browser stand-in until the web-sys client (step 4).
- engine: live session state (status, peers, presence, follow) and the
  collab.host/stop/join/leave/peers/follow/cursor commands; the network
  is reached only through the LiveLink the app installs.
- ui-egui: remote cursors (pointer drawn in code, name tag, gliding),
  selection outlines (once documents sync), avatars in the right panel
  with follow, the Share popover (start, invite link + URL with Copy,
  stop), File > Join live session.
- desktop app and MCP headless session install the link; xtask layers
  keeps networking crates out of L0-L5.
- Documents don't sync between apps yet (follow-up).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: live sessions: document sync between apps

- The host's session runs figcraft_collab::Server seeded with the shared
  document; every app (the host's too, through the hub without a socket)
  is a collab Client. Guests open the host's file from the welcome as a
  new tab; leaving keeps it as a local copy.
- Engine: after each local action (a gesture once it ends; nothing during
  version or text previews) the session document is diffed into one
  Changes batch; server batches go through Client::receive with
  EngineRecompute and are installed with Session::apply_remote, which
  never touches the undo history. Batches arriving mid-gesture wait.
- Transport: Changes/Reject/Welcome reach the engine as Event::Sync;
  LiveLink::send carries batches in order; hellos are limited to 64 KiB,
  joined clients to the core's message size.
- Tests: engine sync against a real Server (ids in the client's space,
  one batch per gesture, remote edits skip undo, deferral, closing the
  file leaves), three apps over loopback converging (create, move,
  recolour, delete, rename) and a late joiner getting the server's state.
- Docs: collab-protocol, mcp, README, roadmap docs; the UI's
  "cursors only" note is gone, replaced by the per-person undo caveat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:40:59 +07:00

215 lines
10 KiB
Rust

//! Two engine sessions in one process, linked over loopback through `collab.*` commands: hosting,
//! joining with the invite, presence in each other's live state, following, leaving.
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use figcraft_engine::live::Status;
use serde_json::{Value, json};
use crate::NetLink;
use crate::loopback_tests::quick;
fn session(name: &str) -> Session {
let mut s = Session::new();
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
s.execute("pref.userName", &json!({"name": name})).unwrap();
s
}
/// Pump both sessions (as their UIs would every frame) until `f` holds.
fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Session) -> bool) {
let start = Instant::now();
loop {
a.live_poll();
b.live_poll();
a.live_sync();
b.live_sync();
if f(a, b) {
return;
}
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}: {} / {}", a.live_summary(), b.live_summary());
std::thread::sleep(Duration::from_millis(5));
}
}
fn host_and_join() -> (Session, Session, u32, u32) {
let mut host = session("Hana");
let mut guest = session("Ada");
let r = host.execute("collab.host", &json!({"port": 0})).unwrap();
assert_eq!(r["status"], "live");
assert_eq!(r["role"], "host");
assert_eq!(r["network"], false, "shared on this machine only by default");
let link = r["link"].as_str().unwrap().to_string();
assert!(link.starts_with("figcraft://join?host=127.0.0.1:"), "{link}");
let r = guest.execute("collab.join", &json!({"link": link})).unwrap();
assert_eq!(r["status"], "connecting");
pump(&mut host, &mut guest, "the join", |h, g| {
g.live.status == Status::Live && h.live.peers.len() == 1 && g.live.info.as_ref().is_some_and(|i| !i.color.is_empty())
});
let hana = host.live.info.as_ref().unwrap().me;
let ada = guest.live.info.as_ref().unwrap().me;
(host, guest, hana, ada)
}
#[test]
fn host_join_and_see_each_other() {
let (mut host, mut guest, hana, ada) = host_and_join();
let g = guest.live_summary();
assert_eq!(g["role"], "guest");
assert_eq!(g["fileName"], "Untitled", "the host's file name");
assert_eq!(g["name"], "Ada");
assert_eq!(g["peers"][0]["name"], "Hana");
assert_eq!(host.live.peers[&ada].name, "Ada");
// The pointer: the guest's cursor shows up in the host's state.
guest.execute("collab.cursor", &json!({"x": 120, "y": 80})).unwrap();
pump(&mut host, &mut guest, "the guest's cursor", |h, _| {
h.live.presence.get(&ada).and_then(|p| p.cursor).is_some_and(|c| c.1 == 120.0 && c.2 == 80.0)
});
// A selection travels too.
let r = host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
let id = r["id"].as_u64().unwrap();
host.execute("select.set", &json!({"ids": [id]})).unwrap();
pump(&mut host, &mut guest, "the host's selection", |_, g| {
g.live.presence.get(&hana).and_then(|p| p.selection.clone()).is_some_and(|s| s == vec![NodeId(id)])
});
// Leaving: the host sees it, the guest is back to no session.
assert_eq!(guest.execute("collab.leave", &json!({})).unwrap()["ended"], true);
assert_eq!(guest.live.status, Status::Off);
pump(&mut host, &mut guest, "the leave", |h, _| h.live.peers.is_empty() && h.live.presence.is_empty());
host.execute("collab.stop", &json!({})).unwrap();
assert_eq!(host.live_summary()["status"], "off");
}
#[test]
fn following_a_viewport_until_you_move() {
let (mut host, mut guest, hana, ada) = host_and_join();
host.execute("collab.follow", &json!({"client": ada})).unwrap();
assert_eq!(host.live.follow, Some(ada));
// The guest pans and zooms: the host's view goes along.
{
let ds = guest.ds_mut().unwrap();
ds.view.cx = 5000.0;
ds.view.cy = -300.0;
ds.view.zoom = 2.0;
}
pump(&mut host, &mut guest, "the follow", |h, _| {
(h.ds().unwrap().view.cx - 5000.0).abs() < 1e-6 && (h.ds().unwrap().view.cy + 300.0).abs() < 1e-6
});
let (hv, gv) = (host.ds().unwrap().view.clone(), guest.ds().unwrap().view.clone());
assert!((hv.zoom - gv.zoom).abs() < 1e-9, "same size viewports, same zoom");
// The host pans: following stops, and later moves of the guest don't move the host.
host.ds_mut().unwrap().view.cx += 100.0;
host.live_sync();
assert_eq!(host.live.follow, None);
guest.ds_mut().unwrap().view.cx = -9000.0;
pump(&mut host, &mut guest, "the guest's new viewport at the host", |h, _| {
h.live.presence.get(&ada).and_then(|p| p.viewport).is_some_and(|v| (v.1 + v.3 / 2.0 + 9000.0).abs() < 1e-6)
});
assert!((host.ds().unwrap().view.cx - 5100.0).abs() < 1e-6);
// Escape stops following too; following someone who isn't there fails.
guest.execute("collab.follow", &json!({"client": hana})).unwrap();
guest.escape().unwrap();
assert_eq!(guest.live.follow, None);
assert!(guest.execute("collab.follow", &json!({"client": 999})).is_err());
// The followed peer leaving ends the follow.
host.execute("collab.follow", &json!({"client": ada})).unwrap();
guest.execute("collab.leave", &json!({})).unwrap();
pump(&mut host, &mut guest, "the leave", |h, _| h.live.follow.is_none() && h.live.peers.is_empty());
}
#[test]
fn the_host_stopping_ends_the_session_for_guests() {
let (mut host, mut guest, _, _) = host_and_join();
host.execute("collab.stop", &json!({})).unwrap();
pump(&mut host, &mut guest, "the end", |_, g| g.live.status == Status::Off);
assert!(guest.live.peers.is_empty());
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
}
#[test]
fn a_bad_invite_is_reported() {
let mut guest = session("Ada");
let e = guest.execute("collab.join", &json!({"link": "figcraft://join?host=nope"})).unwrap_err().to_string();
assert!(e.contains("invite"), "{e}");
let mut host = session("Hana");
let r: Value = host.execute("collab.host", &json!({"port": 0})).unwrap();
let link = r["link"].as_str().unwrap().replace("token=", "token=x");
guest.execute("collab.join", &json!({"link": link})).unwrap();
pump(&mut host, &mut guest, "the refusal", |_, g| matches!(&g.live.status, Status::Failed { reason } if reason.contains("token")));
assert!(host.live.peers.is_empty());
}
/// The synced properties of two documents agree (nothing to send from one to the other).
fn same(a: &figcraft_doc::Document, b: &figcraft_doc::Document) -> bool {
figcraft_collab::diff(&figcraft_collab::Replica::new(a.clone()), b).is_ok_and(|d| d.changes.is_empty())
}
fn id_of(v: &Value) -> u64 {
v["id"].as_u64().unwrap()
}
#[test]
fn edits_sync_between_apps() {
let mut host = session("Hana");
host.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 80})).unwrap();
let link = host.execute("collab.host", &json!({"port": 0})).unwrap()["link"].as_str().unwrap().to_string();
let mut a = session("Ada");
let mut b = session("Bo");
a.execute("collab.join", &json!({"link": link})).unwrap();
b.execute("collab.join", &json!({"link": link})).unwrap();
let all = |host: &mut Session, a: &mut Session, b: &mut Session, what: &str, f: &dyn Fn(&Session, &Session, &Session) -> bool| {
let start = Instant::now();
loop {
for s in [&mut *host, &mut *a, &mut *b] {
s.live_poll();
s.live_sync();
}
if f(host, a, b) {
return;
}
assert!(start.elapsed() < Duration::from_secs(10), "timed out waiting for {what}");
std::thread::sleep(Duration::from_millis(5));
}
};
all(&mut host, &mut a, &mut b, "everyone syncing", &|h, a, b| {
h.live.syncing() && a.live.syncing() && b.live.syncing() && h.live.peers.len() == 2
});
// The guests got the host's file as a new tab.
assert_eq!(a.docs.len(), 2);
assert!(same(host.doc().unwrap(), a.doc().unwrap()) && same(host.doc().unwrap(), b.doc().unwrap()));
// Ada creates, moves, recolours; makes and deletes an ellipse; Bo renames; the host adds text.
let rect = id_of(&a.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 40})).unwrap());
a.live_sync();
a.execute("node.set", &json!({"ids": [rect], "props": {"x": 300, "y": 120}})).unwrap();
a.live_sync();
a.execute("fill.set", &json!({"ids": [rect], "color": "#FF0000"})).unwrap();
let ellipse = id_of(&a.execute("shape.ellipse", &json!({"x": 0, "y": 200, "width": 30, "height": 30})).unwrap());
a.live_sync();
a.execute("edit.delete", &json!({"ids": [ellipse]})).unwrap();
all(&mut host, &mut a, &mut b, "Ada's rectangle at Bo", &|_, _, b| b.doc().unwrap().contains(figcraft_doc::NodeId(rect)));
b.execute("node.set", &json!({"ids": [rect], "props": {"name": "Card"}})).unwrap();
let text = id_of(&host.execute("text.create", &json!({"x": 10, "y": 300, "text": "Hello"})).unwrap());
all(&mut host, &mut a, &mut b, "everyone agreeing", &|h, a, b| {
let (hd, ad, bd) = (h.doc().unwrap(), a.doc().unwrap(), b.doc().unwrap());
same(hd, ad)
&& same(hd, bd)
&& ad.contains(figcraft_doc::NodeId(text))
&& hd.node(figcraft_doc::NodeId(rect)).is_some_and(|n| n.name == "Card")
});
let (hd, ad) = (host.doc().unwrap().clone(), a.doc().unwrap().clone());
let n = ad.node(figcraft_doc::NodeId(rect)).unwrap();
assert_eq!(n.name, "Card");
assert!(!ad.contains(figcraft_doc::NodeId(ellipse)), "the deleted ellipse is gone everywhere");
assert!(!b.doc().unwrap().contains(figcraft_doc::NodeId(ellipse)));
assert_eq!(hd.node(figcraft_doc::NodeId(rect)).unwrap().fills, n.fills, "the colour came across");
// And the server's: someone joining now gets its document in the welcome.
let mut c = session("Cy");
c.execute("collab.join", &json!({"link": link})).unwrap();
all(&mut host, &mut a, &mut c, "the late joiner", &|_, _, c| c.live.syncing());
assert!(same(&hd, c.doc().unwrap()), "the server's document is everyone's");
host.execute("collab.stop", &json!({})).unwrap();
}