feat: power reference site with in-process WIP WASM
Add the public-document Worldspace, Rust Client/Host bridge, and WIP-backed tree and document rendering while preserving static fallback and navigation. Pin the Rust source and build tools, verify resource and trap boundaries, and cover dev and preview loading including same-origin generated module URLs.
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use wip_interface::WipType;
|
||||
use wip_site_worldspace::wip_protocol::{
|
||||
CallOperationRequest, FetchInterfaceRequest, InterfaceReference, InterfaceTarget,
|
||||
ObserveRequest, ProtocolErrorCode, Target, Value,
|
||||
};
|
||||
use wip_site_worldspace::{
|
||||
DocumentContent, DocumentNode, MAX_DEPTH, MAX_NODES, Worldspace, WorldspaceData,
|
||||
};
|
||||
|
||||
fn node(path: &str, parent: Option<&str>, display: &str, href: &str) -> DocumentNode {
|
||||
DocumentNode {
|
||||
path: path.into(),
|
||||
name: if path == "/" {
|
||||
""
|
||||
} else {
|
||||
path.rsplit('/').next().unwrap()
|
||||
}
|
||||
.into(),
|
||||
display_path: display.into(),
|
||||
parent: parent.map(str::to_owned),
|
||||
title: format!("Title {display}"),
|
||||
href: href.into(),
|
||||
html: format!("<h1>{display}</h1>"),
|
||||
}
|
||||
}
|
||||
|
||||
fn data() -> WorldspaceData {
|
||||
WorldspaceData {
|
||||
nodes: vec![
|
||||
node("/", None, "/", "/"),
|
||||
node("/book", Some("/"), "/book", "/book/"),
|
||||
node(
|
||||
"/book/1-chapter.md",
|
||||
Some("/book"),
|
||||
"/1-chapter.md",
|
||||
"/book/1-chapter/",
|
||||
),
|
||||
node(
|
||||
"/book/1-chapter.md/1.1-section.md",
|
||||
Some("/book/1-chapter.md"),
|
||||
"/1.1-section.md",
|
||||
"/book/1.1-section/",
|
||||
),
|
||||
// This intentional order differs from lexical order.
|
||||
node(
|
||||
"/book/10-last.md",
|
||||
Some("/book"),
|
||||
"/10-last.md",
|
||||
"/book/10-last/",
|
||||
),
|
||||
node(
|
||||
"/book/2-second.md",
|
||||
Some("/book"),
|
||||
"/2-second.md",
|
||||
"/book/2-second/",
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn read(path: &str) -> CallOperationRequest {
|
||||
CallOperationRequest {
|
||||
target: Target {
|
||||
path: path.into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: Worldspace::document_interface(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_three_interactions_use_the_published_document_interface() {
|
||||
let world = Worldspace::new(data()).unwrap();
|
||||
let request = ObserveRequest {
|
||||
path: "/book/1-chapter.md".into(),
|
||||
depth: 1,
|
||||
};
|
||||
let observation = world.observe(request.clone()).unwrap();
|
||||
request.validate_response(&observation).unwrap();
|
||||
assert_eq!(
|
||||
observation.object.interfaces,
|
||||
vec![Worldspace::document_interface()]
|
||||
);
|
||||
assert_eq!(
|
||||
observation.children.unwrap()[0].object.name,
|
||||
"1.1-section.md"
|
||||
);
|
||||
let fetch = FetchInterfaceRequest {
|
||||
interface: Worldspace::document_interface(),
|
||||
};
|
||||
let interface = world.fetch_interface(fetch.clone()).unwrap();
|
||||
interface.validate_for(&fetch).unwrap();
|
||||
assert_eq!(interface.descriptor.operations.len(), 1);
|
||||
assert_eq!(interface.descriptor.operations[0].name, "read");
|
||||
assert!(interface.descriptor.operations[0].parameters.is_empty());
|
||||
assert!(interface.scope_ref.is_none());
|
||||
for expected in data().nodes {
|
||||
let call = read(&expected.path);
|
||||
let response = world.call_operation(call.clone()).unwrap();
|
||||
response
|
||||
.validate_for(&interface.descriptor, &call.operation)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
DocumentContent::decode(response.result).unwrap(),
|
||||
DocumentContent {
|
||||
path: expected.path,
|
||||
title: expected.title,
|
||||
href: expected.href,
|
||||
html: expected.html,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_boundaries_empty_children_and_order_are_protocol_observations() {
|
||||
let world = Worldspace::new(data()).unwrap();
|
||||
let observe = |path: &str, depth| {
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: path.into(),
|
||||
depth,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert!(observe("/", 0).children.is_none());
|
||||
let root = observe("/", 1);
|
||||
let children = root.children.unwrap();
|
||||
assert_eq!(children.len(), 1);
|
||||
assert_eq!(children[0].object.name, "book");
|
||||
assert!(children[0].children.is_none());
|
||||
let root = observe("/", 3);
|
||||
let book = &root.children.as_ref().unwrap()[0];
|
||||
let chapters = book.children.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
chapters
|
||||
.iter()
|
||||
.map(|node| node.object.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["1-chapter.md", "10-last.md", "2-second.md"]
|
||||
);
|
||||
assert!(chapters[0].children.as_ref().unwrap()[0].children.is_none());
|
||||
assert_eq!(chapters[1].children, Some(vec![]));
|
||||
assert_eq!(
|
||||
observe("/book/1-chapter.md/1.1-section.md", 1).children,
|
||||
Some(vec![])
|
||||
);
|
||||
assert!(observe("/", MAX_DEPTH).children.is_some());
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: MAX_DEPTH + 1
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::ResourceLimitExceeded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_host_failures_are_not_replaced_by_direct_body_lookup() {
|
||||
let world = Worldspace::new(data()).unwrap();
|
||||
for path in ["relative", "/book/", "/book//x", "/book/../x", "/./book"] {
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: path.into(),
|
||||
depth: 0
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
assert_eq!(
|
||||
world.call_operation(read(path)).unwrap_err().code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"/missing",
|
||||
"/1-chapter.md",
|
||||
"/draft/private.md",
|
||||
"/book/draft/private.md",
|
||||
] {
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: path.into(),
|
||||
depth: 1
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::NotFound
|
||||
);
|
||||
assert_eq!(
|
||||
world.call_operation(read(path)).unwrap_err().code,
|
||||
ProtocolErrorCode::NotFound
|
||||
);
|
||||
}
|
||||
for reference in [
|
||||
InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: "missing".into(),
|
||||
},
|
||||
InterfaceReference {
|
||||
scope: "/book".into(),
|
||||
name: "document".into(),
|
||||
},
|
||||
InterfaceReference {
|
||||
scope: "/draft".into(),
|
||||
name: "document".into(),
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
world
|
||||
.fetch_interface(FetchInterfaceRequest {
|
||||
interface: reference.clone()
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.interface.reference = reference;
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
world
|
||||
.fetch_interface(FetchInterfaceRequest {
|
||||
interface: InterfaceReference {
|
||||
scope: "relative".into(),
|
||||
name: "document".into()
|
||||
},
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.interface.scope_ref = Some("not-the-published-root".into());
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceMismatch
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.operation = "missing".into();
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::OperationNotFound
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.arguments
|
||||
.insert("extra".into(), Value::String("not accepted".into()));
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InvalidArguments
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.target.validator = Some(vec![1]);
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::ValidatorMismatch
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.interface.validator = Some(vec![1]);
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceValidatorMismatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_invalid_disconnected_and_private_input() {
|
||||
let reject = |mutate: fn(&mut WorldspaceData)| {
|
||||
let mut input = data();
|
||||
mutate(&mut input);
|
||||
assert!(Worldspace::new(input).is_err());
|
||||
};
|
||||
reject(|data| data.nodes.push(data.nodes[0].clone()));
|
||||
reject(|data| data.nodes[2].display_path = "/book".into());
|
||||
reject(|data| data.nodes[2].href = "/book/".into());
|
||||
reject(|data| data.nodes[2].name = "a/b".into());
|
||||
reject(|data| data.nodes[2].name = "..".into());
|
||||
reject(|data| data.nodes[2].name.clear());
|
||||
reject(|data| data.nodes[2].path = "/book//x".into());
|
||||
reject(|data| data.nodes[2].path = "/elsewhere".into());
|
||||
reject(|data| data.nodes[2].parent = None);
|
||||
reject(|data| data.nodes[3].parent = Some("/missing".into()));
|
||||
reject(|data| data.nodes[0].name = "root".into());
|
||||
reject(|data| data.nodes[0].parent = Some("/".into()));
|
||||
reject(|data| data.nodes[0].display_path = "/home".into());
|
||||
reject(|data| {
|
||||
data.nodes.remove(0);
|
||||
});
|
||||
reject(|data| data.nodes[2].display_path = "/draft/secret.md".into());
|
||||
reject(|data| {
|
||||
data.nodes
|
||||
.push(node("/draft", Some("/"), "/private", "/private/"))
|
||||
});
|
||||
reject(|data| data.nodes[0].href = "//external.example/".into());
|
||||
reject(|data| data.nodes[0].title.clear());
|
||||
assert!(Worldspace::new(WorldspaceData { nodes: vec![] }).is_err());
|
||||
assert!(
|
||||
Worldspace::new(WorldspaceData {
|
||||
nodes: vec![data().nodes[0].clone(); MAX_NODES + 1]
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
let mut wide = WorldspaceData {
|
||||
nodes: vec![data().nodes[0].clone()],
|
||||
};
|
||||
for index in 1..MAX_NODES {
|
||||
let path = format!("/node-{index}");
|
||||
wide.nodes
|
||||
.push(node(&path, Some("/"), &path, &format!("{path}/")));
|
||||
}
|
||||
let world = Worldspace::new(wide).unwrap();
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 1
|
||||
})
|
||||
.unwrap()
|
||||
.children
|
||||
.unwrap()
|
||||
.len(),
|
||||
MAX_NODES - 1
|
||||
);
|
||||
let mut deep = WorldspaceData {
|
||||
nodes: vec![data().nodes[0].clone()],
|
||||
};
|
||||
let mut parent = "/".to_string();
|
||||
for _ in 0..=MAX_DEPTH {
|
||||
let path = format!("{}/a", parent.trim_end_matches('/'));
|
||||
deep.nodes
|
||||
.push(node(&path, Some(&parent), &path, &format!("{path}/")));
|
||||
parent = path;
|
||||
if deep.nodes.len() == MAX_DEPTH as usize + 1 {
|
||||
assert!(Worldspace::new(deep.clone()).is_ok());
|
||||
}
|
||||
}
|
||||
assert!(Worldspace::new(deep).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_contract_roundtrips_and_rejects_malformed_or_unknown_fields() {
|
||||
let json = serde_json::to_string(&data()).unwrap();
|
||||
assert!(json.contains("displayPath"));
|
||||
let world = Worldspace::from_json(&json).unwrap();
|
||||
assert!(world.call_operation(read("/")).is_ok());
|
||||
assert!(Worldspace::from_json("not json").is_err());
|
||||
assert!(Worldspace::from_json(r#"{"nodes":[],"draft":true}"#).is_err());
|
||||
let too_large = " ".repeat(wip_site_worldspace::MAX_JSON_BYTES + 1);
|
||||
assert!(Worldspace::from_json(&too_large).is_err());
|
||||
// Input order need not be topological; child order still follows the input.
|
||||
let mut shuffled = data();
|
||||
shuffled.nodes.reverse();
|
||||
assert!(
|
||||
Worldspace::new(shuffled)
|
||||
.unwrap()
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 4
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
/// This is deliberately explicit, not silently skipped when generated input is absent.
|
||||
/// Run after `npm run build`: cargo test -p wip-site-worldspace --test worldspace -- --include-ignored
|
||||
#[test]
|
||||
#[ignore = "requires the site's generated dist/worldspace.json (npm run build)"]
|
||||
fn generated_real_public_documents_support_all_interactions() {
|
||||
let json = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../dist/worldspace.json"
|
||||
))
|
||||
.unwrap();
|
||||
let data: WorldspaceData = serde_json::from_str(&json).unwrap();
|
||||
assert!(data.nodes.len() > 10);
|
||||
let world = Worldspace::from_json(&json).unwrap();
|
||||
let full = world
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: MAX_DEPTH,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(full.children.unwrap()[0].object.name, "book");
|
||||
let descriptor = world
|
||||
.fetch_interface(FetchInterfaceRequest {
|
||||
interface: Worldspace::document_interface(),
|
||||
})
|
||||
.unwrap()
|
||||
.descriptor;
|
||||
for node in &data.nodes {
|
||||
assert!(!node.display_path.starts_with("/draft/"));
|
||||
assert!(!node.html.is_empty());
|
||||
let request = read(&node.path);
|
||||
let response = world.call_operation(request.clone()).unwrap();
|
||||
response
|
||||
.validate_for(&descriptor, &request.operation)
|
||||
.unwrap();
|
||||
let content = DocumentContent::decode(response.result).unwrap();
|
||||
assert_eq!(content.path, node.path);
|
||||
assert_eq!(content.href, node.href);
|
||||
assert_eq!(content.html, node.html);
|
||||
if let Some(parent) = &node.parent {
|
||||
assert_eq!(
|
||||
node.path,
|
||||
format!("{}/{}", parent.trim_end_matches('/'), node.name)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(data.nodes.iter().any(|node| node.path
|
||||
== "/book/1-concept.md/1.1-interaction-abstraction.md"
|
||||
&& node.display_path == "/1.1-interaction-abstraction.md"
|
||||
&& node.href.ends_with("/book/1.1-interaction-abstraction/")));
|
||||
for path in ["/draft", "/book/draft", "/book/draft/6-security.md"] {
|
||||
assert_eq!(
|
||||
world.call_operation(read(path)).unwrap_err().code,
|
||||
ProtocolErrorCode::NotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user