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:
@@ -10,6 +10,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout pinned Rust source
|
||||
run: |
|
||||
git clone https://gitea.hareworks.net/Hare/wip-rs ../wip-rs
|
||||
git -C ../wip-rs checkout "$(python3 -c 'import json; print(json.load(open("site/rust-source.json"))["revision"])')"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.97.1'
|
||||
targets: wasm32-unknown-unknown
|
||||
components: rustfmt, clippy
|
||||
- name: Install matching WASM bindings tool
|
||||
run: cargo install wasm-bindgen-cli --version 0.2.121 --locked
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
@@ -21,16 +32,25 @@ jobs:
|
||||
- name: Check and build
|
||||
working-directory: site
|
||||
run: npm run verify
|
||||
- name: Validate generated Worldspace and Rust bridge
|
||||
working-directory: site
|
||||
run: |
|
||||
WIP_TEST_GENERATED=1 node --test tests/worldspace-data.test.mjs
|
||||
cargo fmt --all -- --check
|
||||
cargo test --workspace --locked -- --include-ignored
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
- name: Browser smoke tests
|
||||
working-directory: site
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
npm run test:browser
|
||||
npm run test:dev
|
||||
- name: Verify subpath deployment
|
||||
working-directory: site
|
||||
run: |
|
||||
SITE_BASE=/reference SITE_URL=https://example.test npm run verify
|
||||
SITE_BASE=/reference npm run test:browser
|
||||
SITE_BASE=/reference SITE_URL=https://example.test npm run test:browser
|
||||
SITE_BASE=/reference SITE_URL=https://example.test npm run test:dev
|
||||
- name: Build deployment artifact
|
||||
working-directory: site
|
||||
env:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
site/node_modules/
|
||||
site/target/
|
||||
site/test-assets/
|
||||
site/public/wip-wasm/
|
||||
site/dist/
|
||||
site/.astro/
|
||||
site/test-results/
|
||||
|
||||
@@ -1,91 +1,107 @@
|
||||
# WIP Reference
|
||||
|
||||
Worldspace Interface Protocolの仕様文書と、その文書を読むための静的サイトです。
|
||||
Worldspace Interface Protocolの仕様文書と、その文書を実際のWIPで読むサイトです。
|
||||
|
||||
## 構成
|
||||
|
||||
- [`contents/`](contents/) — 仕様の正本。番号付きMarkdownを章順に読みます。
|
||||
- [`contents/draft/`](contents/draft/) — API設計・投影方針などの草案。正本とは区別します。
|
||||
- [`site/`](site/) — AstroによるトップページとBook。文書のコピーは持たず、`contents/**/*.md`をビルド時に読み込みます。
|
||||
- [`contents/draft/`](contents/draft/) — 検討中の草案。公開サイトとWIPの生成データには含めません。
|
||||
- [`site/`](site/) — Astroの静的サイトと、ブラウザ内で動くRust Client / Host。
|
||||
|
||||
[第1章:コンセプト](contents/1-concept.md) / [Core Model](contents/2-core-model.md) / [Protocol](contents/3-protocol.md) / [Client](contents/4-client.md) / [WIP over HTTP](contents/5.1-wip-over-http.md)
|
||||
|
||||
以前の直下Markdownと`draft/`は`contents/`へ移動しました。文書の内容・ファイル名・相互の相対リンクは維持しています。外部からraw Markdownを参照する場合はパスへ`contents/`を追加してください。
|
||||
## ブラウザ内のWIP
|
||||
|
||||
```text
|
||||
ツリー・本文UI (TypeScript)
|
||||
→ BrowserSession (WASM公開API)
|
||||
→ wip-client::direct::Client
|
||||
→ 型付き要求・応答の直接呼び出し
|
||||
→ サイト用Worldspace / wip-host
|
||||
```
|
||||
|
||||
ClientとHostは同じWASM内にあります。三つのinteraction(observe、fetch_interface、call_operation)にHTTP binding、JSON wire envelope、socketは使いません。WASMと`worldspace.json`の取得、Astroによる静的ページ遷移は通常のasset/page配信です。
|
||||
|
||||
公開文書と既存のhome/indexコンポーネントから`worldspace.json`をビルド時に生成します。本文の別コピーを編集する必要はありません。Rust ProviderはそれをimmutableなWorldspaceとして公開し、本文はroot scopeの`document.read()`で取得します。章Objectは本文と子の節を同時に持ちます。
|
||||
|
||||
Worldspace path、表示用の既存識別子、Web URLは別です。たとえば:
|
||||
|
||||
- Worldspace: `/book/1-concept.md/1.1-interaction-abstraction.md`
|
||||
- 表示識別子: `/1.1-interaction-abstraction.md`
|
||||
- Web URL: `/book/1.1-interaction-abstraction/`
|
||||
|
||||
ツリーはClientの観測とOperation結果を使い、URL・履歴・折りたたみ・読書位置はUIが管理します。WASM sessionはAstroのページ切替をまたいで存続します。JavaScriptなし、WASM未対応、asset読み込み失敗時も、静的HTMLと通常リンクで閲覧できます。`data-wip-state="ready"`だけが実WIP経由の反映成功を表し、失敗時は`fallback`です。
|
||||
|
||||
主なファイル:
|
||||
|
||||
- `site/worldspace/` — サイト専用Rust Provider、入力検証、再帰observe、document Interface。
|
||||
- `site/browser/` — Rust ClientとHostを所有するWASM session。
|
||||
- `site/src/pages/worldspace.json.ts` — 公開文書・home/indexのデータ生成。
|
||||
- `site/src/scripts/browser-wip.ts` — WASM初期化、session寿命、結果検証と本文反映。
|
||||
- `site/src/scripts/landing.ts` — 共通ツリー、導入演出、ページ切替と読書位置。
|
||||
|
||||
### 実行上の境界
|
||||
|
||||
このサイトの操作は同期・メモリ内・読み取り専用です。入力JSONは16MiB、Object数4096、階層32を上限とし、Clientにもstate/history上限を設けています。JS APIにdeadlineや並行実行中のcancellation設定はなく、HostにはdeadlineなしのExecutionContextを渡します。ブラウザ非対応の`std::time::Instant::now()`を呼ぶdeadline経路は利用しません。将来deadlineを公開する場合はブラウザ対応時計と実行テストが必要です。
|
||||
|
||||
release WASMは`panic=abort`です。Rustのpanic/trapは`catch_unwind`でProtocol failureへ回復できるとは扱いません。通常の入力・ProtocolエラーはJS例外として返り、サイトは静的表示へfallbackします。trapしたinstanceは以後呼び出し・解放せず、そのブラウザdocumentの残りの寿命は静的表示へfallbackします。ページ切替でOperationを再実行せず、復帰には再読み込みによる新instanceを使います。
|
||||
|
||||
JS境界のJSONはサイト専用のtree/document DTOであり、汎用WIP wire形式ではありません。本文はビルド時に既存Markdown rendererが生成した公開HTMLです。任意の外部Hostが返すHTMLを無条件に挿入する汎用クライアントではありません。
|
||||
|
||||
## 開発・検証
|
||||
|
||||
Node.js 22.12以上(CIは24)、npmを使用します。
|
||||
Node.js 22.12以上(CIは24)、npm、Rust 1.97.1、`wasm32-unknown-unknown`標準ライブラリ、`wasm-bindgen-cli` 0.2.121を使います。Rust sourceはこのrepoの隣の`wip-rs/`に置き、`site/rust-source.json`で指定するrevisionをcheckoutします。
|
||||
|
||||
```sh
|
||||
# このrepositoryのrootで実行
|
||||
# 既存の隣接checkoutがある場合はcloneせず、その作業を保全してください。
|
||||
git clone https://gitea.hareworks.net/Hare/wip-rs ../wip-rs
|
||||
git -C ../wip-rs checkout "$(node -p 'require("./site/rust-source.json").revision')"
|
||||
rustup toolchain install 1.97.1 --component rustfmt --component clippy --target wasm32-unknown-unknown
|
||||
cargo +1.97.1 install wasm-bindgen-cli --version 0.2.121 --locked
|
||||
cd site
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
開発サーバーは通常 `http://localhost:4321/` です。
|
||||
`npm run dev`と`npm run build`は先にWASMをsourceから生成します。Rustとwasm-bindgenがPATHにないNix環境では、隣接`wip-rs/flake.lock`を使って開発環境・LLD・wasm-bindgenを起動します。Cargo.lockとwasm-bindgenのバージョンは固定し、rustup環境では`site/rust-toolchain.toml`を使います。通常のWASMビルドはRust sourceのrevisionとclean状態を確認します。Rust側を同時編集するローカル開発に限り`WIP_ALLOW_DIRTY_SOURCE=1`で明示的に解除できますが、release検証では解除しません。生成物`site/public/wip-wasm/`と`site/target/`はGit管理しません。
|
||||
|
||||
```sh
|
||||
npm run verify # unit tests → Astro/TypeScript check → production build → link checks
|
||||
npm run verify # unit tests → Astro check → WASM/build → link checks
|
||||
WIP_TEST_GENERATED=1 node --test tests/worldspace-data.test.mjs
|
||||
cargo test --workspace --locked -- --include-ignored # build後の実文書テストも実施
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
npx playwright install chromium
|
||||
npm run test:browser
|
||||
npm run test:dev # dev serverでも実WASMの読み込み・本文反映を確認
|
||||
# インストール済みChromeを使う場合:CHROME_PATH=/path/to/chrome npm run test:browser
|
||||
npm run preview
|
||||
```
|
||||
|
||||
`/`がトップページ、`/book/`が目次です。各章は`/book/3-protocol/`のように元のファイル名を保持します。草案は`/book/draft/rust-api-model/`などに分離し、草案である旨を表示します。サイドバー、章内目次、前後の章へのリンクはsourceから生成します。
|
||||
Nixでnativeテストを行う場合は`nix develop ../../wip-rs --command cargo test --workspace --locked -- --include-ignored`のように実行します。テスト用previewは`127.0.0.1:4322`、`test:dev`は別のdev serverを4323で起動し、通常のdev server(4321)を再利用・停止しません。`SITE_BASE=/reference npm run test:dev`でサブパスのWASM読み込みも確認できます。
|
||||
|
||||
ブラウザーでデスクトップ/モバイルの表示とナビゲーションを検証する場合(事前にbuildが必要):
|
||||
`npm run test:browser`は先に`test-probes` feature付きの別WASMを`site/test-assets/`へ生成します。`wasm-limits.spec.ts`は実WASMの入力上限と意図的なRust panicを検証し、trapがProtocol failureへ誤変換されず、サイトがsessionを再利用しないことを確認します。このprobeはテストrouteでのみ配信され、通常の`public/`・`dist/`には入りません。
|
||||
|
||||
```sh
|
||||
npx playwright install chromium
|
||||
npm run test:browser
|
||||
# インストール済みChromeを使う場合:CHROME_PATH=/path/to/chrome npm run test:browser
|
||||
```
|
||||
ブラウザテストはPC/mobile、no-JS、実WASMの独立session・三つのinteraction、WIP結果によるUI更新、失敗fallback、履歴、初回演出、ライト/ダークを検証します。`wasm.spec.ts`は静的asset取得後にfetchを禁止して直接呼び出しを確認し、`wip.spec.ts`は壊した初期HTMLを実WIP結果が修復することまで確認します。
|
||||
|
||||
Bookの閲覧テストはJavaScript無効、ランディングのテストは有効にして実行します。スクリーンショットは`site/test-results/`に保存され、ランディングは動きが重なる途中フレームをデスクトップ/モバイルで記録し、移動・縮小・幹伸長の同時進行と、移動中のSVG/罫線の接続位置を検証します。サブパスbuildの検証時は同じ`SITE_BASE`を設定してください。テスト用previewは`127.0.0.1:4322`を使用し、4321のdev serverとは別に起動します。
|
||||
## 表示と文書編集
|
||||
|
||||
### ランディング
|
||||
`/`が独立したホーム、`/book/`が目次です。各章の既存Web URLを維持し、共通ツリーでは章の下に節を表示します。初回ホームの導入演出は入力で開始し、EscapeやSkipで完了できます。`prefers-reduced-motion`では演出を省略します。配色はOSの`prefers-color-scheme`へ追従し、JavaScriptなしでも有効です。
|
||||
|
||||
トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、全体を約3.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。
|
||||
|
||||
ホバーの「Tap」はルートと同じSVG座標系に置き、スラッシュの上辺・下辺(`y=4`〜`60`)に一致するマスクで切り抜きます。左右の文字列は斜辺と平行に逆方向へ流れ、上下端は水平に揃います。
|
||||
|
||||
ルートは中間位置で停止せず、一つの弧状の軌道でツリーへ移動します。円周文字の上端接線への流出、ルートの縮小、直下への幹伸長を重ね、着地を待たずに右向きの枝とパネルのglitchを始めます。円の半径・曲率は変えません。移動中の枝もルートと同じ座標変換に乗せ、縦幹全体を`├`・`│`・`└`へずれなく引き継ぎます。主幹は連続したまま保ち、枝先とラベルにglitchを適用します。ページのヘッダーや周辺要素も末尾の収束に重ねて現れます。
|
||||
|
||||
ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。
|
||||
|
||||
- `src/components/Landing.astro` — SVGと、最終的に操作するツリー/パネルの静的HTML。
|
||||
- `src/components/BoxFrame.astro` — `┌─┐`・`│`・`└─┘`によるText View/Call resultの枠。CSS borderやSVGで枠を代用せず、幅・高さに合わせて罫線素片をクリップします。JavaScript無効時も表示されます。
|
||||
- `src/lib/landing-ascii.mjs` — セル幅を保つ決定的なglitch文字置換。
|
||||
- `src/scripts/landing.ts` — 単一の`requestAnimationFrame`時計、役割別の速度曲線、固定円周+上端接線の文字経路、幹の伸長から罫線への接続、ツリー操作。
|
||||
- `src/lib/landing-demo.ts` — ブラウザ内だけで使うサンプル。外部Hostへの接続、変更操作、通信はありません。Object signatureとInterface signatureを分け、本文は明示的なCallで表示します。
|
||||
- `src/styles/landing.css` — トップ全体を14pxのmonoフォント・24pxの行高で構成。フレーム幅を4ch単位に揃え、列・ガター・パディングもセルと行の整数倍にします。ツリーはSVGの線ではなく`├──`・`└──`・`│`などの罫線素片を使い、JavaScript無効時も同じ構造を表示します。モバイルはツリーとパネルを縦に配置します。
|
||||
|
||||
`prefers-reduced-motion: reduce`では演出を省略し、操作可能なデモを表示します。JavaScript無効時は静的なサンプルと説明・Bookリンクを表示し、モジュール取得失敗時も導入画面に閉じ込めません。再確認はページのリロードで行えます。
|
||||
|
||||
## 文書の編集
|
||||
|
||||
文書は`contents/`だけを編集します。各文書の先頭の`#`見出しをタイトルに使用するため、frontmatterは不要です。番号付き文書は番号順、草案は別グループで表示します。
|
||||
|
||||
Markdown上の相対`.md`リンクはリポジトリ上でもそのまま読めます。サイト生成時にremark pluginがBookのURLへ変換し、queryとfragmentを維持します。コードブロックや外部URLは変換しません。`npm run check:links`は生成ページ、文書の欠落、重複ID、内部リンク・画像・CSS/JSと見出しanchorを検証します。
|
||||
文書は`contents/`だけを編集します。先頭の`#`見出しをタイトルに使うためfrontmatterは不要です。Markdownの相対`.md`リンクはビルド時にBook URLへ変換し、query/fragmentを維持します。draftへのリンクは公開リンクにしません。`npm run check:links`で生成ページ・内部リンク・見出しanchorを検証します。
|
||||
|
||||
## サイト公開
|
||||
|
||||
静的出力の`site/dist/`を公開します。SSR、常駐Nodeサーバー、データベースは不要です。ホスティング先や公開ドメインはまだ固定していません。**このリポジトリへのpushだけでインターネット上へ自動デプロイされる設定ではありません。**
|
||||
静的出力の`site/dist/`を公開します。常駐WIPサーバー、Nodeサーバー、データベースは不要です。このrepositoryへのpushだけでインターネットへ自動デプロイする設定ではありません。
|
||||
|
||||
```sh
|
||||
cd site
|
||||
npm ci
|
||||
SITE_URL=https://your-domain.example npm run verify
|
||||
```
|
||||
|
||||
サブパスへ公開する場合:
|
||||
|
||||
```sh
|
||||
# サブパス配信の検証
|
||||
SITE_URL=https://your-domain.example SITE_BASE=/wip-reference npm run verify
|
||||
SITE_URL=https://your-domain.example SITE_BASE=/wip-reference npm run test:browser
|
||||
```
|
||||
|
||||
- `SITE_URL`:公開サイトのorigin(例:`https://your-domain.example`)。path prefixは`SITE_BASE`へ分離します。設定時にcanonical URLを生成し、未指定時に仮ドメインは埋め込みません。
|
||||
- `SITE_BASE`:公開path prefix。既定は`/`。サブパスの場合は、そのprefixへ`dist/`の内容を配置します。
|
||||
- 本番ビルドはリポジトリ全体をcheckoutし、作業directoryを`site/`、build commandを`npm ci && npm run verify`、output directoryを`site/dist/`(作業directory基準では`dist/`)にします。
|
||||
- Static hostではディレクトリの`index.html`を配信し、未知のURLへ`404.html`を返す設定を使用します。SPA fallbackは不要です。
|
||||
- 文書は静的HTML、コードの色付けはビルド時のShiki、モバイルの目次はHTMLの`details`です。閲覧にクライアント側JavaScriptを必要としません。
|
||||
|
||||
Gitea Actionsの`.gitea/workflows/site.yml`で、通常ビルドとサブパスビルドを検証し、公開用の`wip-reference-site` artifactを作成します。GiteaのActionsと対応runnerが必要です。Repository variablesの`SITE_URL`/`SITE_BASE`を設定すると成果物へ反映します。ホスティングへのアップロードは、その公開先を決めてから別途接続してください。
|
||||
- `SITE_URL`はorigin、`SITE_BASE`はpath prefixです。既定のbaseは`/`です。
|
||||
- Static hostはディレクトリの`index.html`を配信し、未知URLに`404.html`を返します。SPA fallbackは不要です。
|
||||
- `.wasm`は`application/wasm`で配信してください。モジュール、WASM、manifest、HTMLを同じbuildの成果物として公開します。
|
||||
- Gitea Actionsは固定revisionのRust sourceとサイトを組み合わせ、通常/サブパスの検証後に`wip-reference-site` artifactを作ります。Rust revisionの更新時は両repositoryのテストを実施し、参照commitを取得可能にしてからCIを動かしてください。
|
||||
|
||||
Generated
+237
@@ -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"
|
||||
@@ -0,0 +1,9 @@
|
||||
[workspace]
|
||||
members = ["worldspace", "browser"]
|
||||
resolver = "3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -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"
|
||||
@@ -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:?}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"repository": "https://gitea.hareworks.net/Hare/wip-rs",
|
||||
"revision": "eb92878e3c696c768f1d2294c3718ecd04cf6a87",
|
||||
"toolchain": "1.97.1",
|
||||
"wasmBindgen": "0.2.121"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
targets = ["wasm32-unknown-unknown"]
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "minimal"
|
||||
@@ -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]);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' },
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
@@ -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.
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use wip_interface::WipType;
|
||||
use wip_site_worldspace::wip_protocol::{
|
||||
CallOperationRequest, FetchInterfaceRequest, InterfaceReference, InterfaceTarget,
|
||||
ObserveRequest, ProtocolErrorCode, Target, Value,
|
||||
};
|
||||
use wip_site_worldspace::{
|
||||
DocumentContent, DocumentNode, MAX_DEPTH, MAX_NODES, Worldspace, WorldspaceData,
|
||||
};
|
||||
|
||||
fn node(path: &str, parent: Option<&str>, display: &str, href: &str) -> DocumentNode {
|
||||
DocumentNode {
|
||||
path: path.into(),
|
||||
name: if path == "/" {
|
||||
""
|
||||
} else {
|
||||
path.rsplit('/').next().unwrap()
|
||||
}
|
||||
.into(),
|
||||
display_path: display.into(),
|
||||
parent: parent.map(str::to_owned),
|
||||
title: format!("Title {display}"),
|
||||
href: href.into(),
|
||||
html: format!("<h1>{display}</h1>"),
|
||||
}
|
||||
}
|
||||
|
||||
fn data() -> WorldspaceData {
|
||||
WorldspaceData {
|
||||
nodes: vec![
|
||||
node("/", None, "/", "/"),
|
||||
node("/book", Some("/"), "/book", "/book/"),
|
||||
node(
|
||||
"/book/1-chapter.md",
|
||||
Some("/book"),
|
||||
"/1-chapter.md",
|
||||
"/book/1-chapter/",
|
||||
),
|
||||
node(
|
||||
"/book/1-chapter.md/1.1-section.md",
|
||||
Some("/book/1-chapter.md"),
|
||||
"/1.1-section.md",
|
||||
"/book/1.1-section/",
|
||||
),
|
||||
// This intentional order differs from lexical order.
|
||||
node(
|
||||
"/book/10-last.md",
|
||||
Some("/book"),
|
||||
"/10-last.md",
|
||||
"/book/10-last/",
|
||||
),
|
||||
node(
|
||||
"/book/2-second.md",
|
||||
Some("/book"),
|
||||
"/2-second.md",
|
||||
"/book/2-second/",
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn read(path: &str) -> CallOperationRequest {
|
||||
CallOperationRequest {
|
||||
target: Target {
|
||||
path: path.into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: InterfaceTarget {
|
||||
reference: Worldspace::document_interface(),
|
||||
scope_ref: None,
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_three_interactions_use_the_published_document_interface() {
|
||||
let world = Worldspace::new(data()).unwrap();
|
||||
let request = ObserveRequest {
|
||||
path: "/book/1-chapter.md".into(),
|
||||
depth: 1,
|
||||
};
|
||||
let observation = world.observe(request.clone()).unwrap();
|
||||
request.validate_response(&observation).unwrap();
|
||||
assert_eq!(
|
||||
observation.object.interfaces,
|
||||
vec![Worldspace::document_interface()]
|
||||
);
|
||||
assert_eq!(
|
||||
observation.children.unwrap()[0].object.name,
|
||||
"1.1-section.md"
|
||||
);
|
||||
let fetch = FetchInterfaceRequest {
|
||||
interface: Worldspace::document_interface(),
|
||||
};
|
||||
let interface = world.fetch_interface(fetch.clone()).unwrap();
|
||||
interface.validate_for(&fetch).unwrap();
|
||||
assert_eq!(interface.descriptor.operations.len(), 1);
|
||||
assert_eq!(interface.descriptor.operations[0].name, "read");
|
||||
assert!(interface.descriptor.operations[0].parameters.is_empty());
|
||||
assert!(interface.scope_ref.is_none());
|
||||
for expected in data().nodes {
|
||||
let call = read(&expected.path);
|
||||
let response = world.call_operation(call.clone()).unwrap();
|
||||
response
|
||||
.validate_for(&interface.descriptor, &call.operation)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
DocumentContent::decode(response.result).unwrap(),
|
||||
DocumentContent {
|
||||
path: expected.path,
|
||||
title: expected.title,
|
||||
href: expected.href,
|
||||
html: expected.html,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_boundaries_empty_children_and_order_are_protocol_observations() {
|
||||
let world = Worldspace::new(data()).unwrap();
|
||||
let observe = |path: &str, depth| {
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: path.into(),
|
||||
depth,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert!(observe("/", 0).children.is_none());
|
||||
let root = observe("/", 1);
|
||||
let children = root.children.unwrap();
|
||||
assert_eq!(children.len(), 1);
|
||||
assert_eq!(children[0].object.name, "book");
|
||||
assert!(children[0].children.is_none());
|
||||
let root = observe("/", 3);
|
||||
let book = &root.children.as_ref().unwrap()[0];
|
||||
let chapters = book.children.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
chapters
|
||||
.iter()
|
||||
.map(|node| node.object.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["1-chapter.md", "10-last.md", "2-second.md"]
|
||||
);
|
||||
assert!(chapters[0].children.as_ref().unwrap()[0].children.is_none());
|
||||
assert_eq!(chapters[1].children, Some(vec![]));
|
||||
assert_eq!(
|
||||
observe("/book/1-chapter.md/1.1-section.md", 1).children,
|
||||
Some(vec![])
|
||||
);
|
||||
assert!(observe("/", MAX_DEPTH).children.is_some());
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: MAX_DEPTH + 1
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::ResourceLimitExceeded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_host_failures_are_not_replaced_by_direct_body_lookup() {
|
||||
let world = Worldspace::new(data()).unwrap();
|
||||
for path in ["relative", "/book/", "/book//x", "/book/../x", "/./book"] {
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: path.into(),
|
||||
depth: 0
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
assert_eq!(
|
||||
world.call_operation(read(path)).unwrap_err().code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
}
|
||||
for path in [
|
||||
"/missing",
|
||||
"/1-chapter.md",
|
||||
"/draft/private.md",
|
||||
"/book/draft/private.md",
|
||||
] {
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: path.into(),
|
||||
depth: 1
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::NotFound
|
||||
);
|
||||
assert_eq!(
|
||||
world.call_operation(read(path)).unwrap_err().code,
|
||||
ProtocolErrorCode::NotFound
|
||||
);
|
||||
}
|
||||
for reference in [
|
||||
InterfaceReference {
|
||||
scope: "/".into(),
|
||||
name: "missing".into(),
|
||||
},
|
||||
InterfaceReference {
|
||||
scope: "/book".into(),
|
||||
name: "document".into(),
|
||||
},
|
||||
InterfaceReference {
|
||||
scope: "/draft".into(),
|
||||
name: "document".into(),
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
world
|
||||
.fetch_interface(FetchInterfaceRequest {
|
||||
interface: reference.clone()
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InterfaceNotFound
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.interface.reference = reference;
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceMismatch
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
world
|
||||
.fetch_interface(FetchInterfaceRequest {
|
||||
interface: InterfaceReference {
|
||||
scope: "relative".into(),
|
||||
name: "document".into()
|
||||
},
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ProtocolErrorCode::InvalidRequest
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.interface.scope_ref = Some("not-the-published-root".into());
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceMismatch
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.operation = "missing".into();
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::OperationNotFound
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.arguments
|
||||
.insert("extra".into(), Value::String("not accepted".into()));
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InvalidArguments
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.target.validator = Some(vec![1]);
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::ValidatorMismatch
|
||||
);
|
||||
let mut call = read("/book");
|
||||
call.interface.validator = Some(vec![1]);
|
||||
assert_eq!(
|
||||
world.call_operation(call).unwrap_err().code,
|
||||
ProtocolErrorCode::InterfaceValidatorMismatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_invalid_disconnected_and_private_input() {
|
||||
let reject = |mutate: fn(&mut WorldspaceData)| {
|
||||
let mut input = data();
|
||||
mutate(&mut input);
|
||||
assert!(Worldspace::new(input).is_err());
|
||||
};
|
||||
reject(|data| data.nodes.push(data.nodes[0].clone()));
|
||||
reject(|data| data.nodes[2].display_path = "/book".into());
|
||||
reject(|data| data.nodes[2].href = "/book/".into());
|
||||
reject(|data| data.nodes[2].name = "a/b".into());
|
||||
reject(|data| data.nodes[2].name = "..".into());
|
||||
reject(|data| data.nodes[2].name.clear());
|
||||
reject(|data| data.nodes[2].path = "/book//x".into());
|
||||
reject(|data| data.nodes[2].path = "/elsewhere".into());
|
||||
reject(|data| data.nodes[2].parent = None);
|
||||
reject(|data| data.nodes[3].parent = Some("/missing".into()));
|
||||
reject(|data| data.nodes[0].name = "root".into());
|
||||
reject(|data| data.nodes[0].parent = Some("/".into()));
|
||||
reject(|data| data.nodes[0].display_path = "/home".into());
|
||||
reject(|data| {
|
||||
data.nodes.remove(0);
|
||||
});
|
||||
reject(|data| data.nodes[2].display_path = "/draft/secret.md".into());
|
||||
reject(|data| {
|
||||
data.nodes
|
||||
.push(node("/draft", Some("/"), "/private", "/private/"))
|
||||
});
|
||||
reject(|data| data.nodes[0].href = "//external.example/".into());
|
||||
reject(|data| data.nodes[0].title.clear());
|
||||
assert!(Worldspace::new(WorldspaceData { nodes: vec![] }).is_err());
|
||||
assert!(
|
||||
Worldspace::new(WorldspaceData {
|
||||
nodes: vec![data().nodes[0].clone(); MAX_NODES + 1]
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
let mut wide = WorldspaceData {
|
||||
nodes: vec![data().nodes[0].clone()],
|
||||
};
|
||||
for index in 1..MAX_NODES {
|
||||
let path = format!("/node-{index}");
|
||||
wide.nodes
|
||||
.push(node(&path, Some("/"), &path, &format!("{path}/")));
|
||||
}
|
||||
let world = Worldspace::new(wide).unwrap();
|
||||
assert_eq!(
|
||||
world
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 1
|
||||
})
|
||||
.unwrap()
|
||||
.children
|
||||
.unwrap()
|
||||
.len(),
|
||||
MAX_NODES - 1
|
||||
);
|
||||
let mut deep = WorldspaceData {
|
||||
nodes: vec![data().nodes[0].clone()],
|
||||
};
|
||||
let mut parent = "/".to_string();
|
||||
for _ in 0..=MAX_DEPTH {
|
||||
let path = format!("{}/a", parent.trim_end_matches('/'));
|
||||
deep.nodes
|
||||
.push(node(&path, Some(&parent), &path, &format!("{path}/")));
|
||||
parent = path;
|
||||
if deep.nodes.len() == MAX_DEPTH as usize + 1 {
|
||||
assert!(Worldspace::new(deep.clone()).is_ok());
|
||||
}
|
||||
}
|
||||
assert!(Worldspace::new(deep).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_contract_roundtrips_and_rejects_malformed_or_unknown_fields() {
|
||||
let json = serde_json::to_string(&data()).unwrap();
|
||||
assert!(json.contains("displayPath"));
|
||||
let world = Worldspace::from_json(&json).unwrap();
|
||||
assert!(world.call_operation(read("/")).is_ok());
|
||||
assert!(Worldspace::from_json("not json").is_err());
|
||||
assert!(Worldspace::from_json(r#"{"nodes":[],"draft":true}"#).is_err());
|
||||
let too_large = " ".repeat(wip_site_worldspace::MAX_JSON_BYTES + 1);
|
||||
assert!(Worldspace::from_json(&too_large).is_err());
|
||||
// Input order need not be topological; child order still follows the input.
|
||||
let mut shuffled = data();
|
||||
shuffled.nodes.reverse();
|
||||
assert!(
|
||||
Worldspace::new(shuffled)
|
||||
.unwrap()
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: 4
|
||||
})
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
/// This is deliberately explicit, not silently skipped when generated input is absent.
|
||||
/// Run after `npm run build`: cargo test -p wip-site-worldspace --test worldspace -- --include-ignored
|
||||
#[test]
|
||||
#[ignore = "requires the site's generated dist/worldspace.json (npm run build)"]
|
||||
fn generated_real_public_documents_support_all_interactions() {
|
||||
let json = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../dist/worldspace.json"
|
||||
))
|
||||
.unwrap();
|
||||
let data: WorldspaceData = serde_json::from_str(&json).unwrap();
|
||||
assert!(data.nodes.len() > 10);
|
||||
let world = Worldspace::from_json(&json).unwrap();
|
||||
let full = world
|
||||
.observe(ObserveRequest {
|
||||
path: "/".into(),
|
||||
depth: MAX_DEPTH,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(full.children.unwrap()[0].object.name, "book");
|
||||
let descriptor = world
|
||||
.fetch_interface(FetchInterfaceRequest {
|
||||
interface: Worldspace::document_interface(),
|
||||
})
|
||||
.unwrap()
|
||||
.descriptor;
|
||||
for node in &data.nodes {
|
||||
assert!(!node.display_path.starts_with("/draft/"));
|
||||
assert!(!node.html.is_empty());
|
||||
let request = read(&node.path);
|
||||
let response = world.call_operation(request.clone()).unwrap();
|
||||
response
|
||||
.validate_for(&descriptor, &request.operation)
|
||||
.unwrap();
|
||||
let content = DocumentContent::decode(response.result).unwrap();
|
||||
assert_eq!(content.path, node.path);
|
||||
assert_eq!(content.href, node.href);
|
||||
assert_eq!(content.html, node.html);
|
||||
if let Some(parent) = &node.parent {
|
||||
assert_eq!(
|
||||
node.path,
|
||||
format!("{}/{}", parent.trim_end_matches('/'), node.name)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(data.nodes.iter().any(|node| node.path
|
||||
== "/book/1-concept.md/1.1-interaction-abstraction.md"
|
||||
&& node.display_path == "/1.1-interaction-abstraction.md"
|
||||
&& node.href.ends_with("/book/1.1-interaction-abstraction/")));
|
||||
for path in ["/draft", "/book/draft", "/book/draft/6-security.md"] {
|
||||
assert_eq!(
|
||||
world.call_operation(read(path)).unwrap_err().code,
|
||||
ProtocolErrorCode::NotFound
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user