web-api: one window.<app> command API for the web builds

This commit is contained in:
Helen Kwok
2026-11-05 13:31:10 +08:00
parent d836bea31b
commit a94d849df2
10 changed files with 669 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
name: Web API (page command API)
on:
workflow_dispatch:
push:
branches: [main]
paths: ['crates/web-api/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/web-api.yml']
pull_request:
paths: ['crates/web-api/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/web-api.yml']
permissions:
contents: read
jobs:
web-api:
# One OS: the crate is platform-independent JSON handling plus wasm32; no platform code.
timeout-minutes: 30
runs-on: ubuntu-latest
env:
CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- name: Install the matching WASM test runner
shell: bash
run: |
runner_version=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); print(next(p["version"] for p in d["package"] if p["name"] == "wasm-bindgen"))')
cargo install wasm-bindgen-cli --version "$runner_version" --locked
- run: cargo fmt --check
- run: cargo clippy -p craft-web-api --all-targets --locked -- -D warnings
- run: cargo clippy -p craft-web-api --all-targets --locked --target wasm32-unknown-unknown -- -D warnings
- run: cargo test -p craft-web-api --locked
- run: cargo test -p craft-web-api --locked --target wasm32-unknown-unknown
Generated
+11
View File
@@ -1192,6 +1192,17 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test",
]
[[package]]
name = "craft-window"
version = "0.1.0"
+1
View File
@@ -52,6 +52,7 @@ craft-text = { path = "crates/text" }
craft-ui = { path = "crates/ui" }
craft-update = { path = "crates/update" }
craft-vectorcraft-format = { path = "crates/vectorcraft-format" }
craft-web-api = { path = "crates/web-api" }
craft-window = { path = "crates/window" }
egui = { version = "0.37", default-features = false }
rayon = "1"
+3
View File
@@ -111,6 +111,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-vectorcraft-format`](crates/vectorcraft-format/README.md): the envelope of VectorCraft's
`.vectorcraft` file: its members, versions, embedded images and the data after the JSON, with the
document as a type parameter. The document and its migrations stay in VectorCraft.
- [`craft-web-api`](crates/web-api/README.md): the page command API every Craft web build offers its page — one
`window.<app>` shape (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, `screenshot`) over the app's
JSON control channel.
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
and compatibility boundaries across the Craft suite.
- [Theme infrastructure](docs/theme-infrastructure.md): eight-app comparison and the small shared
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "craft-web-api"
description = "The page command API for Craft web builds: one window.<app> shape over the app's JSON control channel."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[dependencies]
serde_json = { workspace = true }
# Already in every web build's Cargo.lock through eframe.
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
[lints]
workspace = true
+61
View File
@@ -0,0 +1,61 @@
# craft-web-api
The page command API for every Craft web build: one `window.<app>` shape over the app's JSON
control channel.
## The JS API
`PageApi::install` puts this on `window.<app>` (`"vectorcraft"` → `window.vectorcraft`), without
replacing an existing `window.<app>`: missing functions are added, an app that already has its own
keeps them. Each function returns a Promise that resolves with the method's `result` or rejects with
an `Error` carrying `error`. Every call has a timeout (30 s by default, `PageApi::with_timeout`);
when the app does not answer in time, the error says so.
Which functions are installed follows the app's `Methods` table: only the functions whose control
method the table provides are installed; `request` is always there.
| Function | Call | Table entry | Default method |
|---|---|---|---|
| `request` | `request(method, params)` | — | — (as given) |
| `execute` | `execute(command, params)` | `execute` | `engine.execute {command, params}` |
| `commands` | `commands()` | `commands` | `engine.commands {}` |
| `docInspect` | `docInspect(params)` | `doc_inspect` | `document.inspect` |
| `uiInspect` | `uiInspect(params)` | `ui_inspect` | `ui.inspect` |
| `screenshot` | `screenshot(params)` | `screenshot` | `ui.screenshot` (with `data: true`) |
`Methods::default()` is the desktop control-server method names; an app whose control channel
names a method differently, or lacks one, says so in its table (`Methods::NONE` is the empty table).
## How an app wires it
The app hands the crate its control channel as a `Request`; the crate never knows the app's
internals. Using VectorCraft's channel (the same one the desktop control server uses) as the example:
```rust
use std::rc::Rc;
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
// Send a ControlRequest to the app and await its reply object ({ok, result} / {ok: false, error}).
let request: Request = Rc::new(move |method: String, params: serde_json::Value| {
let (req, rx) = ControlRequest::new(method, params);
control_tx.send(req);
Box::pin(poll_reply(rx)) // resolves when the app answers the request
});
let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), request));
page.install()?; // window.vectorcraft
```
## Beside the host messages
Opening a document, handing a saved or exported file to the host, the document name and the dirty
flag stay with the app's own host messages (`<app>:open`, `<app>:save`, `<app>:dirty`, …) and its
own control methods; a page reaches any control method through `request(method, params)`.
## Tests
`src/methods.rs` carries the native unit tests (the default and empty tables). `tests/page.rs` runs
under `wasm-bindgen-test` in Node with a canned control channel: install and keeping an app's own
functions, the table deciding which functions exist, the method and params each function sends,
error replies as rejections, and the timeout.
+40
View File
@@ -0,0 +1,40 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! One page command API for every Craft web build.
//!
//! Craft web apps (the `apps/<app>-web` builds of VectorCraft, PhotoCraft, FilmCraft, …) accept
//! JSON control requests `{method, params}` from their page and answer `{ok, result}` or
//! `{ok: false, error}` — the same protocol as the desktop control server. Until now each app kept
//! its own copy of the code that exposes this to the page as `window.<app>`, in a different shape.
//! This crate is the one shared shape.
//!
//! - [`page`] (wasm32 only) installs `window.<app>` — `request(method, params)`,
//! `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and
//! `screenshot(params)`; each function other than `request` is installed only when the app's
//! [`methods::Methods`] table provides its control method.
//! - [`methods`] is the target-independent table naming the control method behind each function.
//!
//! The app hands the crate its request function (its control channel) and a [`methods::Methods`]
//! table; the crate never knows the app's internals.
//!
//! # How an app wires it
//!
//! ```ignore
//! use std::rc::Rc;
//! use craft_web_api::methods::Methods;
//! use craft_web_api::page::{PageApi, Request};
//!
//! // The control channel: send a ControlRequest, await its reply object.
//! let request: Request = Rc::new(move |method: String, params: serde_json::Value| {
//! let (req, rx) = ControlRequest::new(method, params);
//! control_tx.send(req); // the same channel the desktop control server uses
//! Box::pin(receive_reply(rx)) // resolves with {ok, result} / {ok: false, error}
//! });
//! let page = Rc::new(PageApi::new("vectorcraft", Methods::default(), request));
//! page.install()?; // window.vectorcraft
//! ```
pub mod methods;
#[cfg(target_arch = "wasm32")]
pub mod page;
+58
View File
@@ -0,0 +1,58 @@
//! The method table: which control method backs each `window.<app>` function. Target-independent,
//! so it is natively tested and usable from any build.
/// The method table one app hands the crate: the control method behind each `window.<app>`
/// function. `None` means that function is not installed; `request` is always installed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Methods {
/// Behind `commands()`.
pub commands: Option<&'static str>,
/// Behind `execute(command, params)`, sent as `{command, params}`.
pub execute: Option<&'static str>,
/// Behind `docInspect(params)`.
pub doc_inspect: Option<&'static str>,
/// Behind `uiInspect(params)`.
pub ui_inspect: Option<&'static str>,
/// Behind `screenshot(params)`, sent with `data: true` unless the caller set `data`.
pub screenshot: Option<&'static str>,
}
impl Methods {
/// A table with no methods: only `request` is installed.
pub const NONE: Self = Self { commands: None, execute: None, doc_inspect: None, ui_inspect: None, screenshot: None };
}
impl Default for Methods {
/// The desktop control-server method names every Craft web build shares.
fn default() -> Self {
Self {
commands: Some("engine.commands"),
execute: Some("engine.execute"),
doc_inspect: Some("document.inspect"),
ui_inspect: Some("ui.inspect"),
screenshot: Some("ui.screenshot"),
}
}
}
#[cfg(test)]
mod tests {
use super::Methods;
#[test]
fn default_is_the_desktop_control_methods() {
let m = Methods::default();
assert_eq!(m.commands, Some("engine.commands"));
assert_eq!(m.execute, Some("engine.execute"));
assert_eq!(m.doc_inspect, Some("document.inspect"));
assert_eq!(m.ui_inspect, Some("ui.inspect"));
assert_eq!(m.screenshot, Some("ui.screenshot"));
}
#[test]
fn none_has_no_methods() {
let none = Methods::NONE;
assert!([none.commands, none.execute, none.doc_inspect, none.ui_inspect, none.screenshot].iter().all(Option::is_none));
assert_ne!(none, Methods::default());
}
}
+273
View File
@@ -0,0 +1,273 @@
//! `window.<app>`: the page's command API. wasm32 only. The app hands this crate its control channel
//! as a [`Request`]; the crate never knows the app's internals.
//!
//! Every function goes through one path, [`PageApi::call`], with the page's timeout; replies
//! resolve with the method's `result` or reject with an `Error` carrying `error`.
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use serde_json::{Value, json};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::{Closure, JsValue};
use wasm_bindgen_futures::{JsFuture, future_to_promise};
use crate::methods::Methods;
/// One control request: method and params in, the protocol's reply object out (`{ok, result}` or
/// `{ok: false, error}`). The app builds this from its control channel — send a `ControlRequest`,
/// await its reply.
pub type Request = Rc<dyn Fn(String, Value) -> Pin<Box<dyn Future<Output = Value>>>>;
/// The page surface of one web build: the `window.<app>` object.
pub struct PageApi {
app: String,
/// The control method behind each function: the desktop control-server method names this app uses.
methods: Methods,
request: Request,
/// Each control request gets a timeout: if the app does not answer within this many
/// milliseconds, the error says so (a hidden tab answers when it is shown again).
timeout_ms: f64,
}
impl PageApi {
/// `app` is the global name to install (`"vectorcraft"` → `window.vectorcraft`); `methods` is
/// the control method behind each function ([`Methods::default`] is the desktop control-server
/// method names).
pub fn new(app: &str, methods: Methods, request: Request) -> Self {
Self { app: app.to_string(), methods, request, timeout_ms: 30_000.0 }
}
/// Set the timeout for every control request, in milliseconds (default 30 s).
pub fn with_timeout(mut self, timeout_ms: u32) -> Self {
self.timeout_ms = timeout_ms as f64;
self
}
/// Install `window.<app>` = `{request, execute, commands, docInspect, uiInspect, screenshot}` —
/// each a Promise-returning function that resolves with the method's `result` or rejects with
/// an `Error` carrying `error`. Only the functions whose control method the app's [`Methods`]
/// table provides are installed; `request` is always there.
///
/// An existing `window.<app>` is never replaced: the missing functions are added to it, and an
/// app that already has its own keeps them.
pub fn install(self: Rc<Self>) -> Result<(), JsValue> {
let g = global();
let name = JsValue::from_str(&self.app);
let existing = get_prop(&g, &self.app);
let existed = existing.as_ref().is_some_and(|v| v.is_object());
let target: js_sys::Object = match existing {
Some(v) if v.is_object() => v.unchecked_into(),
_ => js_sys::Object::new(),
};
let add = |name: &str, f: JsValue| -> Result<(), JsValue> {
if js_sys::Reflect::has(&target, &JsValue::from_str(name)).unwrap_or(true) {
return Ok(()); // an app that already has its own function keeps it
}
js_sys::Reflect::set(&target, &JsValue::from_str(name), &f).map(|_| ())
};
let page = self.clone();
add(
"request",
Closure::<dyn FnMut(String, JsValue) -> js_sys::Promise>::new(move |method: String, params: JsValue| {
let page = page.clone();
future_to_promise(async move { request_promise_outcome(&page.call(&method, from_js(&params)).await) })
})
.into_js_value(),
)?;
if let Some(method) = self.methods.execute {
let page = self.clone();
add(
"execute",
Closure::<dyn FnMut(String, JsValue) -> js_sys::Promise>::new(move |command: String, params: JsValue| {
let page = page.clone();
future_to_promise(async move {
let params = json!({"command": command, "params": from_js(&params)});
request_promise_outcome(&page.call(method, params).await)
})
})
.into_js_value(),
)?;
}
if let Some(method) = self.methods.commands {
let page = self.clone();
add(
"commands",
Closure::<dyn FnMut() -> js_sys::Promise>::new(move || {
let page = page.clone();
future_to_promise(async move { request_promise_outcome(&page.call(method, json!({})).await) })
})
.into_js_value(),
)?;
}
for (name, method) in [("docInspect", self.methods.doc_inspect), ("uiInspect", self.methods.ui_inspect)] {
if let Some(method) = method {
let page = self.clone();
add(
name,
Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(move |params: JsValue| {
let page = page.clone();
future_to_promise(async move { request_promise_outcome(&page.call(method, from_js(&params)).await) })
})
.into_js_value(),
)?;
}
}
if let Some(method) = self.methods.screenshot {
let page = self.clone();
add(
"screenshot",
Closure::<dyn FnMut(JsValue) -> js_sys::Promise>::new(move |params: JsValue| {
let page = page.clone();
let params = screenshot_params(from_js(&params));
future_to_promise(async move { request_promise_outcome(&page.call(method, params).await) })
})
.into_js_value(),
)?;
}
if existed { Ok(()) } else { js_sys::Reflect::set(&g, &name, &target).map(|_| ()) }
}
/// One control request: the single path through the app's channel that every function takes,
/// with the page's timeout. If the app does not answer within the timeout, the error says so —
/// a hidden tab answers when it is shown again.
async fn call(&self, method: &str, params: Value) -> Value {
let timeout = Timeout::new(self.timeout_ms);
let request = self.request.clone();
let method = method.to_string();
let sent = method.clone();
let reply = future_to_promise(async move {
let reply = request(sent, params).await;
Ok(to_js(&reply))
});
let raced = js_sys::Promise::race(&js_sys::Array::of2(&reply, &timeout.promise));
let outcome = JsFuture::from(raced).await;
drop(timeout); // clear the timer and free its callback once the race is over
match outcome {
Err(_) => json!({
"ok": false,
"error": format!(
"{} did not answer {} within {} s (a hidden tab answers when it is shown again)",
self.app, method, self.timeout_ms / 1000.0
),
}),
Ok(value) => from_js(&value),
}
}
}
/// `screenshot` params: `data: true` is added so the reply carries the PNG, unless the caller set
/// `data` itself.
fn screenshot_params(mut params: Value) -> Value {
match params.as_object_mut() {
Some(o) => {
o.entry("data").or_insert(json!(true));
params
}
None => json!({"data": true}),
}
}
/// A promise that rejects `ms` milliseconds from now. Dropping it clears the timer (harmless once
/// fired) and frees the timer's callback, so a call leaves nothing behind. The timer comes from
/// `setTimeout` on the global object, reached through `Reflect` — no web-sys, so the crate keeps
/// working in any JS realm.
struct Timeout {
promise: js_sys::Promise,
id: f64,
_fire: Option<Closure<dyn FnMut()>>,
}
impl Timeout {
fn new(ms: f64) -> Self {
let mut fire = None;
let mut id = f64::INFINITY;
// `Promise::new` runs its executor before returning, so `fire` and `id` are set here.
let promise = js_sys::Promise::new(&mut |_resolve: js_sys::Function, reject: js_sys::Function| {
let callback = Closure::<dyn FnMut()>::new(move || {
let _ = reject.call1(&JsValue::NULL, &JsValue::NULL);
});
id = set_timeout(callback.as_ref().clone(), ms);
fire = Some(callback);
});
Self { promise, id, _fire: fire }
}
}
impl Drop for Timeout {
fn drop(&mut self) {
clear_timeout(self.id);
}
}
/// The `setTimeout` global, reached through the global object + `Reflect` (no web-sys). Returns the
/// timer id; a missing `setTimeout` yields an id of infinity, which [`clear_timeout`] ignores.
fn set_timeout(callback: JsValue, ms: f64) -> f64 {
let g = global();
get_prop(&g, "setTimeout")
.filter(|v| v.is_function())
.and_then(|f| {
let f: js_sys::Function = f.unchecked_into();
f.call2(&g, &callback, &JsValue::from_f64(ms)).ok()
})
.and_then(|v| v.as_f64())
.unwrap_or(f64::INFINITY)
}
/// Clear a `setTimeout` id, if the environment has `clearTimeout`.
fn clear_timeout(id: f64) {
let g = global();
if let Some(f) = get_prop(&g, "clearTimeout").filter(|v| v.is_function()) {
let f: js_sys::Function = f.unchecked_into();
let _ = f.call1(&g, &JsValue::from_f64(id));
}
}
/// The `Promise` outcome of a `window.<app>` call: resolve with `result`, reject with an `Error`
/// carrying `error`.
fn request_promise_outcome(reply: &Value) -> Result<JsValue, JsValue> {
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
} else {
Err(js_sys::Error::new(&error_text(reply)).into())
}
}
/// A reply's `error` as displayable text.
fn error_text(reply: &Value) -> String {
match reply.get("error") {
Some(Value::String(s)) => s.clone(),
Some(other) => serde_json::to_string(other).unwrap_or_else(|_| "error".to_string()),
None => "error".to_string(),
}
}
/// The global object (`window` in a browser, `globalThis` in Node): `window.<app>`, `setTimeout`
/// and `clearTimeout` live on it.
fn global() -> js_sys::Object {
js_sys::global()
}
fn get_prop(target: &js_sys::Object, name: &str) -> Option<JsValue> {
js_sys::Reflect::get(target, &JsValue::from_str(name)).ok()
}
/// JSON → JS. VectorCraft's `to_js`.
fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
/// JS → JSON. VectorCraft's `from_js`; absent values become an empty object.
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
}
+161
View File
@@ -0,0 +1,161 @@
#![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::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}");
}