diff --git a/.gitea/workflows/site.yml b/.gitea/workflows/site.yml index 34eba2a..456d048 100644 --- a/.gitea/workflows/site.yml +++ b/.gitea/workflows/site.yml @@ -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: diff --git a/.gitignore b/.gitignore index bd339f8..7c4ef87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ site/node_modules/ +site/target/ +site/test-assets/ +site/public/wip-wasm/ site/dist/ site/.astro/ site/test-results/ diff --git a/README.md b/README.md index 44d4003..5c0a0b6 100644 --- a/README.md +++ b/README.md @@ -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を動かしてください。 diff --git a/site/Cargo.lock b/site/Cargo.lock new file mode 100644 index 0000000..4021bb0 --- /dev/null +++ b/site/Cargo.lock @@ -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" diff --git a/site/Cargo.toml b/site/Cargo.toml new file mode 100644 index 0000000..1ff2e0d --- /dev/null +++ b/site/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +members = ["worldspace", "browser"] +resolver = "3" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" diff --git a/site/browser/Cargo.toml b/site/browser/Cargo.toml new file mode 100644 index 0000000..77c2d51 --- /dev/null +++ b/site/browser/Cargo.toml @@ -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" diff --git a/site/browser/src/lib.rs b/site/browser/src/lib.rs new file mode 100644 index 0000000..d43faf6 --- /dev/null +++ b/site/browser/src/lib.rs @@ -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, + 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 { + 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 { + 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 { + 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 { + 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 { + self.call_document(path, "read") + } + + fn call_document(&mut self, path: &str, operation: &str) -> Result { + 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:?}"))), + } + } +} diff --git a/site/package.json b/site/package.json index 83e62b1..f03b2c4 100644 --- a/site/package.json +++ b/site/package.json @@ -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": { diff --git a/site/playwright.dev.config.ts b/site/playwright.dev.config.ts new file mode 100644 index 0000000..b683fdf --- /dev/null +++ b/site/playwright.dev.config.ts @@ -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, + }, +}); diff --git a/site/rust-source.json b/site/rust-source.json new file mode 100644 index 0000000..2a5b38d --- /dev/null +++ b/site/rust-source.json @@ -0,0 +1,6 @@ +{ + "repository": "https://gitea.hareworks.net/Hare/wip-rs", + "revision": "eb92878e3c696c768f1d2294c3718ecd04cf6a87", + "toolchain": "1.97.1", + "wasmBindgen": "0.2.121" +} diff --git a/site/rust-toolchain.toml b/site/rust-toolchain.toml new file mode 100644 index 0000000..34d7e52 --- /dev/null +++ b/site/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.97.1" +targets = ["wasm32-unknown-unknown"] +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/site/scripts/build-wasm.mjs b/site/scripts/build-wasm.mjs new file mode 100644 index 0000000..ab9eaa0 --- /dev/null +++ b/site/scripts/build-wasm.mjs @@ -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]); diff --git a/site/src/lib/worldspace-data.ts b/site/src/lib/worldspace-data.ts new file mode 100644 index 0000000..b229d95 --- /dev/null +++ b/site/src/lib/worldspace-data.ts @@ -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>, +): 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(); + 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(); + const hrefs = new Set(); + 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 }; +} diff --git a/site/src/pages/worldspace.json.ts b/site/src/pages/worldspace.json.ts new file mode 100644 index 0000000..7dee553 --- /dev/null +++ b/site/src/pages/worldspace.json.ts @@ -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 = { + '/': { + 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' }, + }); +}; diff --git a/site/src/scripts/browser-wip.ts b/site/src/scripts/browser-wip.ts new file mode 100644 index 0000000..cccea41 --- /dev/null +++ b/site/src/scripts/browser-wip.ts @@ -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; + 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 | 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(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 { + 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, + 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 => value !== null && typeof value === 'object'; +const strings = (value: Record, 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 { + 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; +} diff --git a/site/src/scripts/landing.ts b/site/src/scripts/landing.ts index 89327af..a3c149b 100644 --- a/site/src/scripts/landing.ts +++ b/site/src/scripts/landing.ts @@ -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('[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('[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('.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(); 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; } diff --git a/site/tests/browser/wasm-limits.spec.ts b/site/tests/browser/wasm-limits.spec.ts new file mode 100644 index 0000000..bf90e6f --- /dev/null +++ b/site/tests/browser/wasm-limits.spec.ts @@ -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: '

