//! Live session state and the `collab.*` commands against a scripted link (the real one, over //! the network, is tested in `figcraft-collab-net`). use std::sync::{Arc, Mutex}; use figcraft_collab::message::{ClientMsg, ServerMsg}; use figcraft_collab::{Client, Limits, Outgoing, Server}; use figcraft_doc::{Document, NodeId}; use serde_json::json; use crate::live::{Access, Event, HostOptions, LiveLink, Peer, Presence, PublishOptions, Role, SessionInfo, Status}; use crate::{EngineError, Session}; /// What the scripted link was asked to do, and the events it hands out next. #[derive(Default)] pub(crate) struct Script { pub(crate) events: Vec, pub(crate) sent: Vec, pub(crate) hosted: Vec, joined: Vec, pub(crate) left: usize, pub(crate) msgs: Vec, pub(crate) published: Vec, /// The session started last: the scripted events are its. pub(crate) session: u64, /// The sessions ended, in order. pub(crate) ended: Vec, /// The session each presence in `sent` and each message in `msgs` was for. pub(crate) sent_to: Vec, pub(crate) msgs_to: Vec, /// The people removed: `(session, client)`. pub(crate) removed: Vec<(u64, u32)>, } pub(crate) struct Mock(pub(crate) Arc>); impl LiveLink for Mock { fn host(&mut self, session: u64, opts: HostOptions) -> Result { let mut s = self.0.lock().unwrap(); s.session = session; s.hosted.push(opts); // Each session listens on its own port (47900 for the first). let mut i = info(Role::Host, 1); i.address = format!("127.0.0.1:{}", 47_899 + session); i.link = format!("figcraft://join?host={}&file=f&token=t", i.address); i.url = format!("ws://{}/?file=f&token=t", i.address); Ok(i) } fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> { if !link.starts_with("figcraft://") { return Err("not an invite".into()); } let mut s = self.0.lock().unwrap(); s.session = session; s.joined.push(link.to_string()); Ok(()) } fn publish(&mut self, session: u64, opts: PublishOptions) -> Result<(), String> { if !opts.server.starts_with("ws") { return Err("not a server address".into()); } let mut s = self.0.lock().unwrap(); s.session = session; s.published.push(opts); Ok(()) } fn remove(&mut self, session: u64, client: u32) -> Result<(), String> { self.0.lock().unwrap().removed.push((session, client)); Ok(()) } fn leave(&mut self, session: u64) { let mut s = self.0.lock().unwrap(); s.left += 1; s.ended.push(session); } fn presence(&mut self, session: u64, p: &Presence) { let mut s = self.0.lock().unwrap(); s.sent_to.push(session); s.sent.push(p.clone()); } fn send(&mut self, session: u64, msg: ClientMsg) { let mut s = self.0.lock().unwrap(); s.msgs_to.push(session); s.msgs.push(msg); } fn poll(&mut self) -> Vec<(u64, Event)> { let mut s = self.0.lock().unwrap(); let session = s.session; std::mem::take(&mut s.events).into_iter().map(|e| (session, e)).collect() } } pub(crate) fn info(role: Role, me: u32) -> SessionInfo { SessionInfo { role, me, name: "Me".into(), color: "#0D99FF".into(), file_name: "Shared".into(), pages: vec![], link: "figcraft://join?host=127.0.0.1:47900&file=f&token=t".into(), url: "ws://127.0.0.1:47900/?file=f&token=t".into(), address: "127.0.0.1:47900".into(), network: false, access: crate::live::Access::Edit, } } pub(crate) fn peer(id: u32, name: &str) -> Peer { Peer { client_id: id, name: name.into(), color: "#9747FF".into() } } pub(crate) fn session() -> (Session, Arc>) { let mut s = Session::new(); let script = Arc::new(Mutex::new(Script::default())); s.live.install(Box::new(Mock(script.clone()))); (s, script) } pub(crate) fn push(script: &Arc>, events: Vec) { script.lock().unwrap().events.extend(events); } /// A session server for `doc` and the welcome it gives a first client (as `Event::Sync`). pub(crate) fn serve(doc: &Document) -> (Server, u32, Event) { serve_as(doc, Access::Edit) } /// [`serve`] for a client that may do `access`. pub(crate) fn serve_as(doc: &Document, access: Access) -> (Server, u32, Event) { let mut server = Server::new(doc.clone(), "", Limits::default()); let ClientMsg::Hello(h) = Client::hello("f", "", "Me", "") else { panic!("no hello") }; let (id, outs) = server.join_as(h, access).unwrap(); let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap(); (server, id, Event::Sync(w)) } /// Feed what the session sent to the server; the server's answers for the session, in order. pub(crate) fn relay(script: &Arc>, server: &mut Server, from: u32) -> Vec { let msgs = std::mem::take(&mut script.lock().unwrap().msgs); let mut out = Vec::new(); for m in msgs { for o in server.receive(from, m) { match o { Outgoing::To(id, m) | Outgoing::Others(id, m) if id == from && !matches!(m, ServerMsg::Presence { .. }) => out.push(Event::Sync(m)), Outgoing::All(m) => out.push(Event::Sync(m)), _ => {} } } } out } fn viewport(page: NodeId, x: f64, y: f64, w: f64, h: f64) -> Presence { Presence { viewport: Some((page, x, y, w, h)), ..Presence::default() } } #[test] fn without_a_link_live_sessions_are_unavailable() { let mut s = Session::new(); for cmd in ["collab.host", "collab.join"] { let e = s.execute(cmd, &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap_err(); assert!(matches!(e, EngineError::Disabled(..)), "{cmd}: {e}"); } assert_eq!(s.execute("collab.leave", &json!({})).unwrap()["ended"], false); let p = s.execute("collab.peers", &json!({})).unwrap(); assert_eq!(p["available"], false); assert_eq!(p["status"], "off"); } #[test] fn hosting_and_joining_drive_the_link() { let (mut s, script) = session(); let r = s.execute("collab.host", &json!({"bind": "lan", "port": 50000})).unwrap(); assert_eq!(r["status"], "live"); assert_eq!(r["role"], "host"); assert_eq!(r["link"], "figcraft://join?host=127.0.0.1:47900&file=f&token=t"); { let sc = script.lock().unwrap(); assert_eq!(sc.hosted.len(), 1); assert_eq!(sc.hosted[0].port, Some(50000)); assert_eq!(sc.hosted[0].bind, crate::live::Bind::Lan); assert_eq!(sc.hosted[0].doc.name, "Untitled"); assert_eq!(sc.hosted[0].name, "", "the default name lets the host pick one"); } for bad in [json!({"bind": "everywhere"}), json!({"port": 70000}), json!({"port": -1}), json!({"port": "x"})] { assert!(matches!(s.execute("collab.host", &bad), Err(EngineError::BadParams { .. })), "{bad}"); } assert!(s.execute("collab.host", &json!({"bind": "10.1.2.3"})).is_ok()); assert!(matches!(s.execute("collab.join", &json!({"link": "http://x"})), Err(EngineError::BadParams { .. }))); let r = s.execute("collab.join", &json!({"link": " figcraft://join?host=a:1&file=f&token=t "})).unwrap(); assert_eq!(r["status"], "connecting"); assert_eq!(script.lock().unwrap().joined, ["figcraft://join?host=a:1&file=f&token=t"]); // The join is a session of its own: this file's goes on until it is stopped, and then the // join still on its way shows here. assert_eq!(s.live().role(), Some(Role::Host)); assert_eq!(s.execute("collab.stop", &json!({})).unwrap()["ended"], true); assert!(script.lock().unwrap().left >= 1); assert_eq!(s.live().status, Status::Connecting); assert_eq!(s.execute("collab.leave", &json!({})).unwrap()["ended"], true); assert_eq!(s.live().status, Status::Off); assert_eq!(s.live.iter().count(), 0); } #[test] fn events_build_who_is_here() { let (mut s, script) = session(); s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana")] }, Event::PeerJoined(peer(3, "Bo"))]); assert!(s.live_poll()); assert_eq!(s.live().status, Status::Live); assert_eq!(s.live().peers.len(), 2); assert_eq!(s.message.as_deref(), Some("Bo joined")); // Presence of someone not in the session is ignored; a leaver's presence goes with them. push(&script, vec![Event::Presence(9, viewport(NodeId(1), 0.0, 0.0, 10.0, 10.0)), Event::Presence(3, viewport(NodeId(1), 0.0, 0.0, 10.0, 10.0))]); s.live_poll(); assert_eq!(s.live().presence.keys().copied().collect::>(), [3]); push(&script, vec![Event::PeerLeft(3)]); s.live_poll(); assert!(s.live().presence.is_empty()); assert_eq!(s.message.as_deref(), Some("Bo left")); let p = s.live_summary(); assert_eq!(p["peers"][0]["name"], "Hana"); assert_eq!(p["fileName"], "Shared"); // Reconnecting forgets who's here until the next welcome; failing ends it all. push(&script, vec![Event::Status(Status::Reconnecting { attempt: 1 })]); s.live_poll(); assert!(s.live().peers.is_empty()); assert_eq!(s.live_summary()["attempt"], 1); push(&script, vec![Event::Status(Status::Failed { reason: "Lost the connection".into() })]); s.live_poll(); assert!(s.live().info.is_none()); assert_eq!(s.live_summary()["reason"], "Lost the connection"); assert_eq!(s.message.as_deref(), Some("Lost the connection")); assert!(!s.live_poll(), "nothing new"); } #[test] fn presence_is_sent_when_it_changes_and_only_when_live() { let (mut s, script) = session(); s.live_sync(); assert!(script.lock().unwrap().sent.is_empty(), "not in a session"); assert!(s.live_mut().is_none(), "nothing to set a pointer in"); s.execute("collab.host", &json!({})).unwrap(); s.live_mut().unwrap().cursor = Some((10.0, 20.0)); s.live_sync(); s.live_sync(); let sent = script.lock().unwrap().sent.clone(); assert_eq!(sent.len(), 1, "unchanged presence isn't sent again"); let page = s.page().unwrap(); assert_eq!(sent[0].cursor, Some((page, 10.0, 20.0))); assert_eq!(sent[0].selection, Some(vec![])); let v = sent[0].viewport.unwrap(); assert_eq!(v.0, page); assert!((v.3 - 1200.0).abs() < 1e-9 && (v.4 - 800.0).abs() < 1e-9, "the viewport at 100%: {v:?}"); // `collab.cursor` moves it (agents); a non-finite pointer never goes out. s.execute("collab.cursor", &json!({"x": 1, "y": 2})).unwrap(); assert_eq!(script.lock().unwrap().sent.last().unwrap().cursor, Some((page, 1.0, 2.0))); assert!(s.execute("collab.cursor", &json!({"x": 1})).is_err()); s.live_mut().unwrap().cursor = Some((f64::NAN, 0.0)); s.live_sync(); assert_eq!(script.lock().unwrap().sent.last().unwrap().cursor, None); s.execute("collab.cursor", &json!({})).unwrap(); assert_eq!(s.live().cursor, None); } #[test] fn following_shows_their_viewport_and_page() { let (mut s, script) = session(); s.execute("collab.host", &json!({})).unwrap(); let second = s.execute("page.add", &json!({"name": "Two"})).unwrap()["id"].as_u64().map(NodeId).unwrap(); let first = s.doc().unwrap().pages[0]; s.execute("page.select", &json!({"id": first.0})).unwrap(); push(&script, vec![Event::PeerJoined(peer(5, "Ada")), Event::Presence(5, viewport(second, 100.0, 200.0, 600.0, 400.0))]); s.live_poll(); assert!(s.execute("collab.follow", &json!({"client": 6})).is_err()); // Before the document syncs, their page ids aren't this document's: only the viewport counts. s.execute("collab.follow", &json!({"client": 5})).unwrap(); assert_eq!(s.ds().unwrap().page, first); assert_eq!(s.ds().unwrap().view.cx, 400.0); let (_server, _, welcome) = serve(s.doc().unwrap()); push(&script, vec![welcome]); s.live_poll(); assert!(s.live_shared()); s.execute("collab.follow", &json!({"client": 5})).unwrap(); let ds = s.ds().unwrap(); assert_eq!(ds.page, second, "their page, in a shared document"); assert_eq!((ds.view.cx, ds.view.cy), (400.0, 400.0)); assert!((ds.view.zoom - 2.0).abs() < 1e-9, "their area fills the viewport"); // A page this document doesn't have: only the viewport. push(&script, vec![Event::Presence(5, viewport(NodeId(987_654), 0.0, 0.0, 1200.0, 800.0))]); s.live_poll(); assert_eq!(s.ds().unwrap().page, second); assert_eq!(s.ds().unwrap().view.cx, 600.0); // Zooming yourself stops following. s.ds_mut().unwrap().view.zoom = 3.0; s.live_sync(); assert_eq!(s.live().follow, None); // So does Escape, before anything else it does. s.execute("collab.follow", &json!({"client": 5})).unwrap(); s.execute("select.all", &json!({})).ok(); s.escape().unwrap(); assert_eq!(s.live().follow, None); // And `collab.follow {}`. s.execute("collab.follow", &json!({"client": 5})).unwrap(); assert_eq!(s.execute("collab.follow", &json!({})).unwrap()["follow"], serde_json::Value::Null); } #[test] fn the_host_ending_resets_everything() { let (mut s, script) = session(); s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana")] }]); s.live_poll(); s.execute("collab.follow", &json!({"client": 1})).unwrap(); push(&script, vec![Event::Ended { reason: "The host ended the session".into() }]); s.live_poll(); assert_eq!(s.live().status, Status::Off); assert!(s.live().peers.is_empty() && s.live().follow.is_none() && s.live().info.is_none()); assert_eq!(s.message.as_deref(), Some("The host ended the session")); } #[test] fn the_host_removes_people_and_guests_cant() { let (mut s, script) = session(); let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client })); // Not in a session: nobody to remove. assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. }))); assert!(!crate::live::can_remove_people(&s)); s.execute("collab.host", &json!({})).unwrap(); let id = s.live().id; push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]); s.live_poll(); assert!(crate::live::can_remove_people(&s)); // Only someone who is here, by a client id. for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] { assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}"); } let r = remove(&mut s, json!(2)).unwrap(); assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada"))); assert_eq!(script.lock().unwrap().removed, [(id, 2)]); assert_eq!(s.message.as_deref(), Some("Ada was removed from the session")); // The host's own session tells them she left. push(&script, vec![Event::PeerLeft(2)]); s.live_poll(); assert_eq!(s.live().peers.keys().copied().collect::>(), [3]); s.execute("collab.stop", &json!({})).unwrap(); // A guest in someone else's session can't remove anyone. let (mut s, script) = session(); s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]); s.live_poll(); assert!(!crate::live::can_remove_people(&s)); let e = remove(&mut s, json!(3)).unwrap_err().to_string(); assert!(e.contains("only the person hosting"), "{e}"); assert!(script.lock().unwrap().removed.is_empty()); } #[test] fn collab_commands_survive_junk() { let junk = [ json!(null), json!([1, 2]), json!({"port": 1e300, "bind": 5, "link": 7, "client": -3, "x": "NaN", "y": {}}), json!({"link": "", "client": 4_294_967_296u64}), json!({"text": 5, "on": "yes"}), json!({"text": "\u{0}\u{7f}".repeat(10_000), "on": null}), ]; for cmd in crate::all_commands().iter().filter(|c| c.id.starts_with("collab.")) { for j in &junk { let (mut s, _) = session(); if let Err(EngineError::Internal { msg, .. }) = s.execute(cmd.id, j) { panic!("{} panicked on {j}: {msg}", cmd.id); } } } } #[test] fn edits_sync_through_the_server_and_remote_ones_skip_undo() { let (mut s, script) = session(); s.execute("collab.host", &json!({})).unwrap(); let (mut server, me, welcome) = serve(s.doc().unwrap()); push(&script, vec![welcome]); s.live_poll(); assert!(s.live().syncing() && s.live_shared()); assert!(!s.ds().unwrap().is_dirty(), "starting a session doesn't dirty the file"); // An edit made while hosting goes out as one batch, and the server confirms it. let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap(); let rect = NodeId(r["id"].as_u64().unwrap()); assert_eq!(rect.0 >> 32, u64::from(me), "new layers take ids in this client's space"); s.live_sync(); assert_eq!(script.lock().unwrap().msgs.len(), 1); s.live_sync(); assert_eq!(script.lock().unwrap().msgs.len(), 1, "nothing new, nothing sent"); let back = relay(&script, &mut server, me); assert!(server.document().contains(rect), "the server has the rectangle"); push(&script, back); s.live_poll(); // A gesture is sent once, when it ends. s.begin_interaction("Move").unwrap(); s.preview(|d, _| { if let Some(n) = d.node_mut(rect) { n.opacity = 0.5; } Ok(()) }) .unwrap(); s.live_sync(); assert!(script.lock().unwrap().msgs.is_empty(), "nothing while dragging"); s.commit_interaction().unwrap(); s.live_sync(); assert_eq!(script.lock().unwrap().msgs.len(), 1); push(&script, relay(&script, &mut server, me)); s.live_poll(); // Someone else recolours it: applied, with no undo step of its own. let undo_before = s.ds().unwrap().history.undo.len(); let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "") else { panic!("no hello") }; let (ada, outs) = server.join(h).unwrap(); let Some(Outgoing::To(_, w)) = outs.into_iter().find(|o| matches!(o, Outgoing::To(_, ServerMsg::Welcome { .. }))) else { panic!("no welcome") }; let mut other = Client::join(w).unwrap(); let mut doc = other.document().clone(); doc.node_mut(rect).unwrap().opacity = 0.25; let batch = other.edit(&doc).unwrap().unwrap(); let mut remote = Vec::new(); for o in server.receive(ada, batch) { if let Outgoing::All(m) = o { remote.push(Event::Sync(m)); } } push(&script, remote); assert!(s.live_poll()); assert_eq!(s.doc().unwrap().node(rect).unwrap().opacity, 0.25); assert_eq!(s.ds().unwrap().history.undo.len(), undo_before, "remote edits never enter your undo history"); assert_eq!(s.doc().unwrap().node(rect), server.document().node(rect), "the same as the server"); // While a gesture shows, remote batches wait for it to end. let mut doc = other.document().clone(); doc.node_mut(rect).unwrap().name = "Card".into(); let batch = other.edit(&doc).unwrap().unwrap(); let remote: Vec = server.receive(ada, batch).into_iter().filter_map(|o| if let Outgoing::All(m) = o { Some(Event::Sync(m)) } else { None }).collect(); s.begin_interaction("Move").unwrap(); push(&script, remote); s.live_poll(); assert_ne!(s.doc().unwrap().node(rect).unwrap().name, "Card"); s.cancel_interaction().unwrap(); s.live_sync(); assert_eq!(s.doc().unwrap().node(rect).unwrap().name, "Card"); // Closing the session's file leaves the session. let uid = s.live().doc_uid.unwrap(); s.execute("file.new", &json!({})).unwrap(); let i = s.docs.iter().position(|d| d.uid == uid).unwrap(); s.execute("file.close", &json!({"index": i, "force": true})).unwrap(); s.live_sync(); assert_eq!(s.live_of(uid).status, Status::Off); assert_eq!(s.live.iter().count(), 0); } #[test] fn a_guest_opens_the_hosts_file_as_a_new_tab() { let (mut s, script) = session(); let mut host_doc = Document::new("Team file"); host_doc.repair(); let (mut server, _, _) = serve(&host_doc); let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "") else { panic!("no hello") }; let (ada, outs) = server.join(h).unwrap(); let Some(Outgoing::To(_, w)) = outs.into_iter().find(|o| matches!(o, Outgoing::To(_, ServerMsg::Welcome { .. }))) else { panic!("no welcome") }; s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); push(&script, vec![Event::Welcome { info: info(Role::Guest, ada), peers: vec![peer(1, "Hana")] }, Event::Sync(w)]); s.live_poll(); assert_eq!(s.docs.len(), 2, "your own file stays open"); assert_eq!(s.doc().unwrap().name, "Team file"); assert_eq!(s.live_summary()["fileName"], "Team file"); assert_eq!(s.message.as_deref(), Some("Joined Team file")); assert!(s.live_shared()); // Your edit reaches the server. let r = s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); s.live_sync(); let back = relay(&script, &mut server, ada); assert!(server.document().contains(NodeId(r["id"].as_u64().unwrap()))); push(&script, back); s.live_poll(); // Leaving keeps the file as a local copy. s.execute("collab.leave", &json!({})).unwrap(); assert_eq!(s.docs.len(), 2); assert!(s.doc().unwrap().contains(NodeId(r["id"].as_u64().unwrap()))); } #[test] fn hosting_commands_check_what_they_are_given() { let mut s = Session::new(); // The publish server preference: a WebSocket address without a key, or nothing. assert_eq!(s.execute("pref.publishServer", &json!({"url": " wss://figcraft.example.com/ "})).unwrap()["url"], "wss://figcraft.example.com/"); for bad in ["https://figcraft.example.com/", "wss://host/?key=secret", "wss://a b/"] { assert!(s.execute("pref.publishServer", &json!({"url": bad})).is_err(), "{bad}"); } assert_eq!(s.prefs.publish_server, "wss://figcraft.example.com/", "a refused address changes nothing"); s.execute("pref.publishServer", &json!({"url": ""})).unwrap(); assert!(s.prefs.publish_server.is_empty()); // No network here: the server can't be asked, nothing can be reported. let e = s.execute("collab.serverInfo", &json!({"server": "wss://figcraft.example.com/"})).unwrap_err().to_string(); assert!(e.contains("desktop app or the MCP server"), "{e}"); let e = s.execute("collab.manageFile", &json!({"server": "wss://x/", "file": "f", "owner": "o", "action": "explode"})).unwrap_err().to_string(); assert!(e.contains("`action` must be"), "{e}"); let e = s.execute("collab.report", &json!({"reason": "Spam"})).unwrap_err().to_string(); assert!(e.contains("join a file"), "{e}"); assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words")); } #[test] fn parse_color_rejects_signs_and_non_hex() { use crate::live::parse_color; assert_eq!(parse_color("#+1+2+3"), None); assert_eq!(parse_color("#-1-2-3"), None); assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255))); }