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:
Helen Kwok
2026-11-05 13:31:10 +08:00
parent 5fcc392302
commit ce99c8d61c
5 changed files with 181 additions and 0 deletions
+45
View File
@@ -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
View File
@@ -1197,6 +1197,7 @@ name = "craft-web-host"
version = "0.1.0"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
"web-sys",
]
+3
View File
@@ -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
+26
View File
@@ -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;
+106
View File
@@ -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()));
}
}