mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
Live sessions are per document instead of one for the whole app.
- **Engine** (`crates/engine/src/live.rs`): `Session.live` is now `Lives`: the app-wide parts (the
installed link, the invite-link registration) plus one `Live` per session. One place resolves
"the session of this document": `Session::live()` (the active document's; status off when it
has none), `live_mut()`, `live_of(uid)`. Sync, undo, read-only checks, follow, cursor chat,
spotlight and presence all go through the session of the document they are about, so edits and
presence never cross. Sessions behind the active tab keep syncing; your presence there is empty.
- **Link** (`LiveLink`): every call takes the `SessionId` the engine picked and `poll` returns
`(SessionId, Event)`, so one link carries several sessions. The desktop link and the browser
link keep them in a shared table with one mailbox per session
(`crates/collab-net/src/sessions.rs`); each hosted document listens on its own port.
- **Commands**: `collab.*` act on the active document's session; no parameters changed.
`collab.peers` keeps its shape and gains `session` (its id, null when the document isn't in
one) and `sessions` (every session: `{session, document, status, reason, role, fileName, link,
peers}`). `collab.join` returns the new session and opens its file in a tab of its own without
touching other sessions; until then (or if it fails) it shows on the tab it was started from.
Starting a session for a document replaces only that document's own session. Closing a document
ends its session only.
- **UI** (`crates/ui-egui/src/live.rs`): the Share popover, avatars, cursors, chat, spotlight
banner, follow frame and read-only reason read the session of the tab in front, per window. What
belongs to one session in the window (cursors shown, your chat box, a report being written) is
dropped when another tab comes to the front. `ui.inspect`'s `live` gains `session` and
`sessions`. No new strings or command ids.
- **Docs**: `docs/collab-protocol.md`, `docs/mcp.md`, `docs/control-protocol.md`,
`docs/collaboration-parity.md`, `docs/gaps.md` (gap 2), `ROADMAP.md`.
467 lines
21 KiB
Rust
467 lines
21 KiB
Rust
//! 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<Event>,
|
|
pub(crate) sent: Vec<Presence>,
|
|
pub(crate) hosted: Vec<HostOptions>,
|
|
joined: Vec<String>,
|
|
pub(crate) left: usize,
|
|
pub(crate) msgs: Vec<ClientMsg>,
|
|
pub(crate) published: Vec<PublishOptions>,
|
|
/// The session started last: the scripted events are its.
|
|
pub(crate) session: u64,
|
|
/// The sessions ended, in order.
|
|
pub(crate) ended: Vec<u64>,
|
|
/// The session each presence in `sent` and each message in `msgs` was for.
|
|
pub(crate) sent_to: Vec<u64>,
|
|
pub(crate) msgs_to: Vec<u64>,
|
|
}
|
|
|
|
pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
|
|
|
|
impl LiveLink for Mock {
|
|
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
|
|
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 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<Mutex<Script>>) {
|
|
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<Mutex<Script>>, events: Vec<Event>) {
|
|
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<Mutex<Script>>, server: &mut Server, from: u32) -> Vec<Event> {
|
|
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::<Vec<_>>(), [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 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<Event> =
|
|
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"));
|
|
}
|