diff --git a/gui/docs/enso-protocol.md b/gui/docs/enso-protocol.md new file mode 100644 index 0000000000..2b233e77bb --- /dev/null +++ b/gui/docs/enso-protocol.md @@ -0,0 +1,12 @@ +Enso protocol mainly consists of two services: Project Picker and Language Server. The protocol +is defined on top of JSON-RPC 2.0. An up-to-date and complete list of possible operations can be +found in the [enso protocol specification document](https://github.com/luna/enso/blob/master/doc/language-server/specification/enso-protocol.md). + +# Setup +Follow the contribution guidelines of [Enso repository](https://github.com/luna/enso/blob/master/CONTRIBUTING.md#hacking-on-enso) +to setup the project. Once you have all the requirements configured, you are able to run the project +manager service with the command bellow: + +luna/enso$ `sbt -java-home $JAVA_HOME -J-Xss10M project-manager/run` + +Where `$JAVA_HOME` is the path where `graalvm-ce-java8-20.0.0` is located. diff --git a/gui/src/rust/Cargo.lock b/gui/src/rust/Cargo.lock index 2cb5f52feb..4aa6e1ab96 100644 --- a/gui/src/rust/Cargo.lock +++ b/gui/src/rust/Cargo.lock @@ -576,6 +576,21 @@ dependencies = [ "web-sys 0.3.35 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "enso-protocol" +version = "0.1.0" +dependencies = [ + "chrono 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)", + "enso-prelude 0.1.0", + "futures 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", + "json-rpc 0.1.0", + "serde 1.0.106 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_json 1.0.51 (registry+https://github.com/rust-lang/crates.io-index)", + "shapely 0.1.0", + "utils 0.1.0", + "uuid 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "ensogl" version = "0.1.0" @@ -1174,6 +1189,7 @@ dependencies = [ "data 0.1.0", "enso-frp 0.1.0", "enso-prelude 0.1.0", + "enso-protocol 0.1.0", "ensogl 0.1.0", "ensogl-core-msdf-sys 0.1.0", "ensogl-system-web 0.1.0", diff --git a/gui/src/rust/Cargo.toml b/gui/src/rust/Cargo.toml index a0455962b3..e6eec056d7 100644 --- a/gui/src/rust/Cargo.toml +++ b/gui/src/rust/Cargo.toml @@ -8,6 +8,7 @@ members = [ "ide", "ide/ast/impl", "ide/ast/macros", + "ide/enso-protocol", "ide/file-manager", "ide/file-manager/mock-server", "ide/json-rpc", diff --git a/gui/src/rust/ide/Cargo.toml b/gui/src/rust/ide/Cargo.toml index d86037d0a3..cefbeaacb2 100644 --- a/gui/src/rust/ide/Cargo.toml +++ b/gui/src/rust/ide/Cargo.toml @@ -18,6 +18,7 @@ graph-editor = { version = "0.1.0" , path = "../lib/graph-editor" shapely = { version = "0.1.0" , path = "../lib/shapely/impl" } ast = { version = "0.1.0" , path = "ast/impl" } +enso-protocol = { version = "0.1.0" , path = "enso-protocol" } file-manager-client = { version = "0.1.0" , path = "file-manager" } json-rpc = { version = "0.1.0" , path = "json-rpc" } parser = { version = "0.1.0" , path = "parser" } diff --git a/gui/src/rust/ide/enso-protocol/Cargo.toml b/gui/src/rust/ide/enso-protocol/Cargo.toml new file mode 100644 index 0000000000..1b02a98ee0 --- /dev/null +++ b/gui/src/rust/ide/enso-protocol/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "enso-protocol" +version = "0.1.0" +authors = ["Enso Team "] +edition = "2018" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +json-rpc = { version = "0.1.0" , path = "../json-rpc" } +enso-prelude = { version = "0.1.0" , path = "../../lib/prelude" } +shapely = { version = "0.1.0" , path = "../../lib/shapely/impl" } +utils = { version = "0.1.0" , path = "../utils" } + +chrono = { version = "0.4" , features = ["serde"] } +futures = { version = "0.3.1" } +serde = { version = "1.0" , features = ["derive"] } +serde_json = { version = "1.0" } +uuid = { version = "0.8" , features = ["serde", "v5"] } diff --git a/gui/src/rust/ide/enso-protocol/src/file_manager.rs b/gui/src/rust/ide/enso-protocol/src/file_manager.rs new file mode 100644 index 0000000000..dc65d567b7 --- /dev/null +++ b/gui/src/rust/ide/enso-protocol/src/file_manager.rs @@ -0,0 +1,404 @@ +//! Client library for the JSON-RPC-based File Manager service. + +//FIXME: We need to review the structures' names in Enso Protocol specification +// https://github.com/luna/enso/issues/708 + +use crate::prelude::*; + +use crate::types::UTCDateTime; + +use json_rpc::api::Result; +use json_rpc::Handler; +use json_rpc::make_rpc_methods; +use futures::Stream; +use serde::Serialize; +use serde::Deserialize; +use std::future::Future; +use uuid::Uuid; + + + +// ============= +// === Event === +// ============= + +/// Event emitted by the File Manager `Client`. +pub type Event = json_rpc::handler::Event; + + + +// ============ +// === Path === +// ============ + +// FIXME[dg]: We don't want Path with anonymous fields. This needs to be fixed in the File Manager +// Client task. +/// Path to a file. +#[derive(Clone,Debug,Display,Eq,Hash,PartialEq,PartialOrd,Ord)] +#[derive(Serialize, Deserialize)] +#[derive(Shrinkwrap)] +pub struct Path(pub String); + +impl Path { + /// Wraps a `String`-like entity into a new `Path`. + pub fn new(s:impl Str) -> Path { + Path(s.into()) + } +} + + + +// ==================== +// === Notification === +// ==================== + +/// Notification generated by the File Manager. +#[derive(Clone,Debug,PartialEq)] +#[derive(Serialize, Deserialize)] +#[serde(tag="method", content="params")] +pub enum Notification { + /// Filesystem event occurred for a watched path. + #[serde(rename = "filesystemEvent")] + FilesystemEvent(FilesystemEvent), +} + + + +// ======================= +// === FilesystemEvent === +// ======================= + +/// Filesystem event notification, generated by an active file watch. +#[derive(Clone,Debug,PartialEq)] +#[derive(Serialize, Deserialize)] +pub struct FilesystemEvent { + /// Path of the file that the event is about. + pub path : Path, + #[allow(missing_docs)] + pub kind : FilesystemEventKind +} + +/// Describes kind of filesystem event (was the file created or deleted, etc.) +#[derive(Clone,Copy,Debug,PartialEq)] +#[derive(Serialize, Deserialize)] +pub enum FilesystemEventKind { + /// A new file under path was created. + Created, + /// Existing file under path was deleted. + Deleted, + /// File under path was modified. + Modified, + /// An overflow occurred and some events were lost, + Overflow +} + + + +// ================== +// === Attributes === +// ================== + +/// Attributes of the file in the filesystem. +#[derive(Clone,Copy,Debug,PartialEq)] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Attributes{ + /// When the file was created. + pub creation_time : UTCDateTime, + /// When the file was last accessed. + pub last_access_time : UTCDateTime, + /// When the file was last modified. + pub last_modified_time : UTCDateTime, + /// What kind of file is this. + pub file_kind : FileKind, + /// Size of the file in bytes. + /// (size of files not being `RegularFile`s is unspecified). + pub byte_size : u64 +} + +/// What kind of file (regular, directory, symlink) is this. +#[derive(Clone,Copy,Debug,PartialEq)] +#[derive(Serialize, Deserialize)] +pub enum FileKind { + /// File being a directory. + Directory, + /// File being a symbolic link. + SymbolicLink, + /// File being a regular file with opaque content. + RegularFile, + /// File being none of the above, e.g. a physical device or a pipe. + Other +} + +make_rpc_methods! { +/// An interface containing all the available file management operations. +trait API { + /// Copies a specified directory to another location. + #[MethodInput=CopyDirectoryInput,rpc_name="file/copy",result=copy_directory_result,set_result=set_copy_directory_result] + fn copy_directory(&self, from:Path, to:Path) -> (); + + /// Copies a specified file to another location. + #[MethodInput=CopyFileInput,rpc_name="file/copy",result=copy_file_result,set_result=set_copy_file_result] + fn copy_file(&self, from:Path, to:Path) -> (); + + /// Deletes the specified file. + #[MethodInput=DeleteFileInput,rpc_name="file/delete",result=delete_file_result, + set_result=set_delete_file_result] + fn delete_file(&self, path:Path) -> (); + + /// Check if file exists. + #[MethodInput=ExistsInput,rpc_name="file/exists",result=exists_result, + set_result=set_exists_result] + fn exists(&self, path:Path) -> bool; + + /// List all file-system objects in the specified path. + #[MethodInput=ListInput,rpc_name="file/list",result=list_result,set_result=set_list_result] + fn list(&self, path:Path) -> Vec; + + /// Moves directory to another location. + #[MethodInput=MoveDirectoryInput,rpc_name="file/move",result=move_directory_result, + set_result=set_move_directory_result] + fn move_directory(&self, from:Path, to:Path) -> (); + + /// Moves file to another location. + #[MethodInput=MoveFileInput,rpc_name="file/move",result=move_file_result, + set_result=set_move_file_result] + fn move_file(&self, from:Path, to:Path) -> (); + + /// Reads file's content as a String. + #[MethodInput=ReadInput,rpc_name="file/read",result=read_result,set_result=set_read_result] + fn read(&self, path:Path) -> String; + + /// Gets file's status. + #[MethodInput=StatusInput,rpc_name="file/status",result=status_result,set_result=set_status_result] + fn status(&self, path:Path) -> Attributes; + + /// Creates a file in the specified path. + #[MethodInput=TouchInput,rpc_name="file/touch",result=touch_result,set_result=set_touch_result] + fn touch(&self, path:Path) -> (); + + /// Writes String contents to a file in the specified path. + #[MethodInput=WriteInput,rpc_name="file/write",result=write_result,set_result=set_write_result] + fn write(&self, path:Path, contents:String) -> (); + + /// Watches the specified path. + #[MethodInput=CreateWatchInput,rpc_name="file/createWatch",result=create_watch_result,set_result=set_create_watch_result] + fn create_watch(&self, path:Path) -> Uuid; + + /// Delete the specified watcher. + #[MethodInput=DeleteWatchInput,rpc_name="file/deleteWatch",result=delete_watch_result, + set_result=set_delete_watch_result] + fn delete_watch(&self, watch_id:Uuid) -> (); +}} + + + +// ============= +// === Tests === +// ============= + +#[cfg(test)] +mod tests { + use super::*; + use super::FileKind::RegularFile; + + use futures::task::LocalSpawnExt; + use json_rpc::messages::Message; + use json_rpc::messages::RequestMessage; + use json_rpc::test_util::transport::mock::MockTransport; + use serde_json::json; + use serde_json::Value; + use std::future::Future; + use utils::test::poll_future_output; + use utils::test::poll_stream_output; + + struct Fixture { + transport : MockTransport, + client : Client, + executor : futures::executor::LocalPool, + } + + fn setup_file_manager() -> Fixture { + let transport = MockTransport::new(); + let client = Client::new(transport.clone()); + let executor = futures::executor::LocalPool::new(); + executor.spawner().spawn_local(client.runner()).unwrap(); + Fixture {transport,client,executor} + } + + #[test] + fn test_notification() { + let mut fixture = setup_file_manager(); + let mut events = Box::pin(fixture.client.events()); + assert!(poll_stream_output(&mut events).is_none()); + + let expected_notification = FilesystemEvent { + path : Path::new("./Main.txt"), + kind : FilesystemEventKind::Modified, + }; + let notification_text = r#"{ + "jsonrpc": "2.0", + "method": "filesystemEvent", + "params": {"path" : "./Main.txt", "kind" : "Modified"} + }"#; + fixture.transport.mock_peer_message_text(notification_text); + assert!(poll_stream_output(&mut events).is_none()); + + fixture.executor.run_until_stalled(); + + let event = poll_stream_output(&mut events); + if let Some(Event::Notification(n)) = event { + assert_eq!(n, Notification::FilesystemEvent(expected_notification)); + } else { + panic!("expected notification event"); + } + } + + /// This function tests making a request using file manager. It + /// * creates FM client and uses `make_request` to make a request, + /// * checks that request is made for `expected_method`, + /// * checks that request input is `expected_input`, + /// * mocks receiving a response from server with `result` and + /// * checks that FM-returned Future yields `expected_output`. + fn test_request + ( make_request:Fun + , expected_method:&str + , expected_input:Value + , result:Value + , expected_output:T ) + where Fun : FnOnce(&mut Client) -> Fut, + Fut : Future>, + T : Debug + PartialEq { + let mut fixture = setup_file_manager(); + let mut request_future = Box::pin(make_request(&mut fixture.client)); + + let request = fixture.transport.expect_message::>(); + assert_eq!(request.method, expected_method); + assert_eq!(request.params, expected_input); + + let response = Message::new_success(request.id, result); + fixture.transport.mock_peer_message(response); + fixture.executor.run_until_stalled(); + let output = poll_future_output(&mut request_future).unwrap().unwrap(); + assert_eq!(output, expected_output); + } + + #[test] + fn test_requests() { + let main = Path::new("./Main.txt"); + let target = Path::new("./Target.txt"); + let path_main = json!({"path" : "./Main.txt"}); + let from_main_to_target = json!({ + "from" : "./Main.txt", + "to" : "./Target.txt" + }); + let true_json = json!(true); + let unit_json = json!(null); + + test_request( + |client| client.copy_directory(main.clone(), target.clone()), + "file/copy", + from_main_to_target.clone(), + unit_json.clone(), + ()); + test_request( + |client| client.copy_file(main.clone(), target.clone()), + "file/copy", + from_main_to_target.clone(), + unit_json.clone(), + ()); + test_request( + |client| client.delete_file(main.clone()), + "file/delete", + path_main.clone(), + unit_json.clone(), + ()); + test_request( + |client| client.exists(main.clone()), + "file/exists", + path_main.clone(), + true_json, + true); + + let list_response_json = json!([ "Bar.txt", "Foo.txt" ]); + let list_response_value = vec! [Path::new("Bar.txt"),Path::new("Foo.txt")]; + test_request( + |client| client.list(main.clone()), + "file/list", + path_main.clone(), + list_response_json, + list_response_value); + test_request( + |client| client.move_directory(main.clone(), target.clone()), + "file/move", + from_main_to_target.clone(), + unit_json.clone(), + ()); + test_request( + |client| client.move_file(main.clone(), target.clone()), + "file/move", + from_main_to_target.clone(), + unit_json.clone(), + ()); + test_request( + |client| client.read(main.clone()), + "file/read", + path_main.clone(), + json!("Hello world!"), + "Hello world!".into()); + + let parse_rfc3339 = |s| { + chrono::DateTime::parse_from_rfc3339(s).unwrap() + }; + let expected_attributes = Attributes { + creation_time : parse_rfc3339("2020-01-07T21:25:26Z"), + last_access_time : parse_rfc3339("2020-01-21T22:16:51.123994500+00:00"), + last_modified_time : parse_rfc3339("2020-01-07T21:25:26Z"), + file_kind : RegularFile, + byte_size : 125125, + }; + let sample_attributes_json = json!({ + "creationTime" : "2020-01-07T21:25:26Z", + "lastAccessTime" : "2020-01-21T22:16:51.123994500+00:00", + "lastModifiedTime" : "2020-01-07T21:25:26Z", + "fileKind" : "RegularFile", + "byteSize" : 125125 + }); + test_request( + |client| client.status(main.clone()), + "file/status", + path_main.clone(), + sample_attributes_json, + expected_attributes); + test_request( + |client| client.touch(main.clone()), + "file/touch", + path_main.clone(), + unit_json.clone(), + ()); + test_request( + |client| client.write(main.clone(), "Hello world!".into()), + "file/write", + json!({"path" : "./Main.txt", "contents" : "Hello world!"}), + unit_json.clone(), + ()); + + let uuid_value = uuid::Uuid::parse_str("02723954-fbb0-4641-af53-cec0883f260a").unwrap(); + let uuid_json = json!("02723954-fbb0-4641-af53-cec0883f260a"); + test_request( + |client| client.create_watch(main.clone()), + "file/createWatch", + path_main.clone(), + uuid_json.clone(), + uuid_value); + let watch_id = json!({ + "watchId" : "02723954-fbb0-4641-af53-cec0883f260a" + }); + test_request( + |client| client.delete_watch(uuid_value.clone()), + "file/deleteWatch", + watch_id.clone(), + unit_json.clone(), + ()); + } +} diff --git a/gui/src/rust/ide/enso-protocol/src/lib.rs b/gui/src/rust/ide/enso-protocol/src/lib.rs new file mode 100644 index 0000000000..7e1ec4326d --- /dev/null +++ b/gui/src/rust/ide/enso-protocol/src/lib.rs @@ -0,0 +1,16 @@ +//! Client side implementation of Enso protocol. + +#![warn(missing_docs)] +#![warn(trivial_casts)] +#![warn(trivial_numeric_casts)] +#![warn(unused_import_braces)] +#![warn(unused_qualifications)] +#![warn(unsafe_code)] +#![warn(missing_copy_implementations)] +#![warn(missing_debug_implementations)] + +pub mod types; +pub mod file_manager; +pub mod project_manager; + +pub use enso_prelude as prelude; diff --git a/gui/src/rust/ide/enso-protocol/src/project_manager.rs b/gui/src/rust/ide/enso-protocol/src/project_manager.rs new file mode 100644 index 0000000000..1bc095a78e --- /dev/null +++ b/gui/src/rust/ide/enso-protocol/src/project_manager.rs @@ -0,0 +1,384 @@ +//! Client library for the JSON-RPC-based Project Manager service. + +//FIXME: We need to review the structures' names in Enso Protocol specification +// https://github.com/luna/enso/issues/708 + +use crate::prelude::*; + +use crate::types::UTCDateTime; +use json_rpc::api::Result; +use json_rpc::Handler; +use json_rpc::make_rpc_methods; +use futures::Stream; +use serde::Serialize; +use serde::Deserialize; +use std::future::Future; +use uuid::Uuid; + + + +// ============= +// === Event === +// ============= + +// Project Manager has no notifications, so we create a dummy Notification type for it. +type Notification = (); + +/// Event emitted by the Project Manager `Client`. +pub type Event = json_rpc::handler::Event; + + + +// =================== +// === RPC Methods === +// =================== + +make_rpc_methods! { +/// An interface containing all the available project management operations. +trait API { + /// Request the project picker to open a specified project. This operation also + /// includes spawning an instance of the language server open on the specified project. + #[MethodInput=OpenProjectInput,rpc_name="project/open",result=open_project_result, + set_result=set_open_project_result] + fn open_project(&self, project_id:Uuid) -> OpenProjectResponse; + + /// Request the project picker to close a specified project. This operation + /// includes shutting down the language server gracefully so that it can persist state to disk + /// as needed. + #[MethodInput=CloseProjectInput,rpc_name="project/close",result=close_project_result, + set_result=set_close_project_result] + fn close_project(&self, project_id:Uuid) -> (); + + /// Request the project picker to list the user's most recently opened projects. + #[MethodInput=ListRecentProjectsInput,rpc_name="project/listRecent", + result=list_recent_projects_result,set_result=set_list_recent_projects_result] + fn list_recent_projects(&self, number_of_projects:u32) -> ProjectListResponse; + + /// Request the creation of a new project. + #[MethodInput=CreateProjectInput,rpc_name="project/create",result=create_project_result, + set_result=set_create_project_result] + fn create_project(&self, name:String) -> CreateProjectResponse; + + /// Request the deletion of a project. + #[MethodInput=DeleteProjectInput,rpc_name="project/delete",result=delete_project_result, + set_result=set_delete_project_result] + fn delete_project(&self, project_id:Uuid) -> (); + + /// Request a list of sample projects that are available to the user. + #[MethodInput=ListSamplesInput,rpc_name="project/listSample",result=list_samples_result, + set_result=set_list_samples_result] + fn list_samples(&self, num_projects:u32) -> ProjectListResponse; +}} + + + +// ============= +// === Types === +// ============= + +/// Address consisting of host and port. +#[derive(Debug,Clone,Serialize,Deserialize,PartialEq)] +pub struct IpWithSocket { + /// Host name. + pub host : String, + /// Port number. + pub port : u16 +} + +/// Project name. +#[derive(Debug,Clone,Serialize,Deserialize,PartialEq,Shrinkwrap)] +pub struct ProjectName { + #[allow(missing_docs)] + pub name : String +} + +/// Project information, such as name, its id and last time it was opened. +#[derive(Debug,Clone,Serialize,Deserialize,PartialEq)] +pub struct ProjectMetadata { + /// Project's name. + #[serde(flatten)] + pub name : ProjectName, + /// Project's uuid. + pub id : Uuid, + /// Last time the project was opened. + pub last_opened : Option +} + +/// Response of `list_recent_projects` and `list_samples`. +#[derive(Debug,Clone,Serialize,Deserialize,PartialEq)] +pub struct ProjectListResponse { + /// List of projects. + pub projects : Vec +} + +/// Response of `create_project`. +#[derive(Debug,Clone,Copy,Serialize,Deserialize,PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateProjectResponse { + /// Created project uuid. + pub project_id : Uuid +} + +/// Response of `open_project`. +#[derive(Debug,Clone,Serialize,Deserialize,PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct OpenProjectResponse { + /// Language server address. + pub language_server_address : IpWithSocket +} + + + +// ======================== +// === MockClient tests === +// ======================== + +#[cfg(test)] +mod mock_client_tests { + use super::*; + use chrono::DateTime; + use json_rpc::error::RpcError; + use json_rpc::messages::Error; + use json_rpc::Result; + use std::future::Future; + use utils::test::poll_future_output; + use uuid::Uuid; + + fn error(message:&str) -> Result { + let code = 1; + let data = None; + let message = message.to_string(); + let error = Error {code,data,message}; + Err(RpcError::RemoteError(error)) + } + + fn result>>(fut:F) -> Result { + let mut fut = Box::pin(fut); + poll_future_output(&mut fut).expect("Promise isn't ready") + } + + #[test] + fn project_life_cycle() { + let mock_client = MockClient::default(); + let expected_uuid = Uuid::default(); + let creation_response = CreateProjectResponse { project_id : expected_uuid.clone() }; + let host = "localhost".to_string(); + let port = 30500; + let language_server_address = IpWithSocket {host,port}; + let expected_ip_with_socket = OpenProjectResponse { language_server_address }; + let open_result = Ok(expected_ip_with_socket.clone()); + mock_client.set_create_project_result("HelloWorld".into(),Ok(creation_response)); + mock_client.set_open_project_result(expected_uuid.clone(),open_result); + mock_client.set_close_project_result(expected_uuid.clone(),error("Project isn't open.")); + mock_client.set_delete_project_result(expected_uuid.clone(),error("Project doesn't exist.")); + + let delete_result = mock_client.delete_project(expected_uuid.clone()); + result(delete_result).expect_err("Project shouldn't exist."); + + let creation_response = mock_client.create_project("HelloWorld".into()); + let uuid = result(creation_response).expect("Couldn't create project").project_id; + assert_eq!(uuid, expected_uuid); + + let close_result = result(mock_client.close_project(uuid.clone())); + close_result.expect_err("Project shouldn't be open."); + + let ip_with_socket = result(mock_client.open_project(uuid.clone())); + let ip_with_socket = ip_with_socket.expect("Couldn't open project"); + assert_eq!(ip_with_socket, expected_ip_with_socket); + + mock_client.set_close_project_result(expected_uuid.clone(), Ok(())); + result(mock_client.close_project(uuid)).expect("Couldn't close project."); + + mock_client.set_delete_project_result(expected_uuid.clone(), Ok(())); + result(mock_client.delete_project(uuid)).expect("Couldn't delete project."); + } + + #[test] + fn list_recent_projects() { + let mock_client = MockClient::default(); + let project1 = ProjectMetadata { + name : ProjectName { name : "project1".to_string() }, + id : Uuid::default(), + last_opened : Some(DateTime::parse_from_rfc3339("2020-01-07T21:25:26Z").unwrap()) + }; + let project2 = ProjectMetadata { + name : ProjectName { name : "project2".to_string() }, + id : Uuid::default(), + last_opened : Some(DateTime::parse_from_rfc3339("2020-02-02T13:15:20Z").unwrap()) + }; + let expected_recent_projects = ProjectListResponse { projects : vec![project1,project2] }; + let sample1 = ProjectMetadata { + name : ProjectName { name : "sample1".to_string() }, + id : Uuid::default(), + last_opened : Some(DateTime::parse_from_rfc3339("2019-11-23T05:30:12Z").unwrap()) + }; + let sample2 = ProjectMetadata { + name : ProjectName { name : "sample2".to_string() }, + id : Uuid::default(), + last_opened : Some(DateTime::parse_from_rfc3339("2019-12-25T00:10:58Z").unwrap()) + }; + let expected_sample_projects = ProjectListResponse { projects : vec![sample1,sample2] }; + mock_client.set_list_recent_projects_result(2,Ok(expected_recent_projects.clone())); + mock_client.set_list_samples_result(2,Ok(expected_sample_projects.clone())); + + let list_recent_error = "Couldn't get recent projects."; + let list_sample_error = "Couldn't get sample projects."; + let recent_projects = result(mock_client.list_recent_projects(2)).expect(list_recent_error); + assert_eq!(recent_projects, expected_recent_projects); + let sample_projects = result(mock_client.list_samples(2)).expect(list_sample_error); + assert_eq!(sample_projects, expected_sample_projects); + } +} + + + +// ==================== +// === Client tests === +// ==================== + +#[cfg(test)] +mod remote_client_tests { + use super::*; + + use chrono::DateTime; + use json_rpc::messages::Message; + use json_rpc::messages::RequestMessage; + use json_rpc::test_util::transport::mock::MockTransport; + use serde_json::json; + use serde_json::Value; + use std::future::Future; + use utils::test::poll_future_output; + use futures::task::LocalSpawnExt; + + struct Fixture { + transport : MockTransport, + client : Client, + executor : futures::executor::LocalPool, + } + + fn setup_fm() -> Fixture { + let transport = MockTransport::new(); + let client = Client::new(transport.clone()); + let executor = futures::executor::LocalPool::new(); + executor.spawner().spawn_local(client.runner()).unwrap(); + Fixture {transport,client,executor} + } + + /// Tests making a request using file manager: + /// * creates PM client and uses `make_request` to make a request + /// * checks that request is made for `expected_method` + /// * checks that request input is `expected_input` + /// * mocks receiving a response from server with `result` + /// * checks that FM-returned Future yields `expected_output` + fn test_request + ( make_request : Fun + , expected_method : &str + , expected_input : &Value + , result : &Value + , expected_output : &T) + where Fun : FnOnce(&mut Client) -> Fut, + Fut : Future>, + T : Debug + PartialEq { + let mut fixture = setup_fm(); + let mut fut = Box::pin(make_request(&mut fixture.client)); + + let request = fixture.transport.expect_message::>(); + assert_eq!(request.method, *expected_method); + assert_eq!(request.params, *expected_input); + + let response = Message::new_success(request.id, result); + fixture.transport.mock_peer_message(response); + fixture.executor.run_until_stalled(); + let output = poll_future_output(&mut fut).unwrap().unwrap(); + assert_eq!(output, *expected_output); + } + + #[test] + fn test_requests() { + let unit_json = json!(null); + let project_id = Uuid::default(); + let create_project_response = CreateProjectResponse { project_id }; + let project_id_json = json!({"projectId":"00000000-0000-0000-0000-000000000000"}); + let language_server_address = IpWithSocket{host:"localhost".to_string(),port:27015}; + let ip_with_address = OpenProjectResponse { language_server_address }; + let ip_with_address_json = json!({ + "languageServerAddress" : { + "host" : "localhost", + "port" : 27015 + } + }); + let project_name = String::from("HelloWorld"); + let project_name_json = json!({"name":serde_json::to_value(&project_name).unwrap()}); + let number_of_projects = 2; + let number_of_projects_json = json!({"numberOfProjects":number_of_projects}); + let num_projects_json = json!({"numProjects":number_of_projects}); + let project1 = ProjectMetadata { + name : ProjectName { name : "project1".to_string() }, + id : Uuid::default(), + last_opened : Some(DateTime::parse_from_rfc3339("2020-01-07T21:25:26Z").unwrap()) + }; + let project2 = ProjectMetadata { + name : ProjectName { name : "project2".to_string() }, + id : Uuid::default(), + last_opened : Some(DateTime::parse_from_rfc3339("2020-02-02T13:15:20Z").unwrap()) + }; + let project_list = ProjectListResponse { projects : vec![project1,project2] }; + let project_list_json = json!({ + "projects" : [ + { + "id" : "00000000-0000-0000-0000-000000000000", + "last_opened" : "2020-01-07T21:25:26+00:00", + "name" : "project1" + }, + { + "id" : "00000000-0000-0000-0000-000000000000", + "last_opened" : "2020-02-02T13:15:20+00:00", + "name" : "project2" + } + ] + }); + + test_request( + |client| client.list_recent_projects(number_of_projects), + "project/listRecent", + &number_of_projects_json, + &project_list_json, + &project_list + ); + test_request( + |client| client.list_samples(number_of_projects), + "project/listSample", + &num_projects_json, + &project_list_json, + &project_list + ); + test_request( + |client| client.open_project(project_id.clone()), + "project/open", + &project_id_json, + &ip_with_address_json, + &ip_with_address + ); + test_request( + |client| client.close_project(project_id.clone()), + "project/close", + &project_id_json, + &unit_json, + &() + ); + test_request( + |client| client.delete_project(project_id.clone()), + "project/delete", + &project_id_json, + &unit_json, + &() + ); + test_request( + |client| client.create_project(project_name.clone()), + "project/create", + &project_name_json, + &project_id_json, + &create_project_response + ); + } +} diff --git a/gui/src/rust/ide/enso-protocol/src/types.rs b/gui/src/rust/ide/enso-protocol/src/types.rs new file mode 100644 index 0000000000..d89b92dc63 --- /dev/null +++ b/gui/src/rust/ide/enso-protocol/src/types.rs @@ -0,0 +1,4 @@ +//! Common types of JSON-RPC-based Enso services used by both Project Manager and File Manager. + +/// Time in UTC time zone. +pub type UTCDateTime = chrono::DateTime; diff --git a/gui/src/rust/ide/json-rpc/src/lib.rs b/gui/src/rust/ide/json-rpc/src/lib.rs index b82741948d..5135f76c0d 100644 --- a/gui/src/rust/ide/json-rpc/src/lib.rs +++ b/gui/src/rust/ide/json-rpc/src/lib.rs @@ -15,6 +15,7 @@ pub mod api; pub mod error; pub mod handler; +pub mod macros; pub mod messages; pub mod test_util; pub mod transport; diff --git a/gui/src/rust/ide/json-rpc/src/macros.rs b/gui/src/rust/ide/json-rpc/src/macros.rs new file mode 100644 index 0000000000..6df6f10c4c --- /dev/null +++ b/gui/src/rust/ide/json-rpc/src/macros.rs @@ -0,0 +1,136 @@ +//! Helper macros to generate RemoteClient and MockClient. + +// FIXME[dg]: https://github.com/luna/ide/issues/401 We want to make the generated methods to +// take references instead of ownership. +/// This macro reads a `trait API` item and generates asynchronous methods for RPCs. Each method +/// should be signed with `MethodInput`, `rpc_name`, `result` and `set_result` attributes. e.g.: +/// ```rust,compile_fail +/// make_rpc_method!{ +/// trait API { +/// #[MethodInput=CallMePleaseInput,camelCase=callMePlease,result=call_me_please_result, +/// set_result=set_call_me_please_result] +/// fn call_me_please(&self, my_number_is:String) -> (); +/// } +/// } +/// ``` +/// +/// This macro generates an `API` trait and creates two structs implementing `API` +/// called `Client`, with the actual RPC methods, and `MockClient`, with mocked methods with +/// return types setup by: +/// ```rust,compile_fail +/// fn set_call_me_please_result +/// (&mut self, my_number_is:String,result:json_rpc::api::Result<()>) { /* impl */ } +/// ``` +#[macro_export] +macro_rules! make_rpc_methods { + ( + $(#[doc = $impl_doc:expr])+ + trait API { + $($(#[doc = $doc:expr])+ + #[MethodInput=$method_input:ident,rpc_name=$rpc_name:expr,result=$method_result:ident, + set_result=$set_result:ident] + fn $method:ident(&self $(,$param_name:ident:$param_ty:ty)+) -> $result:ty; + )* + } + ) => { + // =========== + // === API === + // =========== + + $(#[doc = $impl_doc])+ + pub trait API { + $( + $(#[doc = $doc])+ + fn $method(&self $(,$param_name:$param_ty)+) + -> std::pin::Pin>>>; + )* + } + + + + // ============== + // === Client === + // ============== + + $(#[doc = $impl_doc])+ + #[derive(Debug)] + pub struct Client { + /// JSON-RPC protocol handler. + handler : RefCell>, + } + + impl Client { + /// Create a new client that will use given transport. + pub fn new(transport:impl json_rpc::Transport + 'static) -> Self { + let handler = RefCell::new(Handler::new(transport)); + Self { handler } + } + + /// Asynchronous event stream with notification and errors. + /// + /// On a repeated call, previous stream is closed. + pub fn events(&self) -> impl Stream { + self.handler.borrow_mut().handler_event_stream() + } + + /// Returns a future that performs any background, asynchronous work needed + /// for this Client to correctly work. Should be continually run while the + /// `Client` is used. Will end once `Client` is dropped. + pub fn runner(&self) -> impl Future { + self.handler.borrow_mut().runner() + } + } + + impl API for Client { + $(fn $method(&self, $($param_name:$param_ty),*) + -> std::pin::Pin>>> { + let input = $method_input { $($param_name:$param_name),* }; + Box::pin(self.handler.borrow().open_request(input)) + })* + } + + $( + /// Structure transporting method arguments. + #[derive(Serialize,Deserialize,Debug,PartialEq)] + #[serde(rename_all = "camelCase")] + struct $method_input { + $($param_name : $param_ty),* + } + + impl json_rpc::RemoteMethodCall for $method_input { + const NAME:&'static str = $rpc_name; + type Returned = $result; + } + )* + + + + // ================== + // === MockClient === + // ================== + + /// Mock used for tests. + #[derive(Debug,Default)] + pub struct MockClient { + $($method_result : RefCell>>,)* + } + + impl API for MockClient { + $(fn $method(&self $(,$param_name:$param_ty)+) + -> std::pin::Pin>>> { + let mut result = self.$method_result.borrow_mut(); + let result = result.remove(&($($param_name),+)).unwrap(); + Box::pin(async move { result }) + })* + } + + impl MockClient { + $( + /// Sets `$method`'s result to be returned when it is called. + pub fn $set_result(&self $(,$param_name:$param_ty)+, result:Result<$result>) { + self.$method_result.borrow_mut().insert(($($param_name),+),result); + } + )* + } + } +} diff --git a/gui/src/rust/ide/tests/project_manager.rs b/gui/src/rust/ide/tests/project_manager.rs new file mode 100644 index 0000000000..8bb89994d5 --- /dev/null +++ b/gui/src/rust/ide/tests/project_manager.rs @@ -0,0 +1,35 @@ +//! Project Manager tests. + +#[cfg(test)] +mod tests { + use enso_protocol::project_manager::API; + use enso_protocol::project_manager::Client; + use ide::*; + use ide::transport::web::WebSocket; + + use wasm_bindgen_test::wasm_bindgen_test_configure; + + wasm_bindgen_test_configure!(run_in_browser); + + + //#[wasm_bindgen_test::wasm_bindgen_test(async)] + #[allow(dead_code)] + async fn project_life_cycle() { + let ws = WebSocket::new_opened("ws://localhost:30535").await; + let ws = ws.expect("Couldn't connect to WebSocket server."); + let client = Client::new(ws); + let _executor = setup_global_executor(); + + executor::global::spawn(client.runner()); + + let name = "TestProject".to_string(); + let creation = client.create_project(name).await.expect("Couldn't create project."); + let uuid = creation.project_id; + let _address = client.open_project(uuid.clone()).await.expect("Couldn't open project."); + client.close_project(uuid.clone()).await.expect("Couldn't close project."); + client.delete_project(uuid).await.expect("Couldn't delete project."); + client.list_recent_projects(10).await.expect("Couldn't list recent projects."); + // FIXME[dg]: project/listSample isn't implemented on the server-side yet. + //client.list_samples(10).await.expect("Couldn't list samples."); + } +}