//! Commands run without waiting on a server, against a scripted link that holds each background //! request until the test answers it: nothing is waited for, scripts still wait, answers resume the //! command (twice for a command with two requests), a cancelled command ignores its answer, errors //! are its outcome, a re-run acts on the file it started on, and a link that can't send in the //! background is waited on as before. use std::sync::{Arc, Mutex}; use figcraft_collab::message::{ClientMsg, LibraryReply, LibraryReq, PluginEntry, PluginReply, PluginReq}; use figcraft_plugin::bundle::{Bundle, sha256_hex}; use serde_json::json; use crate::library_server::{archive, read_archive}; use crate::live::{Event, HostOptions, LiveLink, NetReply, NetRequest, Presence, SessionInfo}; use crate::{EngineError, Session}; const ID: &str = "org.example.tiny"; const SERVER: &str = "ws://127.0.0.1:9/"; const LINK: &str = "ws://127.0.0.1:9/?file=f&token=t"; /// What the scripted link saw: background requests out, answers to hand back, waited-for calls. #[derive(Default)] struct Wire { background: bool, out: Vec<(u64, NetRequest)>, answers: Vec, waited: usize, /// The team library the scripted server keeps (an archive), if any. library: Option>, } struct Link(Arc>); impl LiveLink for Link { fn host(&mut self, _: u64, _: HostOptions) -> Result { Err("no".into()) } fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> { Err("no".into()) } fn leave(&mut self, _: u64) {} fn presence(&mut self, _: u64, _: &Presence) {} fn send(&mut self, _: u64, _: ClientMsg) {} fn poll(&mut self) -> Vec<(u64, Event)> { std::mem::take(&mut self.0.lock().unwrap().answers).into_iter().map(|e| (0, e)).collect() } fn plugins(&mut self, _: &str, req: PluginReq) -> Result { self.0.lock().unwrap().waited += 1; Ok(directory(req)) } fn library(&mut self, _: &str, req: LibraryReq) -> Result { let mut w = self.0.lock().unwrap(); w.waited += 1; Ok(library(&mut w, req)) } fn background(&mut self, job: u64, req: NetRequest) -> bool { let mut w = self.0.lock().unwrap(); if w.background { w.out.push((job, req)); } w.background } } fn wasm() -> Vec { wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) i32.const 0))"#).unwrap() } fn bundle() -> Vec { let manifest = json!({"id": ID, "name": "Tiny tool", "version": "1.0.0", "main": "t.wasm", "permissions": {"access": "write", "commands": ["shape.rectangle"]}}); Bundle { manifest: manifest.to_string(), wasm: wasm(), assets: Vec::new() }.encode() } fn directory(req: PluginReq) -> PluginReply { let bytes = bundle(); let entry = PluginEntry { id: ID.into(), name: "Tiny tool".into(), version: "1.0.0".into(), sha256: sha256_hex(&bytes), ..PluginEntry::default() }; match req { PluginReq::List { .. } => PluginReply::List(vec![entry]), PluginReq::Fetch { .. } => PluginReply::Bundle { entry, bundle: bytes }, _ => PluginReply::Failed { reason: "no".into() }, } } fn library(w: &mut Wire, req: LibraryReq) -> LibraryReply { match (req, &w.library) { (LibraryReq::Fetch { .. }, Some(a)) => LibraryReply::Library { version: 1, archive: a.clone() }, (LibraryReq::Fetch { .. }, None) => LibraryReply::Empty, (LibraryReq::Publish { archive }, _) => { w.library = Some(archive); LibraryReply::Published { version: 1 } } (LibraryReq::Usage, _) => LibraryReply::Usage(Vec::new()), } } fn session(background: bool) -> (Session, Arc>) { let wire = Arc::new(Mutex::new(Wire { background, ..Wire::default() })); let mut s = Session::new(); s.live.install(Box::new(Link(wire.clone()))); (s, wire) } /// Answer the requests out (as the scripted server would) and let the session take the answers; /// how many there were. fn answer(s: &mut Session, wire: &Arc>) -> usize { let out = std::mem::take(&mut wire.lock().unwrap().out); for (job, req) in &out { let mut w = wire.lock().unwrap(); let reply = match req.clone() { NetRequest::Plugins { req, .. } => NetReply::Plugins(Ok(directory(req))), NetRequest::Library { req, .. } => NetReply::Library(Ok(library(&mut w, req))), NetRequest::Upload(_) => NetReply::Upload(Err("no".into())), NetRequest::Hosting { .. } => NetReply::Hosting(Err("no".into())), NetRequest::Share { .. } => NetReply::Share(Err("no".into())), }; w.answers.push(Event::Net { job: *job, reply: Box::new(reply) }); } s.live_poll(); out.len() } /// A library file's published library as the archive a server keeps. fn published() -> Vec { let mut s = Session::new(); let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": "Chip"})).unwrap(); s.execute("component.create", &json!({"ids": [f["id"]]})).unwrap(); s.execute("library.publish", &json!({})).unwrap(); archive(s.doc().unwrap().published.as_ref().unwrap()).unwrap() } #[test] fn browsing_and_installing_wait_for_nothing_and_scripts_still_wait() { let (mut s, wire) = session(true); assert_eq!(s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap(), None); assert!(s.waiting_for("plugin.directory.list") && s.plugins.directory.is_none()); assert_eq!(wire.lock().unwrap().waited, 0, "nothing was waited for"); // A script (the MCP server, the control channel) still gets its answer at once. let r = s.execute("plugin.directory.list", &json!({"server": SERVER})).unwrap(); assert_eq!(r["plugins"][0]["id"], ID); assert_eq!(wire.lock().unwrap().waited, 1); assert!(s.take_background_done().is_none(), "no answer yet"); assert_eq!(answer(&mut s, &wire), 1); let (cmd, r) = s.take_background_done().unwrap(); assert_eq!((cmd.as_str(), r.unwrap()["plugins"][0]["id"].as_str()), ("plugin.directory.list", Some(ID))); assert!(!s.waiting_for("plugin.directory.list") && s.take_background_done().is_none()); let install = json!({"id": ID, "confirm": true, "server": SERVER}); assert_eq!(s.start_background("plugin.directory.install", install).unwrap(), None); assert!(s.prefs.plugins.is_empty() && !s.plugins.unsaved); answer(&mut s, &wire); let (_, r) = s.take_background_done().unwrap(); assert_eq!(r.unwrap()["installed"], ID); assert_eq!(s.prefs.plugins.len(), 1); assert!(s.plugins.unsaved, "the app is told to save the plugin list at once"); assert_eq!(wire.lock().unwrap().waited, 1, "the background path never waited"); } #[test] fn cancelled_commands_drop_their_answer_and_errors_are_the_outcome() { let (mut s, wire) = session(true); s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap(); s.cancel_background("plugin.directory.list"); assert!(!s.waiting_for("plugin.directory.list")); answer(&mut s, &wire); assert!(s.take_background_done().is_none() && s.plugins.directory.is_none(), "the late answer is ignored"); // Starting it again replaces the one waiting: only the newer answer counts. s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap(); s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap(); assert_eq!(answer(&mut s, &wire), 2); assert!(s.take_background_done().is_some() && s.take_background_done().is_none()); s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap(); let (job, _) = wire.lock().unwrap().out.pop().unwrap(); wire.lock().unwrap().answers.push(Event::Net { job, reply: Box::new(NetReply::Plugins(Err("the server can't be reached".into()))) }); s.live_poll(); let (_, r) = s.take_background_done().unwrap(); assert!(r.unwrap_err().to_string().contains("can't be reached")); // Bad parameters fail at once, without a request. assert!(s.start_background("plugin.directory.install", json!({"id": ID})).is_err()); assert!(wire.lock().unwrap().out.is_empty()); } #[test] fn a_command_with_two_requests_resumes_twice_and_keeps_what_it_sent() { let (mut s, wire) = session(true); let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": "Chip"})).unwrap(); s.execute("component.create", &json!({"ids": [f["id"]]})).unwrap(); // The server's current library first, then the publish. assert_eq!(s.start_background("library.publishToServer", json!({"server": LINK})).unwrap(), None); assert_eq!(answer(&mut s, &wire), 1); assert!(s.take_background_done().is_none() && s.waiting_for("library.publishToServer")); assert!(matches!(&wire.lock().unwrap().out[..], [(_, NetRequest::Library { req: LibraryReq::Publish { .. }, .. })])); assert_eq!(answer(&mut s, &wire), 1); let (_, r) = s.take_background_done().unwrap(); assert_eq!(r.unwrap()["version"], 1); let kept = read_archive(wire.lock().unwrap().library.as_ref().unwrap()).unwrap(); let cached = s.libraries.cached(LINK).unwrap().unwrap(); assert_eq!(cached.manifest, kept.manifest, "the library kept here is the one the server got"); assert_eq!(wire.lock().unwrap().waited, 0); } #[test] fn a_rerun_acts_on_the_file_it_started_on() { let (mut s, wire) = session(true); wire.lock().unwrap().library = Some(published()); let started = s.ds().unwrap().uid; assert_eq!(s.start_background("library.add", json!({"path": LINK})).unwrap(), None); s.execute("file.new", &json!({})).unwrap(); let other = s.ds().unwrap().uid; answer(&mut s, &wire); let (_, r) = s.take_background_done().unwrap(); assert_eq!(r.unwrap()["name"], "Untitled"); assert_eq!(s.ds().unwrap().uid, other, "the file shown stays shown"); let enabled = |s: &Session, uid: u64| s.docs.iter().find(|d| d.uid == uid).unwrap().doc.libraries.enabled.len(); assert_eq!((enabled(&s, started), enabled(&s, other)), (1, 0), "the library went to the file it was added in"); // A file closed meanwhile leaves nothing to add the library to. s.libraries = crate::library::Cache::default(); s.execute("file.new", &json!({})).unwrap(); s.start_background("library.add", json!({"path": LINK})).unwrap(); s.execute("file.close", &json!({})).unwrap(); let shown = s.ds().unwrap().uid; answer(&mut s, &wire); let (_, r) = s.take_background_done().unwrap(); assert!(matches!(r, Err(EngineError::NoDocument)), "{r:?}"); assert_eq!(s.ds().unwrap().uid, shown); } #[test] fn a_link_without_background_requests_is_waited_on() { let (mut s, wire) = session(false); let r = s.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap().unwrap(); assert_eq!(r["plugins"][0]["id"], ID); assert!(!s.waiting_for("plugin.directory.list")); assert_eq!(wire.lock().unwrap().waited, 1); // Without any link, the usual error. let mut bare = Session::new(); assert!(bare.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap_err().to_string().contains("desktop app")); }