mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-07 12:13:20 +00:00
craft-web-host: tests for message types, file names and the host objects the bridge parses, and a CI workflow that runs them
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
name: Shared web host
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["crates/web-host/**", "Cargo.toml", "Cargo.lock", ".github/workflows/web-host.yml"]
|
||||
pull_request:
|
||||
paths: ["crates/web-host/**", "Cargo.toml", "Cargo.lock", ".github/workflows/web-host.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: shared-web-host-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
timeout-minutes: 20
|
||||
name: Web host lint, tests, WASM tests
|
||||
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
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: craft-web-host
|
||||
- run: cargo fmt -p craft-web-host --check
|
||||
- run: cargo clippy -p craft-web-host --all-targets --locked -- -D warnings
|
||||
- run: cargo clippy -p craft-web-host --all-targets --locked --target wasm32-unknown-unknown -- -D warnings
|
||||
- run: cargo test -p craft-web-host --locked
|
||||
- name: Install the matching WASM test runner
|
||||
shell: bash
|
||||
run: |
|
||||
runner_version=$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | sed -n 's/^version = "\(.*\)"$/\1/p')
|
||||
cargo install wasm-bindgen-cli --version "$runner_version" --locked
|
||||
- run: cargo test -p craft-web-host --locked --target wasm32-unknown-unknown
|
||||
Generated
+1
@@ -1197,6 +1197,7 @@ name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
||||
@@ -12,5 +12,8 @@ repository.workspace = true
|
||||
wasm-bindgen = "0.2"
|
||||
web-sys = { version = "0.3", features = ["Blob", "CustomEvent", "CustomEventInit", "Element", "Event", "EventTarget", "Location", "MessageEvent", "Window"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -207,6 +207,32 @@ mod tests {
|
||||
assert_eq!(file_name(Some(&"é".repeat(300)), "x").chars().count(), MAX_NAME_CHARS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_message_types_name_no_kind() {
|
||||
for bad in
|
||||
["", ":", "pdfcraft", "pdfcraft:", ":open", "pdfcraft:open:extra", "pdfcraft:OPEN", "pdfcraft: open", "pdfcraftx:open", "pdfcraft/open"]
|
||||
{
|
||||
assert_eq!(Incoming::parse("pdfcraft", bad), None, "{bad:?}");
|
||||
}
|
||||
assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:open"), Some(Incoming::Open));
|
||||
// The app's own outgoing kinds are not messages to it.
|
||||
assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:save"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_file_names_with_hostile_parts_are_cleaned() {
|
||||
// Only control characters, or only separators: nothing is left, so the fallback.
|
||||
assert_eq!(file_name(Some("\u{0}\u{7}\n\t"), "Drawing.dxf"), "Drawing.dxf");
|
||||
assert_eq!(file_name(Some("///"), "Drawing.dxf"), "Drawing.dxf");
|
||||
assert_eq!(file_name(Some(" "), "Drawing.dxf"), "Drawing.dxf");
|
||||
// Mixed separators and a control character inside the last part.
|
||||
assert_eq!(file_name(Some("a/b\\c\u{1b}[31m.txt"), "x"), "c[31m.txt");
|
||||
// Exactly at the limit stays whole; one over is cut; the limit counts characters, not bytes.
|
||||
assert_eq!(file_name(Some(&"a".repeat(MAX_NAME_CHARS)), "x").chars().count(), MAX_NAME_CHARS);
|
||||
assert_eq!(file_name(Some(&"a".repeat(MAX_NAME_CHARS + 1)), "x").chars().count(), MAX_NAME_CHARS);
|
||||
assert_eq!(file_name(Some(&"\u{1F4C4}".repeat(300)), "x"), "\u{1F4C4}".repeat(MAX_NAME_CHARS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_that_arrive_while_the_handler_runs_wait_their_turn() {
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -247,3 +247,109 @@ fn get(data: &JsValue, key: &str) -> JsValue {
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
get(data, key).as_string()
|
||||
}
|
||||
|
||||
/// Node tests for the parsing of a host's objects: wrong shapes, missing fields, and sizes the
|
||||
/// host states against the data it sends. No browser; run with `wasm-bindgen-test-runner`.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wasm_bindgen_test::wasm_bindgen_test;
|
||||
|
||||
fn obj(fields: &[(&str, JsValue)]) -> JsValue {
|
||||
object(&[], fields).expect("an object").into()
|
||||
}
|
||||
|
||||
fn buffer(len: u32) -> js_sys::ArrayBuffer {
|
||||
js_sys::ArrayBuffer::new(len)
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn an_open_without_bytes_or_a_blob_is_dropped() {
|
||||
for data in [
|
||||
obj(&[("name", "a.pdf".into())]),
|
||||
obj(&[("name", "a.pdf".into()), ("bytes", "text".into())]),
|
||||
obj(&[("name", "a.pdf".into()), ("bytes", 42.into())]),
|
||||
obj(&[("name", "a.pdf".into()), ("file", "not a blob".into())]),
|
||||
JsValue::NULL,
|
||||
JsValue::UNDEFINED,
|
||||
JsValue::from_str("a string"),
|
||||
JsValue::from_f64(7.0),
|
||||
] {
|
||||
assert!(read(Incoming::Open, &data).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn an_open_takes_bytes_as_an_array_buffer_or_a_uint8_array() {
|
||||
let ab = buffer(3);
|
||||
let array = js_sys::Uint8Array::new_with_length(5);
|
||||
for (bytes, len) in [(JsValue::from(ab), 3.0), (JsValue::from(array), 5.0)] {
|
||||
let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("name", "a.pdf".into()), ("bytes", bytes)])) else {
|
||||
panic!("an open with bytes is read");
|
||||
};
|
||||
assert_eq!(data.size(), len);
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn an_open_without_a_name_or_with_a_hostile_one_gets_a_clean_name() {
|
||||
let named = |name: Option<&str>| {
|
||||
let mut fields = vec![("bytes", JsValue::from(buffer(1)))];
|
||||
if let Some(n) = name {
|
||||
fields.push(("name", n.into()));
|
||||
}
|
||||
match read(Incoming::Open, &obj(&fields)) {
|
||||
Some(Message::Open { name, .. }) => name,
|
||||
_ => panic!("an open with bytes is read"),
|
||||
}
|
||||
};
|
||||
assert_eq!(named(None), "document");
|
||||
assert_eq!(named(Some("../../etc/passwd")), "passwd");
|
||||
assert_eq!(named(Some("a\u{0}b.txt")), "ab.txt");
|
||||
assert_eq!(named(Some(&"x".repeat(300))).chars().count(), crate::MAX_NAME_CHARS);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn the_size_is_the_data_s_own_not_the_hosts_claim() {
|
||||
// An ArrayBuffer of 64 MiB with a `size` field of 1: the bytes are viewed in place, not
|
||||
// copied, and the app sees their real length before it decides to read them.
|
||||
let ab = buffer(64 << 20);
|
||||
let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("bytes", ab.clone().into()), ("size", 1.into())])) else {
|
||||
panic!("an open with bytes is read");
|
||||
};
|
||||
assert_eq!(data.size(), f64::from(64u32 << 20));
|
||||
let FileData::Bytes(view) = &data else { panic!("bytes stay bytes") };
|
||||
assert!(js_sys::Object::is(&view.buffer(), &ab), "a view of the host's buffer, not a copy");
|
||||
|
||||
// A Blob of 5 bytes with a `size` field of a terabyte.
|
||||
let blob = web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(&"hello".into())).expect("a Blob");
|
||||
let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("file", blob.into()), ("size", 1e12.into())])) else {
|
||||
panic!("an open with a Blob is read");
|
||||
};
|
||||
assert_eq!(data.size(), 5.0);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn a_saved_needs_a_string_id() {
|
||||
assert!(matches!(read(Incoming::Saved, &obj(&[("id", "s1".into())])), Some(Message::Saved { id }) if id == "s1"));
|
||||
for data in [obj(&[]), obj(&[("id", 3.into())]), JsValue::NULL, JsValue::from_str("s1")] {
|
||||
assert!(read(Incoming::Saved, &data).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn a_host_message_and_a_handoff_done_read_whatever_fields_exist() {
|
||||
// Nothing required: a missing version is the contract's, `handoff` is true only for `true`.
|
||||
for data in [JsValue::NULL, obj(&[]), obj(&[("handoff", "yes".into())]), obj(&[("handoff", 1.into())])] {
|
||||
let Some(Message::Host { version, handoff }) = read(Incoming::Host, &data) else { panic!("a host message is read") };
|
||||
assert_eq!(version, crate::VERSION);
|
||||
assert!(!handoff);
|
||||
}
|
||||
assert!(matches!(read(Incoming::Host, &obj(&[("handoff", true.into())])), Some(Message::Host { handoff: true, .. })));
|
||||
assert!(matches!(
|
||||
read(Incoming::HandoffDone, &obj(&[("name", "a.pdf".into()), ("app", "pdfcraft".into())])),
|
||||
Some(Message::HandoffDone { name, app }) if name == "a.pdf" && app == "pdfcraft"
|
||||
));
|
||||
assert!(matches!(read(Incoming::HandoffDone, &JsValue::NULL), Some(Message::HandoffDone { name, app }) if name.is_empty() && app.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user