Root

' }; + 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[] = [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: '

Node

' }); + 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).__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 }).__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 }).__trappedSession.deref()), + frees: (window as Window & { __panicFreeCalls?: number }).__panicFreeCalls || 0, + }))).toEqual({ retained: true, frees: 0 }); + await cdp.detach(); +}); diff --git a/site/tests/browser/wasm.spec.ts b/site/tests/browser/wasm.spec.ts new file mode 100644 index 0000000..b43c3b1 --- /dev/null +++ b/site/tests/browser/wasm.spec.ts @@ -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 = '

Isolated Worldspace content

'; + 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) => ({ path, displayPath, title, href, html }))); + expect(result.first.html).not.toBe(result.second.html); + expect(result.second.html).toBe('

Isolated Worldspace content

'); + 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); +}); diff --git a/site/tests/browser/wip-loading.spec.ts b/site/tests/browser/wip-loading.spec.ts new file mode 100644 index 0000000..7b8bfca --- /dev/null +++ b/site/tests/browser/wip-loading.spec.ts @@ -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('

Not a WIP result

'); + 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([]); +}); diff --git a/site/tests/browser/wip.spec.ts b/site/tests/browser/wip.spec.ts new file mode 100644 index 0000000..b48b325 --- /dev/null +++ b/site/tests/browser/wip.spec.ts @@ -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('[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('

SSR-only wrong body

'); + await route.fulfill({ response, body: $.html() }); + }); + let release!: () => void; + const gate = new Promise((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((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(`.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([]); + }); +}); diff --git a/site/tests/worldspace-data.test.mjs b/site/tests/worldspace-data.test.mjs new file mode 100644 index 0000000..9b2f939 --- /dev/null +++ b/site/tests/worldspace-data.test.mjs @@ -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: '

Home

' }, + '/book': { title: 'Book', href: '/book/', html: '

Book

' }, +}; +const source = (id) => ({ id, title: id, href: `/book/${id}/`, html: `

${id}

` }); + +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, /

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: '

other/

' }, + }); + 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); + } + }); diff --git a/site/tsconfig.json b/site/tsconfig.json index adbbd07..c5fdefe 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -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"] } diff --git a/site/worldspace/Cargo.toml b/site/worldspace/Cargo.toml new file mode 100644 index 0000000..dbbe330 --- /dev/null +++ b/site/worldspace/Cargo.toml @@ -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" } diff --git a/site/worldspace/README.md b/site/worldspace/README.md new file mode 100644 index 0000000..294232e --- /dev/null +++ b/site/worldspace/README.md @@ -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`. `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. diff --git a/site/worldspace/src/lib.rs b/site/worldspace/src/lib.rs new file mode 100644 index 0000000..d97cdf4 --- /dev/null +++ b/site/worldspace/src/lib.rs @@ -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, +} + +/// 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, + 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; +} + +/// Host-private implementation; no body lookup bypass is exposed to clients. +pub struct SiteObject(Arc); + +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 { + 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) -> DataError { + DataError(message.into()) +} + +fn protocol_error(code: ProtocolErrorCode, message: impl Into) -> 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>, + children: BTreeMap>, +} + +impl Worldspace { + pub fn from_json(json: &str) -> Result { + 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 { + 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> = 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 { + 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 { + 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::, _>>()?, + ) + }; + Ok(ObjectObservation { object, children }) + } + + pub fn fetch_interface( + &self, + request: FetchInterfaceRequest, + ) -> Result { + ObjectProvider::fetch_interface(self, &self.registry, &request.interface)? + .protocol_response() + } + + pub fn call_operation( + &self, + request: CallOperationRequest, + ) -> Result { + 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( + &self, + _context: &ExecutionContext, + path: &str, + operation: impl FnOnce(&PublishedObject) -> R, + ) -> ProviderResult { + 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(()) +} diff --git a/site/worldspace/tests/worldspace.rs b/site/worldspace/tests/worldspace.rs new file mode 100644 index 0000000..2c282be --- /dev/null +++ b/site/worldspace/tests/worldspace.rs @@ -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!("

{display}

"), + } +} + +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!["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 + ); + } +}