mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* web-api: refuse calls beyond an in-flight limit, and say who can call window.<app> At most 64 calls (PageApi::with_max_in_flight) may wait for the app at once. A call made while that many wait rejects at once with a busy error and never reaches the app, so a page script that calls in a loop without awaiting cannot grow the app queue without bound, as the desktop control server already answers busy when its queue is full. The README also says the API is a property of the page global with no key: any script in the page origin has the same command control as the desktop control server. * web-api: turn a busy call away before converting its params, and say a same-origin parent or opener can call window.<app> A call over the in-flight limit used to stringify and convert its params before the busy check, so a large payload still cost the work it was being refused for. The params are now built by a closure the call runs only once it holds a slot. The README said a parent, a child or an opener cannot reach window.<app>. That holds across origins only: a same-origin parent, child or opener with a window reference calls it directly, and the craft-web-host origin and source checks do not apply to that call. --------- Co-authored-by: Helen Kwok <107752876+helenkwok@users.noreply.github.com>
248 lines
10 KiB
Rust
248 lines
10 KiB
Rust
#![cfg(target_arch = "wasm32")]
|
|
//! Node tests for the page part: a canned control channel that records what it is asked, and the
|
|
//! `window.<app>` functions called the way a page calls them. No browser.
|
|
|
|
use std::cell::{Cell, RefCell};
|
|
use std::rc::Rc;
|
|
|
|
use serde_json::{Value, json};
|
|
use wasm_bindgen::JsCast;
|
|
use wasm_bindgen::prelude::{Closure, JsValue};
|
|
use wasm_bindgen_futures::JsFuture;
|
|
use wasm_bindgen_test::wasm_bindgen_test;
|
|
|
|
use craft_web_api::methods::Methods;
|
|
use craft_web_api::page::{PageApi, Request};
|
|
|
|
thread_local! {
|
|
/// (method, params) pairs recorded by the fake control channel.
|
|
static REQUESTS: RefCell<Vec<(String, Value)>> = const { RefCell::new(Vec::new()) };
|
|
}
|
|
|
|
fn reset() {
|
|
REQUESTS.with(|r| r.borrow_mut().clear());
|
|
let _ = js_sys::Reflect::delete_property(&js_sys::global(), &"vectorcraft".into());
|
|
}
|
|
|
|
/// A control channel that always answers `reply`.
|
|
fn canned_request(reply: Value) -> Request {
|
|
Rc::new(move |_method: String, _params: Value| {
|
|
let reply = reply.clone();
|
|
Box::pin(async move { reply })
|
|
})
|
|
}
|
|
|
|
/// A control channel that records every call and always answers `reply`.
|
|
fn recording_request(reply: Value) -> Request {
|
|
Rc::new(move |method: String, params: Value| {
|
|
REQUESTS.with(|r| r.borrow_mut().push((method.clone(), params)));
|
|
let reply = reply.clone();
|
|
Box::pin(async move { reply })
|
|
})
|
|
}
|
|
|
|
fn to_value(v: &JsValue) -> Value {
|
|
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(Value::Null)
|
|
}
|
|
|
|
fn api() -> js_sys::Object {
|
|
js_sys::Reflect::get(&js_sys::global(), &"vectorcraft".into()).expect("window.vectorcraft").unchecked_into()
|
|
}
|
|
|
|
fn has(api: &js_sys::Object, name: &str) -> bool {
|
|
js_sys::Reflect::has(api, &JsValue::from_str(name)).unwrap_or(false)
|
|
}
|
|
|
|
/// Call `window.vectorcraft.<name>(args…)` and await its promise.
|
|
async fn call(name: &str, args: &[JsValue]) -> Result<JsValue, JsValue> {
|
|
let api = api();
|
|
let f: js_sys::Function = js_sys::Reflect::get(&api, &JsValue::from_str(name)).expect(name).unchecked_into();
|
|
let promise: js_sys::Promise = f.apply(&api, &args.iter().collect::<js_sys::Array>()).expect("call").unchecked_into();
|
|
JsFuture::from(promise).await
|
|
}
|
|
|
|
fn obj(json: &str) -> JsValue {
|
|
js_sys::JSON::parse(json).expect("valid JSON")
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
async fn install_keeps_existing_functions() {
|
|
reset();
|
|
let g = js_sys::global();
|
|
let existing = js_sys::Object::new();
|
|
let marker = Closure::<dyn Fn()>::new(|| {}).into_js_value();
|
|
js_sys::Reflect::set(&existing, &"execute".into(), &marker).expect("set execute");
|
|
js_sys::Reflect::set(&g, &"vectorcraft".into(), &existing.into()).expect("set window.vectorcraft");
|
|
|
|
let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), canned_request(json!({"ok": true, "result": {}}))));
|
|
page.install().expect("install");
|
|
|
|
let api = api();
|
|
let kept = js_sys::Reflect::get(&api, &"execute".into()).expect("execute");
|
|
assert_eq!(kept, marker, "an app's own execute is kept");
|
|
for name in ["request", "execute", "commands", "docInspect", "uiInspect", "screenshot"] {
|
|
assert!(has(&api, name), "{name} must be added");
|
|
}
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
async fn methods_table_decides_which_functions_exist() {
|
|
reset();
|
|
let only = Methods { commands: Some("c.commands"), ..Methods::NONE };
|
|
Rc::new(PageApi::new("vectorcraft", only, canned_request(json!({"ok": true, "result": {}})))).install().expect("install");
|
|
let api = api();
|
|
assert!(has(&api, "request"), "request is always installed");
|
|
assert!(has(&api, "commands"));
|
|
for name in ["execute", "docInspect", "uiInspect", "screenshot"] {
|
|
assert!(!has(&api, name), "{name} has no method and must not be installed");
|
|
}
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
async fn functions_send_their_methods() {
|
|
reset();
|
|
let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), recording_request(json!({"ok": true, "result": {"done": 1}}))));
|
|
page.install().expect("install");
|
|
|
|
let out = call("execute", &["object.rectangle".into(), obj(r#"{"width": 100}"#)]).await.expect("execute resolves");
|
|
assert_eq!(to_value(&out), json!({"done": 1}), "a success resolves with the result");
|
|
call("commands", &[]).await.expect("commands");
|
|
call("docInspect", &[obj(r#"{"depth": 2}"#)]).await.expect("docInspect");
|
|
call("uiInspect", &[]).await.expect("uiInspect");
|
|
call("screenshot", &[]).await.expect("screenshot");
|
|
call("screenshot", &[obj(r#"{"data": false, "scale": 2}"#)]).await.expect("screenshot with data");
|
|
call("request", &["custom.method".into(), obj(r#"{"x": 1}"#)]).await.expect("request");
|
|
|
|
let calls = REQUESTS.with(|r| r.borrow().clone());
|
|
assert_eq!(
|
|
calls,
|
|
[
|
|
("engine.execute".to_string(), json!({"command": "object.rectangle", "params": {"width": 100}})),
|
|
("engine.commands".to_string(), json!({})),
|
|
("document.inspect".to_string(), json!({"depth": 2})),
|
|
("ui.inspect".to_string(), json!({})),
|
|
("ui.screenshot".to_string(), json!({"data": true})),
|
|
("ui.screenshot".to_string(), json!({"data": false, "scale": 2})),
|
|
("custom.method".to_string(), json!({"x": 1})),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
async fn error_replies_reject_with_the_error_text() {
|
|
reset();
|
|
Rc::new(PageApi::new("vectorcraft", Methods::default(), canned_request(json!({"ok": false, "error": "boom"})))).install().expect("install");
|
|
let err = match call("commands", &[]).await {
|
|
Err(err) => err,
|
|
Ok(_) => panic!("an error reply must reject"),
|
|
};
|
|
assert_eq!(String::from(err.unchecked_into::<js_sys::Error>().message()), "boom");
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
async fn timeout_gives_an_error_saying_so() {
|
|
reset();
|
|
// A control channel that never answers: it waits on a promise that never settles. (Looping on
|
|
// resolved promises instead would starve the timer queue, so the page's timeout could never fire.)
|
|
let pending: Request = Rc::new(|_method: String, _params: Value| {
|
|
Box::pin(async {
|
|
let _ = JsFuture::from(js_sys::Promise::new(&mut |_, _| {})).await;
|
|
Value::Null
|
|
})
|
|
});
|
|
let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), pending).with_timeout(10));
|
|
page.install().expect("install");
|
|
let err = match call("execute", &["object.rectangle".into(), js_sys::Object::new().into()]).await {
|
|
Err(err) => err,
|
|
Ok(_) => panic!("a never-answering app must time out"),
|
|
};
|
|
let message = String::from(err.unchecked_into::<js_sys::Error>().message());
|
|
assert!(message.contains("vectorcraft did not answer engine.execute within 0.01 s"), "{message}");
|
|
}
|
|
|
|
thread_local! {
|
|
/// Set when the fake control channel's request future is dropped before it answered.
|
|
static RELEASED: Cell<bool> = const { Cell::new(false) };
|
|
}
|
|
|
|
struct ReleaseOnDrop;
|
|
|
|
impl Drop for ReleaseOnDrop {
|
|
fn drop(&mut self) {
|
|
RELEASED.with(|r| r.set(true));
|
|
}
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
async fn a_timed_out_call_drops_the_apps_request_so_the_app_can_release_it() {
|
|
reset();
|
|
RELEASED.with(|r| r.set(false));
|
|
let never: Request = Rc::new(|_method: String, _params: Value| {
|
|
Box::pin(async {
|
|
let _release = ReleaseOnDrop;
|
|
let _ = JsFuture::from(js_sys::Promise::new(&mut |_, _| {})).await;
|
|
Value::Null
|
|
})
|
|
});
|
|
Rc::new(PageApi::new("vectorcraft", Methods::default(), never).with_timeout(10)).install().expect("install");
|
|
assert!(call("commands", &[]).await.is_err(), "the call times out");
|
|
assert!(RELEASED.with(Cell::get), "the request future was dropped when the call timed out");
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
async fn an_answered_call_does_not_look_timed_out() {
|
|
reset();
|
|
RELEASED.with(|r| r.set(false));
|
|
let quick: Request = Rc::new(|_method: String, _params: Value| {
|
|
Box::pin(async {
|
|
let _release = ReleaseOnDrop;
|
|
json!({"ok": true, "result": {"fine": true}})
|
|
})
|
|
});
|
|
Rc::new(PageApi::new("vectorcraft", Methods::default(), quick).with_timeout(10)).install().expect("install");
|
|
let out = call("commands", &[]).await.expect("an answered call resolves");
|
|
assert_eq!(to_value(&out), json!({"fine": true}));
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
async fn screenshot_carries_the_png_as_data_whatever_the_app_calls_it() {
|
|
reset();
|
|
let reply = json!({"ok": true, "result": {"base64": "UE5H", "width": 2}});
|
|
Rc::new(PageApi::new("vectorcraft", Methods::default(), canned_request(reply))).install().expect("install");
|
|
let out = call("screenshot", &[]).await.expect("screenshot");
|
|
assert_eq!(to_value(&out), json!({"base64": "UE5H", "width": 2, "data": "UE5H"}));
|
|
}
|
|
|
|
/// Start `window.vectorcraft.<name>(args…)` without awaiting it, so several calls can be in flight.
|
|
fn start(name: &str, args: &[JsValue]) -> js_sys::Promise {
|
|
let api = api();
|
|
let f: js_sys::Function = js_sys::Reflect::get(&api, &JsValue::from_str(name)).expect(name).unchecked_into();
|
|
f.apply(&api, &args.iter().collect::<js_sys::Array>()).expect("call").unchecked_into()
|
|
}
|
|
|
|
fn message(err: JsValue) -> String {
|
|
String::from(err.unchecked_into::<js_sys::Error>().message())
|
|
}
|
|
|
|
#[wasm_bindgen_test]
|
|
async fn a_call_beyond_the_in_flight_limit_is_refused_and_its_slot_comes_back() {
|
|
reset();
|
|
let never: Request = Rc::new(|_method: String, _params: Value| {
|
|
Box::pin(async {
|
|
let _ = JsFuture::from(js_sys::Promise::new(&mut |_, _| {})).await;
|
|
Value::Null
|
|
})
|
|
});
|
|
Rc::new(PageApi::new("vectorcraft", Methods::default(), never).with_timeout(50).with_max_in_flight(1)).install().expect("install");
|
|
|
|
let first = start("commands", &[]);
|
|
let second = start("commands", &[]);
|
|
let busy = JsFuture::from(second).await.expect_err("a second call while one waits is refused");
|
|
assert!(message(busy).contains("vectorcraft is busy: 1 calls are already waiting"), "the refusal says why");
|
|
let timed_out = JsFuture::from(first).await.expect_err("the first call times out");
|
|
assert!(message(timed_out).contains("did not answer"), "the first call was accepted and waited");
|
|
|
|
let again = call("commands", &[]).await.expect_err("a later call is accepted again");
|
|
assert!(message(again).contains("did not answer"), "its slot came back once the first call ended");
|
|
}
|