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:
2026-10-16 05:59:46 +09:00
parent ad08037f4f
commit c480b67301
26 changed files with 2719 additions and 63 deletions
+237
View File
@@ -0,0 +1,237 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "wasm-bindgen"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.121"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wip-browser"
version = "0.1.0"
dependencies = [
"serde_json",
"wasm-bindgen",
"wip-client",
"wip-protocol",
"wip-site-worldspace",
]
[[package]]
name = "wip-client"
version = "0.2.0"
dependencies = [
"wip-protocol",
]
[[package]]
name = "wip-host"
version = "0.2.0"
dependencies = [
"wip-interface",
"wip-protocol",
]
[[package]]
name = "wip-interface"
version = "0.2.0"
dependencies = [
"wip-interface-macros",
"wip-protocol",
]
[[package]]
name = "wip-interface-macros"
version = "0.2.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "wip-protocol"
version = "0.2.0"
[[package]]
name = "wip-site-worldspace"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"wip-host",
"wip-interface",
"wip-protocol",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+9
View File
@@ -0,0 +1,9 @@
[workspace]
members = ["worldspace", "browser"]
resolver = "3"
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "wip-browser"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
publish = false
[features]
test-probes = []
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wip-client = { path = "../../../wip-rs/crates/wip-client", default-features = false }
wip-protocol = { path = "../../../wip-rs/crates/wip-protocol" }
wip-site-worldspace = { path = "../worldspace" }
serde_json = "1"
wasm-bindgen = "=0.2.121"
+212
View File
@@ -0,0 +1,212 @@
//! Browser-owned Client/Host session. JS conversion is outside the direct WIP exchange.
use std::collections::BTreeMap;
use serde_json::{Value as Json, json};
use wasm_bindgen::prelude::*;
use wip_client::direct::{Client, Request, Response};
use wip_client::{ClientLimits, SecurityContext, SessionId};
use wip_protocol::{InterfaceReference, Value};
use wip_site_worldspace::{Worldspace, WorldspaceData};
/// One isolated in-memory Worldspace and its stateful WIP client.
#[wasm_bindgen]
pub struct BrowserSession {
world: Worldspace,
client: Client,
session: SessionId,
display_paths: BTreeMap<String, String>,
observes: u32,
interfaces: u32,
calls: u32,
}
fn error(value: impl std::fmt::Display) -> JsValue {
JsValue::from_str(&value.to_string())
}
fn value_json(value: Value) -> Json {
match value {
Value::Unit => Json::Null,
Value::Boolean(value) => json!(value),
Value::Integer(value) => json!(value),
Value::Number(value) => json!(value),
Value::String(value) => json!(value),
Value::Bytes(value) => json!(value),
Value::Record(value) => Json::Object(
value
.into_iter()
.map(|(key, value)| (key, value_json(value)))
.collect(),
),
Value::List(value) => Json::Array(value.into_iter().map(value_json).collect()),
}
}
#[wasm_bindgen]
impl BrowserSession {
/// Creates a fresh local view. The supplied manifest is build-generated public content.
#[wasm_bindgen(constructor)]
pub fn new(manifest: &str) -> Result<BrowserSession, JsValue> {
let world = Worldspace::from_json(manifest).map_err(error)?;
let data: WorldspaceData = serde_json::from_str(manifest).map_err(error)?;
let display_paths = data
.nodes
.into_iter()
.map(|node| (node.path, node.display_path))
.collect();
let limits = ClientLimits::new(1, 4096, 64, 32, 64).map_err(error)?;
let mut client = Client::new(limits);
let session = client
.open_session("reference-site", SecurityContext::new("public"))
.map_err(error)?;
Ok(Self {
world,
client,
session,
display_paths,
observes: 0,
interfaces: 0,
calls: 0,
})
}
/// Returns a tree projected from actual client observations and document operations.
pub fn tree(&mut self) -> Result<String, JsValue> {
self.observe_path("/", 32)?;
let mut paths = Vec::new();
let mut pending = vec!["/".to_owned()];
while let Some(path) = pending.pop() {
if let Some(tree) = self.client.tree(&self.session, &path) {
pending.extend(tree.children.iter().rev().cloned());
}
paths.push(path);
}
let mut rows = Vec::new();
for path in paths {
let mut row = self.read_document(&path)?;
let object = self
.client
.object(&self.session, &path)
.and_then(|entry| entry.object)
.ok_or_else(|| error("missing client object"))?;
let fields = row
.as_object_mut()
.ok_or_else(|| error("invalid document result"))?;
fields.remove("html");
fields.insert("name".into(), json!(object.name));
if path != "/" {
let parent = path
.rsplit_once('/')
.map(|(parent, _)| if parent.is_empty() { "/" } else { parent })
.unwrap_or("/");
fields.insert("parent".into(), json!(parent));
}
rows.push(row);
}
Ok(Json::Array(rows).to_string())
}
/// Reads a document through observe, descriptor fetch and validated Host dispatch.
pub fn read(&mut self, path: &str) -> Result<String, JsValue> {
Ok(self.read_document(path)?.to_string())
}
/// Calls a zero-argument operation on the site's document interface.
/// Unknown names are rejected by the same Client descriptor validation as reads.
pub fn call(&mut self, path: &str, operation: &str) -> Result<String, JsValue> {
Ok(self.call_document(path, operation)?.to_string())
}
/// Deliberate abort probe, compiled only into the separately served test artifact.
#[cfg(feature = "test-probes")]
pub fn panic_probe(&mut self) {
panic!("controlled release-WASM panic probe");
}
/// Attempted exchange counters aid integration diagnostics without exposing Host state.
pub fn stats(&self) -> String {
json!({"observes": self.observes, "interfaces": self.interfaces, "calls": self.calls})
.to_string()
}
}
impl BrowserSession {
fn exchange(&mut self, request: wip_client::direct::PreparedRequest) -> Result<(), JsValue> {
let id = request.id;
let response = match request.request {
Request::Observe(request) => {
self.observes += 1;
Response::Observe(self.world.observe(request))
}
Request::FetchInterface(request) => {
self.interfaces += 1;
Response::FetchInterface(self.world.fetch_interface(request))
}
Request::CallOperation(request) => {
self.client.mark_dispatched(id).map_err(error)?;
self.calls += 1;
Response::CallOperation(self.world.call_operation(request))
}
};
let completion = self.client.complete(id, response).map_err(error)?;
if let wip_client::Completion::ProtocolFailure(failure) = completion {
return Err(error(failure));
}
Ok(())
}
fn observe_path(&mut self, path: &str, depth: u32) -> Result<(), JsValue> {
if let Some(request) = self
.client
.ensure_observed(&self.session, path, depth)
.map_err(error)?
{
self.exchange(request)?;
}
Ok(())
}
fn read_document(&mut self, path: &str) -> Result<Json, JsValue> {
self.call_document(path, "read")
}
fn call_document(&mut self, path: &str, operation: &str) -> Result<Json, JsValue> {
self.observe_path(path, 0)?;
let reference = InterfaceReference {
scope: "/".into(),
name: "document".into(),
};
if let Some(request) = self
.client
.ensure_interface(&self.session, reference.clone())
.map_err(error)?
{
self.exchange(request)?;
}
let request = self
.client
.prepare_call(&self.session, path, &reference, operation, BTreeMap::new())
.map_err(error)?;
self.exchange(request)?;
let result = self
.client
.call_history(&self.session)
.and_then(|history| history.back())
.ok_or_else(|| error("missing call result"))?;
match &result.outcome {
wip_client::CallOutcome::Success(response) => {
let mut value = value_json(response.result.clone());
let display_path = self
.display_paths
.get(path)
.ok_or_else(|| error("missing display path"))?;
value
.as_object_mut()
.ok_or_else(|| error("invalid document result"))?
.insert("displayPath".into(), json!(display_path));
Ok(value)
}
outcome => Err(error(format!("document read failed: {outcome:?}"))),
}
}
}
+6
View File
@@ -7,13 +7,19 @@
"node": ">=22.12.0"
},
"scripts": {
"build:wasm": "node scripts/build-wasm.mjs",
"predev": "npm run build:wasm",
"prebuild": "npm run build:wasm",
"test:rust": "cargo test --workspace --locked",
"dev": "astro dev",
"check": "astro check",
"test": "node --test tests/*.test.mjs",
"build": "astro build",
"check:links": "node scripts/check-links.mjs",
"verify": "npm run test && npm run check && npm run build && npm run check:links",
"pretest:browser": "node scripts/build-wasm.mjs --test-probes",
"test:browser": "playwright test",
"test:dev": "playwright test --config playwright.dev.config.ts",
"preview": "astro preview"
},
"dependencies": {
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from '@playwright/test';
import preview from './playwright.config';
const port = process.env.SITE_TEST_PORT || '4323';
const base = process.env.SITE_BASE?.replace(/\/$/, '') || '';
const baseURL = `http://127.0.0.1:${port}${base}/`;
export default defineConfig(preview, {
testMatch: 'wip-loading.spec.ts',
use: { baseURL },
webServer: {
command: `npm run dev -- --host 127.0.0.1 --port ${port} --ignore-lock`,
url: baseURL,
reuseExistingServer: false,
},
});
+6
View File
@@ -0,0 +1,6 @@
{
"repository": "https://gitea.hareworks.net/Hare/wip-rs",
"revision": "eb92878e3c696c768f1d2294c3718ecd04cf6a87",
"toolchain": "1.97.1",
"wasmBindgen": "0.2.121"
}
+5
View File
@@ -0,0 +1,5 @@
[toolchain]
channel = "1.97.1"
targets = ["wasm32-unknown-unknown"]
components = ["rustfmt", "clippy"]
profile = "minimal"
+33
View File
@@ -0,0 +1,33 @@
// Build the browser runtime from source; no prebuilt local WASM is required.
import { spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const site = fileURLToPath(new URL('../', import.meta.url));
const rust = fileURLToPath(new URL('../../../wip-rs/', import.meta.url));
const source = JSON.parse(readFileSync(new URL('../rust-source.json', import.meta.url), 'utf8'));
const revision = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: rust, encoding: 'utf8' });
const status = spawnSync('git', ['status', '--porcelain'], { cwd: rust, encoding: 'utf8' });
if (revision.status !== 0 || revision.stdout.trim() !== source.revision || status.status !== 0 || status.stdout.trim()) {
if (process.env.WIP_ALLOW_DIRTY_SOURCE !== '1') {
throw new Error(`Expected clean sibling wip-rs at ${source.revision}. See rust-source.json; use WIP_ALLOW_DIRTY_SOURCE=1 only for local Rust development.`);
}
console.warn('Building with an explicitly unpinned local Rust source; this is not a reproducible release build.');
}
function run(command, args) {
const result = spawnSync(command, args, { cwd: site, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
function available(command) {
return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0;
}
const testProbes = process.argv.includes('--test-probes');
const output = testProbes ? 'test-assets/wip-wasm' : 'public/wip-wasm';
const cargo = ['build', '--locked', '--manifest-path', 'browser/Cargo.toml', '--target', 'wasm32-unknown-unknown', '--release', ...(testProbes ? ['--features', 'test-probes'] : [])];
if (available('cargo')) run('cargo', cargo);
else run('nix', ['shell', '--inputs-from', rust, 'nixpkgs#lld', '--command', 'nix', 'develop', rust, '--no-write-lock-file', '--command', 'cargo', ...cargo]);
mkdirSync(new URL(`../${output}/`, import.meta.url), { recursive: true });
const bindgen = ['target/wasm32-unknown-unknown/release/wip_browser.wasm', '--target', 'web', '--out-dir', output, '--out-name', 'wip_browser'];
if (available('wasm-bindgen')) run('wasm-bindgen', bindgen);
else run('nix', ['shell', '--inputs-from', rust, 'nixpkgs#wasm-bindgen-cli', '--command', 'wasm-bindgen', ...bindgen]);
+69
View File
@@ -0,0 +1,69 @@
import { bookTree, type DocumentSource } from './landing-tree.ts';
export interface RenderedDocument extends DocumentSource {
html: string;
}
export interface RenderedPage {
title: string;
href: string;
html: string;
}
export interface WorldspaceNode extends RenderedPage {
/** Canonical WIP identity, constructed from the actual parent and name. */
path: string;
name: string;
/** Existing explorer identity; never use this as a WIP request path. */
displayPath: string;
parent?: string;
}
export interface WorldspaceData {
nodes: WorldspaceNode[];
}
/**
* Project the public book display hierarchy into a genuine Worldspace hierarchy.
* Bodies come from the site's renderer; this function authors no document content.
* `pages` supplies existing rendered home/index components, keyed by display path.
*/
export function createWorldspaceData(
documents: readonly RenderedDocument[],
pages: Readonly<Record<string, RenderedPage>>,
): WorldspaceData {
// Defense in depth: do not rely on the caller having filtered its collection.
const published = documents.filter((document) => !document.id.startsWith('draft/'));
const sources = new Map<string, RenderedDocument>();
for (const document of published) {
if (document.id.split('/').some((part) => !part || part === '.' || part === '..' || part === 'draft')) {
throw new Error(`Invalid public document id: ${document.id}`);
}
const displayPath = `/${document.id}.md`;
if (sources.has(displayPath)) throw new Error(`Duplicate document: ${document.id}`);
sources.set(displayPath, document);
}
const paths = new Map<string, string>();
const hrefs = new Set<string>();
const nodes = bookTree(published).map((node): WorldspaceNode => {
if (paths.has(node.path)) throw new Error(`Duplicate display path: ${node.path}`);
const parent = node.parent === undefined ? undefined : paths.get(node.parent);
if (node.parent !== undefined && parent === undefined) throw new Error(`Missing parent: ${node.parent}`);
const name = node.path === '/book' ? 'book' : node.name;
const path = parent === undefined ? '/' : `${parent === '/' ? '' : parent}/${name}`;
const page = sources.get(node.path) ?? pages[node.path];
if (!page) throw new Error(`Missing rendered page: ${node.path}`);
if (!page.title.trim() || !page.href.startsWith('/') || page.href.startsWith('//')) {
throw new Error(`Invalid page metadata: ${node.path}`);
}
if (hrefs.has(page.href)) throw new Error(`Duplicate Web URL: ${page.href}`);
hrefs.add(page.href);
paths.set(node.path, path);
return {
path, name, displayPath: node.path, ...(parent === undefined ? {} : { parent }),
title: page.title, href: page.href, html: page.html,
};
});
if (new Set(nodes.map((node) => node.path)).size !== nodes.length) throw new Error('Duplicate Worldspace path');
return { nodes };
}
+42
View File
@@ -0,0 +1,42 @@
import type { APIRoute } from 'astro';
import { render } from 'astro:content';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import RootPage from '../components/RootPage.astro';
import DirectoryIndex from '../components/DirectoryIndex.astro';
import { documents, bookHref, title, href } from '../lib/book';
import { bookTree } from '../lib/landing-tree';
import { createWorldspaceData, type RenderedPage } from '../lib/worldspace-data';
export const prerender = true;
/** Static build artifact, not a runtime filesystem or network provider. */
export const GET: APIRoute = async () => {
const container = await AstroContainer.create();
const published = await documents();
const rendered = await Promise.all(published.map(async (entry) => {
const { Content } = await render(entry);
return {
id: entry.id, title: title(entry), href: bookHref(entry.id),
html: await container.renderToString(Content),
};
}));
const pages: Record<string, RenderedPage> = {
'/': {
title: 'Worldspace Interface Protocol', href: href(),
html: await container.renderToString(RootPage),
},
'/book': {
title: 'Book', href: href('book/'),
html: await container.renderToString(DirectoryIndex),
},
};
for (const node of bookTree(rendered).filter((node) => !node.href && node.path !== '/' && node.path !== '/book')) {
pages[node.path] = {
title: `${node.path.slice(1)}/`, href: href(`book${node.path}/`),
html: await container.renderToString(DirectoryIndex, { props: { path: node.path } }),
};
}
return new Response(JSON.stringify(createWorldspaceData(rendered, pages)), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
};
+173
View File
@@ -0,0 +1,173 @@
import type { TreeNode } from '../lib/landing-tree';
export interface WipNode {
path: string;
name: string;
displayPath: string;
parent?: string;
title: string;
href: string;
}
interface WipDocument {
path: string;
displayPath: string;
title: string;
href: string;
html: string;
}
interface BrowserSession {
tree(): string;
read(path: string): string;
stats(): string;
free(): void;
}
interface BrowserModule {
default(): Promise<unknown>;
BrowserSession: new (manifestJson: string) => BrowserSession;
}
export interface WipStats { observes: number; interfaces: number; calls: number; }
export interface WipPage {
objects: TreeNode[];
nodes: WipNode[];
document: WipDocument;
stats: WipStats;
}
const base = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/`;
let loading: Promise<BrowserSession> | undefined;
let session: BrowserSession | undefined;
let observedTree: unknown;
let disposed = false;
let trapped: WebAssembly.RuntimeError | undefined;
// An abort can leave Rust's borrowed session half-unwound. Never invoke or free
// that instance again; keep the page on SSR until a new browser document loads.
function invoke<T>(operation: () => T): T {
if (trapped) throw trapped;
try { return operation(); }
catch (error) {
if (error instanceof WebAssembly.RuntimeError) {
trapped = error;
// Retain the wrapper: dropping it would let wasm-bindgen's
// FinalizationRegistry re-enter the aborted instance via its destructor.
observedTree = undefined;
}
throw error;
}
}
// Asset fetches only: the Rust Client and Host communicate inside this WASM
// instance. Never tie shared initialization to a particular Astro route's abort.
function getSession(): Promise<BrowserSession> {
if (trapped) return Promise.reject(trapped);
return loading ??= (async () => {
// Use an absolute same-origin URL so Vite does not append ?import and
// attempt to transform this prebuilt public asset in dev. Keep the real
// module URL so wasm-bindgen can resolve its sibling .wasm via import.meta.url.
const moduleURL = new URL(`${base}wip-wasm/wip_browser.js`, location.origin).href;
const [module, response] = await Promise.all([
import(/* @vite-ignore */ moduleURL) as Promise<BrowserModule>,
fetch(`${base}worldspace.json`),
]);
if (!response.ok) throw new Error('WIP manifest asset unavailable');
const manifest = await response.text();
await module.default(); // wasm-bindgen resolves its sibling .wasm asset.
if (disposed) throw new Error('WIP browser document disposed');
session = new module.BrowserSession(manifest);
return session;
})();
}
// Keep the session across ClientRouter swaps and BFCache suspension, but free
// its Rust allocation when the browser document actually goes away.
window.addEventListener('pagehide', (event) => {
if (event.persisted) return;
disposed = true;
if (!trapped) {
session?.free();
session = undefined;
}
// A trapped wrapper stays strongly reachable through this window listener
// until the entire document realm is discarded, including across BFCache.
});
const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object';
const strings = (value: Record<string, unknown>, keys: string[]) => keys.every((key) => typeof value[key] === 'string');
function publicHref(href: string) {
const url = new URL(href, location.href);
if (url.origin !== location.origin || !url.pathname.startsWith(base) || url.search || url.hash) {
throw new Error('Invalid WIP document URL');
}
}
/** Read and validate before mutating the SSR page. Display paths are UI keys;
* canonical paths alone cross the WIP API boundary. */
export async function readWipPage(displayPath: string | undefined, fallback: readonly TreeNode[], signal: AbortSignal): Promise<WipPage | undefined> {
const client = await getSession();
if (signal.aborted || disposed) return;
// This is an immutable build-generated Worldspace. Observe its tree once per
// session; route changes still execute read() through the same Rust Client.
const raw: unknown = observedTree ??= JSON.parse(invoke(() => client.tree()));
if (!Array.isArray(raw) || raw.length !== fallback.length) throw new Error('WIP tree does not match this site build');
const nodes: WipNode[] = raw.map((value: unknown) => {
if (!record(value) || !strings(value, ['path', 'name', 'displayPath', 'title', 'href'])
|| (value.parent !== undefined && typeof value.parent !== 'string')) throw new Error('Invalid WIP tree node');
publicHref(value.href as string);
return value as unknown as WipNode;
});
const canonical = new Map(nodes.map((node) => [node.path, node]));
const displayed = new Map(nodes.map((node) => [node.displayPath, node]));
if (canonical.size !== nodes.length || displayed.size !== nodes.length) throw new Error('Duplicate WIP tree paths');
// Reuse every existing anchor and SVG branch. A stale asset set with a different
// topology cannot safely hydrate this build's persisted tree: fail open instead.
const objects = fallback.map((old) => {
const node = displayed.get(old.path);
const parent = node?.parent === undefined ? undefined : canonical.get(node.parent)?.displayPath;
if (!node || (node.parent !== undefined && !parent) || parent !== old.parent) throw new Error('Incompatible WIP tree topology');
// The canonical segment is `book`; the existing folder label includes `/`.
const name = node.displayPath === '/book' ? `${node.name.replace(/\/$/, '')}/` : node.name;
return { path: node.displayPath, parent, depth: old.depth, name, title: node.title, href: node.href };
});
const selected = displayPath === undefined ? undefined : displayed.get(displayPath);
if (!selected) throw new Error('Route is not a public WIP document');
// tree()/read() are synchronous Rust calls. Check the route after asynchronous
// initialization, before calling, so superseded pages do not issue stale reads.
if (signal.aborted) return;
const body: unknown = JSON.parse(invoke(() => client.read(selected.path)));
if (!record(body) || !strings(body, ['path', 'displayPath', 'title', 'href', 'html'])
|| body.path !== selected.path || body.displayPath !== selected.displayPath || body.href !== selected.href) {
throw new Error('WIP read returned a different document');
}
const stats: unknown = JSON.parse(invoke(() => client.stats()));
if (!record(stats) || !['observes', 'interfaces', 'calls'].every((key) => typeof stats[key] === 'number' && Number.isSafeInteger(stats[key]) && (stats[key] as number) > 0)) {
throw new Error('WIP execution evidence unavailable');
}
return { objects, nodes, document: body as unknown as WipDocument, stats: stats as unknown as WipStats };
}
/** Update only article children, retaining the scrolling/focusable pane itself.
* Identical rendered HTML needs no DOM churn (notably same-page fragments). */
export function applyWipDocument(article: HTMLElement, page: WipPage) {
const template = document.createElement('template');
template.innerHTML = page.document.html;
if (article.innerHTML !== template.innerHTML) {
const position = { top: article.scrollTop, left: article.scrollLeft, x: scrollX, y: scrollY };
const active = document.activeElement as HTMLElement | null;
const focusId = active && article.contains(active) ? active.id : undefined;
const hadFocus = active !== article && active !== null && article.contains(active);
article.replaceChildren(template.content);
if (hadFocus) {
const replacement = focusId ? document.getElementById(focusId) : null;
(replacement && article.contains(replacement) ? replacement : article).focus({ preventScroll: true });
}
article.scrollTop = position.top;
article.scrollLeft = position.left;
window.scrollTo(position.x, position.y);
}
article.setAttribute('aria-label', page.document.title);
article.dataset.wipPath = page.document.path;
}
+100 -8
View File
@@ -1,6 +1,7 @@
import { animate, createTimeline, cubicBezier } from 'animejs';
import { childrenOf, visibleNodes, treePrefix, type TreeNode } from '../lib/landing-tree';
import { glitchText } from '../lib/landing-ascii.mjs';
import { applyWipDocument, readWipPage } from './browser-wip';
const clamp = (value: number) => Math.max(0, Math.min(1, value));
const progress = (time: number, start: number, duration: number) => clamp((time - start) / duration);
@@ -27,6 +28,7 @@ const circularTrack = 'M0 -180 A180 180 0 1 1 0 180 A180 180 0 1 1 0 -180';
export async function initLanding(lifetime: AbortSignal) {
const element = document.querySelector<HTMLElement>('[data-landing-stage]');
if (!element) return;
element.dataset.wipState = 'loading';
// Keep the visible boot controls usable while font metrics are loading.
const boot = new AbortController();
let bootStart = false;
@@ -443,9 +445,54 @@ export async function initLanding(lifetime: AbortSignal) {
if (state !== 'complete') finish();
if (!event.persisted) dispose();
}, { signal: lifetime });
// Enhancement never blocks the intro, static article, or native link routing.
// Each route owns this continuation, while browser-wip owns the shared WASM.
void readWipPage(currentPath, objects, lifetime).then((page) => {
if (!page || lifetime.aborted || !stage.isConnected) return;
const article = get<HTMLElement>('[data-selected-content]');
applyWipDocument(article, page);
objects.splice(0, objects.length, ...page.objects);
const byDisplayPath = new Map(page.nodes.map((node) => [node.displayPath, node]));
nodes.forEach((node, index) => {
const object = objects[index];
const result = byDisplayPath.get(object.path)!;
node.dataset.wipPath = result.path;
node.setAttribute('href', result.href);
node.setAttribute('title', result.title);
if (node !== root) node.setAttribute('aria-label', object.name);
const label = node.querySelector<HTMLElement>('.node-label');
if (label) {
label.textContent = object.name;
const glitch = glitchTargets.find((target) => target.element === label);
if (glitch) glitch.text = object.name;
}
const toggle = rows[index].querySelector('[data-toggle-path]');
toggle?.setAttribute('aria-label', `${object.name}を開閉`);
});
// Layout/folding continues to use the returned Object metadata, not the
// embedded SSR tree. Node identities, focus and collapse state stay intact.
const treePosition = { top: viewport.scrollTop, left: viewport.scrollLeft };
updateExpanded();
viewport.scrollTop = treePosition.top;
viewport.scrollLeft = treePosition.left;
stage.dataset.wipPath = page.document.path;
stage.dataset.wipObserves = String(page.stats.observes);
stage.dataset.wipInterfaces = String(page.stats.interfaces);
stage.dataset.wipCalls = String(page.stats.calls);
stage.dataset.wipState = 'ready';
}).catch(() => {
if (lifetime.aborted || !stage.isConnected) return;
// A failed enhancement is not WIP success. SSR and ordinary anchors remain
// usable, including unknown routes whose static 404 must not become HOME.
stage.dataset.wipState = 'fallback';
});
}
let navigationInstalled = false;
export function setupExplorerNavigation() {
if (navigationInstalled) return;
navigationInstalled = true;
let controller = new AbortController();
let activeStage = document.querySelector('[data-landing-stage]');
// Initial page-load waits for font downloads. Install boot controls at module
@@ -456,10 +503,59 @@ export function setupExplorerNavigation() {
// Keep this separate from Astro's window scroll and the persistent tree scroll.
const readingPositions = new Map<string, { top: number; left: number }>();
let readingEntry: string | undefined;
let readingURL = new URL(location.href);
let entrySequence = 0;
document.addEventListener('astro:before-swap', () => {
const newReadingEntry = () => `${Date.now()}-${entrySequence++}`;
const sameDocument = (url: URL) => url.origin === readingURL.origin
&& url.pathname === readingURL.pathname && url.search === readingURL.search;
const saveReadingPosition = () => {
const article = document.querySelector('[data-selected-content]');
if (article && readingEntry) readingPositions.set(readingEntry, { top: article.scrollTop, left: article.scrollLeft });
};
const restoreReadingPosition = () => {
const article = document.querySelector('[data-selected-content]');
const saved = readingEntry && readingPositions.get(readingEntry);
if (article && saved) { article.scrollTop = saved.top; article.scrollLeft = saved.left; }
};
// Astro's same-document fragment shortcut calls pushState and scrolls to the
// hash without any swap/page-load events. Capture the outgoing pane BEFORE
// that scroll, and give every push its own key (never inherit the prior key).
// Full-route pushes happen AFTER the swap; their outgoing pane was saved by
// before-swap, so do not record the new DOM under the previous entry there.
const pushState = history.pushState;
history.pushState = function (data, unused, url) {
const nextURL = new URL(url == null ? location.href : url, location.href);
const fragment = !controller.signal.aborted && sameDocument(nextURL);
if (fragment) saveReadingPosition();
const entry = newReadingEntry();
pushState.call(this, { ...data, wipReadingEntry: entry }, unused, url);
if (fragment) { readingEntry = entry; readingURL = nextURL; }
};
window.addEventListener('popstate', (event) => {
if (!event.state || controller.signal.aborted) return;
// Capture before Astro's bubble listener changes window scroll. For a full
// route traversal leave adoption/restoration to page-load after the swap.
saveReadingPosition();
const nextURL = new URL(location.href);
if (!sameDocument(nextURL)) return;
const entry = event.state.wipReadingEntry;
if (!entry) return;
const previousEntry = readingEntry;
// Native history restoration follows popstate dispatch. Restore only the
// nested pane afterwards; Astro/browser still own window scroll and routing.
// A newer navigation or a swap invalidates this pending restoration. Keep
// the outgoing key until then, in case Astro takes its full-swap path.
const currentStage = activeStage;
requestAnimationFrame(() => {
if (controller.signal.aborted || activeStage !== currentStage
|| readingEntry !== previousEntry || history.state?.wipReadingEntry !== entry) return;
readingEntry = entry;
readingURL = nextURL;
restoreReadingPosition();
});
}, { capture: true });
document.addEventListener('astro:before-swap', () => {
saveReadingPosition();
const viewport = document.querySelector('.tree-viewport');
if (viewport) position = { top: viewport.scrollTop, left: viewport.scrollLeft };
controller.abort();
@@ -472,14 +568,10 @@ export function setupExplorerNavigation() {
controller = new AbortController();
activeStage = stage;
}
readingEntry = history.state?.wipReadingEntry ?? `${Date.now()}-${entrySequence++}`;
readingEntry = history.state?.wipReadingEntry ?? newReadingEntry();
readingURL = new URL(location.href);
history.replaceState({ ...history.state, wipReadingEntry: readingEntry }, '');
const article = document.querySelector('[data-selected-content]');
const readingPosition = readingPositions.get(readingEntry!);
if (article && readingPosition) {
article.scrollTop = readingPosition.top;
article.scrollLeft = readingPosition.left;
}
restoreReadingPosition();
if (initialize) {
const viewport = document.querySelector('.tree-viewport');
if (viewport) { viewport.scrollTop = position.top; viewport.scrollLeft = position.left; }
+131
View File
@@ -0,0 +1,131 @@
import { expect, test } from '@playwright/test';
import { readFile } from 'node:fs/promises';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
async function probeAsset(name: string) {
return readFile(new URL(`../../test-assets/wip-wasm/${name}`, import.meta.url));
}
test('real WASM enforces manifest byte, node and depth bounds', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
await module.default();
const root = { path: '/', name: '', displayPath: '/', title: 'Root', href: '/', html: '<p>Root</p>' };
const attempt = (json: string) => {
try { new module.BrowserSession(json).free(); return { error: '', trap: false }; }
catch (error) { return { error: String(error), trap: error instanceof WebAssembly.RuntimeError }; }
};
// Both oversized manifests are otherwise valid; the controls prove that
// removing a limit cannot leave this test green via unrelated validation.
const byteLimit = 16 * 1024 * 1024;
const empty = JSON.stringify({ nodes: [{ ...root, html: '' }] });
const atBytes = JSON.stringify({ nodes: [{ ...root, html: 'x'.repeat(byteLimit - empty.length) }] });
const bytesAccepted = attempt(atBytes);
const bytes = attempt(atBytes.replace('"html":"', '"html":"x'));
const wide = [root, ...Array.from({ length: 4096 }, (_, i) => ({
path: `/n${i}`, name: `n${i}`, displayPath: `/n${i}`, parent: '/', title: `Node ${i}`, href: `/n${i}/`, html: '',
}))];
const nodesAccepted = attempt(JSON.stringify({ nodes: wide.slice(0, 4096) }));
const nodes = attempt(JSON.stringify({ nodes: wide }));
const deep: Record<string, string>[] = [root];
let parent = '/';
for (let i = 0; i < 32; i++) {
const path = `${parent === '/' ? '' : parent}/a`;
deep.push({ path, name: 'a', displayPath: path, parent, title: path, href: `${path}/`, html: '<p>Node</p>' });
parent = path;
}
const accepted = new module.BrowserSession(JSON.stringify({ nodes: deep }));
const deepest = JSON.parse(accepted.read(parent)).path;
accepted.free();
deep.push({ path: `${parent}/a`, name: 'a', displayPath: `${parent}/a`, parent, title: 'Too deep', href: `${parent}/a/`, html: '' });
const depth = attempt(JSON.stringify({ nodes: deep }));
return { bytes, nodes, depth, deepest, bytesAccepted, nodesAccepted, probePublished: 'panic_probe' in module.BrowserSession.prototype };
}, base);
expect(result.bytesAccepted).toEqual({ error: '', trap: false });
expect(result.nodesAccepted).toEqual({ error: '', trap: false });
expect(result.bytes).toEqual({ error: 'document JSON exceeds the input byte limit', trap: false });
expect(result.nodes).toEqual({ error: 'document node count must be between 1 and MAX_NODES', trap: false });
expect(result.depth).toEqual({ error: 'document hierarchy exceeds the depth limit', trap: false });
expect(result.deepest.split('/').length - 1).toBe(32);
expect(result.probePublished).toBe(false);
});
test('a controlled release Rust panic traps, not a Protocol failure; a fresh instance recovers', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.route('**/wip-probe/wip_browser*', async (route) => {
const wasm = new URL(route.request().url()).pathname.endsWith('.wasm');
await route.fulfill({ body: await probeAsset(wasm ? 'wip_browser_bg.wasm' : 'wip_browser.js'), contentType: wasm ? 'application/wasm' : 'text/javascript' });
});
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
const module = await import(new URL('wip-probe/wip_browser.js', base).href);
await module.default();
const session = new module.BrowserSession(manifest);
const before = JSON.parse(session.read('/')).path;
let trap = false, detail = '';
try { session.panic_probe(); } catch (error) {
trap = error instanceof WebAssembly.RuntimeError;
detail = String(error);
}
// Retain the aborted wrapper too: its generated GC finalizer enters WASM.
(window as unknown as Record<string, unknown>).__quarantinedSession = session;
// Do not call/free the aborted Rust object: a mutable borrow was interrupted.
// A distinct glue module creates a genuinely new WASM instance.
const fresh = await import(`${new URL('wip-probe/wip_browser.js', base).href}?fresh`);
await fresh.default();
const replacement = new fresh.BrowserSession(manifest);
const after = JSON.parse(replacement.read('/')).path;
replacement.free();
return { before, after, trap, detail };
}, base);
expect(result.before).toBe('/');
expect(result.trap).toBe(true);
expect(result.detail).toContain('RuntimeError');
expect(result.after).toBe('/');
});
test('the site retires a trapped WASM session and does not replay calls on later routes', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.route('**/wip-wasm/wip_browser.js', async (route) => {
const source = (await probeAsset('wip_browser.js')).toString();
await route.fulfill({ contentType: 'text/javascript', body: `${source}
BrowserSession.prototype.read = function () {
window.__panicReadCalls = (window.__panicReadCalls || 0) + 1;
window.__trappedSession = new WeakRef(this);
return this.panic_probe();
};
const originalFree = BrowserSession.prototype.free;
BrowserSession.prototype.free = function () {
window.__panicFreeCalls = (window.__panicFreeCalls || 0) + 1;
return originalFree.call(this);
};` });
});
await page.route('**/wip-wasm/*.wasm', async (route) => {
await route.fulfill({ contentType: 'application/wasm', body: await probeAsset('wip_browser_bg.wasm') });
});
await page.goto(base);
const stage = page.locator('[data-landing-stage]');
await expect(stage).toHaveAttribute('data-wip-state', 'fallback');
expect(await page.evaluate(() => (window as Window & { __panicReadCalls?: number }).__panicReadCalls)).toBe(1);
await page.locator('.demo-node[data-path="/book"]').click();
await expect(page).toHaveURL(new URL('book/', base).href);
await expect(stage).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator('[data-selected-content] h1')).toHaveText('Book');
expect(await page.evaluate(() => (window as Window & { __panicReadCalls?: number }).__panicReadCalls)).toBe(1);
// Force collection after continuations release their locals. The only test
// reference is weak, so this catches accidentally dropping the quarantine.
const cdp = await page.context().newCDPSession(page);
await cdp.send('HeapProfiler.collectGarbage');
expect(await page.evaluate(() => Boolean((window as unknown as { __trappedSession: WeakRef<object> }).__trappedSession.deref()))).toBe(true);
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false })));
await cdp.send('HeapProfiler.collectGarbage');
expect(await page.evaluate(() => ({
retained: Boolean((window as unknown as { __trappedSession: WeakRef<object> }).__trappedSession.deref()),
frees: (window as Window & { __panicFreeCalls?: number }).__panicFreeCalls || 0,
}))).toEqual({ retained: true, frees: 0 });
await cdp.detach();
});
+81
View File
@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true });
test('real WASM owns isolated Client/Host sessions and operates without a transport', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
// Use an asset page, so the site UI is not the source of this test's observations.
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
await module.default();
const session = new module.BrowserSession(manifest);
const input = JSON.parse(manifest);
const altered = structuredClone(input);
const document = altered.nodes.find((node: { path: string }) => node.path !== '/' && node.path !== '/book');
document.html = '<p>Isolated Worldspace content</p>';
document.title = 'Isolated title';
const other = new module.BrowserSession(JSON.stringify(altered));
// Asset loading has finished. All following interactions must be synchronous
// local calls: neither fetch nor a socket is a transport for WIP.
const originalFetch = window.fetch;
window.fetch = () => { throw new Error('Unexpected WIP network transport'); };
try {
const tree = JSON.parse(session.tree());
const bodies = tree.map((node: { path: string }) => JSON.parse(session.read(node.path)));
const first = JSON.parse(session.read(document.path));
const second = JSON.parse(other.read(document.path));
let missing = '', malformed = '', draft = '', operation = '';
const callsBeforeInvalid = JSON.parse(session.stats()).calls;
try { session.call(document.path, 'not-an-operation'); } catch (error) { operation = String(error); }
const callsAfterInvalid = JSON.parse(session.stats()).calls;
try { session.read('/not-published'); } catch (error) { missing = String(error); }
try { session.read('/book/../hidden'); } catch (error) { malformed = String(error); }
try { session.read('/draft/rust-api-model.md'); } catch (error) { draft = String(error); }
const stats = JSON.parse(session.stats());
const isolatedStats = JSON.parse(other.stats());
session.free();
other.free();
return { tree, bodies, input, first, second, missing, malformed, draft, operation, callsBeforeInvalid, callsAfterInvalid, stats, isolatedStats };
} finally { window.fetch = originalFetch; }
}, base);
expect(result.tree).toEqual(result.input.nodes.map(({ html, ...node }: { html: string }) => node));
expect(result.bodies).toEqual(result.input.nodes.map(({ path, displayPath, title, href, html }: Record<string, string>) => ({ path, displayPath, title, href, html })));
expect(result.first.html).not.toBe(result.second.html);
expect(result.second.html).toBe('<p>Isolated Worldspace content</p>');
expect(result.second.title).toBe('Isolated title');
expect(result.stats.observes).toBeGreaterThan(0);
expect(result.stats.interfaces).toBeGreaterThan(0);
expect(result.stats.calls).toBeGreaterThan(result.tree.length);
expect(result.isolatedStats.calls).toBe(1);
expect(result.missing).not.toBe('');
expect(result.malformed).not.toBe('');
expect(result.draft).not.toBe('');
expect(result.operation).not.toBe('');
expect(result.callsAfterInvalid).toBe(result.callsBeforeInvalid);
});
test('WASM rejects invalid initialization and remains usable after a handled input failure', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
await module.default();
const errors: string[] = [];
for (const input of ['{', '{"nodes":[]}', '{"nodes":null}']) {
try { new module.BrowserSession(input).free(); } catch (error) { errors.push(String(error)); }
}
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
const session = new module.BrowserSession(manifest);
const body = JSON.parse(session.read('/'));
session.free();
let disposed = false;
try { session.read('/'); } catch { disposed = true; }
return { errors, body, disposed };
}, base);
expect(result.errors).toHaveLength(3);
expect(result.body.path).toBe('/');
expect(result.body.html).toContain('data-root-page');
expect(result.disposed).toBe(true);
});
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from '@playwright/test';
import { load } from 'cheerio';
// Shared by dev and preview: a successful build alone cannot catch Vite's
// dynamic-import rewriting of generated modules served from public/.
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
test('generated WASM loads as a static asset and hydrates real WIP data', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const errors: string[] = [];
const assets: { url: string; status: number }[] = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('response', (response) => {
if (response.status() >= 500) errors.push(`${response.status()} ${response.url()}`);
if (new URL(response.url()).pathname.includes('/wip-wasm/')) {
assets.push({ url: response.url(), status: response.status() });
}
});
await page.route(base, async (route) => {
const response = await route.fetch();
const $ = load(await response.text());
$('[data-selected-content]').prepend('<p data-ssr-only>Not a WIP result</p>');
await route.fulfill({ response, body: $.html() });
});
await page.goto(base);
const stage = page.locator('[data-landing-stage]');
await expect(stage).toHaveAttribute('data-wip-state', 'ready');
await expect(page.locator('[data-ssr-only]')).toHaveCount(0);
await expect(page.locator('[data-selected-content]')).toHaveAttribute('data-wip-path', '/');
for (const counter of ['observes', 'interfaces', 'calls']) {
expect(Number(await stage.getAttribute(`data-wip-${counter}`))).toBeGreaterThan(0);
}
const calls = Number(await stage.getAttribute('data-wip-calls'));
await page.locator('.demo-node[data-path="/book"]').click();
await expect(page).toHaveURL(new URL('book/', base).href);
await expect(stage).toHaveAttribute('data-wip-state', 'ready');
await expect(page.locator('[data-selected-content]')).toHaveAttribute('data-wip-path', '/book');
expect(Number(await stage.getAttribute('data-wip-calls'))).toBe(calls + 1);
expect(assets.map(({ url }) => url).sort()).toEqual([
new URL('wip-wasm/wip_browser.js', base).href,
new URL('wip-wasm/wip_browser_bg.wasm', base).href,
].sort());
expect(assets.every(({ status }) => status === 200)).toBe(true);
await expect(page.locator('vite-error-overlay')).toHaveCount(0);
expect(errors).toEqual([]);
});
+397
View File
@@ -0,0 +1,397 @@
import { expect, test, type Page } from '@playwright/test';
import { load } from 'cheerio';
import { bookURL, documentPaths, draftPaths, treePaths } from './documents';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
const stage = '[data-landing-stage]';
const article = '[data-selected-content]';
const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`);
const routeURL = (path: string, base: string) => path === '/' ? base : path === '/book' ? new URL('book/', base).href : bookURL(path, base);
async function ready(page: Page, path: string) {
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'ready');
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
const canonical = await nodeAt(page, path).getAttribute('data-wip-path');
expect(canonical).toBeTruthy();
await expect(page.locator(stage)).toHaveAttribute('data-wip-path', canonical!);
await expect(page.locator(article)).toHaveAttribute('data-wip-path', canonical!);
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
for (const counter of ['observes', 'interfaces', 'calls']) {
expect(Number(await page.locator(stage).getAttribute(`data-wip-${counter}`))).toBeGreaterThan(0);
}
}
async function useNestedArticleScroll(page: Page) {
await page.addInitScript(() => {
const constrain = (document: Document) => {
const article = document.querySelector<HTMLElement>('[data-selected-content]');
if (article) { article.style.maxHeight = '240px'; article.style.overflow = 'auto'; }
};
document.addEventListener('astro:page-load', () => constrain(document));
// Constrain incoming markup BEFORE Astro scrolls to a cross-page fragment.
// Changing overflow after that native scroll makes the mobile test race the
// layout instead of exercising the intended nested scrolling geometry.
document.addEventListener('astro:before-swap', (event) => {
constrain((event as Event & { newDocument: Document }).newDocument);
});
});
}
async function probeBridge(page: Page, base: string, displayPath: string) {
return page.evaluate(async ({ base, displayPath }) => {
const moduleURL = new URL('wip-wasm/wip_browser.js', base).href;
const module = await import(moduleURL);
await module.default();
const client = new module.BrowserSession(await (await fetch(new URL('worldspace.json', base))).text());
try {
const nodes = JSON.parse(client.tree()) as { path: string; displayPath: string; name: string; title: string; href: string }[];
const selected = nodes.find((node) => node.displayPath === displayPath)!;
const result = JSON.parse(client.read(selected.path));
const template = document.createElement('template');
template.innerHTML = result.html;
return { nodes, html: template.innerHTML, stats: JSON.parse(client.stats()) };
} finally { client.free(); }
}, { base, displayPath });
}
// Run this suite against both ordinary builds and SITE_BASE builds. All URLs
// derive from the project's baseURL; no root-only module or document shortcuts.
test('real WASM results replace poisoned SSR labels and article, rather than just observing', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const url = bookURL('/1-concept.md', base);
await page.route(url, async (route) => {
const response = await route.fetch();
const $ = load(await response.text());
$('.node-label').text('SSR-only wrong label');
$(article).prepend('<p data-ssr-only>SSR-only wrong body</p>');
await route.fulfill({ response, body: $.html() });
});
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
await page.route('**/wip-wasm/wip_browser.js', async (route) => { await gate; await route.continue(); });
const wasmResponses: string[] = [];
page.on('response', (response) => { if (new URL(response.url()).pathname.endsWith('.wasm') && response.ok()) wasmResponses.push(response.url()); });
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await expect(page.locator('[data-ssr-only]')).toBeVisible();
await expect(nodeAt(page, '/1-concept.md')).toHaveText('SSR-only wrong label');
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'loading');
const anchor = await nodeAt(page, '/1-concept.md').elementHandle();
release();
await ready(page, '/1-concept.md');
await expect(page.locator('[data-ssr-only]')).toHaveCount(0);
await expect(nodeAt(page, '/1-concept.md')).toHaveText('1-concept.md');
expect(await anchor!.evaluate((el) => el === document.querySelector('.demo-node[data-path="/1-concept.md"]'))).toBe(true);
expect(wasmResponses).toHaveLength(1);
expect(wasmResponses[0]).toContain(new URL('wip-wasm/', base).pathname);
// Independently read the same genuine Rust bridge, without exposing a test
// session on window. Compare actual data/HTML, not success markers alone.
const expected = await probeBridge(page, base, '/1-concept.md');
expect(await page.locator(article).innerHTML()).toBe(expected.html);
for (const node of expected.nodes) {
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('data-wip-path', node.path);
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('href', node.href);
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('title', node.title);
if (node.displayPath !== '/') {
const label = node.displayPath === '/book' ? `${node.name.replace(/\/$/, '')}/` : node.name;
await expect(nodeAt(page, node.displayPath)).toHaveText(label);
}
}
expect(expected.stats.observes).toBeGreaterThan(0);
expect(expected.stats.interfaces).toBeGreaterThan(0);
expect(expected.stats.calls).toBeGreaterThan(0);
} finally {
release();
await page.unrouteAll({ behavior: 'wait' });
}
});
test('every public route uses one persistent WASM session with only static asset/page requests', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const requests: { url: string; method: string; type: string }[] = [];
const sockets: string[] = [];
page.on('request', (request) => requests.push({ url: request.url(), method: request.method(), type: request.resourceType() }));
page.on('websocket', (socket) => sockets.push(socket.url()));
await page.goto('./');
await ready(page, '/');
const anchors = await page.locator('.demo-node').elementHandles();
let calls = Number(await page.locator(stage).getAttribute('data-wip-calls'));
for (const path of treePaths.slice(1)) {
// Hidden children still have native anchors; selection opens their ancestors.
await nodeAt(page, path).evaluate((el) => (el as HTMLAnchorElement).click());
await ready(page, path);
await expect(page).toHaveURL(routeURL(path, base));
await expect(page.locator(article + ' h1')).toHaveCount(1);
const next = Number(await page.locator(stage).getAttribute('data-wip-calls'));
expect(next, `one document call for ${path}`).toBe(calls + 1);
calls = next;
}
for (const anchor of anchors) {
expect(await anchor.evaluate((el) => el === document.querySelector(`.demo-node[data-path="${(el as HTMLElement).dataset.path}"]`))).toBe(true);
}
const paths = requests.map((request) => new URL(request.url).pathname);
expect(paths.filter((path) => path.endsWith('/worldspace.json'))).toHaveLength(1);
expect(paths.filter((path) => path.endsWith('/wip_browser.js'))).toHaveLength(1);
expect(paths.filter((path) => path.endsWith('.wasm'))).toHaveLength(1);
const documents = new Set(treePaths.map((path) => new URL(routeURL(path, base)).pathname));
for (const request of requests.filter((request) => ['fetch', 'xhr'].includes(request.type))) {
expect(request.method).toBe('GET');
const url = new URL(request.url);
expect(url.origin).toBe(new URL(base).origin);
expect(url.pathname.startsWith(new URL(base).pathname)).toBe(true);
expect(documents.has(url.pathname) || url.pathname.endsWith('/worldspace.json') || url.pathname.endsWith('.wasm'), request.url).toBe(true);
}
expect(sockets).toEqual([]);
await expect(page.locator('.demo-node[data-path*="draft"], a[href*="/draft/"]')).toHaveCount(0);
});
test('WIP navigation retains collapse, history reading position, fragments and single handlers', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
// Exercise nested scroll even on mobile, whose normal scroll belongs to window.
await useNestedArticleScroll(page);
await page.goto(bookURL('/3-protocol.md', base));
await ready(page, '/3-protocol.md');
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
const position = await page.locator(article).evaluate((el) => { el.scrollTop = 180; return el.scrollTop; });
expect(position).toBe(180);
const pane = await page.locator('.tree-pane').elementHandle();
// A pointer click scrolls the offscreen Markdown link into view before Astro
// navigates. History must restore that departure position, not the earlier 180.
await page.evaluate(() => {
document.addEventListener('astro:before-swap', () => {
sessionStorage.setItem('wip-test-departure-scroll', String(document.querySelector('[data-selected-content]')!.scrollTop));
}, { once: true });
});
const crossPageLink = () => page.locator(article).getByRole('link', { name: '4.2. Text View', exact: true }).first();
await crossPageLink().click();
await ready(page, '/4.2-text-view.md');
const departurePosition = await page.evaluate(() => Number(sessionStorage.getItem('wip-test-departure-scroll')));
expect(departurePosition).toBeGreaterThan(position);
expect(new URL(page.url()).hash).not.toBe('');
await expect.poll(() => page.evaluate(() => {
const target = document.getElementById(decodeURIComponent(location.hash.slice(1)));
return !!target && target.getBoundingClientRect().top < innerHeight;
})).toBe(true);
await page.goBack();
await ready(page, '/3-protocol.md');
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(departurePosition);
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false');
// Independently check the controlled 180px position without Playwright's
// pointer actionability scrolling. Native anchor activation still uses Astro.
await page.locator(article).evaluate((el) => { el.scrollTop = 180; });
await crossPageLink().evaluate((el) => (el as HTMLAnchorElement).click());
await ready(page, '/4.2-text-view.md');
await page.goBack();
await ready(page, '/3-protocol.md');
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(position);
await page.goForward();
await ready(page, '/4.2-text-view.md');
for (const path of ['/2-core-model.md', '/3-protocol.md', '/2-core-model.md']) {
await nodeAt(page, path).click();
await ready(page, path);
}
await page.locator('.node-toggle[data-toggle-path="/book"]').click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await page.locator('.node-toggle[data-toggle-path="/book"]').click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true');
await nodeAt(page, '/1-concept.md').focus();
await page.keyboard.press('ArrowRight'); // Expand exactly once, without navigation.
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'true');
await page.keyboard.press('ArrowDown');
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused();
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
await page.reload();
await ready(page, '/2-core-model.md');
const fresh = await probeBridge(page, base, '/2-core-model.md');
expect(Number(await page.locator(stage).getAttribute('data-wip-calls'))).toBe(fresh.stats.calls);
});
for (const leaveDocument of [false, true]) {
test(`fragment history restores separate article positions${leaveDocument ? ' after a document round trip' : ' without a swap'}`, async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const path = '/4.2-text-view.md';
const url = bookURL(path, base);
const assets: string[] = [];
page.on('request', (request) => {
if (/worldspace\.json|wip-wasm/.test(request.url())) assets.push(request.url());
});
// The normal mobile layout scrolls window. Force the nested reading pane on
// both projects so this cannot pass by checking Astro's window scroll alone.
await useNestedArticleScroll(page);
await page.goto(url);
await ready(page, path);
const calls = Number(await page.locator(stage).getAttribute('data-wip-calls'));
const initialBody = await page.locator(article).elementHandle();
const originalKey = await page.evaluate(() => history.state.wipReadingEntry);
const setWindowPosition = async (top: number) => {
const position = await page.evaluate((top) => { window.scrollTo(0, top); return scrollY; }, top);
// Let Astro save its own window-scroll state independently of our pane.
await expect.poll(() => page.evaluate(() => history.state.scrollY)).toBe(position);
return position;
};
const originalWindowPosition = await setWindowPosition(70);
await page.locator(article).evaluate((el) => { el.scrollTop = 180; });
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(180);
const fragment = page.locator(article).getByRole('link', { name: '名前と文字列', exact: true }).first();
const fragmentURL = new URL((await fragment.getAttribute('href'))!, url).href;
// Native anchor activation, without Playwright scrolling the link into view
// and silently replacing the controlled departure position before the push.
await fragment.evaluate((el) => (el as HTMLAnchorElement).click());
await expect(page).toHaveURL(fragmentURL);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBeGreaterThan(180);
const fragmentKey = await page.evaluate(() => history.state.wipReadingEntry);
expect(originalKey).toBeTruthy();
expect(fragmentKey).toBeTruthy();
expect(fragmentKey).not.toBe(originalKey);
expect(await initialBody!.evaluate((el) => el === document.querySelector('[data-selected-content]'))).toBe(true);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(calls));
// Restore the reader's latest position, not merely the fragment's anchor.
await page.locator(article).evaluate((el) => { el.scrollTop = 420; });
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(420);
const fragmentWindowPosition = await setWindowPosition(130);
let expectedCalls = calls;
if (leaveDocument) {
await nodeAt(page, '/2-core-model.md').evaluate((el) => (el as HTMLAnchorElement).click());
await ready(page, '/2-core-model.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
await page.goBack();
await ready(page, path);
await expect(page).toHaveURL(fragmentURL);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(420);
}
const body = await page.locator(article).elementHandle();
// Repeat traversals with new reading positions: each departure must update
// only its own entry, including the no-hash entry that shares this document.
for (const [originalPosition, fragmentPosition] of [[180, 420], [230, 470]]) {
await page.goBack();
await expect(page).toHaveURL(url);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(originalPosition);
expect(await page.evaluate(() => history.state.wipReadingEntry)).toBe(originalKey);
await expect.poll(() => page.evaluate(() => scrollY)).toBe(originalWindowPosition);
await page.locator(article).evaluate((el) => { el.scrollTop = 230; });
await page.goForward();
await expect(page).toHaveURL(fragmentURL);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(fragmentPosition);
expect(await page.evaluate(() => history.state.wipReadingEntry)).toBe(fragmentKey);
await expect.poll(() => page.evaluate(() => scrollY)).toBe(fragmentWindowPosition);
await page.locator(article).evaluate((el) => { el.scrollTop = 470; });
await ready(page, path);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(expectedCalls));
expect(await body!.evaluate((el) => el === document.querySelector('[data-selected-content]'))).toBe(true);
}
if (leaveDocument) {
await page.goForward();
await ready(page, '/2-core-model.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
await page.goBack();
await ready(page, path);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(470);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
}
for (const suffix of ['/worldspace.json', '/wip_browser.js', '.wasm']) {
expect(assets.filter((url) => new URL(url).pathname.endsWith(suffix))).toHaveLength(1);
}
});
}
test('WIP-rendered fragment and modifier links retain native navigation semantics', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.goto(bookURL('/4.2-text-view.md', base));
await ready(page, '/4.2-text-view.md');
const calls = await page.locator(stage).getAttribute('data-wip-calls');
const body = await page.locator(article).elementHandle();
const link = page.locator(article).getByRole('link', { name: '名前と文字列', exact: true }).first();
const href = await link.getAttribute('href');
await link.click();
await expect(page).toHaveURL(new URL(href!, bookURL('/4.2-text-view.md', base)).href);
await ready(page, '/4.2-text-view.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', calls!);
expect(await body!.evaluate((el) => el === document.querySelector('[data-selected-content]'))).toBe(true);
const originalURL = page.url();
const [tab] = await Promise.all([
page.context().waitForEvent('page'),
nodeAt(page, '/2-core-model.md').click({ modifiers: ['ControlOrMeta'] }),
]);
await ready(tab, '/2-core-model.md');
await expect(tab).toHaveURL(bookURL('/2-core-model.md', base));
expect(page.url()).toBe(originalURL);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', calls!);
await tab.close();
});
test('slow WASM initialization cannot roll a newer Astro route back to a stale read', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
await page.route('**/wip-wasm/*.wasm', async (route) => { await gate; await route.continue(); });
try {
await page.goto(bookURL('/1-concept.md', base), { waitUntil: 'domcontentloaded' });
await expect(page.locator('.demo-tree')).toHaveAttribute('data-enhanced', 'true');
await page.evaluate(() => {
for (const path of ['/2-core-model.md', '/3-protocol.md', '/4-client.md']) {
document.querySelector<HTMLAnchorElement>(`.demo-node[data-path="${path}"]`)!.click();
}
});
await expect(page).toHaveURL(bookURL('/4-client.md', base));
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/4-client.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'loading');
release();
await ready(page, '/4-client.md');
await expect(page.locator(article + ' h1')).toHaveText('4. クライアント');
// Only the still-live route is allowed to call read after shared init resolves.
const fresh = await probeBridge(page, base, '/4-client.md');
expect(Number(await page.locator(stage).getAttribute('data-wip-calls'))).toBe(fresh.stats.calls);
} finally {
release();
await page.unrouteAll({ behavior: 'wait' });
}
});
for (const asset of ['**/wip-wasm/wip_browser.js', '**/wip-wasm/*.wasm', '**/worldspace.json']) {
test(`failed ${asset} stays explicitly static and navigable`, async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.route(asset, (route) => route.abort());
await page.goto(bookURL('/1-concept.md', base));
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator(article + ' h1')).toHaveText('1. コンセプト');
await expect(page.locator(article)).not.toHaveAttribute('data-wip-path');
await nodeAt(page, '/2-core-model.md').click();
await expect(page).toHaveURL(bookURL('/2-core-model.md', base));
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator(article + ' h1')).toHaveText('2. コアモデル');
await expect(page.locator(stage)).not.toHaveAttribute('data-wip-calls');
});
}
test('unknown and draft routes remain 404 rather than reading the WIP home document', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
for (const url of [new URL('missing-wip-document/', base).href, ...draftPaths.map((path) => bookURL(path, base))]) {
const response = await page.goto(url);
expect(response!.status()).toBe(404);
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator(article + ' h1')).toHaveText('このページは見つかりません。');
await expect(page.locator(article)).not.toHaveAttribute('data-wip-path');
}
});
test.describe('no JavaScript', () => {
test.use({ javaScriptEnabled: false });
test('static public pages remain usable and never claim WIP execution', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const assets: string[] = [];
page.on('request', (request) => { if (/worldspace\.json|wip-wasm/.test(request.url())) assets.push(request.url()); });
await page.goto('./');
for (const path of ['/book', ...documentPaths]) {
await nodeAt(page, path).click();
await expect(page).toHaveURL(routeURL(path, base));
await expect(page.locator(article + ' h1')).toBeVisible();
await expect(page.locator(stage)).not.toHaveAttribute('data-wip-state');
await expect(page.locator(article)).not.toHaveAttribute('data-wip-path');
}
expect(assets).toEqual([]);
});
});
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
import { load } from 'cheerio';
import documentLinks, { contentsRoot } from '../plugins/document-links.mjs';
import { bookTree } from '../src/lib/landing-tree.ts';
import { createWorldspaceData } from '../src/lib/worldspace-data.ts';
const pages = {
'/': { title: 'Worldspace Interface Protocol', href: '/', html: '<h1>Home</h1>' },
'/book': { title: 'Book', href: '/book/', html: '<h1>Book</h1>' },
};
const source = (id) => ({ id, title: id, href: `/book/${id}/`, html: `<h1>${id}</h1>` });
async function sourceFiles(directory, prefix = '') {
return (await Promise.all((await readdir(directory, { withFileTypes: true })).map(async (entry) => {
const relative = prefix + entry.name;
return entry.isDirectory() ? sourceFiles(path.join(directory, entry.name), relative + '/')
: entry.name.endsWith('.md') ? [relative] : [];
}))).flat();
}
const files = await sourceFiles(contentsRoot);
const publicFiles = files.filter((file) => !file.startsWith('draft/'));
const processor = await createMarkdownProcessor({ remarkPlugins: [[documentLinks, { base: '/' }]], smartypants: false });
const documents = await Promise.all(publicFiles.map(async (file) => {
const body = await readFile(path.join(contentsRoot, file), 'utf8');
const result = await processor.render(body, { fileURL: new URL(`file://${path.join(contentsRoot, file)}`) });
return { ...source(file.slice(0, -3)), title: body.match(/^#\s+(.+)$/m)?.[1] ?? file, html: result.code };
}));
function checkIdentity(data) {
for (const field of ['path', 'displayPath', 'href']) {
assert.equal(new Set(data.nodes.map((node) => node[field])).size, data.nodes.length);
}
const root = data.nodes.find((node) => node.path === '/');
assert.equal(root.name, '');
assert.equal(root.parent, undefined);
for (const node of data.nodes.filter((node) => node.path !== '/')) {
assert.ok(data.nodes.some((parent) => parent.path === node.parent));
assert.equal(node.path, `${node.parent === '/' ? '' : node.parent}/${node.name}`);
assert.ok(node.name.length > 0 && !node.name.includes('/'));
assert.ok(!node.path.split('/').includes('draft'));
assert.ok(!node.displayPath.split('/').includes('draft'));
}
}
test('actual public documents keep Web and display identities but acquire real parent paths', () => {
const data = createWorldspaceData(documents, pages);
checkIdentity(data);
const display = bookTree(documents);
assert.equal(data.nodes.length, documents.length + 2);
assert.deepEqual(data.nodes.map((node) => node.displayPath), display.map((node) => node.path));
for (const node of data.nodes) {
const legacy = display.find((item) => item.path === node.displayPath);
assert.equal(node.parent, legacy.parent === undefined ? undefined : data.nodes.find((item) => item.displayPath === legacy.parent).path);
if (legacy.href) {
assert.equal(node.href, legacy.href);
assert.equal(node.html, documents.find((entry) => `/${entry.id}.md` === node.displayPath).html);
assert.match(node.html, /<h1/);
}
}
const section = data.nodes.find((node) => node.displayPath === '/1.1-interaction-abstraction.md');
assert.equal(section.path, '/book/1-concept.md/1.1-interaction-abstraction.md');
assert.equal(section.parent, '/book/1-concept.md');
assert.equal(section.href, '/book/1.1-interaction-abstraction/');
assert.equal(data.nodes[1].name, 'book');
assert.equal(display[1].name, 'book/');
});
test('drafts are removed before hierarchy, body and URL generation', () => {
const marker = 'PRIVATE-DRAFT-CONTENT-MUST-NOT-SERIALIZE';
const drafts = files.filter((file) => file.startsWith('draft/')).map((file) => ({ ...source(file.slice(0, -3)), html: marker }));
assert.ok(drafts.length > 0);
const publicOnly = createWorldspaceData(documents, pages);
const mixed = createWorldspaceData([...documents, ...drafts], pages);
assert.deepEqual(mixed, publicOnly);
assert.ok(!JSON.stringify(mixed).includes(marker));
assert.ok(!mixed.nodes.some((node) => node.href.includes('/draft/')));
});
test('nested numbering, real directories and site base prefixes remain unambiguous', () => {
const docs = ['1-parent', '1.2-child', '1.2.3-leaf', 'other/1-parent', 'other/1.1-child']
.map((id) => ({ ...source(id), href: `/reference/book/${id}/` }));
const data = createWorldspaceData(docs, {
'/': { ...pages['/'], href: '/reference/' },
'/book': { ...pages['/book'], href: '/reference/book/' },
'/other': { title: 'other/', href: '/reference/book/other/', html: '<h1>other/</h1>' },
});
checkIdentity(data);
assert.equal(data.nodes.find((node) => node.displayPath === '/1.2.3-leaf.md').path,
'/book/1-parent.md/1.2-child.md/1.2.3-leaf.md');
assert.equal(data.nodes.find((node) => node.displayPath === '/other/1.1-child.md').path,
'/book/other/1-parent.md/1.1-child.md');
assert.ok(data.nodes.every((node) => node.href.startsWith('/reference/')));
});
test('invalid, duplicate and incomplete generated inputs fail rather than lose documents', () => {
assert.throws(() => createWorldspaceData([source('a'), source('a')], pages), /Duplicate document/);
for (const id of ['', '/absolute', 'a//b', 'a/../b', 'a/./b', 'a/draft/b']) {
assert.throws(() => createWorldspaceData([source(id)], pages), /Invalid public document id/);
}
assert.throws(() => createWorldspaceData([source('a')], {}), /Missing rendered page/);
assert.throws(() => createWorldspaceData([{ ...source('a'), href: '/book/' }], pages), /Duplicate Web URL/);
assert.throws(() => createWorldspaceData([{ ...source('a'), href: '//external.example/' }], pages), /Invalid page metadata/);
assert.throws(() => createWorldspaceData([{ ...source('a'), title: '' }], pages), /Invalid page metadata/);
assert.throws(() => createWorldspaceData([source('dir/a')], pages), /Missing rendered page: \/dir/);
});
test('root and book index content is supplied by their existing page renderers', () => {
const data = createWorldspaceData([], pages);
assert.deepEqual(data.nodes, [
{ path: '/', name: '', displayPath: '/', ...pages['/'] },
{ path: '/book', name: 'book', displayPath: '/book', parent: '/', ...pages['/book'] },
]);
});
// Explicit post-build validation, usable for either root or prefixed deployments.
// WIP_TEST_GENERATED=1 node --test tests/worldspace-data.test.mjs
test('built artifact contains exactly public routes and the same rendered article bodies',
{ skip: process.env.WIP_TEST_GENERATED !== '1' }, async () => {
const data = JSON.parse(await readFile(new URL('../dist/worldspace.json', import.meta.url), 'utf8'));
checkIdentity(data);
assert.equal(data.nodes.length, documents.length + 2);
assert.deepEqual(new Set(data.nodes.filter((node) => node.displayPath.endsWith('.md')).map((node) => node.displayPath.slice(1))), new Set(publicFiles));
for (const node of data.nodes) {
const relative = node.displayPath === '/' ? 'index.html' : node.displayPath === '/book' ? 'book/index.html'
: `book/${node.displayPath.slice(1, -3)}/index.html`;
const page = load(await readFile(new URL(`../dist/${relative}`, import.meta.url), 'utf8'));
// Astro adds hydration/render markers outside the actual article fragment.
// Compare normalized markup, not generated comment/whitespace placement.
const fragment = load(node.html, {}, false);
const article = page('[data-selected-content]');
assert.equal(article.length, 1, `${relative} has a document body`);
assert.equal(fragment.text().replace(/\s+/g, ' ').trim(), article.text().replace(/\s+/g, ' ').trim(), node.path);
assert.deepEqual(fragment('a[href]').map((_, item) => fragment(item).attr('href')).get(),
article.find('a[href]').map((_, item) => page(item).attr('href')).get(), node.path);
}
});
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "node_modules"]
"exclude": ["dist", "node_modules", "public/wip-wasm", "test-assets", "target"]
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "wip-site-worldspace"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
publish = false
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wip-host = { path = "../../../wip-rs/crates/wip-host" }
wip-interface = { path = "../../../wip-rs/crates/wip-interface" }
wip-protocol = { path = "../../../wip-rs/crates/wip-protocol" }
+123
View File
@@ -0,0 +1,123 @@
# Public-document Worldspace
`wip-site-worldspace` is the site's immutable in-memory Host provider. It uses
`wip-host`, `wip-interface` and `wip-protocol`; it does not depend on the filesystem
server, HTTP, a browser, or a network runtime. The native and WASM callers use the
same `Worldspace` instance API.
## Generated data
The static Astro endpoint `src/pages/worldspace.json.ts` emits
`dist/worldspace.json` during the normal site build. It:
1. Reads `documents()` (the existing public-only content collection).
2. Renders each document with Astro's existing Markdown renderer, including the
configured document-link transform and syntax highlighting.
3. Renders the existing `RootPage` and `DirectoryIndex` components for `/`, `/book`
and any real public directory indexes. It does not copy their authored text.
4. Passes rendered bodies to `createWorldspaceData` in
`src/lib/worldspace-data.ts`, which uses `bookTree` and filters drafts again.
The input contract is:
```json
{"nodes":[
{"path":"/","name":"","displayPath":"/","title":"Worldspace Interface Protocol","href":"/","html":"..."},
{"path":"/book","name":"book","displayPath":"/book","parent":"/","title":"Book","href":"/book/","html":"..."},
{"path":"/book/1-concept.md","name":"1-concept.md","displayPath":"/1-concept.md","parent":"/book","title":"...","href":"/book/1-concept/","html":"..."},
{"path":"/book/1-concept.md/1.1-interaction-abstraction.md","name":"1.1-interaction-abstraction.md","displayPath":"/1.1-interaction-abstraction.md","parent":"/book/1-concept.md","title":"...","href":"/book/1.1-interaction-abstraction/","html":"..."}
]}
```
`path` is the only WIP request identity. `parent` is a WIP path and is absent only
for `/`. `displayPath` preserves the old explorer identity and selection state;
`href` preserves the existing Web route (including `SITE_BASE`). The UI can keep
its current `book/` label, but the actual Object name is `book`. No protocol name
has a trailing slash. Nodes retain `bookTree` order. The provider does not sort
children lexically. Every node, including home, book index, and a chapter that
also has sections, has a readable body.
`html` is a trusted, build-rendered fragment, not a full page or an untrusted user
submission. Consumers must retain the site's existing CSS, including home scoped
styles and code-block themes. Do not inject raw JSON into a script element without
escaping `<`; the generated endpoint is served as `application/json` instead.
There is no runtime loading by path: unpublished or guessed draft paths cannot
reach a source file. The Rust loader also rejects draft path segments, duplicate
path/displayPath/URL identities, inconsistent parent/name/path relationships,
missing parents, malformed input and unknown JSON fields.
## Rust API
```rust
use wip_site_worldspace::{Worldspace, DocumentContent};
use wip_site_worldspace::wip_protocol::{
ObserveRequest, FetchInterfaceRequest, CallOperationRequest, Target, InterfaceTarget,
};
use wip_interface::WipType;
# fn example(json: &str) {
let world = Worldspace::from_json(json).unwrap();
let observed = world.observe(ObserveRequest { path: "/".into(), depth: 4 }).unwrap();
let reference = observed.object.interfaces[0].clone();
let interface = world.fetch_interface(FetchInterfaceRequest { interface: reference.clone() }).unwrap();
let result = world.call_operation(CallOperationRequest {
target: Target { path: "/book/1-concept.md".into(), validator: None },
interface: InterfaceTarget {
reference, scope_ref: interface.scope_ref, validator: interface.validator,
},
operation: "read".into(),
arguments: Default::default(),
}).unwrap();
let document = DocumentContent::decode(result.result).unwrap();
assert_eq!(document.path, "/book/1-concept.md");
# }
```
Alternatively use `Worldspace::new(WorldspaceData { nodes })`. Construction returns
`Result<Worldspace, DataError>`. `observe(ObserveRequest)`,
`fetch_interface(FetchInterfaceRequest)` and `call_operation(CallOperationRequest)`
take requests by value and return canonical protocol response types or
`ProtocolError`. The crate re-exports `wip_protocol` for bridge consumers.
The registered root-scoped Interface is `{scope:"/",name:"document"}`. Its only
Operation, `read`, takes **no arguments** and returns the typed `DocumentContent`
record `{path,title,href,html}`. Host-generated Descriptors, scope association,
interface membership, request validation, argument/result validation and error
classification remain on the normal Host path. The provider exposes no alternate
body-fetch method. Documents are immutable, so no validators are advertised.
The recursive adapter calls `ObjectProvider::observe_object` for every node and
validates the assembled response against the request. At the requested depth,
`children` is `None`; above it, a visited leaf has `Some([])`. Unknown paths return
`NotFound` and malformed paths return `InvalidRequest`. Requests above
`MAX_DEPTH = 32` return `ResourceLimitExceeded` rather than silently truncating.
Construction allows at most `MAX_NODES = 4096`, with hierarchy depth at most 32.
`from_json` also caps input at 16 MiB. Together these bounds cap traversal work.
## Validation
From the site directory, with the configured Rust toolchain and dependencies:
```sh
node --test tests/worldspace-data.test.mjs
cargo test -p wip-site-worldspace
npm run build
WIP_TEST_GENERATED=1 node --test tests/worldspace-data.test.mjs
cargo test -p wip-site-worldspace --test worldspace -- --include-ignored
npm run check:links
```
The explicit generated-data tests require `dist/worldspace.json`; they do not
silently substitute fixtures when the artifact is absent. Native fixture tests
exercise all three interactions, depth boundaries/order/empty sets, limits,
identity and constructor errors, unknown paths/interfaces/operations, invalid
arguments, validator failures, draft guesses, and JSON parsing. The generated
native test invokes all three interactions against the actual build artifact.
The JS post-build test compares every returned body and link set with the static
page's article, including home and book index. Run it again with a prefixed
`SITE_BASE` build to check deployment URLs.
Cargo dependency sourcing and the browser bridge are managed by the parent site
workspace. The initial relative Rust dependencies require the companion `wip-rs`
checkout beside `docs`; they must be replaced with the final reproducible source
configuration before a standalone site checkout can build.
+330
View File
@@ -0,0 +1,330 @@
//! Immutable, site-specific Worldspace. All three interactions use the ordinary
//! Host publication boundary; only bounded recursive observation is site logic.
use std::collections::{BTreeMap, HashSet};
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use wip_host::{
ExecutionContext, InterfaceRegistry, ObjectProvider, ProviderResult, PublishedObject, Scope,
ScopeObservation, WipObject,
};
use wip_interface::{OperationResult, WipType, interface};
pub use wip_protocol;
use wip_protocol::{
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse,
InterfaceReference, ObjectObservation, ObserveRequest, ObserveResponse, ProtocolError,
ProtocolErrorCode,
};
pub const DOCUMENT_INTERFACE: &str = "document";
pub const MAX_DEPTH: u32 = 32;
pub const MAX_NODES: usize = 4096;
pub const MAX_JSON_BYTES: usize = 16 * 1024 * 1024;
/// Build-generated input. Array order is also the direct-child observation order.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorldspaceData {
pub nodes: Vec<DocumentNode>,
}
/// Protocol identity, legacy UI identity and Web navigation are deliberately separate.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DocumentNode {
pub path: String,
pub name: String,
pub display_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub title: String,
pub href: String,
pub html: String,
}
/// A rendered public document fragment, including the root and directory indexes.
#[derive(Debug, Clone, PartialEq, WipType)]
pub struct DocumentContent {
pub path: String,
pub title: String,
pub href: String,
pub html: String,
}
/// Read the site's published document without network or filesystem access.
#[interface]
pub trait Document {
/// Returns the rendered HTML fragment and its existing Web URL.
fn read(&self) -> OperationResult<DocumentContent>;
}
/// Host-private implementation; no body lookup bypass is exposed to clients.
pub struct SiteObject(Arc<DocumentNode>);
impl WipObject for SiteObject {
fn name(&self) -> &str {
&self.0.name
}
fn description(&self) -> Option<&str> {
Some(&self.0.title)
}
}
impl Document for SiteObject {
fn read(&self) -> OperationResult<DocumentContent> {
Ok(DocumentContent {
path: self.0.path.clone(),
title: self.0.title.clone(),
href: self.0.href.clone(),
html: self.0.html.clone(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataError(String);
impl Display for DataError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl Error for DataError {}
fn invalid(message: impl Into<String>) -> DataError {
DataError(message.into())
}
fn protocol_error(code: ProtocolErrorCode, message: impl Into<String>) -> ProtocolError {
ProtocolError {
code,
message: message.into(),
}
}
/// An immutable publication snapshot, shared by native and WASM callers.
/// Construction is the only mutation boundary; later interactions require only `&self`.
pub struct Worldspace {
registry: InterfaceRegistry,
objects: BTreeMap<String, PublishedObject<SiteObject>>,
children: BTreeMap<String, Vec<String>>,
}
impl Worldspace {
pub fn from_json(json: &str) -> Result<Self, DataError> {
if json.len() > MAX_JSON_BYTES {
return Err(invalid("document JSON exceeds the input byte limit"));
}
let data = serde_json::from_str(json).map_err(|error| invalid(error.to_string()))?;
Self::new(data)
}
pub fn new(data: WorldspaceData) -> Result<Self, DataError> {
validate_data(&data)?;
let scope = Scope::new("/").map_err(|error| invalid(error.to_string()))?;
let mut registry = InterfaceRegistry::new();
let document = registry
.register(&scope, DOCUMENT_INTERFACE, DocumentDefinition, None)
.map_err(|error| invalid(error.to_string()))?;
// Every publication attests the same actual, immutable root Object.
// Both fetch and calls need this association; a registry token alone is insufficient.
let root = Arc::new(
data.nodes
.iter()
.find(|node| node.path == "/")
.unwrap()
.clone(),
);
let current_scope = ScopeObservation::new(&scope, &SiteObject(Arc::clone(&root)));
let mut objects = BTreeMap::new();
let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
for node in data.nodes {
let path = node.path.clone();
if let Some(parent) = &node.parent {
children
.entry(parent.clone())
.or_default()
.push(path.clone());
}
let object = SiteObject(if path == "/" {
Arc::clone(&root)
} else {
Arc::new(node)
});
let publication = PublishedObject::new(object)
.bind_scope(current_scope.clone())
.expose(&document)
.map_err(|error| invalid(error.to_string()))?;
objects.insert(path, publication);
}
Ok(Self {
registry,
objects,
children,
})
}
/// Recursive Host observations. Boundary children are unknown (`None`), not empty.
/// A visited leaf above the boundary has a known empty child set (`Some(vec![])`).
pub fn observe(&self, request: ObserveRequest) -> Result<ObserveResponse, ProtocolError> {
request.validate().map_err(|error| {
protocol_error(ProtocolErrorCode::InvalidRequest, error.to_string())
})?;
if request.depth > MAX_DEPTH {
return Err(protocol_error(
ProtocolErrorCode::ResourceLimitExceeded,
"observation depth exceeds the site limit",
));
}
let response = self.observe_at(&request.path, request.depth)?;
request.validate_response(&response).map_err(|_| {
protocol_error(ProtocolErrorCode::Internal, "invalid provider observation")
})?;
Ok(response)
}
fn observe_at(&self, path: &str, depth: u32) -> Result<ObjectObservation, ProtocolError> {
let object =
ObjectProvider::observe_object(self, path).map_err(|error| error.protocol_error())?;
let children = if depth == 0 {
None
} else {
Some(
self.children
.get(path)
.into_iter()
.flatten()
.map(|child| self.observe_at(child, depth - 1))
.collect::<Result<Vec<_>, _>>()?,
)
};
Ok(ObjectObservation { object, children })
}
pub fn fetch_interface(
&self,
request: FetchInterfaceRequest,
) -> Result<FetchInterfaceResponse, ProtocolError> {
ObjectProvider::fetch_interface(self, &self.registry, &request.interface)?
.protocol_response()
}
pub fn call_operation(
&self,
request: CallOperationRequest,
) -> Result<CallOperationResponse, ProtocolError> {
ObjectProvider::call_operation(self, request).map_err(|error| error.protocol_error())
}
pub fn document_interface() -> InterfaceReference {
InterfaceReference {
scope: "/".into(),
name: DOCUMENT_INTERFACE.into(),
}
}
}
impl ObjectProvider for Worldspace {
type Object = SiteObject;
fn with_object<R>(
&self,
_context: &ExecutionContext,
path: &str,
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
) -> ProviderResult<R> {
match self.objects.get(path) {
Some(publication) => ProviderResult::Found(operation(publication)),
None => ProviderResult::NotFound,
}
}
}
fn validate_data(data: &WorldspaceData) -> Result<(), DataError> {
if data.nodes.is_empty() || data.nodes.len() > MAX_NODES {
return Err(invalid(
"document node count must be between 1 and MAX_NODES",
));
}
let mut paths = HashSet::new();
let mut display_paths = HashSet::new();
let mut hrefs = HashSet::new();
for node in &data.nodes {
wip_protocol::validate_path(&node.path).map_err(|error| invalid(error.to_string()))?;
wip_protocol::validate_path(&node.display_path)
.map_err(|error| invalid(error.to_string()))?;
if !paths.insert(node.path.as_str())
|| !display_paths.insert(node.display_path.as_str())
|| !hrefs.insert(node.href.as_str())
{
return Err(invalid("duplicate path, displayPath or Web URL"));
}
if [&node.path, &node.display_path]
.into_iter()
.any(|path| path.split('/').any(|segment| segment == "draft"))
{
return Err(invalid("draft paths are not public documents"));
}
if node
.path
.split('/')
.skip(1)
.filter(|part| !part.is_empty())
.count()
> MAX_DEPTH as usize
{
return Err(invalid("document hierarchy exceeds the depth limit"));
}
if node.title.trim().is_empty()
|| !node.href.starts_with('/')
|| node.href.starts_with("//")
{
return Err(invalid(
"documents require a title and a site-relative Web URL",
));
}
if node.path == "/" {
if !node.name.is_empty() || node.parent.is_some() || node.display_path != "/" {
return Err(invalid(
"root requires empty name, no parent and displayPath /",
));
}
} else {
let parent = node
.parent
.as_deref()
.ok_or_else(|| invalid("non-root node needs a parent"))?;
wip_protocol::validate_path(parent).map_err(|error| invalid(error.to_string()))?;
if node.name.is_empty()
|| node.name.contains('/')
|| node.name == "."
|| node.name == ".."
{
return Err(invalid("invalid object name"));
}
let expected = format!("{}/{}", parent.trim_end_matches('/'), node.name);
if node.path != expected {
return Err(invalid(
"path must be formed from parent path and object name",
));
}
}
}
if !paths.contains("/") {
return Err(invalid("Worldspace must contain the root object"));
}
for node in &data.nodes {
if node
.parent
.as_deref()
.is_some_and(|parent| !paths.contains(parent))
{
return Err(invalid("document parent is not published"));
}
}
Ok(())
}
+437
View File
@@ -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
);
}
}