//! Live sessions are per document, against the scripted link: every open document has its own //! session or none, several run at once, the `collab.*` commands and `collab.peers` are the //! active document's, a join shows where it was started until its file opens in its own tab, and //! closing a document ends its session only. use std::sync::{Arc, Mutex}; use figcraft_collab::message::{ClientMsg, ServerMsg}; use figcraft_collab::{Client, Outgoing, Server}; use figcraft_doc::NodeId; use serde_json::{Value, json}; use crate::live::{Event, PUBLISHING, Presence, Role, Signal, Status}; use crate::live_tests::{Script, info, peer, push, relay, serve, session}; use crate::{EngineError, Session}; const LINK: &str = "figcraft://join?host=a:1&file=f&token=t"; /// Hand `events` to the session as link session `id`'s. fn feed(s: &mut Session, script: &Arc>, id: u64, events: Vec) { script.lock().unwrap().session = id; push(script, events); s.live_poll(); } fn uid(s: &Session) -> u64 { s.ds().unwrap().uid } fn id_of(v: &Value) -> u64 { v["session"].as_u64().unwrap() } fn run(s: &mut Session, cmd: &str, params: Value) -> Value { s.execute(cmd, ¶ms).unwrap() } /// Host the active document and start syncing it: its uid, its session id, and its session's /// server with your client id there. fn host(s: &mut Session, script: &Arc>) -> (u64, u64, Server, u32) { let id = id_of(&run(s, "collab.host", json!({}))); let (server, me, welcome) = serve(s.doc().unwrap()); feed(s, script, id, vec![welcome]); assert!(s.live_shared()); (uid(s), id, server, me) } /// Two documents, each hosted and syncing (the second in front): their uids, session ids and /// the scripted link. fn two_hosted() -> (Session, Arc>, [u64; 2], [u64; 2]) { let (mut s, script) = session(); let (ua, a, ..) = host(&mut s, &script); run(&mut s, "file.new", json!({"name": "Second"})); let (ub, b, ..) = host(&mut s, &script); (s, script, [ua, ub], [a, b]) } #[test] fn each_document_has_its_own_session() { let (mut s, script) = session(); let a = run(&mut s, "collab.host", json!({})); let ua = uid(&s); // A new document isn't in the first one's session. run(&mut s, "file.new", json!({})); let ub = uid(&s); let off = run(&mut s, "collab.peers", json!({})); for key in ["session", "role", "link", "url", "address", "fileName", "document", "me"] { assert_eq!(off[key], Value::Null, "{key}: {off}"); } assert_eq!((&off["status"], &off["syncing"], &off["readOnly"]), (&json!("off"), &json!(false), &json!(false))); assert_eq!(off["peers"], json!([])); assert_eq!(off["sessions"].as_array().unwrap().len(), 1); assert_eq!( (&off["sessions"][0]["session"], &off["sessions"][0]["document"], &off["sessions"][0]["link"]), (&a["session"], &json!(0), &a["link"]) ); assert_eq!((s.live().id, s.live().status.clone()), (0, Status::Off)); assert!(s.live_mut().is_none() && s.active_read_only().is_none()); assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], false, "nothing to stop here"); assert_eq!(s.live_of(ua).status, Status::Live, "and the other file's session isn't stopped"); // Hosting it too starts a second session; the first goes on. let b = run(&mut s, "collab.host", json!({})); assert_ne!(a["session"], b["session"]); assert_ne!(a["link"], b["link"]); assert_eq!((b["document"].clone(), b["sessions"].as_array().unwrap().len()), (json!(1), 2)); { let sc = script.lock().unwrap(); assert_eq!((sc.hosted.len(), sc.left), (2, 0)); } // People come and go in their own session. let (a_id, b_id) = (id_of(&a), id_of(&b)); feed(&mut s, &script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]); feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]); assert_eq!((s.live_of(ua).peers.len(), s.live_of(ub).peers.len()), (1, 2)); let names = |v: &Value| v["peers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect::>(); let p = s.live_summary(); assert_eq!((names(&p), &p["link"]), (vec!["Bo".to_string(), "Cy".to_string()], &b["link"])); assert_eq!((&p["sessions"][0]["peers"], &p["sessions"][1]["peers"]), (&json!(1), &json!(2))); // The file in front decides what `collab.peers` and the commands are about. run(&mut s, "doc.switch", json!({"index": 0})); let p = run(&mut s, "collab.peers", json!({})); assert_eq!((names(&p), &p["link"], &p["document"], &p["session"]), (vec!["Ada".to_string()], &a["link"], &json!(0), &a["session"])); assert_eq!(run(&mut s, "collab.follow", json!({"client": 5}))["follow"], 5); assert!(s.execute("collab.follow", &json!({"client": 6})).is_err(), "Bo is in the other file's session"); run(&mut s, "doc.switch", json!({"index": 1})); assert!(s.execute("collab.follow", &json!({"client": 5})).is_err()); assert_eq!((s.live().follow, s.live_of(ua).follow), (None, Some(5))); // Stopping ends this file's session only. assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], true); assert_eq!(script.lock().unwrap().ended, [b_id]); assert_eq!((s.live().status.clone(), s.live_of(ua).status.clone()), (Status::Off, Status::Live)); // Someone ending or leaving the other one doesn't bring this one back. feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]); assert_eq!((s.live.iter().count(), s.live_of(ua).peers.len()), (1, 1), "what a session still sent after it ended is dropped"); // Closing a file ends its session. run(&mut s, "file.close", json!({"index": 0, "force": true})); s.live_sync(); assert_eq!(script.lock().unwrap().ended, [b_id, a_id]); assert_eq!(s.live.iter().count(), 0); assert_eq!(s.message.as_deref(), Some("The live session's file was closed, so you left the session")); } #[test] fn closing_one_document_leaves_the_others_session_running() { let (mut s, script, [ua, ub], [a, b]) = two_hosted(); run(&mut s, "file.close", json!({"index": 1, "force": true})); s.live_sync(); assert_eq!(script.lock().unwrap().ended, [b]); assert_eq!((s.live_of(ua).status.clone(), s.live_of(ub).status.clone()), (Status::Live, Status::Off)); assert_eq!((uid(&s), s.live().id), (ua, a), "the file left in front shows its own session"); // It still syncs. script.lock().unwrap().msgs_to.clear(); run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); s.live_sync(); assert_eq!(script.lock().unwrap().msgs_to, [a]); } #[test] fn edits_and_presence_stay_in_their_session() { let (mut s, script) = session(); let (ua, a, ..) = host(&mut s, &script); run(&mut s, "file.new", json!({})); let (ub, b, mut server, me) = host(&mut s, &script); s.live_sync(); { let mut sc = script.lock().unwrap(); sc.msgs_to.clear(); sc.sent_to.clear(); sc.sent.clear(); } // An edit of the file in front goes to its session, as that file's own undo step. let rect = NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); run(&mut s, "select.set", json!({"ids": [rect.0]})); s.live_sync(); assert_eq!(script.lock().unwrap().msgs_to, [b]); assert_eq!(s.undo_depth().0, 1); assert!(!s.docs.iter().find(|d| d.uid == ua).unwrap().doc.contains(rect)); let confirmed = relay(&script, &mut server, me); feed(&mut s, &script, b, confirmed); // Your selection and view go to that session; the other one isn't told anything new. { let sc = script.lock().unwrap(); assert_eq!(sc.sent_to, [b]); assert_eq!(sc.sent[0].selection, Some(vec![rect])); } // With the other file in front: its session gets your presence there, this one gets none // (no cursor, selection or view of a file you don't look at). run(&mut s, "doc.switch", json!({"index": 0})); assert_eq!(s.undo_depth().0, 0, "undo is each file's own"); run(&mut s, "collab.cursor", json!({"x": 3, "y": 4})); { let sc = script.lock().unwrap(); let last = |id: u64| sc.sent_to.iter().zip(&sc.sent).rev().find(|(to, _)| **to == id).map(|(_, p)| p.clone()).unwrap(); assert_eq!(last(b), Presence::default()); assert_eq!((last(a).cursor.map(|c| (c.1, c.2)), last(a).selection), (Some((3.0, 4.0)), Some(vec![]))); } assert_eq!((s.live().cursor, s.live_of(ub).cursor), (Some((3.0, 4.0)), None)); // What others do in the file behind arrives there, and only there. let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "") else { panic!("no hello") }; let (ada, outs) = server.join(h).unwrap(); let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap(); let mut other = Client::join(w).unwrap(); let mut doc = other.document().clone(); doc.node_mut(rect).unwrap().name = "Theirs".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(); let before = s.ds().unwrap().revision; feed(&mut s, &script, b, remote); assert_eq!(s.docs.iter().find(|d| d.uid == ub).unwrap().doc.node(rect).unwrap().name, "Theirs"); assert_eq!(s.ds().unwrap().revision, before, "the file in front didn't change"); assert_eq!(uid(&s), ua, "and stays in front"); } #[test] fn a_join_shows_where_it_started_until_its_file_opens() { let (mut s, script) = session(); let first = uid(&s); let r = run(&mut s, "collab.join", json!({"link": LINK})); let join = id_of(&r); assert_eq!((&r["status"], &r["document"]), (&json!("connecting"), &Value::Null)); assert_eq!(s.live().status, Status::Connecting); // Another file doesn't show it; the list of sessions does. run(&mut s, "file.new", json!({})); let p = s.live_summary(); assert_eq!((&p["status"], &p["sessions"][0]["status"], &p["sessions"][0]["document"]), (&json!("off"), &json!("connecting"), &Value::Null)); // Its file opens as a tab of its own, in front, with the session. let mut theirs = figcraft_doc::Document::new("Team file"); theirs.repair(); let (_, me, welcome) = serve(&theirs); feed(&mut s, &script, join, vec![Event::Welcome { info: info(Role::Guest, me), peers: vec![peer(1, "Hana")] }, welcome]); assert_eq!((s.docs.len(), s.active), (3, 2)); assert_eq!((s.doc().unwrap().name.as_str(), s.live().id, s.live().syncing()), ("Team file", join, true)); assert_eq!(s.live_summary()["document"], 2); assert_eq!(s.live_of(first).status, Status::Off, "the tab it was started from is in no session"); assert_eq!(s.live.iter().count(), 1); } #[test] fn joining_from_a_file_in_a_session_leaves_that_session_alone() { let (mut s, script) = session(); let host = id_of(&run(&mut s, "collab.host", json!({}))); let first = uid(&s); // A link that can't be used changes nothing. assert!(matches!(s.execute("collab.join", &json!({"link": "http://x"})), Err(EngineError::BadParams { .. }))); assert_eq!((s.live.iter().count(), script.lock().unwrap().left), (1, 0)); let join = id_of(&run(&mut s, "collab.join", json!({"link": LINK}))); let p = run(&mut s, "collab.peers", json!({})); assert_eq!((&p["status"], &p["role"], id_of(&p)), (&json!("live"), &json!("host"), host), "this file still shows its own session"); assert_eq!(p["sessions"].as_array().unwrap().len(), 2); // The join fails: the reason is said, and the file's own session is as it was. feed(&mut s, &script, join, vec![Event::Status(Status::Failed { reason: "wrong session token".into() })]); assert_eq!(s.message.as_deref(), Some("wrong session token")); assert_eq!((s.live().status.clone(), s.live().role()), (Status::Live, Some(Role::Host))); let p = s.live_summary(); assert_eq!((&p["sessions"][1]["status"], &p["sessions"][1]["reason"]), (&json!("failed"), &json!("wrong session token"))); assert_eq!(script.lock().unwrap().ended, [join], "the link is told the failed session is over"); // The next join from here takes the failed one's place. let again = id_of(&run(&mut s, "collab.join", json!({"link": LINK}))); assert_eq!(s.live.iter().map(|l| l.id).collect::>(), [host, again]); // From a file that isn't in a session, a failed join shows there, and nowhere else. run(&mut s, "file.new", json!({})); let idle = id_of(&run(&mut s, "collab.join", json!({"link": LINK}))); feed(&mut s, &script, idle, vec![Event::Status(Status::Failed { reason: "can't reach the host".into() })]); assert_eq!(s.live_summary()["reason"], "can't reach the host"); assert_eq!(s.live_of(first).status, Status::Live); run(&mut s, "file.new", json!({})); assert_eq!(s.live_summary()["status"], "off", "a new file starts with no session"); run(&mut s, "doc.switch", json!({"index": 1})); assert_eq!(run(&mut s, "collab.leave", json!({}))["ended"], false); assert_eq!(s.live_summary()["status"], "off"); } #[test] fn a_join_outlives_the_tab_it_was_started_from() { let (mut s, script) = session(); run(&mut s, "file.new", json!({})); let join = id_of(&run(&mut s, "collab.join", json!({"link": LINK}))); let failed = id_of(&run(&mut s, "collab.join", json!({"link": LINK}))); feed(&mut s, &script, failed, vec![Event::Status(Status::Failed { reason: "no".into() })]); run(&mut s, "file.close", json!({"index": 1, "force": true})); s.live_sync(); // The one on its way goes on, shown on the file now in front; the failed one is gone. assert_eq!(s.live.iter().map(|l| l.id).collect::>(), [join]); assert_eq!((s.live().id, s.live().status.clone()), (join, Status::Connecting)); assert_eq!(script.lock().unwrap().ended, [failed, failed], "told when it failed and when its tab closed; never the join on its way"); } #[test] fn publishing_one_file_keeps_the_others_editable() { let (mut s, script) = session(); let host = id_of(&run(&mut s, "collab.host", json!({}))); let first = uid(&s); run(&mut s, "file.new", json!({})); let r = run(&mut s, "collab.publish", json!({"server": "ws://127.0.0.1:47900/?key=k"})); assert_eq!((&r["status"], &r["readOnly"], &r["document"]), (&json!("connecting"), &json!(true), &json!(1))); assert_eq!(s.active_read_only(), Some(PUBLISHING)); assert!(s.live_read_only(first).is_none()); run(&mut s, "doc.switch", json!({"index": 0})); let p = s.live_summary(); assert_eq!((&p["role"], &p["readOnly"], id_of(&p)), (&json!("host"), &json!(false), host)); assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok()); // Publishing a file again replaces its own session, not the other file's. run(&mut s, "doc.switch", json!({"index": 1})); let again = id_of(&run(&mut s, "collab.publish", json!({"server": "ws://127.0.0.1:47900/?key=k"}))); assert_eq!(script.lock().unwrap().ended, [id_of(&r)]); assert_eq!(s.live.iter().map(|l| l.id).collect::>(), [host, again]); } #[test] fn chat_and_spotlight_stay_in_their_session() { let (mut s, script, [ua, ub], [a, b]) = two_hosted(); // Only the second file's host reads signals. feed(&mut s, &script, b, vec![Event::Signal(1, Signal::ready()), Event::PeerJoined(peer(3, "Ada"))]); script.lock().unwrap().msgs_to.clear(); assert_eq!(run(&mut s, "collab.cursorChat", json!({"text": "here"}))["chat"], "here"); assert_eq!(run(&mut s, "collab.spotlight", json!({"on": true}))["spotlighting"], true); assert_eq!(script.lock().unwrap().msgs_to, [b, b]); feed(&mut s, &script, b, vec![Event::Signal(3, Signal::chat("hello"))]); assert_eq!((s.live().chat.as_str(), s.live().spotlighting, s.live().chats.get(&3).map(String::as_str)), ("here", true, Some("hello"))); // The first file's session has none of it, and can't send any. run(&mut s, "doc.switch", json!({"index": 0})); assert_eq!(uid(&s), ua); let p = s.live_summary(); assert_eq!((&p["chat"], &p["spotlighting"], &p["signals"], id_of(&p)), (&json!(""), &json!(false), &json!(false), a)); assert!(matches!(s.execute("collab.cursorChat", &json!({"text": "hi"})), Err(EngineError::Disabled(..)))); assert_eq!(run(&mut s, "collab.cursorChat", json!({}))["open"], false); assert!(s.live_of(ub).spotlighting, "still spotlighting in the other file"); // A report goes to the session of the file in front, which here isn't a server's. assert!(s.execute("collab.report", &json!({"reason": "spam"})).is_err()); } #[test] fn collab_commands_survive_junk_with_several_sessions() { let junk = [ json!(null), json!([1, 2]), json!({"port": 1e300, "bind": 5, "link": 7, "client": -3, "x": "NaN", "y": {}, "session": -1, "document": 1e300}), json!({"link": "", "client": 4_294_967_296u64, "session": "x", "document": {}}), 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, script, _, [a, _]) = two_hosted(); run(&mut s, "collab.join", json!({"link": LINK})); if let Err(EngineError::Internal { msg, .. }) = s.execute(cmd.id, j) { panic!("{} panicked on {j}: {msg}", cmd.id); } // Whatever it did, the sessions still take what arrives. feed(&mut s, &script, a, vec![Event::PeerJoined(peer(9, "Late")), Event::Status(Status::Off)]); s.live_sync(); assert!(s.live.get(a).is_none()); } } }