mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
web-api: one window.<app> command API for the web builds
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms)).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(¶ms)});
|
||||
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(¶ms)).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(¶ms));
|
||||
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!({}))
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user