Compare commits

...
7 Commits
Author SHA1 Message Date
Hare db4c8ba97f feat: add highlighted protocol views and inline operation forms
Reference site / build (push) Canceled after 0s
Expose Rust Text View tokens and safely highlight signatures and JSON results. Add animated Interface and inline Run disclosures with a shared 10ms line cadence, typed argument inputs, and explicit execution.

Update documentation and browser regressions for the new controls and explicit counter-value responses.
2026-10-26 10:12:22 +09:00
Hare 41e88e3c40 fix: separate playground values from object descriptions 2026-10-26 10:12:22 +09:00
Hare 75a36f2298 feat: add protocol explorer with catalogs and live playground 2026-10-19 18:21:30 +09:00
Hare b82c1d0a70 feat: add explicit protocol API and mutable playground host 2026-10-19 18:21:21 +09:00
Hare 1297d86140 fix: resolve font assets under development base paths
Serve font definitions beside their WOFF2 files through a base-aware stylesheet link, using relative URLs consistently in dev and static builds.

Exercise all six faces, nested-route reloads and existing font fallback/no-JS checks in the dev suite; avoid starting the preview server as part of dev checks.
2026-10-16 06:12:57 +09:00
Hare c480b67301 feat: power reference site with in-process WIP WASM
Add the public-document Worldspace, Rust Client/Host bridge, and WIP-backed tree and document rendering while preserving static fallback and navigation.

Pin the Rust source and build tools, verify resource and trap boundaries, and cover dev and preview loading including same-origin generated module URLs.
2026-10-16 05:59:46 +09:00
Hare ad08037f4f fix: wait for settled theme colors in browser checks 2026-10-16 05:59:39 +09:00
72 changed files with 7700 additions and 129 deletions
+21 -1
View File
@@ -10,6 +10,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - 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 - uses: actions/setup-node@v4
with: with:
node-version: '24' node-version: '24'
@@ -21,16 +32,25 @@ jobs:
- name: Check and build - name: Check and build
working-directory: site working-directory: site
run: npm run verify 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 - name: Browser smoke tests
working-directory: site working-directory: site
run: | run: |
npx playwright install --with-deps chromium npx playwright install --with-deps chromium
npm run test:browser npm run test:browser
npm run test:dev
- name: Verify subpath deployment - name: Verify subpath deployment
working-directory: site working-directory: site
run: | run: |
SITE_BASE=/reference SITE_URL=https://example.test npm run verify 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 - name: Build deployment artifact
working-directory: site working-directory: site
env: env:
+3
View File
@@ -1,4 +1,7 @@
site/node_modules/ site/node_modules/
site/target/
site/test-assets/
site/public/wip-wasm/
site/dist/ site/dist/
site/.astro/ site/.astro/
site/test-results/ site/test-results/
+110 -53
View File
@@ -1,91 +1,148 @@
# WIP Reference # WIP Reference
Worldspace Interface Protocolの仕様文書と、その文書を読むための静的サイトです。 Worldspace Interface Protocolの仕様文書と、その文書を実際のWIPで読むサイトです。
## 構成 ## 構成
- [`contents/`](contents/) — 仕様の正本。番号付きMarkdownを章順に読みます。 - [`contents/`](contents/) — 仕様の正本。番号付きMarkdownを章順に読みます。
- [`contents/draft/`](contents/draft/) — API設計・投影方針などの草案。正本とは区別します。 - [`contents/draft/`](contents/draft/) — 検討中の草案。公開サイトとWIPの生成データには含めません。
- [`site/`](site/) — AstroによるトップページとBook。文書のコピーは持たず、`contents/**/*.md`をビルド時に読み込みます。 - [`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) [第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はそれを変更不可の文書Objectとして公開し、本文は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` — 共通ツリー、導入演出、ページ切替と読書位置。
### Protocolモード
右上の`Read` / `Protocol`で切り替えます。Protocolでは通常モードとは独立したClient session・WASM instanceを使い、`?mode=protocol`をツリー遷移と再読込で保持します。Protocol開始時とツリーでのObject選択時には、選択済みObjectをDepth=1で自動Observeします。直リンク・再読込・履歴移動でも現在のObjectを対象とし、Interface取得と本文Operationは自動実行しません。
1. Protocolを開くと、Rust `wip-text-view`が生成した選択済みObjectのText Viewを表示します。ツリーで別Objectを選ぶと、そのText Viewに切り替わります。同じObjectの再選択でもObserveします。
2. 未取得のInterfaceは`? /::document`の形式で表示します。クリックするとDescriptorとInterface Text Viewを取得して`v /::document`となり、再クリックで`> /::document`に折りたたみます。取得済みの再展開はClientのfresh cacheを利用します。
3. 各Operationのコメントと宣言の間にある`▶ Run`を押すと、その場に`create(name: "…")`のような引数入力フォームが展開されます。`Execute`または単一行入力でのEnterで実行します。引数なしの`document.read()`もフォームを開くだけでは呼び出さず、明示実行が必要です。フォームは再クリックまたはEscapeで閉じられます。
文字列は引用符内の欄にそのまま入力し、整数は10進表記で入力します。UIがパラメーターごとにtagged Valueへ変換し、WASM APIには従来と同じJSON record(例:`{"name":{"kind":"string","value":"example"}}`)を渡します。`integer`はJSのNumberを経由せず文字列として渡し、Protocol Integerの範囲(±9,007,199,254,740,991)などはClientで検証します。省略可能な引数はincludeで送信を選択でき、複合型・名前付き型には個別のtagged Value JSON欄を使います。`bytes`は0〜255の配列、`record`と`list`は再帰的なtagged Value、`unit`は`{"kind":"unit"}`です。API入力上限は64KiB・32階層・4096値、path/scope/name/operationは各4096 UTF-8 bytesです。
Object/Interfaceのハイライトには`wip-text-view`の`tokenize_object` / `tokenize_interface`を使います。WASMは同じtoken列から`textView`と`textViewTokens`(kind/text)を返し、UIは文字列を再解析せず、安全なテキストノードで種別を着色します。ライト/ダークのパレットは`site/src/styles/text-view.css`に定義しています。空白・改行・引用は保持し、未知の種別は無装飾、不整合やtokenなしの応答は元のplain textで表示します。
InterfaceとRunフォームの開閉は同じ行アニメーションを使います。展開時は上から、折りたたみ時は下から1行10msずつフェードし、高さも行数×10msで変化します。reduced-motion設定と印刷では省略します。
Operation結果のハイライトには[Kirari](https://github.com/vortexkit/kirari)(`@vortexlabs/kirari`)のcoreとJSON grammarだけを使い、token配列を安全なspan/text nodeへ変換します。キー・文字列・数値・真偽値・null・記号をライト/ダーク対応で色分けし、整形済みJSONの文字列は変更しません。ハイライトに失敗した場合もplain textで結果を保持します。
Object/InterfaceのText ViewとOperation結果は別領域です。結果はpath・Interface・Operation付きのtagged JSONで、本文HTMLも文字列として表示し、markupとして実行しません。対象・Interface・Operationの変更で以前の結果表示を消します。ObserveとInterface取得はClientのfresh cacheを使えますが、Operationは明示実行ごとに1回呼び出し、自動再試行しません。ページ遷移・モード往復ではsessionを保持し、再読込で新sessionになります。
初期化失敗時は利用不可、trap後はその手動instanceを再利用・解放せず再読込が必要と表示します。`Read`では静的本文を閲覧できます。JavaScriptなしではProtocol URLでも通常の静的閲覧を維持し、未実行を実行成功として表示しません。
### Sources / Packages
`/sources/`と`/packages/`は`/book`と並ぶルート直下の葉Objectです。リンク先リポジトリや各crateを子Objectにはしません。通常ページと`document.read()`は同じrendererから本文を生成します。
- Sources: `wip-reference`と`wip-rs`の2リポジトリ。
- Packages: Rust workspaceのREADMEとCargo manifestに基づく`wip-protocol`、`wip-interface-macros`、`wip-interface`、`wip-host`、`wip-http`、`wip-client`、`wip-text-view`。各crateのsourceとworkspace READMEへリンクします。レジストリでの公開状況や公開バージョンは表しません。
- 一覧の正本は`site/src/lib/catalog.ts`です。内部crate、examples、draftは公開一覧に含めません。
### Playground
`/playground/?mode=protocol`で、メモリ内のObjectを操作できます。親は`/::document`と`/::collection`、子カウンターは`/::counter`を公開します。
- `collection.create(name)` / `remove(name)`:子Objectを作成・削除。名前は`[A-Za-z0-9_-]{1,32}`、最大32個。新規値は0です。
- `counter.get()` / `increment(amount)` / `reset()`:現在値の取得・増減・0へのリセット。Protocol Integerの範囲外への変更は失敗し、状態を変更しません。
- 初期状態は`apple = 3`、`orange = 0`。ツリーはObject名だけを表示します。`description`は`An in-memory counter.`という説明文で、現在値や機械可読データとして解釈しません。値は明示的な`counter.get()` / `increment()` / `reset()`のOperationレスポンスに表示します。
- 操作成功後に親と選択中Objectを再Observeします。変更可能なPlaygroundのObserveはClientの`refresh_observed`で実取得し、作成・削除による子一覧の変更をツリーへ反映します。Observeからカウンター値を読み取ったり、値の取得Operationを自動実行したりしません。再観測に失敗しても成功済みOperationを再実行しません。
- 子の選択は`/playground/?mode=protocol&object=%2Fplayground%2Fapple`のようなURLで保持します。ページ・履歴・Read/Protocol往復ではProtocol sessionが残り、ブラウザの再読み込みで初期化します。削除済みObjectへの履歴移動はエラーを表示し、復活させません。
- 通常Readは静的な操作説明を表示します。状態の永続化・外部送信・文書変更はありません。
例:createの`name`欄に`banana`、incrementの`amount`欄に`2`を入力すると、それぞれ`create(name: "banana")`、`increment(amount: 2)`の呼び出しになります。
### 実行上の境界
このサイトの操作は同期・メモリ内です。文書は読み取り専用で、Playgroundだけがsession内の変更を許可します。入力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 ```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 cd site
npm ci npm ci
npm run dev 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 ```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 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・全6書体の読み込みも確認できます。フォント定義は`public/fonts/fonts.css`をbase付きURLで参照し、各WOFF2はCSSからの相対URLで解決します。
ブラウザーでデスクトップ/モバイルの表示とナビゲーションを検証する場合(事前に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 ブラウザテストはPC/mobile、no-JS、実WASMの独立session・三つのinteraction、WIP結果によるUI更新、失敗fallback、履歴、初回演出、ライト/ダークを検証します。`wasm.spec.ts`は静的asset取得後にfetchを禁止して直接呼び出しを確認し、`wip.spec.ts`は壊した初期HTMLを実WIP結果が修復することまで確認します。
npx playwright install chromium
npm run test:browser
# インストール済みChromeを使う場合:CHROME_PATH=/path/to/chrome npm run test:browser
```
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または画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。 文書は`contents/`だけを編集します。先頭の`#`見出しをタイトルに使うためfrontmatterは不要です。Markdownの相対`.md`リンクはビルド時にBook URLへ変換し、query/fragmentを維持します。draftへのリンクは公開リンクにしません。`npm run check:links`で生成ページ・内部リンク・見出しanchorを検証します。
ホバーの「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を検証します。
## サイト公開 ## サイト公開
静的出力の`site/dist/`を公開します。SSR、常駐Nodeサーバー、データベースは不要です。ホスティング先や公開ドメインはまだ固定していません。**このリポジトリへのpushだけでインターネット上へ自動デプロイされる設定ではありません。** 静的出力の`site/dist/`を公開します。常駐WIPサーバー、Nodeサーバー、データベースは不要です。このrepositoryへのpushだけでインターネットへ自動デプロイする設定ではありません。
```sh ```sh
cd site
npm ci
SITE_URL=https://your-domain.example npm run verify 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 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_URL`はorigin、`SITE_BASE`はpath prefixです。既定のbaseは`/`です。
- `SITE_BASE`:公開path prefix。既定は`/`。サブパスの場合は、そのprefixへ`dist/`の内容を配置します。 - Static hostはディレクトリの`index.html`を配信し、未知URLに`404.html`を返します。SPA fallbackは不要です。
- 本番ビルドはリポジトリ全体をcheckoutし、作業directoryを`site/`、build commandを`npm ci && npm run verify`、output directoryを`site/dist/`(作業directory基準では`dist/`)にします。 - `.wasm`は`application/wasm`で配信してください。モジュール、WASM、manifest、HTMLを同じbuildの成果物として公開します。
- Static hostではディレクトリの`index.html`を配信し、未知のURLへ`404.html`を返す設定を使用します。SPA fallbackは不要です。 - Gitea Actionsは固定revisionのRust sourceとサイトを組み合わせ、通常/サブパスの検証後に`wip-reference-site` artifactを作ります。Rust revisionの更新時は両repositoryのテストを実施し、参照commitを取得可能にしてからCIを動かしてください。
- 文書は静的HTML、コードの色付けはビルド時のShiki、モバイルの目次はHTMLの`details`です。閲覧にクライアント側JavaScriptを必要としません。
Gitea Actionsの`.gitea/workflows/site.yml`で、通常ビルドとサブパスビルドを検証し、公開用の`wip-reference-site` artifactを作成します。GiteaのActionsと対応runnerが必要です。Repository variablesの`SITE_URL`/`SITE_BASE`を設定すると成果物へ反映します。ホスティングへのアップロードは、その公開先を決めてから別途接続してください。
+245
View File
@@ -0,0 +1,245 @@
# 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",
"wip-text-view",
]
[[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 = "wip-text-view"
version = "0.2.0"
dependencies = [
"wip-protocol",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+9
View File
@@ -0,0 +1,9 @@
[workspace]
members = ["worldspace", "browser"]
resolver = "3"
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
+2
View File
@@ -17,6 +17,8 @@ export default defineConfig({
site, site,
base, base,
trailingSlash: 'always', trailingSlash: 'always',
// Browser tests exercise the site, not the development toolbar overlay.
devToolbar: { enabled: process.env.SITE_BROWSER_TEST !== '1' },
markdown: { markdown: {
processor: unified({ remarkPlugins: [[documentLinks, { base }]], smartypants: false }), processor: unified({ remarkPlugins: [[documentLinks, { base }]], smartypants: false }),
shikiConfig: { themes: { light: 'github-light', dark: 'github-dark' }, langAlias: { wip: 'text' } }, shikiConfig: { themes: { light: 'github-light', dark: 'github-dark' }, langAlias: { wip: 'text' } },
+20
View File
@@ -0,0 +1,20 @@
[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-text-view = { path = "../../../wip-rs/crates/wip-text-view" }
wip-site-worldspace = { path = "../worldspace" }
serde_json = { version = "1", features = ["float_roundtrip"] }
wasm-bindgen = "=0.2.121"
+219
View File
@@ -0,0 +1,219 @@
//! Browser-owned Client/Host session. JS conversion is outside the direct WIP exchange.
mod manual;
use std::collections::BTreeMap;
use serde_json::{Value as Json, json};
use wasm_bindgen::prelude::*;
use wip_client::direct::{Client, Request, Response};
use wip_client::{ClientLimits, SecurityContext, SessionId};
use wip_protocol::{InterfaceReference, Value};
use wip_site_worldspace::{Worldspace, WorldspaceData};
/// One isolated in-memory Worldspace and its stateful WIP client.
#[wasm_bindgen]
pub struct BrowserSession {
world: Worldspace,
client: Client,
session: SessionId,
display_paths: BTreeMap<String, String>,
observes: u32,
interfaces: u32,
calls: u32,
}
fn error(value: impl std::fmt::Display) -> JsValue {
JsValue::from_str(&value.to_string())
}
fn value_json(value: Value) -> Json {
match value {
Value::Unit => Json::Null,
Value::Boolean(value) => json!(value),
Value::Integer(value) => json!(value),
Value::Number(value) => json!(value),
Value::String(value) => json!(value),
Value::Bytes(value) => json!(value),
Value::Record(value) => Json::Object(
value
.into_iter()
.map(|(key, value)| (key, value_json(value)))
.collect(),
),
Value::List(value) => Json::Array(value.into_iter().map(value_json).collect()),
}
}
#[wasm_bindgen]
impl BrowserSession {
/// Creates a fresh local view. The supplied manifest is build-generated public content.
#[wasm_bindgen(constructor)]
pub fn new(manifest: &str) -> Result<BrowserSession, JsValue> {
let world = Worldspace::from_json(manifest).map_err(error)?;
let data: WorldspaceData = serde_json::from_str(manifest).map_err(error)?;
let display_paths = data
.nodes
.into_iter()
.map(|node| (node.path, node.display_path))
.collect();
let limits = ClientLimits::new(1, 4096, 64, 32, 64).map_err(error)?;
let mut client = Client::new(limits);
let session = client
.open_session("reference-site", SecurityContext::new("public"))
.map_err(error)?;
Ok(Self {
world,
client,
session,
display_paths,
observes: 0,
interfaces: 0,
calls: 0,
})
}
/// Returns a tree projected from actual client observations and document operations.
pub fn tree(&mut self) -> Result<String, JsValue> {
self.observe_path("/", 32)?;
let mut paths = Vec::new();
let mut pending = vec!["/".to_owned()];
while let Some(path) = pending.pop() {
// Normal document navigation is manifest-only. Dynamic Protocol Objects
// do not implement document.read and have no display path or Web URL.
if !self.display_paths.contains_key(&path) {
continue;
}
if let Some(tree) = self.client.tree(&self.session, &path) {
pending.extend(tree.children.iter().rev().cloned());
}
paths.push(path);
}
let mut rows = Vec::new();
for path in paths {
let mut row = self.read_document(&path)?;
let object = self
.client
.object(&self.session, &path)
.and_then(|entry| entry.object)
.ok_or_else(|| error("missing client object"))?;
let fields = row
.as_object_mut()
.ok_or_else(|| error("invalid document result"))?;
fields.remove("html");
fields.insert("name".into(), json!(object.name));
if path != "/" {
let parent = path
.rsplit_once('/')
.map(|(parent, _)| if parent.is_empty() { "/" } else { parent })
.unwrap_or("/");
fields.insert("parent".into(), json!(parent));
}
rows.push(row);
}
Ok(Json::Array(rows).to_string())
}
/// Reads a document through observe, descriptor fetch and validated Host dispatch.
pub fn read(&mut self, path: &str) -> Result<String, JsValue> {
Ok(self.read_document(path)?.to_string())
}
/// Calls a zero-argument operation on the site's document interface.
/// Unknown names are rejected by the same Client descriptor validation as reads.
pub fn call(&mut self, path: &str, operation: &str) -> Result<String, JsValue> {
Ok(self.call_document(path, operation)?.to_string())
}
/// Deliberate abort probe, compiled only into the separately served test artifact.
#[cfg(feature = "test-probes")]
pub fn panic_probe(&mut self) {
panic!("controlled release-WASM panic probe");
}
/// Attempted exchange counters aid integration diagnostics without exposing Host state.
pub fn stats(&self) -> String {
json!({"observes": self.observes, "interfaces": self.interfaces, "calls": self.calls})
.to_string()
}
}
impl BrowserSession {
fn exchange(&mut self, request: wip_client::direct::PreparedRequest) -> Result<(), JsValue> {
let id = request.id;
let response = match request.request {
Request::Observe(request) => {
self.observes += 1;
Response::Observe(self.world.observe(request))
}
Request::FetchInterface(request) => {
self.interfaces += 1;
Response::FetchInterface(self.world.fetch_interface(request))
}
Request::CallOperation(request) => {
self.client.mark_dispatched(id).map_err(error)?;
self.calls += 1;
Response::CallOperation(self.world.call_operation(request))
}
};
let completion = self.client.complete(id, response).map_err(error)?;
if let wip_client::Completion::ProtocolFailure(failure) = completion {
return Err(error(failure));
}
Ok(())
}
fn observe_path(&mut self, path: &str, depth: u32) -> Result<(), JsValue> {
if let Some(request) = self
.client
.ensure_observed(&self.session, path, depth)
.map_err(error)?
{
self.exchange(request)?;
}
Ok(())
}
fn read_document(&mut self, path: &str) -> Result<Json, JsValue> {
self.call_document(path, "read")
}
fn call_document(&mut self, path: &str, operation: &str) -> Result<Json, JsValue> {
self.observe_path(path, 0)?;
let reference = InterfaceReference {
scope: "/".into(),
name: "document".into(),
};
if let Some(request) = self
.client
.ensure_interface(&self.session, reference.clone())
.map_err(error)?
{
self.exchange(request)?;
}
let request = self
.client
.prepare_call(&self.session, path, &reference, operation, BTreeMap::new())
.map_err(error)?;
self.exchange(request)?;
let result = self
.client
.call_history(&self.session)
.and_then(|history| history.back())
.ok_or_else(|| error("missing call result"))?;
match &result.outcome {
wip_client::CallOutcome::Success(response) => {
let mut value = value_json(response.result.clone());
let display_path = self
.display_paths
.get(path)
.ok_or_else(|| error("missing display path"))?;
value
.as_object_mut()
.ok_or_else(|| error("invalid document result"))?
.insert("displayPath".into(), json!(display_path));
Ok(value)
}
outcome => Err(error(format!("document read failed: {outcome:?}"))),
}
}
}
+483
View File
@@ -0,0 +1,483 @@
//! Explicit protocol API. Only Client-accepted state is projected; no document map reads.
#[cfg(test)]
mod playground_tests;
#[cfg(test)]
mod tests;
use super::*;
use wip_client::{CallOutcome, ClientError, Completion, ObservationState};
use wip_protocol::{Documentation, InterfaceDescriptor, Object, ProtocolError, TypeExpr};
const MAX_TEXT_BYTES: usize = 4096;
const MAX_ARGUMENT_BYTES: usize = 64 * 1024;
const MAX_VALUE_NODES: usize = 4096;
const MAX_VALUE_DEPTH: usize = 32;
#[derive(Debug)]
struct ManualError {
kind: &'static str,
message: String,
code: Option<String>,
}
impl ManualError {
fn new(kind: &'static str, message: impl ToString) -> Self {
Self {
kind,
message: message.to_string(),
code: None,
}
}
fn input(message: impl ToString) -> Self {
Self::new("input", message)
}
fn client(error: ClientError) -> Self {
Self {
kind: "client",
message: error.to_string(),
code: Some(format!("{:?}", error.kind)),
}
}
fn protocol(error: ProtocolError) -> Self {
Self {
kind: "protocol",
message: error.message,
code: Some(format!("{:?}", error.code)),
}
}
fn js(self) -> JsValue {
let mut dto = json!({"kind": self.kind, "message": self.message});
if let Some(code) = self.code {
dto["code"] = json!(code);
}
JsValue::from_str(&dto.to_string())
}
}
type Result<T> = std::result::Result<T, ManualError>;
#[wasm_bindgen]
impl BrowserSession {
/// Explicit Observe only. `depth` must be an integer in 0..=32, not a JS-coerced u32.
/// The signature describes the root Object; children remain structured observation data.
pub fn observe(&mut self, path: &str, depth: f64) -> std::result::Result<String, JsValue> {
self.manual_observe(path, depth)
.map(|value| value.to_string())
.map_err(ManualError::js)
}
/// Explicit FetchInterface only, independent of any Object target.
pub fn fetch_interface(
&mut self,
scope: &str,
name: &str,
) -> std::result::Result<String, JsValue> {
self.manual_interface(scope, name)
.map(|value| value.to_string())
.map_err(ManualError::js)
}
/// Explicit call. Never observes, fetches, retries, or converts result data to a signature.
/// Arguments are a parameter-name map of tagged Values; `{}` means no arguments.
pub fn call_operation(
&mut self,
path: &str,
scope: &str,
name: &str,
operation: &str,
args_json: &str,
) -> std::result::Result<String, JsValue> {
self.manual_call(path, scope, name, operation, args_json)
.map(|value| value.to_string())
.map_err(ManualError::js)
}
}
impl BrowserSession {
fn manual_stats(&self) -> Json {
json!({"observes": self.observes, "interfaces": self.interfaces, "calls": self.calls})
}
// Same normal Host dispatch boundary as the existing composite APIs. Responses
// are correlated and validated before anything is exposed to JS.
fn manual_exchange(
&mut self,
request: wip_client::direct::PreparedRequest,
) -> Result<Completion> {
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(ManualError::client)?;
self.calls += 1;
Response::CallOperation(self.world.call_operation(request))
}
};
match self
.client
.complete(id, response)
.map_err(ManualError::client)?
{
Completion::ProtocolFailure(failure) => Err(ManualError::protocol(failure)),
completion => Ok(completion),
}
}
fn manual_observe(&mut self, path: &str, depth: f64) -> Result<Json> {
bounded_text(path)?;
if !depth.is_finite() || depth.fract() != 0.0 || !(0.0..=32.0).contains(&depth) {
return Err(ManualError::input("depth must be an integer in 0..=32"));
}
let depth = depth as u32;
// The current Client exposes explicit refresh, not public subtree
// invalidation. Playground Observe is a deliberate fresh retrieval,
// including retained NotFound/stale children after remove/recreate. No
// operation is retried, no result is used as observation data, and the
// immutable documents retain their existing ensure/cache behavior.
let playground = self.display_paths.contains_key("/playground")
&& (path == "/playground" || path.starts_with("/playground/"));
let prepared = if playground {
Some(
self.client
.refresh_observed(&self.session, path, depth)
.map_err(ManualError::client)?,
)
} else {
self.client
.ensure_observed(&self.session, path, depth)
.map_err(ManualError::client)?
};
let cached = prepared.is_none();
if let Some(request) = prepared {
self.manual_exchange(request)?;
}
// ensure_* can also return None for retained errors/capacity, not only hits.
// Reconstruct requested coverage from fresh Client state, never from Host data.
let observation = self.manual_observation(path, depth)?;
let object = self.fresh_object(path)?;
let tokens =
wip_text_view::tokenize_object(&object).map_err(|e| ManualError::new("render", e))?;
let text = wip_text_view::to_plain_text(&tokens);
Ok(
json!({"path": path, "observation": observation, "textView": text, "textViewTokens": text_view_tokens(&tokens), "cached": cached, "stats": self.manual_stats()}),
)
}
fn fresh_object(&self, path: &str) -> Result<Object> {
let entry = self
.client
.object(&self.session, path)
.ok_or_else(missing_observation)?;
fresh(&entry.state)?;
entry.object.ok_or_else(missing_observation)
}
fn manual_observation(&self, path: &str, depth: u32) -> Result<Json> {
let object = self.fresh_object(path)?;
let children = if depth == 0 {
Json::Null
} else {
let tree = self
.client
.tree(&self.session, path)
.ok_or_else(missing_observation)?;
fresh(&tree.state)?;
Json::Array(
tree.children
.iter()
.map(|child| self.manual_observation(child, depth - 1))
.collect::<Result<Vec<_>>>()?,
)
};
Ok(json!({"object": object_json(&object), "children": children}))
}
fn manual_interface(&mut self, scope: &str, name: &str) -> Result<Json> {
bounded_text(scope)?;
bounded_text(name)?;
let reference = InterfaceReference {
scope: scope.into(),
name: name.into(),
};
let prepared = self
.client
.ensure_interface(&self.session, reference.clone())
.map_err(ManualError::client)?;
let cached = prepared.is_none();
if let Some(request) = prepared {
self.manual_exchange(request)?;
}
let observation = self
.client
.interface(&self.session, &reference)
.ok_or_else(missing_observation)?;
fresh(&observation.state)?;
let descriptor = observation
.descriptor
.as_ref()
.ok_or_else(missing_observation)?;
let tokens = wip_text_view::tokenize_interface(&wip_text_view::Interface {
reference: &reference,
descriptor,
})
.map_err(|e| ManualError::new("render", e))?;
let text = wip_text_view::to_plain_text(&tokens);
Ok(
json!({"reference": reference_json(&reference), "descriptor": descriptor_json(descriptor), "textView": text, "textViewTokens": text_view_tokens(&tokens), "cached": cached, "stats": self.manual_stats()}),
)
}
fn manual_call(
&mut self,
path: &str,
scope: &str,
name: &str,
operation: &str,
args_json: &str,
) -> Result<Json> {
for text in [path, scope, name, operation] {
bounded_text(text)?;
}
let args = arguments(args_json)?;
let reference = InterfaceReference {
scope: scope.into(),
name: name.into(),
};
let prepared = self
.client
.prepare_call(&self.session, path, &reference, operation, args)
.map_err(ManualError::client)?;
let request_id = prepared.id;
let completion = self.manual_exchange(prepared)?;
let Completion::Call(record) = completion else {
return Err(missing_observation());
};
// Consume this exact completion, never the last unrelated history entry.
if record.request_id != request_id {
return Err(ManualError::new("client", "call correlation mismatch"));
}
match record.outcome {
CallOutcome::Success(response) => Ok(
json!({"path": path, "interface": reference_json(&reference), "operation": operation, "result": tagged_value(response.result), "stats": self.manual_stats()}),
),
CallOutcome::ProtocolFailure(failure) => Err(ManualError::protocol(failure)),
CallOutcome::NotDispatched(failure) => Err(ManualError::client(failure)),
CallOutcome::Unknown { reason, failure } => Err(ManualError {
kind: "client",
message: format!(
"operation outcome unknown ({reason:?}): {failure:?}; do not retry automatically"
),
code: Some("OperationOutcomeUnknown".into()),
}),
}
}
}
// Display-only DTOs: preserve WIP text verbatim, not HTML or protocol identity.
fn text_view_tokens(tokens: &[wip_text_view::Token]) -> Vec<Json> {
tokens
.iter()
.map(|token| json!({"kind": text_view_token_kind(token.kind()), "text": token.text()}))
.collect()
}
fn text_view_token_kind(kind: wip_text_view::TokenKind) -> &'static str {
use wip_text_view::TokenKind;
match kind {
TokenKind::Keyword => "keyword",
TokenKind::TypeName => "type-name",
TokenKind::OperationName => "operation-name",
TokenKind::ParameterName => "parameter-name",
TokenKind::FieldName => "field-name",
TokenKind::CaseName => "case-name",
TokenKind::String => "string",
TokenKind::Reference => "reference",
TokenKind::Documentation => "documentation",
TokenKind::Symbol => "symbol",
TokenKind::Whitespace => "whitespace",
TokenKind::Newline => "newline",
TokenKind::Indentation => "indentation",
_ => "plain",
}
}
fn missing_observation() -> ManualError {
ManualError {
kind: "client",
message: "fresh observation or requested coverage unavailable".into(),
code: Some("MissingObservation".into()),
}
}
fn fresh(state: &ObservationState) -> Result<()> {
match state {
ObservationState::Fresh => Ok(()),
ObservationState::Error(error) => Err(ManualError::client(error.clone())),
_ => Err(missing_observation()),
}
}
fn bounded_text(text: &str) -> Result<()> {
if text.len() > MAX_TEXT_BYTES {
Err(ManualError::input(
"path, scope, name and operation are limited to 4096 UTF-8 bytes each",
))
} else {
Ok(())
}
}
fn reference_json(reference: &InterfaceReference) -> Json {
json!({"scope": reference.scope, "name": reference.name})
}
fn object_json(object: &Object) -> Json {
json!({"name": object.name, "description": object.description, "interfaces": object.interfaces.iter().map(reference_json).collect::<Vec<_>>(), "ref": object.r#ref, "validator": object.validator})
}
fn documentation_json(documentation: &Option<Documentation>) -> Json {
documentation
.as_ref()
.map(|d| json!({"summary": d.summary, "details": d.details}))
.unwrap_or(Json::Null)
}
fn descriptor_json(descriptor: &InterfaceDescriptor) -> Json {
json!({
"format": descriptor.format,
"documentation": documentation_json(&descriptor.documentation),
"types": descriptor.types.iter().map(|t| json!({"name": t.name, "documentation": documentation_json(&t.documentation), "definition": type_json(&t.definition)})).collect::<Vec<_>>(),
"operations": descriptor.operations.iter().map(|o| json!({"name": o.name, "documentation": documentation_json(&o.documentation), "parameters": o.parameters.iter().map(|p| json!({"name": p.name, "required": p.required, "documentation": documentation_json(&p.documentation), "type": type_json(&p.r#type)})).collect::<Vec<_>>(), "returns": {"documentation": documentation_json(&o.returns.documentation), "type": type_json(&o.returns.r#type)}})).collect::<Vec<_>>()
})
}
fn type_json(value: &TypeExpr) -> Json {
match value {
TypeExpr::Unit => json!({"kind": "unit"}),
TypeExpr::Boolean => json!({"kind": "boolean"}),
TypeExpr::Integer => json!({"kind": "integer"}),
TypeExpr::Number => json!({"kind": "number"}),
TypeExpr::String => json!({"kind": "string"}),
TypeExpr::Bytes => json!({"kind": "bytes"}),
TypeExpr::Json => json!({"kind": "json"}),
TypeExpr::Entry => json!({"kind": "entry"}),
TypeExpr::Named { name } => json!({"kind": "named", "name": name}),
TypeExpr::List { items } => json!({"kind": "list", "items": type_json(items)}),
TypeExpr::Record { fields } => {
json!({"kind": "record", "fields": fields.iter().map(|f| json!({"name": f.name, "required": f.required, "documentation": documentation_json(&f.documentation), "type": type_json(&f.r#type)})).collect::<Vec<_>>()})
}
TypeExpr::Enum { cases } => {
json!({"kind": "enum", "cases": cases.iter().map(|c| json!({"name": c.name, "documentation": documentation_json(&c.documentation)})).collect::<Vec<_>>()})
}
TypeExpr::Union { cases } => {
json!({"kind": "union", "cases": cases.iter().map(|c| json!({"name": c.name, "documentation": documentation_json(&c.documentation), "payload": c.payload.as_ref().map(type_json)})).collect::<Vec<_>>()})
}
}
}
fn tagged_value(value: Value) -> Json {
match value {
Value::Unit => json!({"kind": "unit"}),
Value::Boolean(value) => json!({"kind": "boolean", "value": value}),
Value::Integer(value) => json!({"kind": "integer", "value": value.to_string()}),
Value::Number(value) => json!({"kind": "number", "value": value}),
Value::String(value) => json!({"kind": "string", "value": value}),
Value::Bytes(value) => json!({"kind": "bytes", "value": value}),
Value::Record(value) => {
json!({"kind": "record", "value": value.into_iter().map(|(k,v)| (k,tagged_value(v))).collect::<serde_json::Map<_,_>>()})
}
Value::List(value) => {
json!({"kind": "list", "value": value.into_iter().map(tagged_value).collect::<Vec<_>>()})
}
}
}
fn arguments(input: &str) -> Result<BTreeMap<String, Value>> {
if input.len() > MAX_ARGUMENT_BYTES {
return Err(ManualError::input("arguments exceed 65536 UTF-8 bytes"));
}
// serde_json's recursion limit stays enabled, bounding parsing before our tighter Value limits.
let parsed: Json = serde_json::from_str(input).map_err(ManualError::input)?;
let map = parsed.as_object().ok_or_else(|| {
ManualError::input("arguments must be an object mapping parameter names to tagged Values")
})?;
let mut budget = MAX_VALUE_NODES;
map.iter()
.map(|(name, value)| Ok((name.clone(), parse_value(value, 1, &mut budget)?)))
.collect()
}
fn parse_value(input: &Json, depth: usize, budget: &mut usize) -> Result<Value> {
if depth > MAX_VALUE_DEPTH || *budget == 0 {
return Err(ManualError::input("Values exceed depth 32 or 4096 nodes"));
}
*budget -= 1;
let invalid = || {
ManualError::input(
"invalid tagged Value: expected kind and correctly typed value (unit has no value)",
)
};
let object = input.as_object().ok_or_else(invalid)?;
let kind = object
.get("kind")
.and_then(Json::as_str)
.ok_or_else(invalid)?;
if kind == "unit" {
return if object.len() == 1 {
Ok(Value::Unit)
} else {
Err(invalid())
};
}
if object.len() != 2 {
return Err(invalid());
}
let value = object.get("value").ok_or_else(invalid)?;
Ok(match kind {
"boolean" => Value::Boolean(value.as_bool().ok_or_else(invalid)?),
"integer" => {
let text = value.as_str().ok_or_else(invalid)?;
let integer: i64 = text.parse().map_err(|_| invalid())?;
if integer.to_string() != text {
return Err(ManualError::input(
"integer requires a canonical signed decimal string",
));
}
Value::Integer(integer)
}
"number" => Value::Number(
value
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(invalid)?,
),
"string" => Value::String(value.as_str().ok_or_else(invalid)?.to_owned()),
"bytes" => Value::Bytes(
value
.as_array()
.ok_or_else(invalid)?
.iter()
.map(|v| {
v.as_u64()
.and_then(|v| u8::try_from(v).ok())
.ok_or_else(invalid)
})
.collect::<Result<_>>()?,
),
"record" => Value::Record(
value
.as_object()
.ok_or_else(invalid)?
.iter()
.map(|(k, v)| Ok((k.clone(), parse_value(v, depth + 1, budget)?)))
.collect::<Result<_>>()?,
),
"list" => Value::List(
value
.as_array()
.ok_or_else(invalid)?
.iter()
.map(|v| parse_value(v, depth + 1, budget))
.collect::<Result<_>>()?,
),
_ => return Err(invalid()),
})
}
+318
View File
@@ -0,0 +1,318 @@
use super::*;
fn manifest() -> String {
json!({"nodes":[
{"path":"/","name":"","displayPath":"/","title":"Root","href":"/","html":"<p>root</p>"},
{"path":"/book","name":"book","parent":"/","displayPath":"/book","title":"Book","href":"/book/","html":"<p>book</p>"},
{"path":"/playground","name":"playground","parent":"/","displayPath":"/playground","title":"Playground","href":"/playground/","html":"<p>playground</p>"}
]}).to_string()
}
fn session() -> BrowserSession {
BrowserSession::new(&manifest()).unwrap()
}
fn increment(s: &mut BrowserSession, amount: i64) -> Result<Json> {
s.manual_call(
"/playground/apple",
"/",
"counter",
"increment",
&json!({"amount":{"kind":"integer","value":amount.to_string()}}).to_string(),
)
}
fn collection(s: &mut BrowserSession, operation: &str, name: &str) -> Result<Json> {
s.manual_call(
"/playground",
"/",
"collection",
operation,
&json!({"name":{"kind":"string","value":name}}).to_string(),
)
}
fn assert_value(s: &mut BrowserSession, name: &str, value: i64) {
let calls = s.calls;
let observes = s.observes;
let interfaces = s.interfaces;
let result = s
.manual_call(&format!("/playground/{name}"), "/", "counter", "get", "{}")
.unwrap();
assert_eq!(
result["result"],
json!({"kind":"integer","value":value.to_string()})
);
assert_eq!(s.calls, calls + 1, "get dispatches exactly once");
assert_eq!(s.observes, observes, "get does not implicitly Observe");
assert_eq!(s.interfaces, interfaces, "get does not implicitly fetch");
}
fn names(observed: &Json) -> Vec<&str> {
observed["observation"]["children"]
.as_array()
.unwrap()
.iter()
.map(|child| child["object"]["name"].as_str().unwrap())
.collect()
}
#[test]
fn explicit_observe_preserves_counter_metadata_and_does_not_call_retry_or_fill_interfaces() {
let mut s = session();
s.manual_observe("/book", 1.0).unwrap();
let initial = s.manual_observe("/playground", 1.0).unwrap();
assert_eq!(names(&initial), ["apple", "orange"]);
assert_eq!(
initial["observation"]["children"][0]["object"]["description"],
"An in-memory counter."
);
assert!(
increment(&mut s, 4).is_err(),
"descriptor fetch remains explicit"
);
assert_eq!(s.calls, 0);
s.manual_interface("/", "counter").unwrap();
assert_value(&mut s, "apple", 3);
let stats = s.manual_stats();
let result = increment(&mut s, 4).unwrap();
assert_eq!(result["result"], json!({"kind":"integer","value":"7"}));
assert!(result.get("observation").is_none());
assert!(result.get("textView").is_none());
assert_eq!(s.observes, stats["observes"].as_u64().unwrap() as u32);
assert_eq!(s.interfaces, stats["interfaces"].as_u64().unwrap() as u32);
assert_value(&mut s, "apple", 7);
// Counter metadata is descriptive, not a cached counter value.
assert_eq!(
s.client
.object(&s.session, "/playground/apple")
.unwrap()
.object
.unwrap()
.description
.as_deref(),
Some("An in-memory counter.")
);
let calls = s.calls;
let parent = s.manual_observe("/playground", 1.0).unwrap();
assert_eq!(s.calls, calls, "Observe must not implicitly call get");
assert_eq!(parent["observation"], initial["observation"]);
assert_eq!(parent["cached"], false);
assert_eq!(
parent["observation"]["children"][0]["object"]["name"],
"apple"
);
assert_eq!(
parent["observation"]["children"][0]["object"]["description"],
"An in-memory counter."
);
let selected = s.manual_observe("/playground/apple", 1.0).unwrap();
assert_eq!(selected["cached"], false);
assert_eq!(
selected["observation"]["object"],
initial["observation"]["children"][0]["object"]
);
assert_eq!(s.calls, calls, "Observe must not implicitly call get");
assert_eq!(
selected["textView"],
wip_text_view::render_object(&s.fresh_object("/playground/apple").unwrap()).unwrap()
);
assert_eq!(s.manual_observe("/book", 1.0).unwrap()["cached"], true);
assert_eq!(s.manual_interface("/", "counter").unwrap()["cached"], true);
assert_eq!(s.calls, 3, "two explicit gets and one increment");
}
#[test]
fn collection_refresh_removes_stale_children_and_recovers_from_retained_not_found() {
let mut s = session();
s.manual_observe("/playground", 2.0).unwrap();
s.manual_interface("/", "collection").unwrap();
s.manual_interface("/", "counter").unwrap();
assert_eq!(
collection(&mut s, "create", "pear").unwrap()["result"],
json!({"kind":"unit"})
);
assert_eq!(
names(&s.manual_observe("/playground", 1.0).unwrap()),
["apple", "orange", "pear"]
);
collection(&mut s, "remove", "apple").unwrap();
assert_eq!(
names(&s.manual_observe("/playground", 1.0).unwrap()),
["orange", "pear"]
);
assert_eq!(
s.manual_observe("/playground/apple", 1.0)
.unwrap_err()
.code
.as_deref(),
Some("NotFound")
);
collection(&mut s, "create", "apple").unwrap();
// Explicit fresh refresh recovers even from a retained Client NotFound.
let selected = s.manual_observe("/playground/apple", 1.0).unwrap();
assert_eq!(
selected["observation"]["object"]["description"],
"An in-memory counter."
);
assert_value(&mut s, "apple", 0);
assert_eq!(
names(&s.manual_observe("/playground", 1.0).unwrap()),
["apple", "orange", "pear"]
);
assert_eq!(increment(&mut s, 2).unwrap()["result"]["value"], "2");
assert_value(&mut s, "apple", 2);
let calls = s.calls;
let incremented = s.manual_observe("/playground/apple", 1.0).unwrap();
assert_eq!(incremented["observation"], selected["observation"]);
let parent = s.manual_observe("/playground", 1.0).unwrap();
assert_eq!(s.calls, calls, "Observe must not implicitly call get");
assert_eq!(
s.manual_call("/playground/apple", "/", "counter", "reset", "{}")
.unwrap()["result"],
json!({"kind":"integer","value":"0"})
);
assert_value(&mut s, "apple", 0);
assert_eq!(s.calls, calls + 2, "one reset and one explicit get");
let reset = s.manual_observe("/playground/apple", 1.0).unwrap();
assert_eq!(reset["observation"], selected["observation"]);
assert_eq!(
s.manual_observe("/playground", 1.0).unwrap()["observation"],
parent["observation"]
);
assert_eq!(s.calls, calls + 2, "Observe must not implicitly call get");
}
#[test]
fn failures_are_atomic_and_each_explicit_call_dispatches_at_most_once() {
let mut s = session();
s.manual_observe("/playground", 2.0).unwrap();
s.manual_interface("/", "counter").unwrap();
s.manual_interface("/", "collection").unwrap();
assert_eq!(
increment(&mut s, wip_protocol::MAX_SAFE_INTEGER - 3).unwrap()["result"],
json!({"kind":"integer","value":wip_protocol::MAX_SAFE_INTEGER.to_string()})
);
assert_value(&mut s, "apple", wip_protocol::MAX_SAFE_INTEGER);
let calls = s.calls;
let maximum = s.manual_observe("/playground/apple", 1.0).unwrap();
assert_eq!(s.calls, calls, "Observe must not implicitly call get");
let failure = increment(&mut s, 1).unwrap_err();
assert_eq!(failure.kind, "protocol");
assert_eq!(failure.code.as_deref(), Some("Internal"));
assert_eq!(s.calls, calls + 1);
assert_eq!(
s.manual_observe("/playground/apple", 1.0).unwrap()["observation"],
maximum["observation"]
);
assert_eq!(s.calls, calls + 1, "Observe must not implicitly call get");
assert_value(&mut s, "apple", wip_protocol::MAX_SAFE_INTEGER);
assert_eq!(
s.calls,
calls + 2,
"one failed increment and one explicit get"
);
for name in ["apple", "bad/name", "<img>", ""] {
let calls = s.calls;
let before = s.manual_observe("/playground", 1.0).unwrap();
assert_eq!(s.calls, calls, "Observe must not implicitly call get");
assert!(collection(&mut s, "create", name).is_err());
assert_eq!(s.calls, calls + 1);
assert_eq!(
s.manual_observe("/playground", 1.0).unwrap()["observation"],
before["observation"]
);
assert_eq!(s.calls, calls + 1, "Observe must not implicitly call get");
assert_value(&mut s, "apple", wip_protocol::MAX_SAFE_INTEGER);
assert_value(&mut s, "orange", 0);
assert_eq!(
s.calls,
calls + 3,
"one failed create and two explicit gets"
);
}
let calls = s.calls;
assert!(increment(&mut s, i64::MAX).is_err());
assert_eq!(
s.calls, calls,
"out-of-range typed arguments rejected before Host dispatch"
);
assert_value(&mut s, "apple", wip_protocol::MAX_SAFE_INTEGER);
assert_eq!(s.calls, calls + 1, "only the explicit get dispatches");
}
#[test]
fn normal_tree_and_document_reads_ignore_dynamic_children_and_reload_resets_state() {
let mut s = session();
let tree = s.tree().unwrap();
let rows: Json = serde_json::from_str(&tree).unwrap();
assert_eq!(
rows.as_array()
.unwrap()
.iter()
.map(|row| row["path"].as_str().unwrap())
.collect::<Vec<_>>(),
["/", "/book", "/playground"]
);
let document = s.read("/playground").unwrap();
let book = s.read("/book").unwrap();
s.manual_interface("/", "counter").unwrap();
s.manual_interface("/", "collection").unwrap();
assert_eq!(increment(&mut s, 10).unwrap()["result"]["value"], "13");
assert_value(&mut s, "apple", 13);
collection(&mut s, "create", "pear").unwrap();
collection(&mut s, "remove", "orange").unwrap();
s.manual_observe("/playground", 1.0).unwrap();
assert_eq!(s.tree().unwrap(), tree);
assert_eq!(s.read("/playground").unwrap(), document);
assert_eq!(s.read("/book").unwrap(), book);
let calls = s.calls;
assert_eq!(
s.manual_observe("/playground/apple", 1.0).unwrap()["observation"]["object"]["description"],
"An in-memory counter."
);
assert_eq!(s.calls, calls, "Observe must not implicitly call get");
// Navigation does not reset state; verify through an explicit operation.
assert_value(&mut s, "apple", 13);
let mut reloaded = session();
let initial = reloaded.manual_observe("/playground", 1.0).unwrap();
assert_eq!(names(&initial), ["apple", "orange"]);
assert_eq!(
initial["observation"]["children"][0]["object"]["description"],
"An in-memory counter."
);
assert_eq!(
initial["observation"]["children"][1]["object"]["description"],
"An in-memory counter."
);
assert_eq!(reloaded.calls, 0, "Observe must not implicitly call get");
reloaded.manual_interface("/", "counter").unwrap();
assert_value(&mut reloaded, "apple", 3);
assert_value(&mut reloaded, "orange", 0);
assert_eq!(reloaded.calls, 2, "two explicit gets after reload");
assert_value(&mut s, "apple", 13);
}
#[test]
fn old_manifests_do_not_enable_playground_or_change_cached_observation_behavior() {
let mut data: Json = serde_json::from_str(&manifest()).unwrap();
data["nodes"].as_array_mut().unwrap().pop();
let mut s = BrowserSession::new(&data.to_string()).unwrap();
assert_eq!(
s.manual_observe("/playground", 1.0)
.unwrap_err()
.code
.as_deref(),
Some("NotFound")
);
let stats = s.manual_stats();
assert_eq!(
s.manual_observe("/playground", 1.0).unwrap_err().kind,
"client"
);
assert_eq!(s.manual_stats(), stats);
for name in ["collection", "counter"] {
assert_eq!(
s.manual_interface("/", name).unwrap_err().code.as_deref(),
Some("InterfaceNotFound")
);
}
s.manual_observe("/", 1.0).unwrap();
assert_eq!(s.manual_observe("/", 1.0).unwrap()["cached"], true);
}
+432
View File
@@ -0,0 +1,432 @@
use super::*;
fn manifest() -> String {
json!({"nodes": [
{"path":"/", "name":"", "displayPath":"/", "title":"Root", "href":"/", "html":"<p>root body</p>"},
{"path":"/quoted", "name":"quoted", "displayPath":"/quoted", "parent":"/", "title":" 引用 \"quote\" \\ space\nnext\tline ", "href":"/quoted/", "html":"<script>not executed</script>"}
]}).to_string()
}
fn session() -> BrowserSession {
BrowserSession::new(&manifest()).unwrap()
}
#[test]
fn manual_exchanges_do_not_read_until_explicit_call_and_cache_only_fresh_state() {
let mut s = session();
let observed = s.manual_observe("/", 1.0).unwrap();
assert_eq!(
observed["stats"],
json!({"observes":1,"interfaces":0,"calls":0})
);
assert_eq!(observed["cached"], false);
assert_eq!(
observed["observation"]["children"][0]["children"],
Json::Null
);
assert!(!observed.to_string().contains("root body"));
let cached = s.manual_observe("/", 1.0).unwrap();
assert_eq!(cached["observation"], observed["observation"]);
assert_eq!(cached["cached"], true);
assert_eq!(cached["stats"], observed["stats"]);
let deeper = s.manual_observe("/", 2.0).unwrap();
assert_eq!(deeper["cached"], false);
assert_eq!(deeper["observation"]["children"][0]["children"], json!([]));
let fetched = s.manual_interface("/", "document").unwrap();
assert_eq!(
fetched["stats"],
json!({"observes":2,"interfaces":1,"calls":0})
);
assert_eq!(fetched["cached"], false);
assert!(!fetched.to_string().contains("root body"));
assert_eq!(s.manual_interface("/", "document").unwrap()["cached"], true);
let result = s
.manual_call("/quoted", "/", "document", "read", "{}")
.unwrap();
assert_eq!(
result["stats"],
json!({"observes":2,"interfaces":1,"calls":1})
);
assert_eq!(result["path"], "/quoted");
assert_eq!(result["interface"], json!({"scope":"/", "name":"document"}));
assert_eq!(result["result"]["kind"], "record");
assert_eq!(
result["result"]["value"]["path"],
json!({"kind":"string","value":"/quoted"})
);
assert_eq!(
result["result"]["value"]["html"],
json!({"kind":"string","value":"<script>not executed</script>"})
);
assert!(result.get("textView").is_none());
s.manual_call("/quoted", "/", "document", "read", "{}")
.unwrap();
assert_eq!(s.calls, 2);
}
#[test]
fn calls_do_not_fill_preconditions_or_retry_failures() {
let mut s = session();
let error = s
.manual_call("/", "/", "document", "read", "{}")
.unwrap_err();
assert_eq!(error.kind, "client");
assert_eq!(error.code.as_deref(), Some("MissingObservation"));
assert_eq!(
s.manual_stats(),
json!({"observes":0,"interfaces":0,"calls":0})
);
s.manual_observe("/", 0.0).unwrap();
assert_eq!(
s.manual_call("/", "/", "document", "read", "{}")
.unwrap_err()
.code
.as_deref(),
Some("MissingObservation")
);
assert_eq!(s.interfaces, 0);
s.manual_interface("/", "document").unwrap();
for (name, operation, args) in [
("document", "missing", "{}"),
("other", "read", "{}"),
(
"document",
"read",
r#"{"extra":{"kind":"string","value":"x"}}"#,
),
] {
assert_eq!(
s.manual_call("/", "/", name, operation, args)
.unwrap_err()
.kind,
"client"
);
}
assert_eq!(s.calls, 0);
assert_eq!(
s.manual_observe("/absent", 0.0)
.unwrap_err()
.code
.as_deref(),
Some("NotFound")
);
assert_eq!(
s.manual_interface("/", "absent")
.unwrap_err()
.code
.as_deref(),
Some("InterfaceNotFound")
);
let stats = s.manual_stats();
assert_eq!(s.manual_observe("/absent", 0.0).unwrap_err().kind, "client");
assert_eq!(
s.manual_interface("/", "absent").unwrap_err().kind,
"client"
);
assert_eq!(
s.manual_stats(),
stats,
"retained failures are not cache successes or retries"
);
}
#[test]
fn fetch_is_independent_and_sessions_do_not_share_observations() {
let mut normal = session();
normal.tree().unwrap();
let mut manual = session();
let fetch = manual.manual_interface("/", "document").unwrap();
assert_eq!(
fetch["stats"],
json!({"observes":0,"interfaces":1,"calls":0})
);
assert_eq!(
manual
.manual_call("/", "/", "document", "read", "{}")
.unwrap_err()
.code
.as_deref(),
Some("MissingObservation")
);
assert_eq!(manual.calls, 0);
manual.manual_observe("/", 0.0).unwrap();
assert_eq!(
manual
.manual_call("/", "/", "document", "read", "{}")
.unwrap()["result"]["value"]["path"]["value"],
"/"
);
}
#[test]
fn signatures_are_real_renderer_output_and_exclude_metadata() {
let mut s = session();
let observed = s.manual_observe("/quoted", 0.0).unwrap();
let mut object = s.fresh_object("/quoted").unwrap();
assert_eq!(
observed["textView"],
wip_text_view::render_object(&object).unwrap()
);
assert_display_tokens(&observed, &wip_text_view::tokenize_object(&object).unwrap());
let cached = s.manual_observe("/quoted", 0.0).unwrap();
assert_eq!(cached["textViewTokens"], observed["textViewTokens"]);
let text = observed["textView"].as_str().unwrap();
assert!(text.contains("引用 \\\"quote\\\" \\\\ space\\nnext\\tline "));
assert!(text.ends_with("}\n"));
object.r#ref = Some("secret metadata".into());
object.validator = Some(vec![1, 2, 3]);
assert_eq!(
observed["textView"],
wip_text_view::render_object(&object).unwrap()
);
let fetched = s.manual_interface("/", "document").unwrap();
let reference = Worldspace::document_interface();
let observation = s.client.interface(&s.session, &reference).unwrap();
let descriptor = observation.descriptor.as_ref().unwrap();
assert_eq!(
fetched["textView"],
wip_text_view::render_interface(&wip_text_view::Interface {
reference: &reference,
descriptor
})
.unwrap()
);
assert_display_tokens(
&fetched,
&wip_text_view::tokenize_interface(&wip_text_view::Interface {
reference: &reference,
descriptor,
})
.unwrap(),
);
let cached = s.manual_interface("/", "document").unwrap();
assert_eq!(cached["textViewTokens"], fetched["textViewTokens"]);
assert_eq!(fetched["reference"], reference_json(&reference));
assert_eq!(fetched["descriptor"]["operations"][0]["name"], "read");
assert_eq!(
fetched["descriptor"]["operations"][0]["parameters"],
json!([])
);
}
fn assert_display_tokens(response: &Json, tokens: &[wip_text_view::Token]) {
let dto = response["textViewTokens"].as_array().unwrap();
assert_eq!(dto.len(), tokens.len());
for (entry, token) in dto.iter().zip(tokens) {
assert_eq!(entry.as_object().unwrap().len(), 2);
assert_eq!(entry["kind"], text_view_token_kind(token.kind()));
assert_eq!(entry["text"], token.text());
}
let concatenated: String = dto
.iter()
.map(|entry| entry["text"].as_str().unwrap())
.collect();
assert_eq!(concatenated, response["textView"].as_str().unwrap());
}
#[test]
fn display_token_kinds_use_the_explicit_kebab_case_whitelist() {
use wip_text_view::TokenKind;
for (kind, expected) in [
(TokenKind::Keyword, "keyword"),
(TokenKind::TypeName, "type-name"),
(TokenKind::OperationName, "operation-name"),
(TokenKind::ParameterName, "parameter-name"),
(TokenKind::FieldName, "field-name"),
(TokenKind::CaseName, "case-name"),
(TokenKind::String, "string"),
(TokenKind::Reference, "reference"),
(TokenKind::Documentation, "documentation"),
(TokenKind::Symbol, "symbol"),
(TokenKind::Whitespace, "whitespace"),
(TokenKind::Newline, "newline"),
(TokenKind::Indentation, "indentation"),
] {
assert_eq!(text_view_token_kind(kind), expected);
}
}
#[test]
fn display_tokens_preserve_quoted_markup_and_unicode_as_raw_text() {
let raw = r#"<script title="引用 🦀">& 'text'</script>"#;
let quoted = r#""<script title=\"引用 🦀\">& 'text'</script>""#;
let mut data: Json = serde_json::from_str(&manifest()).unwrap();
data["nodes"][1]["title"] = json!(raw);
let mut s = BrowserSession::new(&data.to_string()).unwrap();
let observed = s.manual_observe("/quoted", 0.0).unwrap();
let object = s.fresh_object("/quoted").unwrap();
assert_eq!(
observed["textView"],
wip_text_view::render_object(&object).unwrap()
);
assert_display_tokens(&observed, &wip_text_view::tokenize_object(&object).unwrap());
assert!(
observed["textViewTokens"]
.as_array()
.unwrap()
.contains(&json!({"kind": "string", "text": quoted}))
);
s.manual_interface("/", "document").unwrap();
let reference = Worldspace::document_interface();
let mut descriptor = s
.client
.interface(&s.session, &reference)
.unwrap()
.descriptor
.clone()
.unwrap();
descriptor.documentation = Some(Documentation {
summary: raw.into(),
details: None,
});
descriptor.operations[0].name = raw.into();
let interface = wip_text_view::Interface {
reference: &reference,
descriptor: &descriptor,
};
let tokens = wip_text_view::tokenize_interface(&interface).unwrap();
let dto = json!({
"textView": wip_text_view::render_interface(&interface).unwrap(),
"textViewTokens": text_view_tokens(&tokens),
});
// JSON transport must retain literal text, never HTML entities or generated markup.
let decoded: Json = serde_json::from_str(&dto.to_string()).unwrap();
assert_display_tokens(&decoded, &tokens);
let entries = decoded["textViewTokens"].as_array().unwrap();
let quoted_identifier = r#"`<script title=\"引用 🦀\">& 'text'</script>`"#;
assert!(entries.contains(&json!({"kind": "operation-name", "text": quoted_identifier})));
assert!(entries.contains(&json!({"kind": "documentation", "text": format!("/// {quoted}")})));
assert_eq!(descriptor.operations[0].name, raw);
assert_eq!(reference, Worldspace::document_interface());
}
#[test]
fn all_tagged_values_round_trip_without_integer_or_bytes_coercion() {
let values = vec![
Value::Unit,
Value::Boolean(true),
Value::Integer(i64::MIN),
Value::Integer(i64::MAX),
Value::Number(-0.0),
Value::Number(1.25),
Value::String("引用\n\t\"".into()),
Value::Bytes(vec![0, 255]),
Value::Record(BTreeMap::from([("__proto__".into(), Value::Integer(1))])),
Value::List(vec![Value::Unit, Value::String("x".into())]),
];
for value in values {
let dto = tagged_value(value.clone());
let encoded = json!({"arg": dto}).to_string();
let parsed = arguments(&encoded).unwrap();
assert_eq!(parsed["arg"], value);
if let Value::Number(n) = value {
let Value::Number(actual) = parsed["arg"] else {
panic!()
};
assert_eq!(n.to_bits(), actual.to_bits());
}
}
assert_eq!(arguments("{}").unwrap(), BTreeMap::new());
for input in [
"null",
"[]",
"{",
r#"{"x":1}"#,
r#"{"x":{"kind":"integer","value":9007199254740993}}"#,
r#"{"x":{"kind":"integer","value":"01"}}"#,
r#"{"x":{"kind":"bytes","value":[256]}}"#,
r#"{"x":{"kind":"unit","value":null}}"#,
r#"{"x":{"kind":"number","value":1e400}}"#,
r#"{"x":{"kind":"unknown"}}"#,
] {
assert_eq!(arguments(input).unwrap_err().kind, "input", "{input}");
}
// DTO conversion can represent all i64, but Protocol Integer remains JS-safe.
let descriptor = Worldspace::new(serde_json::from_str(&manifest()).unwrap())
.unwrap()
.fetch_interface(wip_protocol::FetchInterfaceRequest {
interface: Worldspace::document_interface(),
})
.unwrap()
.descriptor;
assert!(
descriptor
.validate_value(&TypeExpr::Integer, &Value::Integer(i64::MAX))
.is_err()
);
}
#[test]
fn bounds_fail_before_dispatch_and_render_failures_are_distinct() {
let mut s = session();
for depth in [-1.0, 0.5, 33.0, f64::NAN, f64::INFINITY, 4294967296.0] {
assert_eq!(s.manual_observe("/", depth).unwrap_err().kind, "input");
}
assert_eq!(
s.manual_observe(&"/".repeat(MAX_TEXT_BYTES + 1), 0.0)
.unwrap_err()
.kind,
"input"
);
assert_eq!(
s.manual_observe("relative", 0.0).unwrap_err().kind,
"client"
);
assert_eq!(
arguments(&" ".repeat(MAX_ARGUMENT_BYTES + 1))
.unwrap_err()
.kind,
"input"
);
let mut nested = json!({"kind":"unit"});
for _ in 0..MAX_VALUE_DEPTH {
nested = json!({"kind":"list", "value":[nested]});
}
assert_eq!(
arguments(&json!({"arg":nested}).to_string())
.unwrap_err()
.kind,
"input"
);
let mut budget = 1;
assert_eq!(
parse_value(
&json!({"kind":"list","value":[{"kind":"unit"}]}),
1,
&mut budget
)
.unwrap_err()
.kind,
"input"
);
assert_eq!(
s.manual_stats(),
json!({"observes":0,"interfaces":0,"calls":0})
);
let mut data: Json = serde_json::from_str(&manifest()).unwrap();
data["nodes"][0]["title"] = json!("x".repeat(1024 * 1024 + 1));
let mut large = BrowserSession::new(&data.to_string()).unwrap();
assert_eq!(large.manual_observe("/", 0.0).unwrap_err().kind, "render");
assert_eq!(
large.manual_stats(),
json!({"observes":1,"interfaces":0,"calls":0})
);
}
#[test]
fn host_call_failure_is_typed_and_counted_once() {
let mut s = session();
s.manual_observe("/quoted", 0.0).unwrap();
s.manual_interface("/", "document").unwrap();
// Simulate a publication disappearing after observation: real Host dispatch,
// not a fabricated Client response or a document-map shortcut.
let mut data: WorldspaceData = serde_json::from_str(&manifest()).unwrap();
data.nodes.pop();
s.world = Worldspace::new(data).unwrap();
let error = s
.manual_call("/quoted", "/", "document", "read", "{}")
.unwrap_err();
assert_eq!(error.kind, "protocol");
assert_eq!(error.code.as_deref(), Some("NotFound"));
assert_eq!(s.calls, 1);
}
+7
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@astrojs/markdown-remark": "7.3.2", "@astrojs/markdown-remark": "7.3.2",
"@vortexlabs/kirari": "1.1.0",
"animejs": "4.5.0", "animejs": "4.5.0",
"astro": "7.3.8" "astro": "7.3.8"
}, },
@@ -1932,6 +1933,12 @@
"vscode-uri": "^3.0.8" "vscode-uri": "^3.0.8"
} }
}, },
"node_modules/@vortexlabs/kirari": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@vortexlabs/kirari/-/kirari-1.1.0.tgz",
"integrity": "sha512-HKM06r3Y3hrmPTOeX4r9Q1UqZMoFFwC2MU2BsXDrblPPi5Q1Ht5eDgF6b2mcGO7GKbJpoWkxXWsho7W/uCIbEA==",
"license": "MIT"
},
"node_modules/@vscode/emmet-helper": { "node_modules/@vscode/emmet-helper": {
"version": "2.11.0", "version": "2.11.0",
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz", "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
+7
View File
@@ -7,17 +7,24 @@
"node": ">=22.12.0" "node": ">=22.12.0"
}, },
"scripts": { "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", "dev": "astro dev",
"check": "astro check", "check": "astro check",
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"build": "astro build", "build": "astro build",
"check:links": "node scripts/check-links.mjs", "check:links": "node scripts/check-links.mjs",
"verify": "npm run test && npm run check && npm run build && npm run check:links", "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:browser": "playwright test",
"test:dev": "playwright test --config playwright.dev.config.ts",
"preview": "astro preview" "preview": "astro preview"
}, },
"dependencies": { "dependencies": {
"@astrojs/markdown-remark": "7.3.2", "@astrojs/markdown-remark": "7.3.2",
"@vortexlabs/kirari": "1.1.0",
"animejs": "4.5.0", "animejs": "4.5.0",
"astro": "7.3.8" "astro": "7.3.8"
}, },
+19
View File
@@ -0,0 +1,19 @@
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,
// Override rather than merge webServer: dev checks must not require dist/.
testMatch: ['wip-loading.spec.ts', 'fonts.spec.ts', 'protocol.spec.ts', 'protocol-api.spec.ts', 'playground.spec.ts', 'text-view.spec.ts', 'operation-form.spec.ts', 'json-view.spec.ts'],
use: { ...preview.use, baseURL },
webServer: {
command: `npm run dev -- --host 127.0.0.1 --port ${port} --ignore-lock`,
env: { SITE_BROWSER_TEST: '1' },
url: baseURL,
reuseExistingServer: false,
},
});
@@ -1,44 +1,45 @@
/* Self-hosted, unmodified upstream fonts; revisions and licenses: /fonts/README.md. /* Self-hosted, unmodified upstream fonts; revisions and licenses: README.md.
Vite resolves these public asset URLs against the configured site base. */ Linked through the site's base-aware href helper. Relative URLs resolve beside
this stylesheet in both dev and static builds, without Vite URL rewriting. */
@font-face { @font-face {
font-family: 'Lyth Mono'; font-family: 'Lyth Mono';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('/fonts/LythMono-Regular.woff2') format('woff2'); src: url('LythMono-Regular.woff2') format('woff2');
} }
@font-face { @font-face {
font-family: 'Lyth Mono'; font-family: 'Lyth Mono';
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('/fonts/LythMono-Bold.woff2') format('woff2'); src: url('LythMono-Bold.woff2') format('woff2');
} }
@font-face { @font-face {
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('/fonts/EBGaramond-Regular.woff2') format('woff2'); src: url('EBGaramond-Regular.woff2') format('woff2');
} }
@font-face { @font-face {
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('/fonts/EBGaramond-Bold.woff2') format('woff2'); src: url('EBGaramond-Bold.woff2') format('woff2');
} }
@font-face { @font-face {
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: italic; font-style: italic;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('/fonts/EBGaramond-Italic.woff2') format('woff2'); src: url('EBGaramond-Italic.woff2') format('woff2');
} }
@font-face { @font-face {
font-family: 'EB Garamond'; font-family: 'EB Garamond';
font-style: italic; font-style: italic;
font-weight: 700; font-weight: 700;
font-display: swap; font-display: swap;
src: url('/fonts/EBGaramond-BoldItalic.woff2') format('woff2'); src: url('EBGaramond-BoldItalic.woff2') format('woff2');
} }
+6
View File
@@ -0,0 +1,6 @@
{
"repository": "https://gitea.hareworks.net/Hare/wip-rs",
"revision": "eb92878e3c696c768f1d2294c3718ecd04cf6a87",
"toolchain": "1.97.1",
"wasmBindgen": "0.2.121"
}
+5
View File
@@ -0,0 +1,5 @@
[toolchain]
channel = "1.97.1"
targets = ["wasm32-unknown-unknown"]
components = ["rustfmt", "clippy"]
profile = "minimal"
+33
View File
@@ -0,0 +1,33 @@
// Build the browser runtime from source; no prebuilt local WASM is required.
import { spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const site = fileURLToPath(new URL('../', import.meta.url));
const rust = fileURLToPath(new URL('../../../wip-rs/', import.meta.url));
const source = JSON.parse(readFileSync(new URL('../rust-source.json', import.meta.url), 'utf8'));
const revision = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: rust, encoding: 'utf8' });
const status = spawnSync('git', ['status', '--porcelain'], { cwd: rust, encoding: 'utf8' });
if (revision.status !== 0 || revision.stdout.trim() !== source.revision || status.status !== 0 || status.stdout.trim()) {
if (process.env.WIP_ALLOW_DIRTY_SOURCE !== '1') {
throw new Error(`Expected clean sibling wip-rs at ${source.revision}. See rust-source.json; use WIP_ALLOW_DIRTY_SOURCE=1 only for local Rust development.`);
}
console.warn('Building with an explicitly unpinned local Rust source; this is not a reproducible release build.');
}
function run(command, args) {
const result = spawnSync(command, args, { cwd: site, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
function available(command) {
return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0;
}
const testProbes = process.argv.includes('--test-probes');
const output = testProbes ? 'test-assets/wip-wasm' : 'public/wip-wasm';
const cargo = ['build', '--locked', '--manifest-path', 'browser/Cargo.toml', '--target', 'wasm32-unknown-unknown', '--release', ...(testProbes ? ['--features', 'test-probes'] : [])];
if (available('cargo')) run('cargo', cargo);
else run('nix', ['shell', '--inputs-from', rust, 'nixpkgs#lld', '--command', 'nix', 'develop', rust, '--no-write-lock-file', '--command', 'cargo', ...cargo]);
mkdirSync(new URL(`../${output}/`, import.meta.url), { recursive: true });
const bindgen = ['target/wasm32-unknown-unknown/release/wip_browser.wasm', '--target', 'web', '--out-dir', output, '--out-name', 'wip_browser'];
if (available('wasm-bindgen')) run('wasm-bindgen', bindgen);
else run('nix', ['shell', '--inputs-from', rust, 'nixpkgs#wasm-bindgen-cli', '--command', 'wasm-bindgen', ...bindgen]);
+19
View File
@@ -0,0 +1,19 @@
---
import { catalogs, type CatalogName } from '../lib/catalog';
interface Props { name: CatalogName; }
const { name } = Astro.props;
const catalog = catalogs[name];
---
<div class="catalog-page" data-catalog={name}>
<h1>{catalog.title}</h1>
<p>{catalog.description}</p>
<ul class="catalog-list">
{catalog.entries.map((entry) => <li>
<h2>{entry.name}</h2>
<p>{entry.description}</p>
<ul aria-label={`${entry.name} links`}>
{entry.links.map((link) => <li><a href={link.href}>{link.label} <span aria-hidden="true">↗</span></a></li>)}
</ul>
</li>)}
</ul>
</div>
+4 -2
View File
@@ -1,15 +1,16 @@
--- ---
import { documents, bookHref, title, href } from '../lib/book'; import { documents, bookHref, title, href } from '../lib/book';
import { bookTree, childrenOf, treePrefix, serializeTree } from '../lib/landing-tree'; import { publicTree, childrenOf, treePrefix, serializeTree } from '../lib/landing-tree';
import '../styles/landing.css'; import '../styles/landing.css';
import TreePrefix from './TreePrefix.astro'; import TreePrefix from './TreePrefix.astro';
import TreeGlyphs from './TreeGlyphs.astro'; import TreeGlyphs from './TreeGlyphs.astro';
import ProtocolPanel from './ProtocolPanel.astro';
import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs'; import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs';
interface Props { currentPath?: string; label?: string; draft?: boolean; } interface Props { currentPath?: string; label?: string; draft?: boolean; }
const entries = await documents(); const entries = await documents();
const { currentPath = '/', label, draft = false } = Astro.props; const { currentPath = '/', label, draft = false } = Astro.props;
const folderHref = (path: string) => path === '/' ? href() : path === '/book' ? href('book/') : href(`book${path}/`); const folderHref = (path: string) => path === '/' ? href() : path === '/book' ? href('book/') : href(`book${path}/`);
const objects = bookTree(entries.map((entry) => ({ id: entry.id, title: title(entry), href: bookHref(entry.id) }))) const objects = publicTree(entries.map((entry) => ({ id: entry.id, title: title(entry), href: bookHref(entry.id) })), import.meta.env.BASE_URL)
.map((object) => ({ ...object, href: object.href ?? folderHref(object.path) })); .map((object) => ({ ...object, href: object.href ?? folderHref(object.path) }));
const current = objects.find((object) => object.path === currentPath); const current = objects.find((object) => object.path === currentPath);
const initialContent = entries.find((entry) => `/${entry.id}.md` === currentPath)?.rendered?.html; const initialContent = entries.find((entry) => `/${entry.id}.md` === currentPath)?.rendered?.html;
@@ -72,6 +73,7 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1)
</div> </div>
</div> </div>
<div class="document-pane" data-reveal> <div class="document-pane" data-reveal>
<ProtocolPanel />
{draft && <aside class="draft-notice"><strong>Draft / 検討中の文書</strong><p>このページは草案です。確定したProtocolの契約は正本を参照してください。</p></aside>} {draft && <aside class="draft-notice"><strong>Draft / 検討中の文書</strong><p>このページは草案です。確定したProtocolの契約は正本を参照してください。</p></aside>}
<article class="selected-content prose" data-selected-content aria-label={label ?? current?.title ?? currentPath} tabindex="0"> <article class="selected-content prose" data-selected-content aria-label={label ?? current?.title ?? currentPath} tabindex="0">
<slot><Fragment set:html={initialContent} /></slot> <slot><Fragment set:html={initialContent} /></slot>
+14
View File
@@ -0,0 +1,14 @@
---
import { href } from '../lib/book';
---
<h1>Playground</h1>
<p>ProtocolのOperationでObjectを作り、状態を変えるメモリ内のデモです。<a href={`${href('playground/')}?mode=protocol`}>Protocolで開く</a>と、ObserveでカウンターObjectの一覧を取得します。</p>
<ul>
<li><code>/::collection</code>:<code>create(name)</code>でカウンターを追加し、<code>remove(name)</code>で削除します。</li>
<li><code>/::counter</code>:<code>get()</code>で現在値、<code>increment(amount)</code>で増減、<code>reset()</code>で0に戻します。</li>
</ul>
<p>初期状態はappleが3、orangeが0です。作成・削除は再Observeでツリーに反映します。ツリーにはObject名だけを表示し、値は<code>get()</code>などのOperationレスポンスで確認します。名前は1〜32文字の英数字・<code>_</code>・<code>-</code>、カウンターは最大32個です。</p>
<p>Interfaceを開き、各Operationのコメントと宣言の間にある<code>▶ Run</code>を押すと、関数呼び出し風の引数入力欄が展開されます。文字列は引用符の内側、整数は数値の欄に入力し、<code>Execute</code>またはEnterで実行します。</p>
<pre><code>{'create(name: "banana")'}</code></pre>
<pre><code>{'increment(amount: 2)'}</code></pre>
<p>状態はこのタブのProtocol session内だけに保存され、再読み込みで初期状態に戻ります。文書Objectや他の利用者の状態は変更しません。JavaScriptやWASMが利用できない場合は、この説明のみ表示します。</p>
+15
View File
@@ -0,0 +1,15 @@
---
import '../styles/protocol.css';
import '../styles/text-view.css';
import '../styles/json-view.css';
---
<div class="mode-switch" data-mode-switch hidden role="group" aria-label="表示モード">
<button type="button" data-mode="read" aria-pressed="true">Read</button>
<button type="button" data-mode="protocol" aria-pressed="false">Protocol</button>
</div>
<section class="protocol-panel" data-protocol-panel hidden aria-label="Protocol操作">
<p data-protocol-status role="status" aria-live="polite" hidden></p>
<pre class="protocol-signature" tabindex="0" aria-label="Object Text View" data-object-view hidden></pre>
<div data-protocol-interfaces aria-label="Interfaces"></div>
<pre class="protocol-json" tabindex="0" data-protocol-result aria-label="Operation result" hidden></pre>
</section>
+9 -4
View File
@@ -1,5 +1,5 @@
--- ---
import { href, repository } from '../lib/book'; import { href } from '../lib/book';
--- ---
<div class="root-page" data-root-page> <div class="root-page" data-root-page>
<header class="root-heading"> <header class="root-heading">
@@ -14,10 +14,15 @@ import { href, repository } from '../lib/book';
<span class="root-destination">Book<span class="root-detail">コンセプトと仕様</span></span> <span class="root-destination">Book<span class="root-detail">コンセプトと仕様</span></span>
<span class="root-arrow" aria-hidden="true">→</span> <span class="root-arrow" aria-hidden="true">→</span>
</a> </a>
<a href={repository}> <a href={href('sources/')}>
<span class="root-number" aria-hidden="true">02</span> <span class="root-number" aria-hidden="true">02</span>
<span class="root-destination">Source<span class="root-detail">ソースと変更履歴</span></span> <span class="root-destination">Sources<span class="root-detail">仕様と実装のソース</span></span>
<span class="root-arrow" aria-hidden="true">↗</span> <span class="root-arrow" aria-hidden="true">→</span>
</a>
<a href={href('packages/')}>
<span class="root-number" aria-hidden="true">03</span>
<span class="root-destination">Packages<span class="root-detail">Rustパッケージ一覧</span></span>
<span class="root-arrow" aria-hidden="true">→</span>
</a> </a>
</nav> </nav>
</div> </div>
+2 -1
View File
@@ -14,7 +14,7 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
<script is:inline define:vars={{ intro }}> <script is:inline define:vars={{ intro }}>
// Only a fresh visit to the landing route plays the introduction. // Only a fresh visit to the landing route plays the introduction.
// The window flag survives client navigation, but not a full reload. // The window flag survives client navigation, but not a full reload.
if (intro && !window.__wipVisited && !matchMedia('(prefers-reduced-motion: reduce)').matches && !location.hash) { if (intro && new URL(location.href).searchParams.get('mode') !== 'protocol' && !window.__wipVisited && !matchMedia('(prefers-reduced-motion: reduce)').matches && !location.hash) {
document.documentElement.dataset.landing = 'boot'; document.documentElement.dataset.landing = 'boot';
setTimeout(() => { setTimeout(() => {
if (document.documentElement.dataset.landing === 'boot') delete document.documentElement.dataset.landing; if (document.documentElement.dataset.landing === 'boot') delete document.documentElement.dataset.landing;
@@ -34,6 +34,7 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#171a17" /> <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#171a17" />
<link rel="icon" type="image/svg+xml" href={href('favicon.svg')} /> <link rel="icon" type="image/svg+xml" href={href('favicon.svg')} />
<link rel="preload" href={href('fonts/LythMono-Regular.woff2')} as="font" type="font/woff2" crossorigin="anonymous" /> <link rel="preload" href={href('fonts/LythMono-Regular.woff2')} as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="stylesheet" href={href('fonts/fonts.css')} />
</head> </head>
<body class:list={{ 'landing-page': landing }}> <body class:list={{ 'landing-page': landing }}>
<a class="skip-link" href="#main">本文へスキップ</a> <a class="skip-link" href="#main">本文へスキップ</a>
+64
View File
@@ -0,0 +1,64 @@
/** Root Objects are documents, not containers for the links in their bodies. */
export const catalogPages = [
{ name: 'sources', path: '/sources', title: 'Sources' },
{ name: 'packages', path: '/packages', title: 'Packages' },
] as const;
export type CatalogName = typeof catalogPages[number]['name'];
export interface CatalogEntry {
name: string;
description: string;
links: readonly { label: string; href: string }[];
}
export interface Catalog {
title: string;
description: string;
entries: readonly CatalogEntry[];
}
const rustRepository = 'https://gitea.hareworks.net/Hare/wip-rs';
// README's published-crate order; descriptions are copied from each Cargo manifest.
// These manifests permit publication. Registry availability/version is not inferred.
const rustCrates = [
{ name: 'wip-protocol', description: 'Transport-independent logical model and validation for WIP' },
{ name: 'wip-interface-macros', description: 'Procedural macros for WIP interface declarations and DTO codecs' },
{ name: 'wip-interface', description: 'Public Rust authoring API for WIP interfaces and operation value codecs' },
{ name: 'wip-host', description: 'Host-side object publication and operation dispatch for WIP' },
{ name: 'wip-http', description: 'Framework-independent standard WIP over HTTP v1 binding' },
{ name: 'wip-client', description: 'Presentation-independent stateful WIP client runtime' },
{ name: 'wip-text-view', description: 'Standalone Object and Interface Text View signatures for WIP' },
];
/** Entries are verified against source manifests/READMEs, not registry versions. */
export const catalogs: Record<CatalogName, Catalog> = {
sources: {
title: 'Sources',
description: 'WIPの仕様文書と実装のソースリポジトリ。',
entries: [
{
name: 'wip-reference',
description: 'WIPのコンセプト・仕様文書と、この文書サイト。',
links: [{ label: 'Source', href: 'https://gitea.hareworks.net/Hare/wip-reference' }],
},
{
name: 'wip-rs',
description: 'WIPのProtocol・Interface・Host・HTTP・Client・Text ViewのRust実装。',
links: [{ label: 'Source', href: rustRepository }],
},
],
},
packages: {
title: 'Packages',
description: 'Rust実装のCargo manifestで公開対象になっているcrateの一覧です。レジストリでの公開状況・公開バージョンを示すものではありません。',
entries: rustCrates.map((crate) => ({
...crate,
links: [
{ label: 'Source', href: `${rustRepository}/src/branch/master/crates/${crate.name}` },
{ label: 'Workspace README', href: `${rustRepository}/src/branch/master/README.md` },
],
})),
},
};
+13 -2
View File
@@ -1,3 +1,6 @@
import { catalogPages } from './catalog.ts';
import { playgroundPage } from './playground.ts';
export interface DocumentSource { export interface DocumentSource {
id: string; id: string;
title: string; title: string;
@@ -53,14 +56,22 @@ export function chapterTree(documents: readonly DocumentSource[]): TreeNode[] {
return [nodes[0], ...visit(nodes[0])]; return [nodes[0], ...visit(nodes[0])];
} }
// The public explorer has a single book/ container below the site root. // The public explorer has a book Object below the site root.
export function bookTree(documents: readonly DocumentSource[]): TreeNode[] { export function bookTree(documents: readonly DocumentSource[]): TreeNode[] {
const [root, ...chapters] = chapterTree(documents); const [root, ...chapters] = chapterTree(documents);
return [root, { name: 'book/', path: '/book', parent: '/', depth: 2 }, ...chapters.map((node) => ({ return [root, { name: 'book', path: '/book', parent: '/', depth: 2 }, ...chapters.map((node) => ({
...node, parent: node.parent === '/' ? '/book' : node.parent, depth: node.depth + 1, ...node, parent: node.parent === '/' ? '/book' : node.parent, depth: node.depth + 1,
}))]; }))];
} }
/** Catalogs are leaves; Playground children are discovered through live Observe. */
export function publicTree(documents: readonly DocumentSource[], base = '/'): TreeNode[] {
return [...bookTree(documents), ...[...catalogPages, playgroundPage].map((page) => ({
...page, parent: '/', depth: 2,
href: `${base.replace(/\/$/, '')}${page.path}/`,
}))];
}
export const childrenOf = (nodes: readonly TreeNode[], path: string) => nodes.filter((node) => node.parent === path); export const childrenOf = (nodes: readonly TreeNode[], path: string) => nodes.filter((node) => node.parent === path);
export function visibleNodes(nodes: readonly TreeNode[], collapsed: ReadonlySet<string>): TreeNode[] { export function visibleNodes(nodes: readonly TreeNode[], collapsed: ReadonlySet<string>): TreeNode[] {
+2
View File
@@ -0,0 +1,2 @@
export const playgroundPage = { name: 'playground', path: '/playground', title: 'Playground' } as const;
export const isPlaygroundPath = (path: string) => path === '/playground' || /^\/playground\/[A-Za-z0-9_-]{1,32}$/.test(path);
+69
View File
@@ -0,0 +1,69 @@
import { publicTree, type DocumentSource } from './landing-tree.ts';
export interface RenderedDocument extends DocumentSource {
html: string;
}
export interface RenderedPage {
title: string;
href: string;
html: string;
}
export interface WorldspaceNode extends RenderedPage {
/** Canonical WIP identity, constructed from the actual parent and name. */
path: string;
name: string;
/** Existing explorer identity; never use this as a WIP request path. */
displayPath: string;
parent?: string;
}
export interface WorldspaceData {
nodes: WorldspaceNode[];
}
/**
* Project the public book display hierarchy into a genuine Worldspace hierarchy.
* Bodies come from the site's renderer; this function authors no document content.
* `pages` supplies existing rendered home/index components, keyed by display path.
*/
export function createWorldspaceData(
documents: readonly RenderedDocument[],
pages: Readonly<Record<string, RenderedPage>>,
): WorldspaceData {
// Defense in depth: do not rely on the caller having filtered its collection.
const published = documents.filter((document) => !document.id.startsWith('draft/'));
const sources = new Map<string, RenderedDocument>();
for (const document of published) {
if (document.id.split('/').some((part) => !part || part === '.' || part === '..' || part === 'draft')) {
throw new Error(`Invalid public document id: ${document.id}`);
}
const displayPath = `/${document.id}.md`;
if (sources.has(displayPath)) throw new Error(`Duplicate document: ${document.id}`);
sources.set(displayPath, document);
}
const paths = new Map<string, string>();
const hrefs = new Set<string>();
const nodes = publicTree(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 };
}
+9
View File
@@ -0,0 +1,9 @@
---
import Site from '../../layouts/Site.astro';
import Landing from '../../components/Landing.astro';
import CatalogPage from '../../components/CatalogPage.astro';
import { catalogs } from '../../lib/catalog';
---
<Site title={catalogs.packages.title} description={catalogs.packages.description}>
<main id="main"><Landing currentPath="/packages" label="Packages"><CatalogPage name="packages" /></Landing></main>
</Site>
+8
View File
@@ -0,0 +1,8 @@
---
import Site from '../../layouts/Site.astro';
import Landing from '../../components/Landing.astro';
import PlaygroundPage from '../../components/PlaygroundPage.astro';
---
<Site title="Playground" description="実ProtocolでObjectとカウンターを操作するデモ。">
<main id="main"><Landing currentPath="/playground" label="Playground"><PlaygroundPage /></Landing></main>
</Site>
+9
View File
@@ -0,0 +1,9 @@
---
import Site from '../../layouts/Site.astro';
import Landing from '../../components/Landing.astro';
import CatalogPage from '../../components/CatalogPage.astro';
import { catalogs } from '../../lib/catalog';
---
<Site title={catalogs.sources.title} description={catalogs.sources.description}>
<main id="main"><Landing currentPath="/sources" label="Sources"><CatalogPage name="sources" /></Landing></main>
</Site>
+56
View File
@@ -0,0 +1,56 @@
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 CatalogPage from '../components/CatalogPage.astro';
import PlaygroundPage from '../components/PlaygroundPage.astro';
import { playgroundPage } from '../lib/playground';
import { catalogPages } from '../lib/catalog';
import { documents, bookHref, title, href } from '../lib/book';
import { bookTree } from '../lib/landing-tree';
import { createWorldspaceData, type RenderedPage } from '../lib/worldspace-data';
export const prerender = true;
/** Static build artifact, not a runtime filesystem or network provider. */
export const GET: APIRoute = async () => {
const container = await AstroContainer.create();
const published = await documents();
const rendered = await Promise.all(published.map(async (entry) => {
const { Content } = await render(entry);
return {
id: entry.id, title: title(entry), href: bookHref(entry.id),
html: await container.renderToString(Content),
};
}));
const pages: Record<string, RenderedPage> = {
'/': {
title: 'Worldspace Interface Protocol', href: href(),
html: await container.renderToString(RootPage),
},
[playgroundPage.path]: {
title: playgroundPage.title, href: href('playground/'),
html: await container.renderToString(PlaygroundPage),
},
'/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 } }),
};
}
for (const page of catalogPages) {
pages[page.path] = {
title: page.title, href: href(`${page.name}/`),
html: await container.renderToString(CatalogPage, { props: { name: page.name } }),
};
}
return new Response(JSON.stringify(createWorldspaceData(rendered, pages)), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
};
+171
View File
@@ -0,0 +1,171 @@
import type { TreeNode } from '../lib/landing-tree';
export interface WipNode {
path: string;
name: string;
displayPath: string;
parent?: string;
title: string;
href: string;
}
interface WipDocument {
path: string;
displayPath: string;
title: string;
href: string;
html: string;
}
interface BrowserSession {
tree(): string;
read(path: string): string;
stats(): string;
free(): void;
}
interface BrowserModule {
default(): Promise<unknown>;
BrowserSession: new (manifestJson: string) => BrowserSession;
}
export interface WipStats { observes: number; interfaces: number; calls: number; }
export interface WipPage {
objects: TreeNode[];
nodes: WipNode[];
document: WipDocument;
stats: WipStats;
}
const base = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/`;
let loading: Promise<BrowserSession> | undefined;
let session: BrowserSession | undefined;
let observedTree: unknown;
let disposed = false;
let trapped: WebAssembly.RuntimeError | undefined;
// An abort can leave Rust's borrowed session half-unwound. Never invoke or free
// that instance again; keep the page on SSR until a new browser document loads.
function invoke<T>(operation: () => T): T {
if (trapped) throw trapped;
try { return operation(); }
catch (error) {
if (error instanceof WebAssembly.RuntimeError) {
trapped = error;
// Retain the wrapper: dropping it would let wasm-bindgen's
// FinalizationRegistry re-enter the aborted instance via its destructor.
observedTree = undefined;
}
throw error;
}
}
// Asset fetches only: the Rust Client and Host communicate inside this WASM
// instance. Never tie shared initialization to a particular Astro route's abort.
function getSession(): Promise<BrowserSession> {
if (trapped) return Promise.reject(trapped);
return loading ??= (async () => {
// Use an absolute same-origin URL so Vite does not append ?import and
// attempt to transform this prebuilt public asset in dev. Keep the real
// module URL so wasm-bindgen can resolve its sibling .wasm via import.meta.url.
const moduleURL = new URL(`${base}wip-wasm/wip_browser.js`, location.origin).href;
const [module, response] = await Promise.all([
import(/* @vite-ignore */ moduleURL) as Promise<BrowserModule>,
fetch(`${base}worldspace.json`),
]);
if (!response.ok) throw new Error('WIP manifest asset unavailable');
const manifest = await response.text();
await module.default(); // wasm-bindgen resolves its sibling .wasm asset.
if (disposed) throw new Error('WIP browser document disposed');
session = new module.BrowserSession(manifest);
return session;
})();
}
// Keep the session across ClientRouter swaps and BFCache suspension, but free
// its Rust allocation when the browser document actually goes away.
window.addEventListener('pagehide', (event) => {
if (event.persisted) return;
disposed = true;
if (!trapped) {
session?.free();
session = undefined;
}
// A trapped wrapper stays strongly reachable through this window listener
// until the entire document realm is discarded, including across BFCache.
});
const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object';
const strings = (value: Record<string, unknown>, keys: string[]) => keys.every((key) => typeof value[key] === 'string');
function publicHref(href: string) {
const url = new URL(href, location.href);
if (url.origin !== location.origin || !url.pathname.startsWith(base) || url.search || url.hash) {
throw new Error('Invalid WIP document URL');
}
}
/** Read and validate before mutating the SSR page. Display paths are UI keys;
* canonical paths alone cross the WIP API boundary. */
export async function readWipPage(displayPath: string | undefined, fallback: readonly TreeNode[], signal: AbortSignal): Promise<WipPage | undefined> {
const client = await getSession();
if (signal.aborted || disposed) return;
// This is an immutable build-generated Worldspace. Observe its tree once per
// session; route changes still execute read() through the same Rust Client.
const raw: unknown = observedTree ??= JSON.parse(invoke(() => client.tree()));
if (!Array.isArray(raw) || raw.length !== fallback.length) throw new Error('WIP tree does not match this site build');
const nodes: WipNode[] = raw.map((value: unknown) => {
if (!record(value) || !strings(value, ['path', 'name', 'displayPath', 'title', 'href'])
|| (value.parent !== undefined && typeof value.parent !== 'string')) throw new Error('Invalid WIP tree node');
publicHref(value.href as string);
return value as unknown as WipNode;
});
const canonical = new Map(nodes.map((node) => [node.path, node]));
const displayed = new Map(nodes.map((node) => [node.displayPath, node]));
if (canonical.size !== nodes.length || displayed.size !== nodes.length) throw new Error('Duplicate WIP tree paths');
// Reuse every existing anchor and SVG branch. A stale asset set with a different
// topology cannot safely hydrate this build's persisted tree: fail open instead.
const objects = fallback.map((old) => {
const node = displayed.get(old.path);
const parent = node?.parent === undefined ? undefined : canonical.get(node.parent)?.displayPath;
if (!node || (node.parent !== undefined && !parent) || parent !== old.parent) throw new Error('Incompatible WIP tree topology');
return { path: node.displayPath, parent, depth: old.depth, name: node.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;
}
+25
View File
@@ -0,0 +1,25 @@
import { tokenize } from '@vortexlabs/kirari/core';
import { json } from '@vortexlabs/kirari/languages/json';
const highlighted = new Set(['property', 'string', 'number', 'boolean', 'null', 'punctuation']);
/** Highlight presentation only: never turn response strings into executable HTML. */
export function renderJson(view: HTMLElement, source: string) {
try {
const tokens = tokenize(source, json);
if (tokens.map(token => token.value).join('') !== source) throw new Error('JSON token text mismatch');
const fragment = document.createDocumentFragment();
for (const token of tokens) {
if (highlighted.has(token.type)) {
const span = document.createElement('span');
span.className = `kirari-${token.type}`;
span.textContent = token.value;
fragment.append(span);
} else fragment.append(document.createTextNode(token.value));
}
view.replaceChildren(fragment);
} catch {
// A presentation failure must not discard a successful result or replay a call.
view.textContent = source;
}
}
+176 -18
View File
@@ -1,6 +1,9 @@
import { animate, createTimeline, cubicBezier } from 'animejs'; import { animate, createTimeline, cubicBezier } from 'animejs';
import { childrenOf, visibleNodes, treePrefix, type TreeNode } from '../lib/landing-tree'; import { childrenOf, visibleNodes, treePrefix, type TreeNode } from '../lib/landing-tree';
import { glitchText } from '../lib/landing-ascii.mjs'; import { glitchText } from '../lib/landing-ascii.mjs';
import { applyWipDocument, readWipPage } from './browser-wip';
import { isProtocolMode, setupProtocol } from './protocol';
import { playgroundNodes, playgroundRow, type PlaygroundChild } from './playground-tree';
const clamp = (value: number) => Math.max(0, Math.min(1, value)); const clamp = (value: number) => Math.max(0, Math.min(1, value));
const progress = (time: number, start: number, duration: number) => clamp((time - start) / duration); const progress = (time: number, start: number, duration: number) => clamp((time - start) / duration);
@@ -27,6 +30,8 @@ const circularTrack = 'M0 -180 A180 180 0 1 1 0 180 A180 180 0 1 1 0 -180';
export async function initLanding(lifetime: AbortSignal) { export async function initLanding(lifetime: AbortSignal) {
const element = document.querySelector<HTMLElement>('[data-landing-stage]'); const element = document.querySelector<HTMLElement>('[data-landing-stage]');
if (!element) return; if (!element) return;
element.dataset.wipState = isProtocolMode() ? 'manual' : 'loading';
if (isProtocolMode()) delete document.documentElement.dataset.landing;
// Keep the visible boot controls usable while font metrics are loading. // Keep the visible boot controls usable while font metrics are loading.
const boot = new AbortController(); const boot = new AbortController();
let bootStart = false; let bootStart = false;
@@ -60,6 +65,11 @@ export async function initLanding(lifetime: AbortSignal) {
lifetime.removeEventListener('abort', abortBoot); lifetime.removeEventListener('abort', abortBoot);
if (lifetime.aborted || !element.isConnected) return; if (lifetime.aborted || !element.isConnected) return;
const stage: HTMLElement = element; const stage: HTMLElement = element;
// The persisted pane may contain live rows from the outgoing Protocol page.
// Rebuild them from that session's observation after restoring the static tree.
let pendingPlaygroundFocus = stage.contains(document.activeElement)
? (document.activeElement as HTMLElement)?.closest<HTMLElement>('[data-playground-row] .demo-node')?.dataset.path : undefined;
stage.querySelectorAll('[data-playground-row], [data-playground-toggle]').forEach(row => row.remove());
const objects: TreeNode[] = JSON.parse(stage.querySelector('#landing-documents')!.textContent!); const objects: TreeNode[] = JSON.parse(stage.querySelector('#landing-documents')!.textContent!);
const currentPath = stage.dataset.currentPath; const currentPath = stage.dataset.currentPath;
const html = document.documentElement; const html = document.documentElement;
@@ -212,6 +222,13 @@ export async function initLanding(lifetime: AbortSignal) {
indicator.textContent = expanded ? '−' : '+'; indicator.textContent = expanded ? '−' : '+';
indicator.setAttribute('aria-expanded', String(expanded)); indicator.setAttribute('aria-expanded', String(expanded));
} }
} else {
nodes[index].removeAttribute('aria-expanded');
}
if (object.parent) {
const siblings = childrenOf(objects, object.parent);
nodes[index].setAttribute('aria-setsize', String(siblings.length));
nodes[index].setAttribute('aria-posinset', String(siblings.findIndex(sibling => sibling.path === object.path) + 1));
} }
const prefixElement = rows[index].querySelector<HTMLElement>('.branch-prefix'); const prefixElement = rows[index].querySelector<HTMLElement>('.branch-prefix');
if (prefixElement && paths.has(object.path)) { if (prefixElement && paths.has(object.path)) {
@@ -232,20 +249,22 @@ export async function initLanding(lifetime: AbortSignal) {
} }
} }
// Ordinary anchors own navigation (including modifier clicks and no-JS). // Delegate tree interaction so observed Playground children share keyboard/focus behavior.
nodes.forEach((node, index) => node.addEventListener('click', (event) => { tree.addEventListener('click', (event) => {
if (state !== 'complete') { event.preventDefault(); return; } const node = (event.target as Element).closest<HTMLElement>('.demo-node');
focusNode(index); if (node) {
}, { signal: lifetime })); if (state !== 'complete') { event.preventDefault(); return; }
stage.querySelectorAll<HTMLButtonElement>('[data-toggle-path]').forEach((toggle) => { const index = nodes.indexOf(node);
toggle.addEventListener('click', () => { if (index >= 0) focusNode(index);
if (state !== 'complete') return; }
const toggle = (event.target as Element).closest<HTMLElement>('[data-toggle-path]');
if (toggle && state === 'complete') {
const path = toggle.dataset.togglePath!; const path = toggle.dataset.togglePath!;
collapsed.has(path) ? collapsed.delete(path) : collapsed.add(path); collapsed.has(path) ? collapsed.delete(path) : collapsed.add(path);
updateExpanded(); updateExpanded();
focusNode(objects.findIndex((object) => object.path === path), true); focusNode(objects.findIndex((object) => object.path === path), true);
}, { signal: lifetime }); }
}); }, { signal: lifetime });
tree.addEventListener('keydown', (event) => { tree.addEventListener('keydown', (event) => {
if (state !== 'complete' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; if (state !== 'complete' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
@@ -443,9 +462,103 @@ export async function initLanding(lifetime: AbortSignal) {
if (state !== 'complete') finish(); if (state !== 'complete') finish();
if (!event.persisted) dispose(); if (!event.persisted) dispose();
}, { signal: lifetime }); }, { 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.
let enhancement: AbortController | undefined;
const enhanceNormal = () => {
enhancement?.abort();
enhancement = new AbortController();
const normalSignal = AbortSignal.any([lifetime, enhancement.signal]);
void readWipPage(currentPath, objects, normalSignal).then((page) => {
if (!page || normalSignal.aborted || !stage.isConnected || isProtocolMode()) return;
const article = get<HTMLElement>('[data-selected-content]');
applyWipDocument(article, page);
objects.splice(0, objects.length, ...page.objects);
const byDisplayPath = new Map(page.nodes.map((node) => [node.displayPath, node]));
nodes.forEach((node, index) => {
const object = objects[index];
const result = byDisplayPath.get(object.path)!;
node.dataset.wipPath = result.path;
node.setAttribute('href', result.href);
node.setAttribute('title', result.title);
if (node !== root) node.setAttribute('aria-label', object.name);
const label = node.querySelector<HTMLElement>('.node-label');
if (label) {
label.textContent = object.name;
const glitch = glitchTargets.find((target) => target.element === label);
if (glitch) glitch.text = object.name;
}
const toggle = rows[index].querySelector('[data-toggle-path]');
toggle?.setAttribute('aria-label', `${object.name}を開閉`);
});
// Layout/folding continues to use the returned Object metadata, not the
// embedded SSR tree. Node identities, focus and collapse state stay intact.
const treePosition = { top: viewport.scrollTop, left: viewport.scrollLeft };
updateExpanded();
viewport.scrollTop = treePosition.top;
viewport.scrollLeft = treePosition.left;
stage.dataset.wipPath = page.document.path;
stage.dataset.wipObserves = String(page.stats.observes);
stage.dataset.wipInterfaces = String(page.stats.interfaces);
stage.dataset.wipCalls = String(page.stats.calls);
stage.dataset.wipState = 'ready';
}).catch(() => {
if (normalSignal.aborted || !stage.isConnected || isProtocolMode()) 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';
});
};
const prefixTemplate = rows[objects.findIndex(object => object.depth === 3)].querySelector<HTMLElement>('.branch-prefix')!;
const syncPlayground = (children: readonly PlaygroundChild[] | undefined, selected: string) => {
const parentIndex = objects.findIndex(object => object.path === '/playground');
if (parentIndex < 0 || (!children && !objects.some(object => object.parent === '/playground'))) return;
const added = children ? playgroundNodes(children, objects[parentIndex].href!) : [];
const focusedPath = pendingPlaygroundFocus ?? objects[focused]?.path;
const hadFocus = pendingPlaygroundFocus !== undefined || nodes.includes(document.activeElement as HTMLElement);
pendingPlaygroundFocus = undefined;
for (let index = objects.length - 1; index >= 0; index--) {
if (objects[index].parent !== '/playground') continue;
rows[index].remove(); rows.splice(index, 1); nodes.splice(index, 1); objects.splice(index, 1);
}
let previous = rows[parentIndex];
added.forEach((object, index) => {
const row = playgroundRow(object, prefixTemplate, index);
previous.after(row); previous = row;
const position = parentIndex + index + 1;
objects.splice(position, 0, object); rows.splice(position, 0, row);
nodes.splice(position, 0, row.querySelector<HTMLElement>('.demo-node')!);
});
rows[parentIndex].querySelector('[data-playground-toggle]')?.remove();
if (added.length) {
const toggle = document.createElement('button'); toggle.type = 'button'; toggle.tabIndex = -1;
toggle.className = 'node-toggle node-indicator'; toggle.dataset.togglePath = '/playground'; toggle.dataset.playgroundToggle = '';
toggle.setAttribute('aria-label', 'playgroundを開閉'); rows[parentIndex].append(toggle);
}
if (!selected.startsWith('/playground')) selected = currentPath ?? '/';
if (selected.startsWith('/playground/')) { collapsed.delete('/'); collapsed.delete('/playground'); }
nodes.forEach(node => {
const current = node.dataset.path === selected;
node.setAttribute('aria-selected', String(current));
if (current) node.setAttribute('aria-current', 'page'); else node.removeAttribute('aria-current');
});
focused = Math.max(0, objects.findIndex(object => object.path === (hadFocus ? focusedPath : selected)));
if (hadFocus && !objects.some(object => object.path === focusedPath)) focused = parentIndex;
updateExpanded(); focusNode(focused, hadFocus);
};
setupProtocol(stage, objects, lifetime, (protocol, currentDocument) => {
enhancement?.abort();
stage.dataset.wipState = protocol ? 'manual' : 'loading';
if (!protocol) syncPlayground(undefined, currentPath ?? '/');
if (!protocol && currentDocument) enhanceNormal();
}, syncPlayground);
} }
let navigationInstalled = false;
export function setupExplorerNavigation() { export function setupExplorerNavigation() {
if (navigationInstalled) return;
navigationInstalled = true;
let controller = new AbortController(); let controller = new AbortController();
let activeStage = document.querySelector('[data-landing-stage]'); let activeStage = document.querySelector('[data-landing-stage]');
// Initial page-load waits for font downloads. Install boot controls at module // Initial page-load waits for font downloads. Install boot controls at module
@@ -456,10 +569,59 @@ export function setupExplorerNavigation() {
// Keep this separate from Astro's window scroll and the persistent tree scroll. // Keep this separate from Astro's window scroll and the persistent tree scroll.
const readingPositions = new Map<string, { top: number; left: number }>(); const readingPositions = new Map<string, { top: number; left: number }>();
let readingEntry: string | undefined; let readingEntry: string | undefined;
let readingURL = new URL(location.href);
let entrySequence = 0; 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]'); const article = document.querySelector('[data-selected-content]');
if (article && readingEntry) readingPositions.set(readingEntry, { top: article.scrollTop, left: article.scrollLeft }); 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'); const viewport = document.querySelector('.tree-viewport');
if (viewport) position = { top: viewport.scrollTop, left: viewport.scrollLeft }; if (viewport) position = { top: viewport.scrollTop, left: viewport.scrollLeft };
controller.abort(); controller.abort();
@@ -472,14 +634,10 @@ export function setupExplorerNavigation() {
controller = new AbortController(); controller = new AbortController();
activeStage = stage; 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 }, ''); history.replaceState({ ...history.state, wipReadingEntry: readingEntry }, '');
const article = document.querySelector('[data-selected-content]'); restoreReadingPosition();
const readingPosition = readingPositions.get(readingEntry!);
if (article && readingPosition) {
article.scrollTop = readingPosition.top;
article.scrollLeft = readingPosition.left;
}
if (initialize) { if (initialize) {
const viewport = document.querySelector('.tree-viewport'); const viewport = document.querySelector('.tree-viewport');
if (viewport) { viewport.scrollTop = position.top; viewport.scrollLeft = position.left; } if (viewport) { viewport.scrollTop = position.top; viewport.scrollLeft = position.left; }
+136
View File
@@ -0,0 +1,136 @@
import { animateTextViewLines, collapseTextView } from './text-view';
export interface Operation {
name: string;
parameters: { name: string; required: boolean; type: { kind: string } }[];
}
type Execute = (operation: Operation, argumentsJSON: () => string, form: HTMLFormElement) => void;
function operationForm(operation: Operation, displayName: string, execute: Execute, changed: () => void) {
const form = document.createElement('form');
form.className = 'protocol-call-form'; form.dataset.operationForm = operation.name;
form.setAttribute('aria-label', `${operation.name} arguments`);
const opening = document.createElement('span'); opening.textContent = `${displayName}(`;
const lines: HTMLElement[] = [opening];
form.append(opening);
const readers: (() => [string, unknown] | undefined)[] = [];
for (const parameter of operation.parameters) {
const row = document.createElement('div'); row.className = 'protocol-argument';
const label = document.createElement('label');
const name = document.createElement('span'); name.textContent = `${parameter.name}:`;
label.append(name);
const kind = parameter.type.kind;
const input = document.createElement(kind === 'boolean' ? 'select' : ['string', 'integer', 'number', 'unit'].includes(kind) ? 'input' : 'textarea');
input.setAttribute('aria-label', `${operation.name}.${parameter.name}`);
input.setAttribute('spellcheck', 'false');
if (input instanceof HTMLSelectElement) {
input.append(new Option('false', 'false'), new Option('true', 'true'));
} else {
input.maxLength = 65536;
if (input instanceof HTMLTextAreaElement) {
input.rows = 2; input.placeholder = 'tagged Value JSON';
input.title = 'Tagged Value JSON, e.g. {"kind":"list","value":[]}';
} else {
input.type = 'text'; input.autocomplete = 'off';
input.value = kind === 'integer' ? '1' : kind === 'number' ? '0' : kind === 'unit' ? 'unit' : '';
if (kind === 'unit') input.readOnly = true;
}
}
if (kind === 'string') label.append(document.createTextNode('"'));
label.append(input);
if (kind === 'string') label.append(document.createTextNode('"'));
label.append(document.createTextNode(',')); row.append(label);
let include: HTMLInputElement | undefined;
if (!parameter.required) {
const optional = document.createElement('label'); optional.className = 'protocol-optional';
include = document.createElement('input'); include.type = 'checkbox';
include.setAttribute('aria-label', `Include ${operation.name}.${parameter.name}`);
optional.append(include, document.createTextNode('include')); row.append(optional);
input.disabled = true;
include.addEventListener('change', () => { input.disabled = !include!.checked; });
}
readers.push(() => {
if (include && !include.checked) return undefined;
const value = input.value;
if (kind === 'unit') return [parameter.name, { kind }];
if (kind === 'string' || kind === 'integer') return [parameter.name, { kind, value }];
if (kind === 'boolean') return [parameter.name, { kind, value: value === 'true' }];
if (kind === 'number') {
const number = Number(value);
if (!value.trim() || !Number.isFinite(number)) throw new Error(`${parameter.name}: finite number required`);
return [parameter.name, { kind, value: number }];
}
// Composite/named types retain the existing tagged Value input contract.
return [parameter.name, JSON.parse(value)];
});
form.append(row); lines.push(row);
}
// No-argument calls are one logical line; otherwise the closing call/Execute
// row follows the argument rows with the same cadence as Interface text.
const closing = operation.parameters.length ? document.createElement('span') : opening;
if (closing !== opening) { form.append(closing); lines.push(closing); }
closing.append(document.createTextNode(') '));
const submit = document.createElement('button'); submit.type = 'submit';
submit.textContent = 'Execute'; submit.dataset.protocolCall = operation.name;
submit.setAttribute('aria-label', `Execute ${operation.name}`); closing.append(submit);
form.addEventListener('input', changed);
form.addEventListener('change', changed);
form.addEventListener('submit', event => {
event.preventDefault();
if (!form.isConnected || form.closest('[inert]') || submit.disabled) return;
execute(operation, () => {
try {
return JSON.stringify(Object.fromEntries(readers.flatMap(read => { const entry = read(); return entry ? [entry] : []; })));
} catch (error) { throw new Error(`input: ${String(error)}`); }
}, form);
});
return { form, duration: animateTextViewLines(lines) };
}
/** Rust emits one OperationName per declaration, in Descriptor.operations order.
* Bind by that structural order, never by parsing a displayed/escaped identifier. */
export function attachOperationControls(view: HTMLElement, operations: readonly Operation[], execute: Execute, changed: () => void) {
const declarations = [...view.querySelectorAll<HTMLElement>('.text-view-line')]
.filter(line => line.querySelector('[data-text-kind="operation-name"]'));
if (declarations.length !== operations.length) return false; // Incompatible/plain-text fallback stays read-only.
let opened: { button: HTMLButtonElement; body: HTMLDivElement } | undefined;
const close = (animate: boolean) => {
if (!opened) return;
const { button, body } = opened;
button.setAttribute('aria-expanded', 'false'); button.textContent = '▶ Run';
if (animate) collapseTextView(body);
else { body.hidden = true; body.inert = false; body.replaceChildren(); opened = undefined; }
// Keep a closing body reachable so opening another form retires it immediately.
};
declarations.forEach((line, index) => {
const operation = operations[index];
const displayName = line.querySelector('[data-text-kind="operation-name"]')!.textContent!;
const control = document.createElement('div'); control.className = 'protocol-operation';
control.dataset.operationControl = operation.name;
const button = document.createElement('button'); button.type = 'button';
button.textContent = '▶ Run'; button.setAttribute('aria-label', `Run ${operation.name}`);
button.setAttribute('aria-expanded', 'false');
const body = document.createElement('div'); body.hidden = true;
body.id = `${view.id}-operation-${index}`; button.setAttribute('aria-controls', body.id);
button.addEventListener('click', () => {
if (view.inert || !view.isConnected) return;
changed();
if (opened?.button === button && button.getAttribute('aria-expanded') === 'true') { close(true); return; }
close(false);
const { form, duration } = operationForm(operation, displayName, execute, changed);
const reveal = document.createElement('div'); reveal.className = 'text-view-reveal';
reveal.style.setProperty('--reveal-duration', `${duration}ms`);
const content = document.createElement('div'); content.className = 'text-view-lines';
content.append(form); reveal.append(content); body.replaceChildren(reveal);
body.inert = false; body.hidden = false;
button.setAttribute('aria-expanded', 'true'); button.textContent = '▼ Run';
opened = { button, body };
form.addEventListener('keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); changed(); close(true); button.focus({ preventScroll: true }); }
});
form.querySelector<HTMLElement>('input:not(:disabled), select:not(:disabled), textarea:not(:disabled), button')?.focus({ preventScroll: true });
});
control.append(button, body); line.before(control);
});
return true;
}
+34
View File
@@ -0,0 +1,34 @@
import type { TreeNode } from '../lib/landing-tree';
export interface PlaygroundChild { object: { name: string; description?: string | null }; }
/** Only Observe data supplies tree membership and names, never counter values. */
export function playgroundNodes(children: readonly PlaygroundChild[], href: string): TreeNode[] {
const names = new Set<string>();
if (children.length > 32) throw new Error('Playground child limit exceeded');
return children.map(({ object }) => {
if (!/^[A-Za-z0-9_-]{1,32}$/.test(object.name) || names.has(object.name)) throw new Error('Invalid Playground observation');
names.add(object.name);
const path = `/playground/${object.name}`;
const url = new URL(href, location.href);
url.searchParams.set('mode', 'protocol');
url.searchParams.set('object', path);
return { name: object.name, path, parent: '/playground', depth: 3, href: `${url.pathname}${url.search}` };
});
}
export function playgroundRow(node: TreeNode, prefixTemplate: HTMLElement, index: number): HTMLElement {
const row = document.createElement('div'); row.className = 'node-row'; row.dataset.playgroundRow = '';
const prefix = prefixTemplate.cloneNode(true) as HTMLElement;
const clip = prefix.querySelector('clipPath')!;
clip.id = `playground-join-${index}`;
prefix.querySelector('[clip-path]')!.setAttribute('clip-path', `url(#${clip.id})`);
for (const element of prefix.querySelectorAll<SVGElement>('[style]')) element.removeAttribute('style');
const anchor = document.createElement('a'); anchor.className = 'demo-node'; anchor.href = node.href!;
anchor.dataset.path = node.path; anchor.dataset.wipPath = node.path;
anchor.setAttribute('role', 'treeitem'); anchor.setAttribute('aria-level', '3'); anchor.tabIndex = -1;
anchor.setAttribute('aria-label', node.name);
const label = document.createElement('span'); label.className = 'node-label'; label.textContent = node.name;
anchor.append(label); row.append(prefix, anchor);
return row;
}
+327
View File
@@ -0,0 +1,327 @@
import type { TreeNode } from '../lib/landing-tree';
import { isPlaygroundPath } from '../lib/playground';
import type { PlaygroundChild } from './playground-tree';
import { renderJson } from './json-view';
import { attachOperationControls, type Operation } from './operation-form';
import { collapseTextView, renderTextView, revealTextViewLines, type TextView } from './text-view';
interface Stats { observes: number; interfaces: number; calls: number; }
interface Reference { scope: string; name: string; }
interface Observation { object: { name: string; description?: string | null; interfaces: Reference[] }; children?: Observation[] | null; }
interface Descriptor { operations: Operation[]; }
interface Observed extends TextView { path: string; observation: Observation; cached: boolean; stats: Stats; }
interface Fetched extends TextView { reference: Reference; descriptor: Descriptor; cached: boolean; stats: Stats; }
interface Called { path: string; interface: Reference; operation: string; result: unknown; stats: Stats; }
interface ManualSession {
observe(path: string, depth: number): string;
fetch_interface(scope: string, name: string): string;
call_operation(path: string, scope: string, name: string, operation: string, args: string): string;
stats(): string;
free(): void;
}
interface ManualModule { default(): Promise<unknown>; BrowserSession: new (manifest: string) => ManualSession; }
let loading: Promise<ManualSession> | undefined;
let session: ManualSession | undefined;
let trapped: WebAssembly.RuntimeError | undefined;
let disposed = false;
let playgroundSnapshot: Observation[] | undefined;
// Knowledge belongs to the manual session, never the automatic Read session.
// Descriptors themselves stay in the bounded Rust Client cache; expanding an
// entry uses fetch_interface, which reuses fresh observations without exchange.
const knownInterfaces = new Set<string>();
const referenceKey = (reference: Reference) => JSON.stringify([reference.scope, reference.name]);
const base = `${import.meta.env.BASE_URL.replace(/\/$/, '')}/`;
function invoke<T>(action: () => T): T {
if (trapped) throw trapped;
if (disposed) throw new Error('Protocol document disposed');
try { return action(); }
catch (error) {
// Retain the wrapper so its generated FinalizationRegistry cannot re-enter
// an aborted Rust instance. Normal mode owns a different module/instance.
if (error instanceof WebAssembly.RuntimeError) trapped = error;
throw error;
}
}
function getSession(): Promise<ManualSession> {
if (trapped) return Promise.reject(trapped);
return loading ??= (async () => {
const url = new URL(`${base}wip-wasm/wip_browser.js`, location.origin);
url.searchParams.set('instance', 'protocol');
const [module, response] = await Promise.all([
import(/* @vite-ignore */ url.href) as Promise<ManualModule>, fetch(`${base}worldspace.json`),
]);
if (!response.ok) throw new Error('WIP manifest asset unavailable');
const manifest = await response.text();
await module.default();
session = invoke(() => new module.BrowserSession(manifest));
return session;
})();
}
window.addEventListener('pagehide', (event) => {
if (event.persisted) return;
disposed = true;
if (!trapped) { session?.free(); session = undefined; }
});
export const isProtocolMode = () => new URL(location.href).searchParams.get('mode') === 'protocol';
function message(error: unknown): string {
if (error instanceof WebAssembly.RuntimeError) return 'WASM trap: このsessionは使用できません。再読み込みが必要です。';
if (typeof error === 'string') {
try {
const data = JSON.parse(error);
if (typeof data.message === 'string') return `${data.kind ?? 'error'}: ${data.message}`;
} catch { /* wasm-bindgen can also return ordinary input error strings. */ }
}
return String(error);
}
/** The SSR tree selects addresses only; it is not an observation. All protocol
* data below comes from explicit interactions on an independent Rust Client. */
export function setupProtocol(stage: HTMLElement, objects: readonly TreeNode[], signal: AbortSignal, onMode: (protocol: boolean, currentDocument: boolean) => void, onPlayground: (children: readonly PlaygroundChild[] | undefined, selected: string) => void) {
const documentPath = new URL(objects.find(node => node.path === stage.dataset.currentPath)?.href ?? location.href, location.href).pathname;
const find = <T extends HTMLElement>(selector: string) => stage.querySelector<T>(selector)!;
const panel = find<HTMLElement>('[data-protocol-panel]');
const article = find<HTMLElement>('[data-selected-content]');
const switches = find<HTMLElement>('[data-mode-switch]');
const interfaces = find<HTMLElement>('[data-protocol-interfaces]');
const interfaceRows: { reference: Reference; button: HTMLButtonElement; view: HTMLDivElement }[] = [];
const objectView = find<HTMLElement>('[data-object-view]');
const result = find<HTMLElement>('[data-protocol-result]');
const status = find<HTMLElement>('[data-protocol-status]');
const showStatus = (text: string) => { status.textContent = text; status.hidden = !text; };
const disableControls = () => panel.querySelectorAll<HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('button, input, select, textarea').forEach(control => { control.disabled = true; });
const canonical = new Map<string, string>();
for (const node of objects) {
const parent = node.parent === undefined ? undefined : canonical.get(node.parent);
const name = node.path === '/book' ? 'book' : node.name;
canonical.set(node.path, parent === undefined ? '/' : `${parent === '/' ? '' : parent}/${name}`);
}
const routePath = canonical.get(stage.dataset.currentPath ?? '') ?? stage.dataset.currentPath ?? '/';
const pathFromURL = () => {
const path = new URL(location.href).searchParams.get('object');
return routePath === '/playground' && path && isPlaygroundPath(path) ? path : routePath;
};
let selectedPath = pathFromURL();
panel.dataset.path = selectedPath;
let references: Reference[] = [];
let fetched: Fetched | undefined;
let observedPath: string | undefined;
let client: ManualSession | undefined;
let generation = 0;
const active = () => !signal.aborted && stage.isConnected && isProtocolMode();
const stats = (value: Stats) => {
panel.dataset.observes = String(value.observes);
panel.dataset.interfaces = String(value.interfaces);
panel.dataset.calls = String(value.calls);
};
const refreshStats = () => { if (client && !trapped) stats(JSON.parse(invoke(() => client!.stats()))); };
const resetResult = () => { result.textContent = ''; result.hidden = true; };
const resetInterface = (closingView?: HTMLElement) => {
fetched = undefined;
for (const { reference, button, view } of interfaceRows) {
button.textContent = `${knownInterfaces.has(referenceKey(reference)) ? '>' : '?'} ${reference.scope}::${reference.name}`;
button.setAttribute('aria-expanded', 'false');
if (view === closingView) collapseTextView(view);
else {
view.hidden = true; view.inert = false;
view.textContent = '';
}
}
resetResult();
};
const resetObservation = () => {
observedPath = undefined; references = [];
objectView.textContent = ''; objectView.hidden = true;
resetInterface();
interfaceRows.length = 0;
interfaces.replaceChildren();
};
const fail = (error: unknown) => {
showStatus(message(error));
if (trapped) {
panel.dataset.state = 'trapped';
disableControls();
} else refreshStats();
};
const run = (action: (session: ManualSession) => void) => {
if (!active() || !client) return;
try { showStatus(''); action(client); } catch (error) { fail(error); }
};
const updateLinks = () => {
for (const anchor of stage.querySelectorAll<HTMLAnchorElement>('.demo-node')) {
const original = objects.find((node) => node.path === anchor.dataset.path)?.href;
if (!original) continue;
const url = new URL(original, location.href);
if (isProtocolMode()) url.searchParams.set('mode', 'protocol');
anchor.setAttribute('href', isProtocolMode() ? `${url.pathname}${url.search}${url.hash}` : original);
}
};
const showMode = () => {
const protocol = isProtocolMode();
const current = ++generation;
selectedPath = pathFromURL(); panel.dataset.path = selectedPath;
panel.hidden = !protocol; article.hidden = protocol;
stage.dataset.mode = protocol ? 'protocol' : 'read';
switches.hidden = false;
for (const button of switches.querySelectorAll<HTMLButtonElement>('button')) {
button.setAttribute('aria-pressed', String(button.dataset.mode === stage.dataset.mode));
}
updateLinks();
resetObservation();
// During cross-route history traversal this is still the outgoing DOM.
// Cancel its work now, but only the destination may resume normal reads.
onMode(protocol, location.pathname === documentPath);
if (!protocol) return;
onPlayground(playgroundSnapshot, selectedPath);
updateLinks();
disableControls();
panel.dataset.state = 'loading';
showStatus('Objectを読み込み中…');
// History changes the URL before swapping its DOM. Only the destination
// stage may observe, never the outgoing Object while its page is loading.
if (location.pathname !== documentPath) return;
void getSession().then((loaded) => {
if (!active() || current !== generation || location.pathname !== documentPath) return;
client = loaded;
refreshStats();
panel.dataset.state = 'ready';
// Protocol always has the route's selected Object, including first entry,
// mode switches, reloads and history. There is no unselected UI state.
run(observe);
}).catch((error) => {
if (!active() || current !== generation) return;
panel.dataset.state = trapped ? 'trapped' : 'unavailable';
showStatus(`${message(error)} — Readで静的本文を閲覧できます。`);
});
};
for (const button of switches.querySelectorAll<HTMLButtonElement>('button')) {
button.addEventListener('click', () => {
if ((button.dataset.mode === 'protocol') === isProtocolMode()) return;
const url = new URL(location.href);
if (button.dataset.mode === 'protocol') url.searchParams.set('mode', 'protocol');
else url.searchParams.delete('mode');
history.replaceState(history.state, '', url);
showMode();
}, { signal });
}
// Astro can traverse fragments without a DOM swap. Synchronize immediately
// on popstate, also aborting deferred Read enhancement before route fetches.
document.addEventListener('astro:before-preparation', () => { generation++; }, { signal });
window.addEventListener('popstate', () => {
if (location.pathname !== documentPath) generation++;
if ((stage.dataset.mode === 'protocol') !== isProtocolMode()
|| (location.pathname === documentPath && selectedPath !== pathFromURL())) showMode();
}, { signal, capture: true });
const observePlayground = (session: ManualSession) => {
try {
const parent: Observed = JSON.parse(invoke(() => session.observe('/playground', 1)));
if (parent.path !== '/playground' || !Array.isArray(parent.observation.children)) throw new Error('Playground observation unavailable');
onPlayground(parent.observation.children, selectedPath);
playgroundSnapshot = parent.observation.children;
updateLinks();
return parent;
} catch (error) {
// Do not carry an unmarked stale snapshot into another Protocol route.
playgroundSnapshot = undefined;
onPlayground(undefined, selectedPath);
throw error;
}
};
const observe = (session: ManualSession) => {
resetObservation();
const parent = isPlaygroundPath(selectedPath) ? observePlayground(session) : undefined;
const observed: Observed = selectedPath === '/playground' && parent ? parent : JSON.parse(invoke(() => session.observe(selectedPath, 1)));
observedPath = observed.path;
references = observed.observation.object.interfaces;
renderTextView(objectView, observed); objectView.hidden = false;
interfaces.replaceChildren();
references.forEach((reference, index) => {
const row = document.createElement('div'); row.className = 'protocol-interface';
const button = document.createElement('button'); button.type = 'button';
button.dataset.interfaceToggle = '';
button.textContent = `${knownInterfaces.has(referenceKey(reference)) ? '>' : '?'} ${reference.scope}::${reference.name}`;
button.setAttribute('aria-expanded', 'false');
button.title = JSON.stringify(reference);
const view = document.createElement('div');
view.className = 'protocol-signature protocol-interface-view'; view.dataset.interfaceView = '';
view.setAttribute('role', 'region');
view.id = `protocol-interface-view-${index}`; view.tabIndex = 0;
view.setAttribute('aria-label', 'Interface Text View'); view.hidden = true;
button.setAttribute('aria-controls', view.id);
interfaceRows.push({ reference, button, view });
button.addEventListener('click', () => run((session) => {
if (selectedPath !== observedPath) throw new Error('Objectを観測してください。');
const collapse = button.getAttribute('aria-expanded') === 'true';
resetInterface(collapse ? view : undefined);
if (collapse) return;
try {
const value = JSON.parse(invoke(() => session.fetch_interface(reference.scope, reference.name))) as Fetched;
knownInterfaces.add(referenceKey(reference));
fetched = value;
button.textContent = `v ${reference.scope}::${reference.name}`;
button.setAttribute('aria-expanded', 'true');
renderTextView(view, value); revealTextViewLines(view);
if (!attachOperationControls(view, value.descriptor.operations,
(operation, argumentsJSON, form) => call(value, operation, argumentsJSON, form), resetResult)) {
showStatus('Operationの宣言位置を確認できないため実行欄を表示できません。再読み込みしてください。');
}
view.hidden = false;
stats(value.stats);
} catch (error) {
// A failure is still unknown, not a successfully acquired disclosure.
knownInterfaces.delete(referenceKey(reference));
resetInterface();
throw error;
}
}), { signal });
row.append(button, view);
interfaces.append(row);
});
stats(observed.stats);
};
stage.addEventListener('click', (event) => {
const anchor = (event.target as Element).closest<HTMLAnchorElement>('.demo-node');
if (!anchor || !active() || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey
|| event.shiftKey || event.altKey || anchor.hasAttribute('download')
|| (anchor.target && anchor.target !== '_self')) return;
const url = new URL(anchor.href);
if (url.origin !== location.origin || url.pathname !== documentPath) return;
if (routePath === '/playground' && isPlaygroundPath(anchor.dataset.path ?? '')) {
event.preventDefault();
if (url.href !== location.href) history.pushState(history.state, '', url);
showMode();
} else if (url.search === location.search) {
// A same-route click still observes the current Object without a swap.
event.preventDefault(); resetObservation();
if (client) run(observe);
}
}, { signal });
const call = (target: Fetched, operation: Operation, argumentsJSON: () => string, form: HTMLFormElement) => run((session) => {
if (fetched !== target || selectedPath !== observedPath || !form.isConnected || form.closest('[inert]')) return;
resetResult();
const ref = target.reference;
const called: Called = JSON.parse(invoke(() => session.call_operation(selectedPath, ref.scope, ref.name, operation.name, argumentsJSON())));
renderJson(result, JSON.stringify(called, null, 2)); result.hidden = false;
stats(called.stats);
if (isPlaygroundPath(selectedPath)) {
try {
const parent = observePlayground(session);
const observed: Observed = selectedPath === '/playground' ? parent : JSON.parse(invoke(() => session.observe(selectedPath, 1)));
renderTextView(objectView, observed); objectView.hidden = false;
observedPath = observed.path;
stats(observed.stats);
} catch (error) {
// The call already succeeded. Preserve its result; never replay it to recover a refresh.
objectView.hidden = true;
observedPath = undefined; fetched = undefined;
disableControls();
fail(error);
showStatus(`Operationは成功しましたが再Observeに失敗しました: ${message(error)}`);
}
}
});
signal.addEventListener('abort', () => { generation++; }, { once: true });
showMode();
}
+99
View File
@@ -0,0 +1,99 @@
export interface TextViewToken { kind: string; text: string; }
export interface TextView { textView: string; textViewTokens?: readonly TextViewToken[]; }
// Only semantic roles from wip-text-view may select a style. Layout and unknown
// future kinds remain plain text, never HTML, selectors or operation identities.
const highlighted = new Set([
'keyword', 'type-name', 'operation-name', 'parameter-name', 'field-name',
'case-name', 'string', 'reference', 'documentation', 'symbol',
]);
export function renderTextView(view: HTMLElement, { textView, textViewTokens }: TextView) {
// A missing/mismatched asset must not change the signature being displayed.
if (!Array.isArray(textViewTokens) || textViewTokens.some(token => !token
|| typeof token.text !== 'string' || typeof token.kind !== 'string')
|| textViewTokens.map(token => token.text).join('') !== textView) {
view.textContent = textView;
return;
}
const fragment = document.createDocumentFragment();
for (const token of textViewTokens) {
if (highlighted.has(token.kind)) {
const span = document.createElement('span');
span.dataset.textKind = token.kind;
span.textContent = token.text;
fragment.append(span);
} else fragment.append(document.createTextNode(token.text));
}
view.replaceChildren(fragment);
}
const LINE_DURATION_MS = 10;
/** Shared by Interface text and the logical rows of an inline call form. */
export function animateTextViewLines(lines: readonly HTMLElement[]) {
lines.forEach((element, index) => {
element.classList.add('text-view-line');
element.style.setProperty('--line-duration', `${LINE_DURATION_MS}ms`);
element.style.setProperty('--line-delay', `${index * LINE_DURATION_MS}ms`);
});
return Math.max(1, lines.length) * LINE_DURATION_MS;
}
/** Expand in normal flow and fade logical lines, preserving text and styling. */
export function revealTextViewLines(view: HTMLElement) {
const fragment = document.createDocumentFragment();
const lines: HTMLSpanElement[] = [];
let line = document.createElement('span');
const flush = () => {
if (!line.hasChildNodes()) return;
lines.push(line); fragment.append(line);
line = document.createElement('span');
};
// renderTextView emits only text nodes and flat token spans. Splitting also
// handles plain-text fallback; WIP escapes and Unicode are never reinterpreted.
for (const node of [...view.childNodes]) {
const parts = (node.textContent ?? '').split('\n');
parts.forEach((text, index) => {
if (index) { flush(); fragment.append(document.createTextNode('\n')); }
if (!text) return;
const part = node.cloneNode(false);
part.textContent = text; line.append(part);
});
}
flush();
const duration = animateTextViewLines(lines);
// Animate the intrinsic height, not a guessed max-height: following rows move
// with the reveal, including wrapped lines. Fresh wrappers also restart on reopen.
const wrapperTag = view.tagName === 'PRE' ? 'span' : 'div';
const reveal = document.createElement(wrapperTag); reveal.className = 'text-view-reveal';
reveal.style.setProperty('--reveal-duration', `${duration}ms`);
const content = document.createElement(wrapperTag); content.className = 'text-view-lines';
content.append(fragment); reveal.append(content);
view.replaceChildren(reveal);
}
/** Close from the current rendered height, even if the reveal is still running. */
export function collapseTextView(view: HTMLElement) {
const reveal = view.firstElementChild as HTMLElement | null;
const finish = () => {
// A reopen or Interface switch owns a new wrapper; never clear that content.
if (view.firstElementChild !== reveal) return;
view.hidden = true; view.inert = false; view.replaceChildren();
};
if (!reveal) { finish(); return; }
reveal.style.setProperty('--collapse-height', `${reveal.getBoundingClientRect().height}px`);
const lines = [...reveal.querySelectorAll<HTMLElement>('.text-view-line')];
reveal.style.setProperty('--reveal-duration', `${Math.max(1, lines.length) * LINE_DURATION_MS}ms`);
lines.forEach((line, index) => {
line.style.setProperty('--collapse-opacity', getComputedStyle(line).opacity);
line.style.setProperty('--line-close-delay', `${(lines.length - index - 1) * LINE_DURATION_MS}ms`);
});
view.inert = true;
reveal.classList.add('text-view-closing');
const animation = reveal.getAnimations()[0];
// No animation under reduced-motion/print. Live preference changes cancel the
// CSS animation and settle the same cleanup, without a timer or stale listener.
if (animation) void animation.finished.then(finish, finish);
else finish();
}
-1
View File
@@ -1,4 +1,3 @@
@import './fonts.css';
@import './theme.css'; @import './theme.css';
:root { :root {
+24
View File
@@ -0,0 +1,24 @@
/* Kirari's JSON token classes, scoped to responses. Keep the existing pre layout. */
.protocol-json {
--kirari-property: #205f9c;
--kirari-string: #2d6841;
--kirari-number: #8a4b18;
--kirari-boolean: #713f9c;
--kirari-null: #62685c;
}
.protocol-json .kirari-property { color: var(--kirari-property); }
.protocol-json .kirari-string { color: var(--kirari-string); }
.protocol-json .kirari-number { color: var(--kirari-number); }
.protocol-json .kirari-boolean { color: var(--kirari-boolean); }
.protocol-json .kirari-null { color: var(--kirari-null); }
.protocol-json .kirari-punctuation { color: var(--ink); }
@media screen and (prefers-color-scheme: dark) {
.protocol-json {
--kirari-property: #90bff0;
--kirari-string: #acd699;
--kirari-number: #e6b580;
--kirari-boolean: #d6a6f0;
--kirari-null: #a7b09e;
}
}
+4
View File
@@ -37,6 +37,10 @@ html[data-landing] .tree-viewport { overflow: visible; }
.node-indicator { color: var(--muted); } .node-indicator { color: var(--muted); }
.node-toggle { display: none; border: 0; padding: 0 1ch; height: var(--row); background: transparent; cursor: pointer; } .node-toggle { display: none; border: 0; padding: 0 1ch; height: var(--row); background: transparent; cursor: pointer; }
.demo-tree[data-enhanced] .node-toggle { display: block; } .demo-tree[data-enhanced] .node-toggle { display: block; }
@media (hover: hover) {
.node-toggle { opacity: 0; pointer-events: none; }
.node-row:hover .node-toggle, .node-row:focus-within .node-toggle { opacity: 1; pointer-events: auto; }
}
.node-toggle:hover { background: var(--node-hover); } .node-toggle:hover { background: var(--node-hover); }
html[data-landing] .node-toggle { visibility: hidden; } html[data-landing] .node-toggle { visibility: hidden; }
.document-pane { min-width: 0; } .document-pane { min-width: 0; }
+28
View File
@@ -0,0 +1,28 @@
.document-pane { position: relative; }
.mode-switch { position: absolute; top: 0; right: 0; display: flex; gap: 1ch; }
.mode-switch[hidden], .protocol-panel[hidden], .protocol-panel [hidden] { display: none; }
.mode-switch button, .protocol-panel button { border: 1px solid var(--line); background: var(--surface); color: var(--ink); padding: 0 1ch; cursor: pointer; }
.mode-switch button[aria-pressed="true"] { background: var(--accent); color: var(--node-selected-ink); }
.mode-switch button:focus-visible, .protocol-panel :is(button, input, select, textarea, pre, .protocol-interface-view):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.protocol-panel { margin-top: calc(2 * var(--row)); max-height: calc(100svh - 8 * var(--row)); overflow: auto; padding: 0 1ch 1ch; color: var(--ink); font: 14px/1.6 var(--mono); }
.protocol-panel label { display: block; margin-bottom: 1ch; }
.protocol-panel :is(select, textarea) { display: block; width: 100%; min-width: 0; box-sizing: border-box; font: inherit; color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 0; padding: .3ch .5ch; }
.protocol-panel textarea { resize: vertical; }
.protocol-panel :disabled { opacity: .6; cursor: not-allowed; }
.protocol-panel pre, .protocol-interface-view { box-sizing: border-box; max-width: 100%; margin: 0; padding: 0; border: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; background: transparent; color: inherit; }
.protocol-panel [data-protocol-result] { margin-top: 1ch; }
.protocol-operation { margin-inline-start: 2ch; white-space: normal; user-select: none; }
.protocol-operation button { font: inherit; border: 0; border-radius: 0; background: var(--code-bg); color: var(--code-ink); padding: 0 .5ch; }
.protocol-operation button:is(:hover, :focus-visible) { background: var(--accent); color: var(--node-selected-ink); }
.protocol-call-form { margin: 0; padding: .3ch; white-space: pre-wrap; overflow-wrap: anywhere; }
.protocol-argument { padding-inline-start: 2ch; display: flex; flex-wrap: wrap; gap: .5ch; }
.protocol-panel .protocol-argument label { display: flex; align-items: baseline; gap: .3ch; margin: 0; min-width: 0; max-width: 100%; }
.protocol-argument label > span { overflow-wrap: anywhere; }
.protocol-panel .protocol-argument :is(input:not([type="checkbox"]), select, textarea) { display: inline-block; width: 16ch; min-width: 0; max-width: 100%; box-sizing: border-box; font: inherit; background: var(--surface); color: var(--ink); border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 0 .3ch; user-select: text; }
.protocol-argument .protocol-optional { color: var(--muted); font-size: .85em; }
.protocol-interface { margin-block: .5ch; }
.protocol-panel .protocol-interface > button { border: 0; border-radius: 0; background: var(--code-bg); color: var(--code-ink); padding: 0 .5ch; font: inherit; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
.protocol-panel .protocol-interface > button:is(:hover, :focus-visible) { background: var(--accent); color: var(--node-selected-ink); }
[data-protocol-status] { margin: 0; color: var(--muted); overflow-wrap: anywhere; }
@media (max-width: 800px) { .protocol-panel { max-height: none; margin-top: var(--row); padding-inline: 0; } }
@media print { .mode-switch { display: none; } .protocol-panel { max-height: none; overflow: visible; } }
+71
View File
@@ -0,0 +1,71 @@
/* Token roles come from Rust's structured Text View, not a client-side lexer. */
.protocol-signature {
--tv-keyword: #713f9c;
--tv-type: #14656f;
--tv-operation: #205f9c;
--tv-member: #8a4b18;
--tv-string: #2d6841;
--tv-reference: #0c6c78;
--tv-documentation: #62685c;
}
.protocol-signature [data-text-kind="keyword"] { color: var(--tv-keyword); }
.protocol-signature [data-text-kind="type-name"] { color: var(--tv-type); }
.protocol-signature [data-text-kind="operation-name"] { color: var(--tv-operation); }
.protocol-signature :is([data-text-kind="parameter-name"], [data-text-kind="field-name"], [data-text-kind="case-name"]) { color: var(--tv-member); }
.protocol-signature [data-text-kind="string"] { color: var(--tv-string); }
.protocol-signature [data-text-kind="reference"] { color: var(--tv-reference); }
.protocol-signature [data-text-kind="documentation"] { color: var(--tv-documentation); }
.protocol-signature [data-text-kind="symbol"] { color: var(--ink); }
.protocol-signature .text-view-reveal {
display: grid;
grid-template-rows: 1fr;
animation: text-view-expand var(--reveal-duration) ease-out backwards;
}
.protocol-signature .text-view-lines {
min-height: 0;
overflow: hidden;
}
@keyframes text-view-expand {
from { grid-template-rows: 0fr; }
to { grid-template-rows: 1fr; }
}
.protocol-signature .text-view-line {
animation: text-view-line-in var(--line-duration) ease-out var(--line-delay) backwards;
}
@keyframes text-view-line-in {
from { opacity: 0; }
to { opacity: 1; }
}
.protocol-signature .text-view-closing {
overflow: hidden;
animation: text-view-collapse var(--reveal-duration) ease-out both;
}
.protocol-signature .text-view-closing .text-view-line {
animation: text-view-line-out var(--line-duration) ease-out var(--line-close-delay) both;
}
@keyframes text-view-collapse {
from { height: var(--collapse-height); }
to { height: 0; }
}
@keyframes text-view-line-out {
from { opacity: var(--collapse-opacity); }
to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce), print {
.protocol-signature :is(.text-view-reveal, .text-view-line),
.protocol-signature .text-view-closing .text-view-line { animation: none; }
}
/* Colors only: text, spacing, font metrics and copied content stay unchanged.
Print uses the light palette even when the screen theme is dark. */
@media screen and (prefers-color-scheme: dark) {
.protocol-signature {
--tv-keyword: #d6a6f0;
--tv-type: #82d2ca;
--tv-operation: #90bff0;
--tv-member: #e6b580;
--tv-string: #acd699;
--tv-reference: #84cedd;
--tv-documentation: #a7b09e;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { expect, test } from '@playwright/test';
for (const javaScriptEnabled of [false, true]) {
test.describe(javaScriptEnabled ? 'WASM tree' : 'static tree', () => {
test.use({ javaScriptEnabled, reducedMotion: 'reduce' });
test('book uses its Object name without a directory suffix', async ({ page }, info) => {
await page.goto('./');
if (javaScriptEnabled) await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
const book = page.locator('.demo-node[data-path="/book"]');
await expect(book.locator('.node-label')).toHaveText('book');
await expect(book).toHaveAttribute('aria-label', 'book');
await expect(book).toHaveAttribute('href', new URL('book/', info.project.use.baseURL!).pathname);
await book.click();
await expect(page).toHaveURL(new URL('book/', info.project.use.baseURL!).href);
if (javaScriptEnabled) await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await expect(book.locator('.node-label')).toHaveText('book');
});
});
}
+6 -2
View File
@@ -23,7 +23,8 @@ export const draftPaths = [
'/draft/typescript-api-model.md', '/draft/typescript-api-model.md',
'/draft/unresolved-issues.md', '/draft/unresolved-issues.md',
]; ];
export const treePaths = ['/', '/book', ...documentPaths]; export const catalogPaths = ['/sources', '/packages'];
export const treePaths = ['/', '/book', ...documentPaths, ...catalogPaths, '/playground'];
export const treeNodeCount = treePaths.length; export const treeNodeCount = treePaths.length;
export const chapterSections: Record<string, string[]> = { export const chapterSections: Record<string, string[]> = {
'/1-concept.md': ['/1.1-interaction-abstraction.md', '/1.2-existing-abstractions.md', '/1.3-design-principles.md', '/1.4-concepts-and-scope.md'], '/1-concept.md': ['/1.1-interaction-abstraction.md', '/1.2-existing-abstractions.md', '/1.3-design-principles.md', '/1.4-concepts-and-scope.md'],
@@ -31,9 +32,12 @@ export const chapterSections: Record<string, string[]> = {
'/4-client.md': ['/4.1-ai-tools.md', '/4.2-text-view.md'], '/4-client.md': ['/4.1-ai-tools.md', '/4.2-text-view.md'],
'/5-transports.md': ['/5.1-wip-over-http.md'], '/5-transports.md': ['/5.1-wip-over-http.md'],
}; };
export const treeParents: Record<string, string> = Object.fromEntries(treePaths.filter((path) => path !== '/').map((path) => [path, path === '/book' ? '/' : '/book'])); export const treeParents: Record<string, string> = Object.fromEntries(treePaths.filter((path) => path !== '/').map((path) => [path, path === '/book' || catalogPaths.includes(path) || path === '/playground' ? '/' : '/book']));
for (const [chapter, sections] of Object.entries(chapterSections)) { for (const [chapter, sections] of Object.entries(chapterSections)) {
for (const section of sections) treeParents[section] = chapter; for (const section of sections) treeParents[section] = chapter;
} }
export const rootChildCount = treePaths.filter((path) => treeParents[path] === '/').length; export const rootChildCount = treePaths.filter((path) => treeParents[path] === '/').length;
export const routeURL = (path: string, base: string) => path === '/' ? new URL('./', base).href
: path === '/book' || catalogPaths.includes(path) || path === '/playground' ? new URL(`${path.slice(1)}/`, base).href : bookURL(path, base);
export const bookURL = (path: string, base: string) => new URL(`book/${path.slice(1, -3).split('/').map(encodeURIComponent).join('/')}/`, base).href; export const bookURL = (path: string, base: string) => new URL(`book/${path.slice(1, -3).split('/').map(encodeURIComponent).join('/')}/`, base).href;
+33 -4
View File
@@ -5,19 +5,28 @@ test.use({ javaScriptEnabled: true });
test('self-hosted Lyth Mono and EB Garamond load with the configured base', async ({ page }, testInfo) => { test('self-hosted Lyth Mono and EB Garamond load with the configured base', async ({ page }, testInfo) => {
const external: string[] = []; const external: string[] = [];
const fontResponses: { url: string; status: number }[] = [];
page.on('response', (response) => {
if (response.request().resourceType() === 'font') fontResponses.push({ url: response.url(), status: response.status() });
});
page.on('request', (request) => { page.on('request', (request) => {
if (request.resourceType() === 'font' && !request.url().startsWith(testInfo.project.use.baseURL!)) external.push(request.url()); if (request.resourceType() === 'font' && !request.url().startsWith(testInfo.project.use.baseURL!)) external.push(request.url());
}); });
await page.goto('./'); // Cold dev startup may reload the document while modules initialize. Let
// initial asset traffic settle before probing FontFaceSet in that document.
await page.goto('./', { waitUntil: 'networkidle' });
const fonts = await page.evaluate(async () => { const fonts = await page.evaluate(async () => {
await document.fonts.ready; await document.fonts.ready;
const mono = await document.fonts.load('14px "Lyth Mono"', '0W│├└─'); const mono = await document.fonts.load('14px "Lyth Mono"', '0W│├└─');
const bold = await document.fonts.load('700 14px "Lyth Mono"', 'WIP'); const bold = await document.fonts.load('700 14px "Lyth Mono"', 'WIP');
const serif = await document.fonts.load('16px "EB Garamond"', 'Worldspace'); const serif = await document.fonts.load('16px "EB Garamond"', 'Worldspace');
const italic = await document.fonts.load('italic 16px "EB Garamond"', 'Worldspace'); const italic = await document.fonts.load('italic 16px "EB Garamond"', 'Worldspace');
const serifBold = await document.fonts.load('700 16px "EB Garamond"', 'Worldspace');
const boldItalic = await document.fonts.load('italic 700 16px "EB Garamond"', 'Worldspace');
return { return {
mono: mono.map((font) => font.status), bold: bold.map((font) => font.status), mono: mono.map((font) => font.status), bold: bold.map((font) => font.status),
serif: serif.map((font) => font.status), italic: italic.map((font) => font.status), serif: serif.map((font) => font.status), italic: italic.map((font) => font.status),
serifBold: serifBold.map((font) => font.status), boldItalic: boldItalic.map((font) => font.status),
stack: getComputedStyle(document.body).fontFamily, stack: getComputedStyle(document.body).fontFamily,
prefix: document.querySelector('.branch-prefix')!.textContent!.trim(), prefix: document.querySelector('.branch-prefix')!.textContent!.trim(),
}; };
@@ -26,6 +35,14 @@ test('self-hosted Lyth Mono and EB Garamond load with the configured base', asyn
expect(fonts.bold).toEqual(['loaded']); expect(fonts.bold).toEqual(['loaded']);
expect(fonts.serif).toEqual(['loaded']); expect(fonts.serif).toEqual(['loaded']);
expect(fonts.italic).toEqual(['loaded']); expect(fonts.italic).toEqual(['loaded']);
expect(fonts.serifBold).toEqual(['loaded']);
expect(fonts.boldItalic).toEqual(['loaded']);
const filenames = ['LythMono-Regular', 'LythMono-Bold', 'EBGaramond-Regular', 'EBGaramond-Bold', 'EBGaramond-Italic', 'EBGaramond-BoldItalic'];
expect([...new Set(fontResponses.map(({ url }) => url))].sort()).toEqual(
filenames.map((name) => new URL(`fonts/${name}.woff2`, testInfo.project.use.baseURL!).href).sort(),
);
// The dev server may revalidate the preloaded regular face after a reload.
expect(fontResponses.filter(({ status }) => status !== 200 && status !== 304)).toEqual([]);
expect(fonts.stack).toContain('Lyth Mono'); expect(fonts.stack).toContain('Lyth Mono');
expect(fonts.prefix).toBe(''); // SSR uses the same glyph paths, not HTML wires. expect(fonts.prefix).toBe(''); // SSR uses the same glyph paths, not HTML wires.
expect(external).toEqual([]); expect(external).toEqual([]);
@@ -45,6 +62,18 @@ test('self-hosted Lyth Mono and EB Garamond load with the configured base', asyn
expect(await page.locator('.tree-pane .root-vector .trunk-reveal').evaluate((rect) => (rect as SVGRectElement).height.baseVal.value)).toBeGreaterThan(0); expect(await page.locator('.tree-pane .root-vector .trunk-reveal').evaluate((rect) => (rect as SVGRectElement).height.baseVal.value)).toBeGreaterThan(0);
expect(await page.locator('article.prose').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond'); expect(await page.locator('article.prose').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
await page.screenshot({ path: testInfo.outputPath('eb-garamond-book.png') }); await page.screenshot({ path: testInfo.outputPath('eb-garamond-book.png') });
// A fresh nested route must resolve fonts relative to their stylesheet, not
// relative to the page URL, without relying on ClientRouter's persisted DOM.
await page.reload();
const reloaded = await page.evaluate(async () => {
await document.fonts.ready;
return [
(await document.fonts.load('14px "Lyth Mono"', 'WIP')).map((font) => font.status),
(await document.fonts.load('16px "EB Garamond"', 'Worldspace')).map((font) => font.status),
];
});
expect(reloaded).toEqual([['loaded'], ['loaded']]);
expect(external).toEqual([]);
}); });
test.describe('static font and tree rendering', () => { test.describe('static font and tree rendering', () => {
@@ -63,7 +92,7 @@ test.describe('static font and tree rendering', () => {
const gapEnd = new DOMPoint((3 * (level - 2) + 3) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!); const gapEnd = new DOMPoint((3 * (level - 2) + 3) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!);
return { x: p.x, y: p.y, end: end.x, gapEnd: gapEnd.x, level }; return { x: p.x, y: p.y, end: end.x, gapEnd: gapEnd.x, level };
})); }));
expect(rootChildCount).toBe(1); expect(rootChildCount).toBe(4);
expect(joins.filter(({ level }) => level === 2)).toHaveLength(rootChildCount); expect(joins.filter(({ level }) => level === 2)).toHaveLength(rootChildCount);
expect(joins.filter(({ level }) => level === 3)).toHaveLength(Object.values(treeParents).filter((parent) => parent === '/book').length); expect(joins.filter(({ level }) => level === 3)).toHaveLength(Object.values(treeParents).filter((parent) => parent === '/book').length);
for (const [index, join] of joins.entries()) { for (const [index, join] of joins.entries()) {
@@ -120,10 +149,10 @@ test.describe('static font and tree rendering', () => {
await expect(page.locator('html')).not.toHaveAttribute('data-landing'); await expect(page.locator('html')).not.toHaveAttribute('data-landing');
await expect(page.locator('.trunk-line')).toBeVisible(); await expect(page.locator('.trunk-line')).toBeVisible();
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none'); await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(0); await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(0);
await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(3); await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(3);
await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9492'); await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9500');
await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5); await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5);
await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible(); await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+115
View File
@@ -0,0 +1,115 @@
import { expect, test, type Page } from '@playwright/test';
import { tokenize } from '@vortexlabs/kirari/core';
import { json } from '@vortexlabs/kirari/languages/json';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
const result = (page: Page) => page.getByLabel('Operation result', { exact: true });
async function readDocument(page: Page, base: string) {
await page.goto(new URL('?mode=protocol', base).href);
await page.getByRole('button', { name: '? /::document', exact: true }).click();
await page.getByRole('button', { name: 'Run read', exact: true }).click();
await page.getByRole('button', { name: 'Execute read', exact: true }).click();
await expect(result(page)).toBeVisible();
}
for (const scheme of ['light', 'dark'] as const) {
test(`Kirari JSON tokens preserve response text and use readable ${scheme} colors`, async ({ page }, info) => {
await page.emulateMedia({ colorScheme: scheme });
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
// Preserve the real call and attach a display-only fixture for JSON kinds
// not emitted by this site's current Operations (booleans and null).
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.call_operation; BrowserSession.prototype.call_operation = function(...args) { const value = JSON.parse(original.apply(this,args)); value.preview = { enabled: true, disabled: false, empty: null, fraction: -1.25e-8, nested: [1, { 'escaped"key': '日本語 🐈 \\" <img src=x onerror="window.__jsonExecuted=1"> & \\n\\t' }] }; window.__jsonResponse = value; return JSON.stringify(value); };` });
});
await readDocument(page, info.project.use.baseURL!);
const source = await page.evaluate(() => JSON.stringify((window as unknown as { __jsonResponse: unknown }).__jsonResponse, null, 2));
const view = result(page);
expect(await view.textContent()).toBe(source);
expect(await view.innerText()).toBe(source);
const expected = tokenize(source, json).filter(token => token.type !== 'text').map(token => ({ className: `kirari-${token.type}`, text: token.value }));
expect(await view.locator('span').evaluateAll(elements => elements.map(element => ({ className: element.className, text: element.textContent })))).toEqual(expected);
for (const type of ['property', 'string', 'number', 'boolean', 'null', 'punctuation']) await expect(view.locator(`.kirari-${type}`).first()).toBeVisible();
await expect(view.locator('img, script, a, style, iframe')).toHaveCount(0);
expect(await page.evaluate(() => '__jsonExecuted' in window)).toBe(false);
const selected = await view.evaluate(element => {
const range = document.createRange(); range.selectNodeContents(element);
const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range);
const text = selection.toString(); selection.removeAllRanges(); return text;
});
expect(selected).toBe(source);
const palette = await view.evaluate(element => {
const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--paper)'; element.append(probe);
const background = getComputedStyle(probe).backgroundColor; probe.remove();
return { background, colors: [...element.querySelectorAll('span')].map(span => getComputedStyle(span).color) };
});
const luminance = (color: string) => {
const rgb = color.match(/[\d.]+/g)!.slice(0, 3).map(Number).map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return .2126 * rgb[0] + .7152 * rgb[1] + .0722 * rgb[2];
};
expect(new Set(palette.colors).size).toBe(6);
for (const color of palette.colors) {
const a = luminance(color), b = luminance(palette.background);
expect((Math.max(a, b) + .05) / (Math.min(a, b) + .05)).toBeGreaterThanOrEqual(4.5);
}
const key = view.locator('.kirari-property').first();
const before = await key.evaluate(element => getComputedStyle(element).color);
await page.emulateMedia({ colorScheme: scheme === 'light' ? 'dark' : 'light' });
await expect(key).not.toHaveCSS('color', before);
expect(await view.textContent()).toBe(source);
await page.emulateMedia({ colorScheme: scheme });
await page.screenshot({ path: info.outputPath(`json-${scheme}.png`), fullPage: true });
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
await page.getByRole('button', { name: 'Run read', exact: true }).click();
await expect(view).toBeHidden(); await expect(view.locator('span')).toHaveCount(0);
});
}
test('real response markup stays literal and each new result replaces the old highlighted JSON', async ({ page }, info) => {
await page.route('**/worldspace.json', async route => {
const response = await route.fetch(); const data = await response.json();
data.nodes[0].html = '<script>window.__jsonExecuted=1</script><img src=x onerror="window.__jsonExecuted=1">日本語 🐈 & " \\';
await route.fulfill({ response, json: data });
});
await readDocument(page, info.project.use.baseURL!);
const view = result(page);
await expect(view.locator('.kirari-string').filter({ hasText: '<script>' })).toHaveCount(1);
await expect(view.locator('script, img')).toHaveCount(0);
expect(await page.evaluate(() => '__jsonExecuted' in window)).toBe(false);
await page.locator('.demo-node[data-path="/playground"]').click();
await page.locator('.demo-node[data-path="/playground/apple"]').click();
await expect(view).toBeHidden(); await expect(view.locator('span')).toHaveCount(0);
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(view.locator('.kirari-string').filter({ hasText: '<script>' })).toHaveCount(0);
const called = JSON.parse((await view.textContent())!);
expect(called.result).toEqual({ kind: 'integer', value: '4' });
expect(called.stats.calls).toBe(2);
await expect(view.locator('.kirari-number')).toHaveCount(3); // calls, interfaces, observes
await expect(page.locator('.demo-node[data-path="/playground/apple"]')).toHaveText('apple');
await expect(page.locator('.node-value')).toHaveCount(0);
await page.getByLabel('increment.amount', { exact: true }).fill('2');
await expect(view).toBeHidden(); await expect(view.locator('span')).toHaveCount(0);
});
test('a highlighting failure keeps the successful result as text without replaying the call', async ({ page }, info) => {
await page.goto(new URL('playground/?mode=protocol&object=%2Fplayground%2Fapple', info.project.use.baseURL!).href);
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
await page.evaluate(() => {
const original = document.createDocumentFragment.bind(document);
document.createDocumentFragment = () => { document.createDocumentFragment = original; throw new Error('presentation unavailable'); };
});
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
const view = result(page);
await expect(view).toBeVisible(); await expect(view.locator('span')).toHaveCount(0);
expect(JSON.parse((await view.textContent())!).result).toEqual({ kind: 'integer', value: '4' });
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
await expect(page.locator('.demo-node[data-path="/playground/apple"]')).toHaveText('apple');
await expect(page.locator('.node-value')).toHaveCount(0);
await expect(page.locator('[data-protocol-status]')).toBeHidden();
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(view.locator('.kirari-property').first()).toBeVisible();
expect(JSON.parse((await view.textContent())!).result).toEqual({ kind: 'integer', value: '5' });
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '2');
});
+21 -17
View File
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { documentPaths, treePaths, treeNodeCount, bookURL, chapterSections, treeParents } from './documents'; import { documentPaths, treePaths, treeNodeCount, bookURL, routeURL, chapterSections, treeParents } from './documents';
test.use({ javaScriptEnabled: true }); test.use({ javaScriptEnabled: true });
@@ -11,7 +11,7 @@ const visiblePrefixes = '.node-row:not([hidden]) .branch-prefix';
async function expectNodeGlitch(page: import('@playwright/test').Page) { async function expectNodeGlitch(page: import('@playwright/test').Page) {
expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.some((node) => expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.some((node) =>
node.querySelector('.node-label')!.textContent !== ((node as HTMLElement).dataset.path === '/book' ? 'book/' : (node as HTMLElement).dataset.path!.split('/').at(-1)) node.querySelector('.node-label')!.textContent !== ((node as HTMLElement).dataset.path === '/book' ? 'book' : (node as HTMLElement).dataset.path!.split('/').at(-1))
))).toBe(true); ))).toBe(true);
} }
@@ -511,7 +511,7 @@ for (const time of [1500, 2600]) {
for (const row of await page.locator('.node-row').all()) await expect(row).toHaveCSS('transform', 'none'); for (const row of await page.locator('.node-row').all()) await expect(row).toHaveCSS('transform', 'none');
await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none'); await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none');
await expect(page.locator('.site-header')).toHaveCSS('opacity', '1'); await expect(page.locator('.site-header')).toHaveCSS('opacity', '1');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
await expect(page.locator('.intro-art')).toBeHidden(); await expect(page.locator('.intro-art')).toBeHidden();
await page.clock.runFor(4000); await page.clock.runFor(4000);
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
@@ -533,7 +533,7 @@ test('skipping an active node glitch restores exact filenames without altering d
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await complete(page); await complete(page);
await expect(page.locator('[data-glitching]')).toHaveCount(0); await expect(page.locator('[data-glitching]')).toHaveCount(0);
for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path === '/book' ? 'book/' : path.split('/').at(-1)!); for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path === '/book' ? 'book' : path.split('/').at(-1)!);
await page.clock.runFor(2000); await page.clock.runFor(2000);
expect(await page.locator(selectedContent).innerHTML()).toBe(html); expect(await page.locator(selectedContent).innerHTML()).toBe(html);
await page.locator('.root-mark').hover(); await page.locator('.root-mark').hover();
@@ -578,7 +578,7 @@ test('landing contains only the real filename tree and selected canonical conten
await expect(folder).toHaveAttribute('aria-expanded', 'true'); await expect(folder).toHaveAttribute('aria-expanded', 'true');
await expect(folder).toHaveAttribute('aria-level', path === '/' ? '1' : '2'); await expect(folder).toHaveAttribute('aria-level', path === '/' ? '1' : '2');
if (path === '/book') { if (path === '/book') {
await expect(folder.locator('.node-label')).toHaveText('book/'); await expect(folder.locator('.node-label')).toHaveText('book');
const toggle = page.locator('.node-toggle[data-toggle-path="/book"]'); const toggle = page.locator('.node-toggle[data-toggle-path="/book"]');
await expect(toggle).toHaveText('−'); await expect(toggle).toHaveText('−');
expect(await toggle.evaluate((button) => button.tagName)).toBe('BUTTON'); expect(await toggle.evaluate((button) => button.tagName)).toBe('BUTTON');
@@ -617,7 +617,8 @@ test('landing contains only the real filename tree and selected canonical conten
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
await expect(page.locator(selectedContent + ' .root-page[data-root-page]')).toHaveCount(1); await expect(page.locator(selectedContent + ' .root-page[data-root-page]')).toHaveCount(1);
await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト'); await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト');
expect(await page.locator('.landing h1, .landing h2, .landing h3, .landing h4, .landing h5, .landing h6').evaluateAll((headings) => headings.every((heading) => heading.closest('[data-selected-content]')))).toBe(true); await expect(page.locator('[data-protocol-panel]')).toBeHidden();
expect(await page.locator('.landing :is(h1, h2, h3, h4, h5, h6):visible').evaluateAll((headings) => headings.every((heading) => heading.closest('[data-selected-content]')))).toBe(true);
await checkGlyphRefs(page); await checkGlyphRefs(page);
}); });
@@ -630,9 +631,10 @@ test('numbered chapters nest visually, fold independently, and retain section UR
await expect(chapter).toHaveAttribute('aria-level', '3'); await expect(chapter).toHaveAttribute('aria-level', '3');
await expect(child).toHaveAttribute('aria-level', '4'); await expect(child).toHaveAttribute('aria-level', '4');
const prefix = child.locator('..').locator('.branch-prefix'); const prefix = child.locator('..').locator('.branch-prefix');
await expect(prefix).toHaveAttribute('data-prefix', ' │ ├─ '); await expect(prefix).toHaveAttribute('data-prefix', '│ │ ├─ ');
await expect(nodeAt(page, '/1.4-concepts-and-scope.md').locator('..').locator('.branch-prefix')).toHaveAttribute('data-prefix', ' │ └─ '); await expect(nodeAt(page, '/1.4-concepts-and-scope.md').locator('..').locator('.branch-prefix')).toHaveAttribute('data-prefix', '│ │ └─ ');
const before = await page.locator(selectedContent).innerHTML(); const before = await page.locator(selectedContent).innerHTML();
await nodeAt(page, '/1-concept.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click(); await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
for (const path of chapterSections[initialPath]) await expect(nodeAt(page, path)).toBeHidden(); for (const path of chapterSections[initialPath]) await expect(nodeAt(page, path)).toBeHidden();
await expect(nodeAt(page, '/2-core-model.md')).toBeVisible(); await expect(nodeAt(page, '/2-core-model.md')).toBeVisible();
@@ -717,7 +719,7 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft');
await expect(book).toHaveAttribute('aria-expanded', 'false'); await expect(book).toHaveAttribute('aria-expanded', 'false');
for (const path of documentPaths) await expect(nodeAt(page, path)).toBeHidden(); for (const path of documentPaths) await expect(nodeAt(page, path)).toBeHidden();
await expect(page.getByRole('treeitem')).toHaveCount(2); await expect(page.getByRole('treeitem')).toHaveCount(5);
await unchanged(); await unchanged();
await checkGlyphRefs(page); await checkGlyphRefs(page);
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight');
@@ -743,7 +745,7 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount); await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
await checkGlyphRefs(page); await checkGlyphRefs(page);
await page.keyboard.press('End'); await page.keyboard.press('End');
const finalPath = documentPaths.at(-1)!; const finalPath = '/playground';
const finalLeaf = nodeAt(page, finalPath); const finalLeaf = nodeAt(page, finalPath);
await expect(finalLeaf).toBeFocused(); await expect(finalLeaf).toBeFocused();
await expect(finalLeaf).toHaveAttribute('aria-selected', 'false'); await expect(finalLeaf).toHaveAttribute('aria-selected', 'false');
@@ -754,19 +756,19 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li
if (scroll.needsScroll) expect(scroll.top).toBeGreaterThan(0); if (scroll.needsScroll) expect(scroll.top).toBeGreaterThan(0);
expect(scroll.inside).toBe(true); expect(scroll.inside).toBe(true);
await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp');
const previousPath = '/5-transports.md'; const previousPath = '/packages';
await expect(nodeAt(page, previousPath)).toBeFocused(); await expect(nodeAt(page, previousPath)).toBeFocused();
await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown');
await expect(finalLeaf).toBeFocused(); await expect(finalLeaf).toBeFocused();
await unchanged(); await unchanged();
await expect(page.locator('.demo-node[tabindex="0"]')).toHaveCount(1); await expect(page.locator('.demo-node[tabindex="0"]')).toHaveCount(1);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await expect(page).toHaveURL(bookURL(finalPath, testInfo.project.use.baseURL!)); await expect(page).toHaveURL(routeURL(finalPath, testInfo.project.use.baseURL!));
await expect(finalLeaf).toHaveAttribute('aria-current', 'page'); await expect(finalLeaf).toHaveAttribute('aria-current', 'page');
await expect(page.locator(selectedContent)).toBeVisible(); await expect(page.locator(selectedContent)).toBeVisible();
await nodeAt(page, previousPath).focus(); await nodeAt(page, previousPath).focus();
await page.keyboard.press('Space'); await page.keyboard.press('Space');
await expect(page).toHaveURL(bookURL(previousPath, testInfo.project.use.baseURL!)); await expect(page).toHaveURL(routeURL(previousPath, testInfo.project.use.baseURL!));
await expect(nodeAt(page, previousPath)).toHaveAttribute('aria-current', 'page'); await expect(nodeAt(page, previousPath)).toHaveAttribute('aria-current', 'page');
await expect(page.locator(selectedContent)).toBeVisible(); await expect(page.locator(selectedContent)).toBeVisible();
}); });
@@ -863,14 +865,15 @@ test.describe('glyph trunk regressions', () => {
await book.focus(); await book.focus();
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft');
await expect(book).toHaveAttribute('aria-expanded', 'false'); await expect(book).toHaveAttribute('aria-expanded', 'false');
// Book is the sole root child: its descendants never extend the first-column spine. // Collapsing Book moves the following catalog leaves up and shortens the root spine.
expect(await checkEnd('book')).toBe(expanded); const collapsed = await checkEnd('book');
expect(collapsed).toBeLessThan(expanded);
await page.keyboard.press('Home'); await page.keyboard.press('Home');
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft');
await expect(page.getByRole('treeitem')).toHaveCount(1); await expect(page.getByRole('treeitem')).toHaveCount(1);
await checkEnd('root'); await checkEnd('root');
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight');
expect(await checkEnd('root-reopened')).toBe(expanded); expect(await checkEnd('root-reopened')).toBe(collapsed);
await book.focus(); await book.focus();
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight');
expect(await checkEnd('all-reopened')).toBe(expanded); expect(await checkEnd('all-reopened')).toBe(expanded);
@@ -892,7 +895,8 @@ test.describe('JavaScript-free glyph tree', () => {
await expect(home).toContainText('Worldspace Interface Protocol'); await expect(home).toContainText('Worldspace Interface Protocol');
const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }); const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', testInfo.project.use.baseURL!).pathname); await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', testInfo.project.use.baseURL!).pathname);
await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', (await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href'))!); await expect(nav.getByRole('link', { name: /^Sources(?:\s|$)/ })).toHaveAttribute('href', new URL('sources/', testInfo.project.use.baseURL!).pathname);
await expect(nav.getByRole('link', { name: /^Packages(?:\s|$)/ })).toHaveAttribute('href', new URL('packages/', testInfo.project.use.baseURL!).pathname);
const initialHTML = await page.locator(selectedContent).innerHTML(); const initialHTML = await page.locator(selectedContent).innerHTML();
await expect(page.locator('.static-guides, .guide-body, .ascii-panel')).toHaveCount(0); await expect(page.locator('.static-guides, .guide-body, .ascii-panel')).toHaveCount(0);
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount); await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
+259
View File
@@ -0,0 +1,259 @@
import { expect, test, type Page } from '@playwright/test';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
const form = (page: Page, name: string) => page.getByRole('form', { name: `${name} arguments`, exact: true });
async function collection(page: Page, base: string) {
await page.goto(new URL('playground/?mode=protocol', base).href);
await page.getByRole('button', { name: '? /::collection', exact: true }).click();
}
test('Run belongs between each Operation comment and declaration; inline arguments call the real Host', async ({ page }, info) => {
await collection(page, info.project.use.baseURL!);
await expect(page.locator('[data-protocol-operation], [data-protocol-operation-controls], [data-protocol-arguments]')).toHaveCount(0);
const positions = await page.locator('[data-operation-control]').evaluateAll(controls => controls.map(control => ({
operation: (control as HTMLElement).dataset.operationControl,
previous: control.previousElementSibling?.textContent,
next: control.nextElementSibling?.textContent,
inInterface: Boolean(control.closest('[data-interface-view]')),
})));
expect(positions.map(position => position.operation)).toEqual(['create', 'remove']);
for (const position of positions) {
expect(position.previous?.trim()).toMatch(/^\/\/\//);
expect(position.next?.trim()).toBe(`operation ${position.operation}(`);
expect(position.inInterface).toBe(true);
}
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
await page.getByRole('button', { name: 'Run create', exact: true }).focus();
await page.keyboard.press('Enter');
const create = form(page, 'create');
await expect(create).toBeVisible();
await expect(create.getByLabel('create.name', { exact: true })).toBeFocused();
expect(await create.textContent()).toBe('create(name:"",) Execute');
expect(await create.evaluate(element => element.closest('[data-interface-view]')!.getAnimations({ subtree: true }).length)).toBe(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
await create.getByLabel('create.name', { exact: true }).fill('banana');
await page.screenshot({ path: info.outputPath('inline-create.png'), fullPage: true });
await page.keyboard.press('Enter');
await expect(page.locator('.demo-node[data-path="/playground/banana"]')).toHaveText('banana');
await expect(page.locator('.node-value')).toHaveCount(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
await expect(page.getByLabel('Operation result')).toContainText('create');
await page.getByRole('button', { name: 'Run remove', exact: true }).click();
await expect(create).toHaveCount(0);
await expect(page.getByLabel('Operation result')).toBeHidden();
await form(page, 'remove').getByLabel('remove.name', { exact: true }).fill('banana');
await page.getByRole('button', { name: 'Execute remove', exact: true }).click();
await expect(page.locator('.demo-node[data-path="/playground/banana"]')).toHaveCount(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '2');
await form(page, 'remove').getByLabel('remove.name', { exact: true }).press('Escape');
await expect(form(page, 'remove')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Run remove', exact: true })).toBeFocused();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test('inline call expansion and collapse move the declaration at 10ms per line without stale cleanup', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await collection(page, info.project.use.baseURL!);
const toggle = page.getByRole('button', { name: 'Run create', exact: true });
const frames = await toggle.evaluate(button => {
button.closest('[data-interface-view]')!.getAnimations({ subtree: true }).forEach(animation => animation.finish());
const declaration = button.parentElement!.nextElementSibling!;
const body = button.nextElementSibling as HTMLElement;
const offset = () => declaration.getBoundingClientRect().top - button.getBoundingClientRect().top;
const before = offset();
(button as HTMLButtonElement).click();
const animation = body.firstElementChild!.getAnimations()[0];
const duration = Number(animation.effect!.getComputedTiming().duration);
const animations = body.getAnimations({ subtree: true });
animations.forEach(animation => animation.pause());
const opening = [0, .5, 1].map(progress => {
animations.forEach(animation => { animation.currentTime = progress * duration; });
return { height: body.getBoundingClientRect().height, offset: offset() };
});
animations.forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
const closingAnimation = body.firstElementChild!.getAnimations()[0];
const closingAnimations = body.getAnimations({ subtree: true });
closingAnimations.forEach(animation => animation.pause());
const closing = [0, .5, 1].map(progress => {
closingAnimations.forEach(animation => { animation.currentTime = progress * duration; });
return { height: body.getBoundingClientRect().height, offset: offset() };
});
closingAnimations.forEach(animation => animation.finish());
return { before, opening, closing, duration: animation.effect!.getComputedTiming().duration, closeDuration: closingAnimation.effect!.getComputedTiming().duration };
});
expect(frames.duration).toBe(30); expect(frames.closeDuration).toBe(30);
expect(frames.opening[0].height).toBe(0);
expect(frames.opening[1].height).toBeGreaterThan(0);
expect(frames.opening[2].height).toBeGreaterThan(frames.opening[1].height);
expect(frames.closing[1].height).toBeLessThan(frames.closing[0].height);
expect(frames.closing[2].height).toBe(0);
for (const frame of [...frames.opening, ...frames.closing]) expect(frame.offset - frames.before).toBeCloseTo(frame.height, 1);
await expect(form(page, 'create')).toHaveCount(0);
await toggle.evaluate(async button => {
const click = () => (button as HTMLButtonElement).click();
click(); click();
const body = button.nextElementSibling!;
const old = body.firstElementChild!.getAnimations()[0];
old.pause(); old.currentTime = 5;
const settled = old.finished.catch(() => {});
click(); body.getAnimations({ subtree: true }).forEach(animation => animation.finish());
await settled;
});
await expect(form(page, 'create')).toBeVisible();
await expect(form(page, 'create').getByLabel('create.name', { exact: true })).toBeEditable();
await page.evaluate(() => {
const create = document.querySelector<HTMLFormElement>('[data-operation-form="create"]')!;
create.querySelector('input')!.value = 'must_not_run';
document.querySelector<HTMLButtonElement>('[data-operation-control="create"] > button')!.click();
document.querySelector<HTMLButtonElement>('[data-operation-control="remove"] > button')!.click();
create.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
await expect(page.locator('[data-operation-form]')).toHaveCount(1);
await expect(form(page, 'remove')).toBeVisible();
await page.evaluate(() => {
const remove = document.querySelector<HTMLFormElement>('[data-operation-form="remove"]')!;
remove.querySelector('input')!.value = 'apple';
document.querySelector<HTMLButtonElement>('[data-interface-toggle][aria-expanded="true"]')!.click();
remove.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
});
await expect(page.locator('[data-operation-control], [data-operation-form]')).toHaveCount(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
test('Run rows share Interface fades: 10ms each, top-down open and bottom-up close', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
for (const [iface, operation, count] of [['collection', 'create', 3], ['document', 'read', 1]] as const) {
await page.getByRole('button', { name: `? /::${iface}`, exact: true }).click();
const measured = await page.getByRole('button', { name: `Run ${operation}`, exact: true }).evaluate(button => {
button.closest('[data-interface-view]')!.getAnimations({ subtree: true }).forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
const body = button.nextElementSibling!;
const lines = [...body.querySelectorAll('.text-view-line')];
const timings = () => lines.map(line => {
const animation = line.getAnimations()[0];
return { name: (animation as CSSAnimation).animationName, ...animation.effect!.getTiming() };
});
const opacity = () => lines.map(line => Number(getComputedStyle(line).opacity));
const animations = body.getAnimations({ subtree: true });
animations.forEach(animation => { animation.pause(); animation.currentTime = 5; });
const opening = { timing: timings(), opacity: opacity() };
const duration = body.firstElementChild!.getAnimations()[0].effect!.getComputedTiming().duration;
animations.forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
const closingAnimations = body.getAnimations({ subtree: true });
closingAnimations.forEach(animation => { animation.pause(); animation.currentTime = 5; });
const closing = { timing: timings(), opacity: opacity() };
closingAnimations.forEach(animation => animation.finish());
return { opening, closing, duration };
});
expect(measured.duration).toBe(count * 10);
expect(measured.opening.timing).toHaveLength(count);
measured.opening.timing.forEach((timing, index) => {
expect(timing.name).toBe('text-view-line-in');
expect(timing.duration).toBe(10); expect(timing.delay).toBe(index * 10);
});
expect(measured.opening.opacity[0]).toBeGreaterThan(0);
expect(measured.opening.opacity[0]).toBeLessThan(1);
expect(measured.opening.opacity.slice(1)).toEqual(Array(count - 1).fill(0));
measured.closing.timing.forEach((timing, index) => {
expect(timing.name).toBe('text-view-line-out');
expect(timing.duration).toBe(10); expect(timing.delay).toBe((count - index - 1) * 10);
});
expect(measured.closing.opacity.at(-1)).toBeGreaterThan(0);
expect(measured.closing.opacity.at(-1)).toBeLessThan(1);
expect(measured.closing.opacity.slice(0, -1)).toEqual(Array(count - 1).fill(1));
await expect(form(page, operation)).toHaveCount(0);
}
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
test('Run line fades settle on live reduced-motion and print changes', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await collection(page, info.project.use.baseURL!);
const toggle = page.getByRole('button', { name: 'Run create', exact: true });
await toggle.evaluate(button => {
button.closest('[data-interface-view]')!.getAnimations({ subtree: true }).forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
button.nextElementSibling!.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = 5; });
});
await page.emulateMedia({ reducedMotion: 'reduce' });
const create = form(page, 'create');
await expect(create).toBeVisible();
expect(await create.evaluate(element => [...element.querySelectorAll('.text-view-line')].map(line => getComputedStyle(line).opacity))).toEqual(['1', '1', '1']);
expect(await create.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
await toggle.click(); await expect(create).toHaveCount(0);
await page.emulateMedia({ reducedMotion: 'no-preference' });
await toggle.evaluate(button => {
(button as HTMLButtonElement).click();
const body = button.nextElementSibling!;
body.getAnimations({ subtree: true }).forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
body.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = 5; });
});
await page.emulateMedia({ media: 'print' });
await expect(create).toHaveCount(0);
});
test('integer inputs preserve decimal text and no-argument calls still require Execute', async ({ page }, info) => {
await page.goto(new URL('playground/?mode=protocol&object=%2Fplayground%2Fapple', info.project.use.baseURL!).href);
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
const amount = form(page, 'increment').getByLabel('increment.amount', { exact: true });
for (const invalid of ['1.5', '9007199254740993', '1e2']) {
await amount.fill(invalid);
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
await expect(page.locator('.node-value')).toHaveCount(0);
await expect(page.getByLabel('Operation result')).toBeHidden();
}
await amount.fill('-2'); await amount.press('Enter');
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '1' });
await expect(page.locator('.demo-node[data-path="/playground/apple"]')).toHaveText('apple');
await page.getByRole('button', { name: 'Run get', exact: true }).click();
await expect(form(page, 'get')).toHaveText('get() Execute');
await expect(form(page, 'get').locator('input, textarea, select')).toHaveCount(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
await page.getByRole('button', { name: 'Execute get', exact: true }).click();
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '2');
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '1' });
});
test('missing Interface tokens retain literal text without guessing execution targets', async ({ page }, info) => {
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.fetch_interface; BrowserSession.prototype.fetch_interface = function(...args) { const value = JSON.parse(original.apply(this,args)); delete value.textViewTokens; window.__plainInterface = value.textView; return JSON.stringify(value); };` });
});
await collection(page, info.project.use.baseURL!);
expect(await page.locator('[data-interface-view]:visible').textContent()).toBe(await page.evaluate(() => (window as unknown as { __plainInterface: string }).__plainInterface));
await expect(page.locator('[data-operation-control], [data-protocol-call]')).toHaveCount(0);
await expect(page.locator('[data-protocol-status]')).toContainText('Operationの宣言位置を確認できない');
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
test('Descriptor fields serialize optional and composite tagged Values without evaluating input', async ({ page }, info) => {
// Synthetic DTO boundary only: capture arguments without calling the Host.
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.fetch_interface; BrowserSession.prototype.fetch_interface = function(...args) { const value = JSON.parse(original.apply(this,args)); if (args[1] === 'collection') value.descriptor.operations[0].parameters = [{name:'text',required:true,type:{kind:'string'}},{name:'enabled',required:true,type:{kind:'boolean'}},{name:'ratio',required:true,type:{kind:'number'}},{name:'nothing',required:true,type:{kind:'unit'}},{name:'items',required:true,type:{kind:'list'}},{name:'optional',required:false,type:{kind:'string'}}]; return JSON.stringify(value); }; BrowserSession.prototype.call_operation = function(...args) { window.__callInput = args; throw new Error('fixture captured'); };` });
});
await collection(page, info.project.use.baseURL!);
await page.getByRole('button', { name: 'Run create', exact: true }).click();
const create = form(page, 'create');
const raw = '日本語 " \\ <img src=x onerror=alert(1)>';
await create.getByLabel('create.text', { exact: true }).fill(raw);
await create.getByLabel('create.enabled', { exact: true }).selectOption('true');
await create.getByLabel('create.ratio', { exact: true }).fill('1.25');
await create.getByLabel('create.items', { exact: true }).fill('{"kind":"list","value":[{"kind":"integer","value":"2"}]}');
await expect(create.getByLabel('create.optional', { exact: true })).toBeDisabled();
await create.getByRole('button', { name: 'Execute create', exact: true }).click();
const argumentsJSON = () => page.evaluate(() => (window as unknown as { __callInput: string[] }).__callInput[4]);
expect(JSON.parse(await argumentsJSON())).toEqual({ text: { kind: 'string', value: raw }, enabled: { kind: 'boolean', value: true }, ratio: { kind: 'number', value: 1.25 }, nothing: { kind: 'unit' }, items: { kind: 'list', value: [{ kind: 'integer', value: '2' }] } });
await create.getByLabel('Include create.optional', { exact: true }).check();
await create.getByRole('button', { name: 'Execute create', exact: true }).click();
expect(JSON.parse(await argumentsJSON()).optional).toEqual({ kind: 'string', value: '' });
await expect(page.locator('[data-protocol-panel] img, [data-protocol-panel] script')).toHaveCount(0);
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
+235
View File
@@ -0,0 +1,235 @@
import { expect, test, type Page } from '@playwright/test';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
const panel = (page: Page) => page.locator('[data-protocol-panel]');
const node = (page: Page, name: string) => page.locator(`.demo-node[data-path="/playground${name ? `/${name}` : ''}"]`);
const url = (base: string, child?: string) => {
const result = new URL('playground/?mode=protocol', base);
if (child) result.searchParams.set('object', `/playground/${child}`);
return result.href;
};
async function ready(page: Page, path = '/playground') {
await expect(panel(page)).toHaveAttribute('data-state', 'ready');
await expect(panel(page)).toHaveAttribute('data-path', path);
await expect(page.getByLabel('Object Text View')).toBeVisible();
}
async function openOperation(page: Page, operation: string) {
const form = page.getByRole('form', { name: `${operation} arguments`, exact: true });
if (await form.count() === 0) await page.getByRole('button', { name: `Run ${operation}`, exact: true }).click();
await expect(form).toBeVisible();
await expect(form).toHaveAttribute('data-operation-form', operation);
return form;
}
async function execute(page: Page, iface: string, operation: string, args?: Record<string, { kind: string; value: string }>) {
const toggle = page.getByRole('button', { name: new RegExp(`^[?>v] /::${iface}$`) });
if (await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click();
const calls = Number(await panel(page).getAttribute('data-calls'));
const form = await openOperation(page, operation);
await expect(panel(page)).toHaveAttribute('data-calls', String(calls));
for (const [parameter, argument] of Object.entries(args ?? {})) {
await form.getByLabel(`${operation}.${parameter}`, { exact: true }).fill(argument.value);
}
await form.getByRole('button', { name: `Execute ${operation}`, exact: true }).click();
await expect(page.getByLabel('Operation result')).toBeVisible();
await expect(panel(page)).toHaveAttribute('data-calls', String(calls + 1));
await expect(page.locator('[data-protocol-status]')).toBeHidden();
return JSON.parse(await page.getByLabel('Operation result').innerText());
}
const nameArg = (name: string) => ({ name: { kind: 'string', value: name } });
const amountArg = (amount: string) => ({ amount: { kind: 'integer', value: amount } });
test('Observe updates tree membership while counter values come only from Operations', async ({ page }, info) => {
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nconst observe = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(path, depth) { (window.__observations ||= []).push({path,depth}); return observe.call(this,path,depth); };` });
});
await page.goto(url(info.project.use.baseURL!)); await ready(page);
await expect(node(page, 'apple')).toHaveText('apple');
await expect(node(page, 'orange')).toHaveText('orange');
await expect(page.locator('.node-value')).toHaveCount(0);
await expect(page.getByRole('button', { name: '? /::collection', exact: true })).toBeVisible();
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await execute(page, 'collection', 'create', nameArg('banana'));
await expect(node(page, 'banana')).toHaveText('banana');
await node(page, 'banana').click(); await ready(page, '/playground/banana');
await expect(node(page, 'banana')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('button', { name: '? /::counter', exact: true })).toBeVisible();
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '0' });
const metadata = await page.getByLabel('Object Text View').textContent();
const before = Number(await panel(page).getAttribute('data-observes'));
const increment = await execute(page, 'counter', 'increment', amountArg('2'));
expect(increment.result).toEqual({ kind: 'integer', value: '2' });
await expect(node(page, 'banana')).toHaveText('banana');
await expect(node(page, 'banana')).toHaveAttribute('aria-label', 'banana');
expect(await page.getByLabel('Object Text View').textContent()).toBe(metadata);
await expect(page.getByLabel('Object Text View')).toContainText('description: "An in-memory counter."');
expect(Number(await panel(page).getAttribute('data-observes'))).toBeGreaterThan(before);
expect(await page.evaluate(() => (window as Window & { __observations?: unknown[] }).__observations?.slice(-2)))
.toEqual([{ path: '/playground', depth: 1 }, { path: '/playground/banana', depth: 1 }]);
await execute(page, 'counter', 'increment', amountArg('-1'));
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '1' });
expect((await execute(page, 'counter', 'reset')).result).toEqual({ kind: 'integer', value: '0' });
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '0' });
await expect(node(page, 'banana')).toHaveText('banana');
await page.screenshot({ path: info.outputPath('playground-counter.png'), fullPage: true });
await node(page, '').click(); await ready(page);
await execute(page, 'collection', 'remove', nameArg('banana'));
await expect(node(page, 'banana')).toHaveCount(0);
await execute(page, 'collection', 'create', nameArg('banana'));
await expect(node(page, 'banana')).toHaveText('banana');
await node(page, 'banana').click(); await ready(page, '/playground/banana');
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '0' });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test('state survives selection, history and Read mode, but reload resets the session', async ({ page }, info) => {
const base = info.project.use.baseURL!;
await page.goto(url(base, 'apple')); await ready(page, '/playground/apple');
expect((await execute(page, 'counter', 'increment', amountArg('4'))).result).toEqual({ kind: 'integer', value: '7' });
await node(page, 'orange').click(); await ready(page, '/playground/orange');
await page.goBack(); await ready(page, '/playground/apple');
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await expect(page.getByLabel('Operation result')).toBeHidden();
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '7' });
await page.goForward(); await ready(page, '/playground/orange');
await page.getByRole('button', { name: 'Read', exact: true }).click();
await expect(page.locator('[data-selected-content] h1')).toHaveText('Playground');
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await expect(page.locator('[data-playground-row]')).toHaveCount(0);
await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page, '/playground/orange');
await expect(node(page, 'apple')).toHaveText('apple');
await expect(panel(page)).toHaveAttribute('data-calls', '2');
await page.locator('.demo-node[data-path="/book"]').click();
await ready(page, '/book');
await expect(node(page, 'apple')).toHaveText('apple');
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await expect(panel(page)).toHaveAttribute('data-calls', '2');
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '7' });
await expect(page.locator('.node-value')).toHaveCount(0);
await page.reload(); await ready(page, '/playground/apple');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
});
test('invalid operations do not alter the tree and deleted history does not resurrect a counter', async ({ page }, info) => {
await page.goto(url(info.project.use.baseURL!)); await ready(page);
await execute(page, 'collection', 'create', nameArg('temporary'));
await node(page, 'temporary').click(); await ready(page, '/playground/temporary');
await node(page, '').click(); await ready(page);
await execute(page, 'collection', 'remove', nameArg('temporary'));
await page.goBack();
await expect(panel(page)).toHaveAttribute('data-path', '/playground/temporary');
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(page.getByLabel('Object Text View')).toBeHidden();
await expect(node(page, 'temporary')).toHaveCount(0);
await node(page, '').click(); await ready(page);
await page.getByRole('button', { name: '> /::collection', exact: true }).click();
for (const invalid of ['apple', '../book', '<img src=x onerror=alert(1)>']) {
const form = await openOperation(page, 'create');
await form.getByLabel('create.name', { exact: true }).fill(invalid);
await form.getByRole('button', { name: 'Execute create', exact: true }).click();
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(page.getByLabel('Operation result')).toBeHidden();
await expect(page.locator('[data-playground-row]')).toHaveCount(2);
await expect(node(page, 'apple')).toHaveText('apple');
}
await expect(panel(page).locator('img')).toHaveCount(0);
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
const incrementForm = await openOperation(page, 'increment');
await incrementForm.getByLabel('increment.amount', { exact: true }).fill('9223372036854775807');
await incrementForm.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(node(page, 'apple')).toHaveText('apple');
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
});
for (const variant of ['missing', 'null', 'prose', 'numeric'] as const) {
test(`tree membership does not depend on ${variant} descriptions or fetch counter values`, async ({ page }, info) => {
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(...args) { const value = JSON.parse(original.apply(this,args)); for (const child of value.observation.children || []) { const variant = ${JSON.stringify(variant)}; if (variant === 'missing') delete child.object.description; else child.object.description = variant === 'null' ? null : variant === 'numeric' ? '999999' : 'カウンターの説明 <img src=x onerror="window.__descriptionExecuted=1">'; } return JSON.stringify(value); };` });
});
await page.goto(url(info.project.use.baseURL!)); await ready(page);
for (const name of ['apple', 'orange']) {
await expect(node(page, name)).toHaveText(name);
await expect(node(page, name)).toHaveAttribute('aria-label', name);
}
await expect(page.locator('.node-value, [data-playground-row] img')).toHaveCount(0);
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(page.getByLabel('Operation result')).toBeHidden();
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
expect(await page.evaluate(() => '__descriptionExecuted' in window)).toBe(false);
});
}
test('dynamic children participate in tree keyboard navigation and folding', async ({ page }, info) => {
await page.goto(url(info.project.use.baseURL!)); await ready(page);
await page.locator('.demo-node[data-path="/book"]').click(); await ready(page, '/book');
await node(page, 'apple').focus(); await page.keyboard.press('Enter');
await ready(page, '/playground/apple');
await expect(node(page, 'apple')).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(node(page, 'orange')).toBeFocused();
await node(page, '').focus();
await page.keyboard.press('ArrowRight');
await expect(node(page, 'apple')).toBeFocused();
await page.keyboard.press('Enter'); await ready(page, '/playground/apple');
await expect(node(page, 'apple')).toBeFocused();
await page.keyboard.press('ArrowDown');
await expect(node(page, 'orange')).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(node(page, '')).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(node(page, 'apple')).toBeHidden();
await expect(node(page, '')).toHaveAttribute('aria-expanded', 'false');
await page.keyboard.press('ArrowRight');
await expect(node(page, 'apple')).toBeVisible();
});
for (const failurePath of ['/playground', '/playground/apple']) test(`failed refresh at ${failurePath} preserves success without replaying the mutation`, async ({ page }, info) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nconst call = BrowserSession.prototype.call_operation; const observe = BrowserSession.prototype.observe; let failRefresh = false; BrowserSession.prototype.call_operation = function(...args) { const result = call.apply(this,args); if (args[3] === 'increment') failRefresh = true; return result; }; BrowserSession.prototype.observe = function(...args) { if(failRefresh && args[0] === ${JSON.stringify(failurePath)}) { failRefresh = false; throw new Error('refresh unavailable'); } return observe.apply(this,args); };` });
});
await page.goto(url(info.project.use.baseURL!, 'apple')); await ready(page, '/playground/apple');
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
const form = await openOperation(page, 'increment');
await expect(form.getByLabel('increment.amount', { exact: true })).toHaveValue('1');
await form.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(page.locator('[data-protocol-status]')).toContainText('Operationは成功しましたが再Observeに失敗');
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '4' });
await expect(panel(page)).toHaveAttribute('data-calls', '1');
// Stale observations and target readiness are retired, not inferred from the Call result.
if (failurePath === '/playground') await expect(node(page, 'apple')).toHaveCount(0);
else await expect(node(page, 'apple')).toHaveText('apple');
await expect(form.getByRole('button', { name: 'Execute increment', exact: true })).toBeDisabled();
await expect(form.getByLabel('increment.amount', { exact: true })).toBeDisabled();
await expect(page.getByRole('button', { name: 'Run increment', exact: true })).toBeDisabled();
await expect(page.getByLabel('Object Text View')).toBeHidden();
await page.locator('.demo-node[data-path="/book"]').click(); await ready(page, '/book');
if (failurePath === '/playground') await expect(node(page, 'apple')).toHaveCount(0);
else await expect(node(page, 'apple')).toHaveText('apple');
await node(page, '').click(); await ready(page);
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await expect(node(page, 'apple')).toHaveText('apple');
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await expect(page.locator('[data-protocol-status]')).toBeHidden();
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '4' });
await expect(page.locator('.node-value')).toHaveCount(0);
});
test.describe('static explanation', () => {
test.use({ javaScriptEnabled: false });
test('Playground is readable without claiming live counter state', async ({ page }, info) => {
await page.goto(url(info.project.use.baseURL!, 'apple'));
await expect(page.locator('[data-selected-content] h1')).toHaveText('Playground');
await expect(page.locator('[data-selected-content]')).toContainText('再読み込みで初期状態');
await expect(panel(page)).toBeHidden();
await expect(page.locator('[data-playground-row]')).toHaveCount(0);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true });
test('manual exchanges are explicit, cached, session-local, and return genuine signatures', 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 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 normal = new module.BrowserSession(manifest);
const manual = new module.BrowserSession(manifest);
const data = JSON.parse(manifest);
const node = data.nodes.find((n: { path: string }) => n.path !== '/');
const originalFetch = window.fetch;
window.fetch = () => { throw new Error('Manual protocol must not use a network transport'); };
try {
normal.tree();
const initial = JSON.parse(manual.stats());
let missing;
try { manual.call_operation(node.path, '/', 'document', 'read', '{}'); }
catch (error) { missing = JSON.parse(String(error)); }
const observed = JSON.parse(manual.observe(node.path, 0));
const cachedObject = JSON.parse(manual.observe(node.path, 0));
const descriptor = JSON.parse(manual.fetch_interface('/', 'document'));
const cachedInterface = JSON.parse(manual.fetch_interface('/', 'document'));
const called = JSON.parse(manual.call_operation(node.path, '/', 'document', 'read', '{}'));
const second = JSON.parse(manual.call_operation(node.path, '/', 'document', 'read', '{}'));
const result = { initial, missing, observed, cachedObject, descriptor, cachedInterface, called, second, node };
normal.free();
manual.free();
return result;
} finally { window.fetch = originalFetch; }
}, base);
expect(result.initial).toEqual({ observes: 0, interfaces: 0, calls: 0 });
expect(result.missing).toMatchObject({ kind: 'client', code: 'MissingObservation' });
expect(result.observed).toMatchObject({ path: result.node.path, cached: false, stats: { observes: 1, interfaces: 0, calls: 0 } });
expect(result.observed.observation.object).toMatchObject({ name: result.node.name, description: result.node.title, interfaces: [{ scope: '/', name: 'document' }] });
expect(result.observed.observation.children).toBeNull();
expect(result.observed.observation.object).not.toHaveProperty('html');
expect(result.observed.textView).toContain('interfaces: ["/::document"];');
expect(result.observed.textView).not.toContain('validator:');
expect(result.observed.textView).not.toContain('ref:');
expect(result.cachedObject).toEqual({ ...result.observed, cached: true });
expect(result.descriptor).toMatchObject({ reference: { scope: '/', name: 'document' }, cached: false, stats: { observes: 1, interfaces: 1, calls: 0 } });
expect(result.descriptor.descriptor.operations).toEqual([expect.objectContaining({ name: 'read', parameters: [] })]);
expect(result.descriptor.textView).toMatch(/^interface "\/::document" \{\n/m);
expect(result.descriptor.textView).toContain('read(');
expect(result.cachedInterface).toEqual({ ...result.descriptor, cached: true });
expect(result.called).toMatchObject({ path: result.node.path, interface: { scope: '/', name: 'document' }, operation: 'read', result: { kind: 'record', value: { path: { kind: 'string', value: result.node.path }, html: { kind: 'string', value: result.node.html } } }, stats: { observes: 1, interfaces: 1, calls: 1 } });
expect(result.called).not.toHaveProperty('textView');
expect(result.second.stats).toEqual({ observes: 1, interfaces: 1, calls: 2 });
});
test('manual errors distinguish input, Client, Protocol and renderer failures without automatic work', 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 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 attempt = (fn: () => unknown) => {
try { fn(); return { kind: 'unexpected success' }; }
catch (error) {
if (error instanceof WebAssembly.RuntimeError) throw error;
return JSON.parse(String(error));
}
};
const missingObject = attempt(() => session.call_operation('/', '/', 'document', 'read', '{}'));
const depth = [-1, 0.1, 33, NaN, Infinity, 2 ** 32].map((d) => attempt(() => session.observe('/', d)));
const oversizePath = attempt(() => session.observe('/'.repeat(4097), 0));
const malformedPath = attempt(() => session.observe('relative', 0));
const unknownPath = attempt(() => session.observe('/not-published-protocol-test', 0));
const unknownInterface = attempt(() => session.fetch_interface('/', 'not-published-protocol-test'));
const afterMissing = JSON.parse(session.stats());
const retainedPath = attempt(() => session.observe('/not-published-protocol-test', 0));
const retainedInterface = attempt(() => session.fetch_interface('/', 'not-published-protocol-test'));
const afterRetained = JSON.parse(session.stats());
session.observe('/', 0);
const missingInterface = attempt(() => session.call_operation('/', '/', 'document', 'read', '{}'));
session.fetch_interface('/', 'document');
const unknownOperation = attempt(() => session.call_operation('/', '/', 'document', 'unknown', '{}'));
const mismatchedInterface = attempt(() => session.call_operation('/', '/', 'other', 'read', '{}'));
const invalidArguments = attempt(() => session.call_operation('/', '/', 'document', 'read', '{"unexpected":{"kind":"integer","value":"9007199254740993"}}'));
const malformedArguments = ['{', '[]', '{"x":42}', '{"x":{"kind":"integer","value":9007199254740993}}', '{"x":{"kind":"bytes","value":[256]}}', ' '.repeat(65537)].map((args) => attempt(() => session.call_operation('/', '/', 'document', 'read', args)));
const beforeCall = JSON.parse(session.stats());
const called = JSON.parse(session.call_operation('/', '/', 'document', 'read', '{}'));
session.free();
const data = JSON.parse(manifest);
data.nodes.find((n: { path: string }) => n.path === '/').title = 'x'.repeat(1024 * 1024 + 1);
const large = new module.BrowserSession(JSON.stringify(data));
const render = attempt(() => large.observe('/', 0));
const renderStats = JSON.parse(large.stats());
large.free();
return { missingObject, missingInterface, depth, oversizePath, malformedPath, unknownPath, unknownInterface, afterMissing, retainedPath, retainedInterface, afterRetained, unknownOperation, mismatchedInterface, invalidArguments, malformedArguments, beforeCall, called, render, renderStats };
}, base);
expect(result.missingObject).toMatchObject({ kind: 'client', code: 'MissingObservation' });
expect(result.missingInterface).toMatchObject({ kind: 'client', code: 'MissingObservation' });
for (const error of [...result.depth, result.oversizePath, ...result.malformedArguments]) expect(error.kind).toBe('input');
expect(result.malformedPath).toMatchObject({ kind: 'client', code: 'InvalidRequest' });
expect(result.unknownPath).toMatchObject({ kind: 'protocol', code: 'NotFound' });
expect(result.unknownInterface).toMatchObject({ kind: 'protocol', code: 'InterfaceNotFound' });
expect(result.afterMissing).toEqual({ observes: 1, interfaces: 1, calls: 0 });
expect(result.afterRetained).toEqual(result.afterMissing);
expect(result.retainedPath.kind).toBe('client');
expect(result.retainedInterface.kind).toBe('client');
for (const error of [result.unknownOperation, result.mismatchedInterface, result.invalidArguments]) expect(error.kind).toBe('client');
expect(result.beforeCall.calls).toBe(0);
expect(result.called.stats.calls).toBe(1);
expect(result.render.kind).toBe('render');
expect(result.renderStats).toEqual({ observes: 1, interfaces: 0, calls: 0 });
});
test('Rust Text View preserves Unicode, whitespace and quoted escapes without becoming HTML', 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 title = ' 引用 "quote" \\ space\nnext\tline <script>window.__textViewExecuted=true</script>';
const session = new module.BrowserSession(JSON.stringify({ nodes: [{ path: '/', name: '', displayPath: '/', title, href: '/', html: '<p>body only on call</p>' }] }));
const observed = JSON.parse(session.observe('/', 0));
const fetched = JSON.parse(session.fetch_interface('/', 'document'));
const pre = document.createElement('pre');
pre.textContent = observed.textView;
document.body.append(pre);
session.free();
return { observed, fetched, title, displayed: pre.textContent, scripts: pre.querySelectorAll('script').length, executed: '__textViewExecuted' in window };
}, base);
// Golden output is also checked against render_object directly in native tests.
expect(result.observed.textView).toBe('object {\n name: "";\n description: " 引用 \\"quote\\" \\\\ space\\nnext\\tline <script>window.__textViewExecuted=true</script>";\n interfaces: ["/::document"];\n}\n');
expect(result.observed.observation.object.description).toBe(result.title);
expect(result.displayed).toBe(result.observed.textView);
expect(result.scripts).toBe(0);
expect(result.executed).toBe(false);
expect(result.fetched.stats).toEqual({ observes: 1, interfaces: 1, calls: 0 });
});
+524
View File
@@ -0,0 +1,524 @@
import { expect, test, type Page } from '@playwright/test';
import { readFile } from 'node:fs/promises';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
const panel = (page: Page) => page.locator('[data-protocol-panel]');
const protocolURL = (base: string, route = '') => { const url = new URL(route, base); url.searchParams.set('mode', 'protocol'); return url.href; };
async function ready(page: Page) {
await expect(panel(page)).toBeVisible();
await expect(panel(page)).toHaveAttribute('data-state', 'ready');
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'manual');
await expect(page.locator('[data-selected-content]')).toBeHidden();
}
async function descriptor(page: Page) {
await expect(page.getByLabel('Object Text View')).toContainText('object {');
await page.locator('[data-interface-toggle]').first().click();
await expect(page.getByLabel('Interface Text View')).toContainText('interface');
await page.getByRole('button', { name: 'Run read', exact: true }).click();
}
async function traceObserves(page: Page) {
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nconst originalObserve = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(path, depth) { (window.__observed ||= []).push({ path, depth }); return originalObserve.call(this, path, depth); };` });
});
}
const observedCalls = (page: Page) => page.evaluate(() => (window as Window & { __observed?: { path: string; depth: number }[] }).__observed ?? []);
for (const [route, path, name] of [
['', '/', ''], ['book/', '/book', 'book'],
['book/1.1-interaction-abstraction/', '/book/1-concept.md/1.1-interaction-abstraction.md', '1.1-interaction-abstraction.md'],
['sources/', '/sources', 'sources'], ['packages/', '/packages', 'packages'],
]) {
test(`Protocol opens the selected Object immediately: ${path}`, async ({ page }, info) => {
await traceObserves(page);
// No tree click or Observe action is needed on any of these entry paths.
await page.goto(protocolURL(info.project.use.baseURL!, route)); await ready(page);
const object = page.getByLabel('Object Text View');
await expect(object).toBeVisible();
await expect(object).toContainText(`name: ${JSON.stringify(name)}`);
await expect(panel(page)).toHaveAttribute('data-path', path);
await expect(panel(page)).toHaveAttribute('data-observes', '1');
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(page.locator('[data-protocol-status]')).toBeHidden();
expect(await observedCalls(page)).toEqual([{ path, depth: 1 }]);
await page.getByRole('button', { name: 'Read', exact: true }).click();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page);
await expect(object).toBeVisible();
await expect(object).toContainText(`name: ${JSON.stringify(name)}`);
expect(await observedCalls(page)).toEqual([{ path, depth: 1 }, { path, depth: 1 }]);
await expect(panel(page)).toHaveAttribute('data-observes', '1'); // Fresh Client cache.
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await page.reload(); await ready(page);
await expect(object).toBeVisible();
await expect(object).toContainText(`name: ${JSON.stringify(name)}`);
await expect(page.locator('[data-protocol-status]')).toBeHidden();
expect(await observedCalls(page)).toEqual([{ path, depth: 1 }]);
});
}
for (const colorScheme of ['light', 'dark'] as const) {
test(`Interface buttons use a text-width TUI background in ${colorScheme} mode`, async ({ page }, info) => {
await page.emulateMedia({ colorScheme });
await page.goto(protocolURL(info.project.use.baseURL!, 'playground/')); await ready(page);
const button = page.getByRole('button', { name: '? /::collection', exact: true });
const toggle = page.locator('[data-interface-toggle]').filter({ hasText: '/::collection' });
await expect(button).toBeVisible();
const background = await button.evaluate(element => {
const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--code-bg)';
element.append(probe); const color = getComputedStyle(probe).backgroundColor; probe.remove(); return color;
});
expect(background).not.toBe('rgba(0, 0, 0, 0)');
for (const state of ['?', 'v', '>']) {
await page.mouse.move(0, 0);
await page.getByRole('button', { name: 'Read', exact: true }).focus();
await expect(toggle).toHaveText(`${state} /::collection`);
await expect(toggle).toHaveCSS('background-color', background);
await expect(toggle).toHaveCSS('border-radius', '0px');
await expect(toggle).toHaveCSS('border-top-width', '0px');
await expect(toggle).toHaveCSS('padding-top', '0px');
expect(await toggle.evaluate(element => element.getBoundingClientRect().width < element.parentElement!.getBoundingClientRect().width)).toBe(true);
await toggle.hover();
await expect(toggle).not.toHaveCSS('background-color', background);
if (state !== '>') await toggle.click();
}
await page.mouse.move(0, 0); await toggle.focus(); await page.keyboard.press('Enter');
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(toggle).toHaveCSS('outline-width', '2px');
await expect(page.getByLabel('Interface Text View').filter({ visible: true })).toContainText('/::collection');
await page.screenshot({ path: info.outputPath(`interface-background-${colorScheme}.png`), fullPage: true });
});
}
test('Object selection shows an unboxed Text View without Observe forms or step panels', async ({ page }, info) => {
await page.goto(protocolURL(info.project.use.baseURL!, 'sources/')); await ready(page);
const object = page.getByLabel('Object Text View');
await expect(object).toBeVisible();
await expect(object).toContainText('object {');
await expect(object).toContainText('name: "sources"');
await expect(object).toHaveCSS('border-top-width', '0px');
await expect(object).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(panel(page).locator('fieldset, legend, h2, h3, [data-protocol-path], [data-protocol-depth], [data-protocol-observe], [data-protocol-stats], [data-protocol-children]')).toHaveCount(0);
await expect(page.locator('[data-protocol-status]')).toBeHidden();
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-operation-form], [data-protocol-call]')).toHaveCount(0);
await expect(page.getByLabel('Operation result')).toBeHidden();
await expect(page.getByRole('button', { name: '? /::document', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('protocol-object.png'), fullPage: true });
await page.getByRole('button', { name: '? /::document', exact: true }).click();
await expect(page.getByLabel('Interface Text View')).toBeVisible();
await expect(page.getByRole('button', { name: 'Run read', exact: true })).toBeEnabled();
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-protocol-call]')).toHaveCount(0);
await page.getByRole('button', { name: 'Run read', exact: true }).click();
await expect(page.getByRole('form', { name: 'read arguments', exact: true })).toHaveAttribute('data-operation-form', 'read');
await expect(page.getByRole('button', { name: 'Execute read', exact: true })).toBeEnabled();
await expect(page.getByLabel('Operation result')).toBeHidden();
await page.getByRole('button', { name: 'v /::document', exact: true }).click();
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-operation-form], [data-protocol-call]')).toHaveCount(0);
await expect(object).toBeVisible();
});
test('tree selection automatically observes Depth 1 only, including root, nested paths and reselection', async ({ page }, info) => {
await traceObserves(page);
await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
const expected: { path: string; depth: number }[] = [{ path: '/', depth: 1 }];
for (const [display, canonicalPath] of [['/', '/'], ['/book', '/book'], ['/1-concept.md', '/book/1-concept.md'], ['/sources', '/sources'], ['/packages', '/packages']]) {
await page.locator(`.demo-node[data-path="${display}"]`).click();
await ready(page);
await expect(panel(page)).toHaveAttribute('data-path', canonicalPath);
await expect(page.getByLabel('Object Text View')).toContainText('object {');
await expect(page.getByRole('button', { name: '? /::document', exact: true })).toBeEnabled();
await expect(page.getByLabel('Interface Text View')).toBeHidden();
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
expected.push({ path: canonicalPath, depth: 1 });
expect(await observedCalls(page)).toEqual(expected);
}
await page.locator('[data-interface-toggle]').first().click();
await page.getByRole('button', { name: 'Run read', exact: true }).click();
await page.locator('[data-protocol-call]').click();
await expect(panel(page)).toHaveAttribute('data-calls', '1');
// Selecting the current Object resets the previous Interface and result.
await page.locator('.demo-node[data-path="/packages"]').click();
await expect(panel(page)).toHaveAttribute('data-path', '/packages');
await expect(page.getByLabel('Object Text View')).toContainText('name: "packages"');
await expect(page.getByRole('button', { name: '> /::document', exact: true })).toBeVisible();
await expect(page.getByLabel('Interface Text View')).toBeHidden();
await expect(page.getByLabel('Operation result')).toBeHidden();
await expect(panel(page)).toHaveAttribute('data-calls', '1');
expected.push({ path: '/packages', depth: 1 });
expect(await observedCalls(page)).toEqual(expected);
await page.goBack(); await ready(page);
await expect(panel(page)).toHaveAttribute('data-path', '/sources');
await expect(page.getByLabel('Object Text View')).toContainText('name: "sources"');
expected.push({ path: '/sources', depth: 1 });
expect(await observedCalls(page)).toEqual(expected);
});
test('pending tree observations wait for WASM and only the latest selection runs', async ({ page }, info) => {
await traceObserves(page);
let release!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
await page.route('**/wip-wasm/*.wasm', async route => { await gate; await route.continue(); });
try {
await page.goto(protocolURL(info.project.use.baseURL!));
await expect(panel(page)).toHaveAttribute('data-state', 'loading');
await page.locator('.demo-node[data-path="/"]').click();
await page.locator('.demo-node[data-path="/packages"]').click();
await expect(page).toHaveURL(protocolURL(info.project.use.baseURL!, 'packages/'));
await expect(panel(page)).toHaveAttribute('data-path', '/packages');
await page.locator('.demo-node[data-path="/sources"]').click();
await expect(page).toHaveURL(protocolURL(info.project.use.baseURL!, 'sources/'));
await expect(panel(page)).toHaveAttribute('data-path', '/sources');
release(); await ready(page);
await expect(page.getByLabel('Object Text View')).toContainText('name: "sources"');
expect(await observedCalls(page)).toEqual([{ path: '/sources', depth: 1 }]);
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
} finally { release(); }
});
test('Interface clicks fetch unknown signatures and fold/reopen acquired ones without another exchange', async ({ page }, info) => {
// Read's automatic acquisition must not mark the manual session as known.
await page.goto(info.project.use.baseURL!);
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page);
await expect(page.getByLabel('Object Text View')).toBeVisible();
const toggle = page.locator('[data-interface-toggle]');
const view = page.getByLabel('Interface Text View');
await expect(toggle).toHaveText('? /::document');
await expect(page.locator('.protocol-interface-scope')).toHaveCount(0);
await expect(page.locator('[data-protocol-interfaces]')).not.toContainText('scope:');
await expect(toggle).not.toHaveAttribute('aria-describedby');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(view).toBeHidden();
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
await toggle.focus(); await page.keyboard.press('Enter');
await expect(toggle).toHaveText('v /::document');
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(view).toBeVisible();
await expect(view).toContainText('interface "/::document"');
const signature = await view.textContent();
await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(page.getByRole('button', { name: 'Run read', exact: true })).toBeEnabled();
await toggle.focus(); await page.keyboard.press('Space');
await expect(toggle).toHaveText('> /::document');
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
await expect(view).toBeHidden();
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
await toggle.click();
await expect(view).toHaveText(signature!);
await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await page.locator('.demo-node[data-path="/sources"]').click(); await ready(page);
await expect(toggle).toHaveText('> /::document');
await expect(view).toBeHidden();
await toggle.click();
await expect(view).toHaveText(signature!);
await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await page.reload(); await ready(page);
await expect(page.getByLabel('Object Text View')).toBeVisible();
await expect(toggle).toHaveText('? /::document');
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
});
test('failed Interface acquisition remains unknown and cannot retain a previous Operation target', async ({ page }, info) => {
// Advertise an additional unsupported reference at the UI boundary. Its
// acquisition still goes through the real WASM Client/Host failure path.
const missingName = '<img src=x onerror=window.__interfaceExecuted=1>';
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nconst originalObserve = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(...args) { const value = JSON.parse(originalObserve.apply(this, args)); value.observation.object.interfaces.push({scope: '/', name: ${JSON.stringify(missingName)}}); return JSON.stringify(value); };` });
});
await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
await page.locator('.demo-node[data-path="/"]').click();
await page.getByRole('button', { name: '? /::document', exact: true }).click();
await page.getByRole('button', { name: 'Run read', exact: true }).click();
const missing = page.getByRole('button', { name: `? /::${missingName}`, exact: true });
await missing.click();
await expect(missing).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByRole('button', { name: '> /::document', exact: true })).toBeVisible();
await expect(page.locator('[data-protocol-status]')).toContainText('protocol');
await expect(page.locator('[data-interface-view]:visible')).toHaveCount(0);
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
await expect(page.getByLabel('Operation result')).toBeHidden();
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(page.locator('[data-protocol-interfaces] img')).toHaveCount(0);
expect(await page.evaluate(() => '__interfaceExecuted' in window)).toBe(false);
await page.getByRole('button', { name: '> /::document', exact: true }).click();
await expect(page.locator('[data-interface-view]:visible')).toContainText('interface "/::document"');
await expect(page.getByRole('button', { name: 'Run read', exact: true })).toBeEnabled();
});
test('Protocol observes automatically but acquires Interfaces and calls Operations only explicitly', async ({ page }, info) => {
const unexpected: string[] = [];
page.on('pageerror', error => unexpected.push(error.message));
// Catch accidental use of the automatic convenience path, including hidden
// normal-mode reads whose separate Client counters would not reveal them.
await page.route('**/wip-wasm/wip_browser.js*', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nfor (const key of ['tree', 'read']) { const original = BrowserSession.prototype[key]; BrowserSession.prototype[key] = function(...args) { window.__autoReads = (window.__autoReads || 0) + 1; return original.apply(this, args); }; }` });
});
await page.goto(protocolURL(info.project.use.baseURL!));
await ready(page);
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(panel(page)).toHaveAttribute('data-observes', '1');
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
await descriptor(page);
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(panel(page)).toHaveAttribute('data-observes', '1');
await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
await page.locator('[data-protocol-call]').click();
await expect(panel(page)).toHaveAttribute('data-calls', '1');
const result = JSON.parse(await page.getByLabel('Operation result').innerText());
expect(result.path).toBe('/');
expect(result.operation).toBe('read');
expect(result.interface).toEqual({ scope: '/', name: 'document' });
expect(JSON.stringify(result.result)).toContain('Worldspace');
expect(JSON.stringify(result.result)).toContain('html');
expect(await page.evaluate(() => (window as Window & { __autoReads?: number }).__autoReads ?? 0)).toBe(0);
expect(unexpected).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await panel(page).evaluate(element => { element.scrollTop = 0; });
await page.screenshot({ path: info.outputPath('protocol.png'), fullPage: true });
});
for (const route of ['book/1-concept/', 'sources/', 'packages/']) {
test(`manual read of ${route} agrees with normal content without creating child catalog Objects`, async ({ page }, info) => {
await page.goto(protocolURL(info.project.use.baseURL!, route));
await ready(page);
const expectedTitle = await page.locator('[data-selected-content] h1').textContent();
await descriptor(page);
await page.locator('[data-protocol-call]').click();
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await expect(page.getByLabel('Operation result')).toContainText(expectedTitle!);
await page.getByRole('button', { name: 'Read', exact: true }).click();
await expect(page.locator('[data-selected-content]')).toBeVisible();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await page.getByRole('button', { name: 'Protocol', exact: true }).click();
await ready(page);
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await expect(page.getByLabel('Operation result')).toBeHidden();
});
}
test('selection, history, reload and mode switches never replay an operation', async ({ page }, info) => {
const base = info.project.use.baseURL!;
await page.goto(protocolURL(base));
await ready(page);
await descriptor(page);
await page.locator('[data-protocol-call]').click();
await expect(panel(page)).toHaveAttribute('data-calls', '1');
for (const name of ['sources', 'packages']) {
await page.locator(`.demo-node[data-path="/${name}"]`).click();
await expect(page).toHaveURL(protocolURL(base, `${name}/`));
await ready(page);
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await expect(page.getByLabel('Operation result')).toBeHidden();
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
}
await page.goBack(); await ready(page);
await expect(panel(page)).toHaveAttribute('data-path', '/sources');
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await page.goForward(); await ready(page);
await expect(panel(page)).toHaveAttribute('data-path', '/packages');
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await page.reload(); await ready(page);
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await expect(page.getByLabel('Operation result')).toBeHidden();
});
test('fragment history synchronizes Read and Protocol without requiring an Astro swap', async ({ page }, info) => {
await page.goto(new URL('book/1-concept/', info.project.use.baseURL!).href);
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
// Use a real same-document link so Astro records its fragment shortcut state.
await page.evaluate(() => {
const heading = document.querySelector('[data-selected-content] [id]')!;
const link = document.createElement('a'); link.href = `#${heading.id}`; link.textContent = 'fragment regression';
document.querySelector('[data-selected-content]')!.append(link); link.click();
});
await expect(page).toHaveURL(/#/);
await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page);
await page.goBack();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-mode', 'read');
await expect(panel(page)).toBeHidden();
await expect(page.locator('[data-selected-content]')).toBeVisible();
await page.goForward(); await ready(page);
await expect(panel(page)).toHaveAttribute('data-calls', '0');
});
test('history into Protocol cancels deferred normal dispatch before the destination swaps', async ({ page }, info) => {
await traceObserves(page);
const base = info.project.use.baseURL!;
let releaseNormal!: () => void; let releasePage!: () => void;
const normalGate = new Promise<void>(resolve => { releaseNormal = resolve; });
const pageGate = new Promise<void>(resolve => { releasePage = resolve; });
let normalRequested = false; let destinationRequested = false; let holdDestination = false;
await page.route('**/wip-wasm/wip_browser.js', async route => {
normalRequested = true;
const response = await route.fetch(); await normalGate;
await route.fulfill({ response, body: `${await response.text()}\nfor (const key of ['tree', 'read']) { const original = BrowserSession.prototype[key]; BrowserSession.prototype[key] = function(...args) { window.__autoReads = (window.__autoReads || 0) + 1; return original.apply(this, args); }; }\nwindow.__normalModuleLoaded = true;` });
});
await page.route('**/sources/?mode=protocol', async route => {
if (holdDestination) { destinationRequested = true; await pageGate; }
await route.continue();
});
try {
await page.goto(protocolURL(base, 'sources/')); await ready(page);
await page.locator('.demo-node[data-path="/packages"]').click(); await ready(page);
await expect(page).toHaveURL(protocolURL(base, 'packages/'));
await expect(page.getByLabel('Object Text View')).toContainText('name: "packages"');
const before = await observedCalls(page);
expect(before).toEqual([{ path: '/sources', depth: 1 }, { path: '/packages', depth: 1 }]);
await page.getByRole('button', { name: 'Read', exact: true }).click();
await expect.poll(() => normalRequested).toBe(true);
holdDestination = true;
await page.evaluate(() => history.back());
await expect.poll(() => destinationRequested).toBe(true);
await expect(page).toHaveURL(protocolURL(base, 'sources/'));
releaseNormal();
await expect.poll(() => page.evaluate(() => Boolean((window as Window & { __normalModuleLoaded?: boolean }).__normalModuleLoaded))).toBe(true);
// Wait for WASM initialization too; no sleeps and no reliance on URL-only counters.
await page.evaluate(async base => { const module = await import(new URL('wip-wasm/wip_browser.js', base).href); await module.default(); }, base);
expect(await page.evaluate(() => (window as Window & { __autoReads?: number }).__autoReads ?? 0)).toBe(0);
expect(await observedCalls(page)).toEqual(before);
releasePage(); await ready(page);
await expect(panel(page)).toHaveAttribute('data-path', '/sources');
await expect(page.getByLabel('Object Text View')).toContainText('name: "sources"');
expect(await observedCalls(page)).toEqual([...before, { path: '/sources', depth: 1 }]);
await expect(panel(page)).toHaveAttribute('data-calls', '0');
} finally { releaseNormal(); releasePage(); }
});
test('history from Protocol to another Read route never reads the outgoing Object', async ({ page }, info) => {
const base = info.project.use.baseURL!;
let release!: () => void; let hold = false; let requested = false;
const gate = new Promise<void>(resolve => { release = resolve; });
await page.route('**/wip-wasm/wip_browser.js', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nconst originalRead = BrowserSession.prototype.read; BrowserSession.prototype.read = function(path) { (window.__readPaths ||= []).push(path); return originalRead.call(this, path); };` });
});
await page.route(new URL('sources/', base).href, async route => {
if (hold) { requested = true; await gate; } await route.continue();
});
const paths = () => page.evaluate(() => (window as Window & { __readPaths?: string[] }).__readPaths ?? []);
try {
await page.goto(new URL('sources/', base).href);
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await page.locator('.demo-node[data-path="/packages"]').click();
await expect(page).toHaveURL(new URL('packages/', base).href);
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page);
const before = await paths(); expect(before).toEqual(['/sources', '/packages']);
hold = true; await page.evaluate(() => history.back());
await expect.poll(() => requested).toBe(true);
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-mode', 'read');
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => resolve())));
expect(await paths()).toEqual(before);
release();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-current-path', '/sources');
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
expect(await paths()).toEqual([...before, '/sources']);
} finally { release(); }
});
test('input and protocol errors remain visible, and tree selection clears stale controls', async ({ page }, info) => {
await page.route('**/worldspace.json', async route => {
const response = await route.fetch(); const data = await response.json();
data.nodes = data.nodes.filter((node: { path: string }) => node.path !== '/sources');
await route.fulfill({ response, json: data });
});
await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
await page.locator('.demo-node[data-path="/sources"]').click(); await ready(page);
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(page.locator('[data-protocol-status]')).toContainText('protocol');
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
await expect(page.getByLabel('Object Text View')).toBeHidden();
await page.locator('.demo-node[data-path="/"]').click();
await expect(panel(page)).toHaveAttribute('data-path', '/');
await ready(page);
await page.locator('.demo-node[data-path="/playground"]').click(); await ready(page);
await page.locator('.demo-node[data-path="/playground/apple"]').click(); await ready(page);
await expect(panel(page)).toHaveAttribute('data-path', '/playground/apple');
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
await page.getByLabel('increment.amount', { exact: true }).fill('broken');
await page.locator('[data-protocol-call]').click();
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(page.locator('[data-protocol-status]')).toContainText('input');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
await page.locator('.demo-node[data-path="/book"]').click(); await ready(page);
await expect(page.getByLabel('Object Text View')).toContainText('name: "book"');
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-operation-form], [data-protocol-call]')).toHaveCount(0);
await expect(page.locator('[data-protocol-status]')).toBeHidden();
});
test('Text View and returned markup are displayed as text, including in dark mode', async ({ page }, info) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.route('**/worldspace.json', async route => {
const response = await route.fetch(); const data = await response.json();
data.nodes[0].title = '<img src=x onerror="window.__executed=1">';
data.nodes[0].html = '<script>window.__executed=1</script><p>UNSAFE RESULT</p>';
await route.fulfill({ response, json: data });
});
await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
await descriptor(page);
await expect(page.getByLabel('Object Text View')).toContainText('<img');
await page.locator('[data-protocol-call]').click();
await expect(page.getByLabel('Operation result')).toContainText('UNSAFE RESULT');
await expect(panel(page).locator('img, script')).toHaveCount(0);
expect(await page.evaluate(() => (window as Window & { __executed?: number }).__executed)).toBeUndefined();
await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
await page.unroute('**/worldspace.json');
await page.getByRole('button', { name: 'Read', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(panel(page)).toBeHidden();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
});
test('asset failure leaves a clear unavailable state and readable static fallback', async ({ page }, info) => {
await page.route('**/wip-wasm/wip_browser.js*', route => route.abort());
await page.goto(protocolURL(info.project.use.baseURL!));
await expect(panel(page)).toHaveAttribute('data-state', 'unavailable');
await expect(page.getByLabel('Object Text View')).toBeHidden();
await page.getByRole('button', { name: 'Read', exact: true }).click();
await expect(page.locator('[data-selected-content]').getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'fallback');
});
test('a trapped manual session is quarantined across routes and normal mode uses an independent instance', async ({ page }, info) => {
const asset = (name: string) => readFile(new URL(`../../test-assets/wip-wasm/${name}`, import.meta.url));
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const source = (await asset('wip_browser.js')).toString();
await route.fulfill({ contentType: 'text/javascript', body: `${source}\nBrowserSession.prototype.observe = function () { window.__trapCalls = (window.__trapCalls || 0) + 1; window.__trappedManual = new WeakRef(this); return this.panic_probe(); };` });
});
await page.route('**/wip-wasm/*.wasm', async route => route.fulfill({ contentType: 'application/wasm', body: await asset('wip_browser_bg.wasm') }));
await page.goto(protocolURL(info.project.use.baseURL!));
await expect(panel(page)).toHaveAttribute('data-state', 'trapped');
await page.locator('.demo-node[data-path="/sources"]').click();
await expect(panel(page)).toHaveAttribute('data-state', 'trapped');
await expect(page.getByLabel('Object Text View')).toBeHidden();
const cdp = await page.context().newCDPSession(page);
await cdp.send('HeapProfiler.collectGarbage');
expect(await page.evaluate(() => Boolean((window as unknown as { __trappedManual: WeakRef<object> }).__trappedManual.deref()))).toBe(true);
expect(await page.evaluate(() => (window as Window & { __trapCalls?: number }).__trapCalls)).toBe(1);
await page.getByRole('button', { name: 'Read', exact: true }).click();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await cdp.detach();
});
test.describe('without JavaScript', () => {
test.use({ javaScriptEnabled: false });
test('protocol URL retains static reading and does not claim execution', async ({ page }, info) => {
await page.goto(protocolURL(info.project.use.baseURL!, 'sources/'));
await expect(page.locator('[data-selected-content]')).toBeVisible();
await expect(page.locator('[data-mode-switch]')).toBeHidden();
await expect(panel(page)).toBeHidden();
await expect(page.locator('[data-landing-stage]')).not.toHaveAttribute('data-wip-state');
});
});
+16 -12
View File
@@ -1,6 +1,6 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { bookURL, documentPaths, draftPaths, treeNodeCount, treePaths } from './documents'; import { bookURL, documentPaths, draftPaths, treeNodeCount, treePaths, routeURL } from './documents';
test.use({ javaScriptEnabled: true }); test.use({ javaScriptEnabled: true });
@@ -8,8 +8,7 @@ const stage = '[data-landing-stage]';
const article = 'article.selected-content.prose[data-selected-content]'; const article = 'article.selected-content.prose[data-selected-content]';
const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`); const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`);
const toggle = (page: Page) => page.locator('button.node-toggle[data-toggle-path="/book"]'); const toggle = (page: Page) => page.locator('button.node-toggle[data-toggle-path="/book"]');
const urlFor = (path: string, base: string) => path === '/' ? new URL('./', base).href const urlFor = routeURL;
: path === '/book' ? new URL('book/', base).href : bookURL(path, base);
async function settled(page: Page, path: string) { async function settled(page: Page, path: string) {
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path); await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
@@ -82,11 +81,10 @@ test('home response contains independent root content and the unchanged public t
expect(copy).toMatch(/インタフェース/); expect(copy).toMatch(/インタフェース/);
const nav = home.find('nav[aria-label="ルートページのナビゲーション"]'); const nav = home.find('nav[aria-label="ルートページのナビゲーション"]');
expect(nav.length).toBe(1); expect(nav.length).toBe(1);
expect(nav.find('a').length).toBe(2); expect(nav.find('a').length).toBe(3);
expect(nav.find('a').filter((_, el) => $(el).text().includes('Book')).attr('href')).toBe(new URL('book/', base).pathname); expect(nav.find('a').filter((_, el) => $(el).text().includes('Book')).attr('href')).toBe(new URL('book/', base).pathname);
const source = $('.site-header a').filter((_, el) => $(el).text().includes('Source')).attr('href'); expect(nav.find('a').filter((_, el) => $(el).text().includes('Sources')).attr('href')).toBe(new URL('sources/', base).pathname);
expect(source).toMatch(/^https:\/\//); expect(nav.find('a').filter((_, el) => $(el).text().includes('Packages')).attr('href')).toBe(new URL('packages/', base).pathname);
expect(nav.find('a').filter((_, el) => $(el).text().includes('Source')).attr('href')).toBe(source);
expect($(article).html()).not.toBe(chapter(article).html()); expect($(article).html()).not.toBe(chapter(article).html());
expect($(article).text()).not.toContain('1. コンセプト'); expect($(article).text()).not.toContain('1. コンセプト');
const chapterCopy = chapter(article).find('p').filter((_, el) => Boolean(chapter(el).text().trim())).first().text().trim(); const chapterCopy = chapter(article).find('p').filter((_, el) => Boolean(chapter(el).text().trim())).first().text().trim();
@@ -131,11 +129,10 @@ test('root content navigation works without changing the tree or overflowing nar
await expect(home.locator('p').filter({ hasText: 'Worldspace Interface Protocol' })).toBeVisible(); await expect(home.locator('p').filter({ hasText: 'Worldspace Interface Protocol' })).toBeVisible();
await expect(page.locator(article)).not.toContainText('1. コンセプト'); await expect(page.locator(article)).not.toContainText('1. コンセプト');
const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }); const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
await expect(nav.getByRole('link')).toHaveCount(2); await expect(nav.getByRole('link')).toHaveCount(3);
await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', base).pathname); await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', base).pathname);
const source = await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href'); await expect(nav.getByRole('link', { name: /^Sources(?:\s|$)/ })).toHaveAttribute('href', new URL('sources/', base).pathname);
expect(source).toMatch(/^https:\/\//); await expect(nav.getByRole('link', { name: /^Packages(?:\s|$)/ })).toHaveAttribute('href', new URL('packages/', base).pathname);
await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', source!);
if (testInfo.project.name === 'mobile') { if (testInfo.project.name === 'mobile') {
for (const width of [320, 360, 390]) { for (const width of [320, 360, 390]) {
await page.setViewportSize({ width, height: 740 }); await page.setViewportSize({ width, height: 740 });
@@ -155,7 +152,7 @@ test('root content navigation works without changing the tree or overflowing nar
test('all 14 document responses contain the current article and metadata-only public tree', async ({ request }, testInfo) => { test('all 14 document responses contain the current article and metadata-only public tree', async ({ request }, testInfo) => {
expect(documentPaths).toHaveLength(14); expect(documentPaths).toHaveLength(14);
expect(treeNodeCount).toBe(16); expect(treeNodeCount).toBe(19);
for (const path of documentPaths) { for (const path of documentPaths) {
const url = bookURL(path, testInfo.project.use.baseURL!); const url = bookURL(path, testInfo.project.use.baseURL!);
const response = await request.get(url); const response = await request.get(url);
@@ -370,6 +367,7 @@ test('directory anchors navigate independently from the sibling plus/minus toggl
const html = await page.locator(article).innerHTML(); const html = await page.locator(article).innerHTML();
const title = await page.title(); const title = await page.title();
await expect(nodeAt(page, '/book')).toHaveAttribute('href', new URL('book/', base).pathname); await expect(nodeAt(page, '/book')).toHaveAttribute('href', new URL('book/', base).pathname);
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click(); await toggle(page).click();
await expect(toggle(page)).toHaveText('+'); await expect(toggle(page)).toHaveText('+');
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
@@ -378,6 +376,7 @@ test('directory anchors navigate independently from the sibling plus/minus toggl
expect(await page.locator(article).innerHTML()).toBe(html); expect(await page.locator(article).innerHTML()).toBe(html);
await expect(page).toHaveTitle(title); await expect(page).toHaveTitle(title);
await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true'); await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true');
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click(); await toggle(page).click();
await expect(toggle(page)).toHaveText('−'); await expect(toggle(page)).toHaveText('−');
await expect(nodeAt(page, path)).toBeVisible(); await expect(nodeAt(page, path)).toBeVisible();
@@ -404,6 +403,7 @@ test('persisted tree retains scroll and collapse, expanding only an ancestor nee
const pane = await page.locator('.tree-pane').elementHandle(); const pane = await page.locator('.tree-pane').elementHandle();
const viewport = await page.locator('.tree-viewport').elementHandle(); const viewport = await page.locator('.tree-viewport').elementHandle();
const root = await nodeAt(page, '/').elementHandle(); const root = await nodeAt(page, '/').elementHandle();
await nodeAt(page, '/4-client.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click(); await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click();
await page.locator('.tree-viewport').evaluate((el) => { await page.locator('.tree-viewport').evaluate((el) => {
(el as HTMLElement).style.height = '120px'; (el as HTMLElement).style.height = '120px';
@@ -451,7 +451,9 @@ test('home preserves a collapsed root until selecting a child requires expansion
await page.goto(bookURL(path, base)); await page.goto(bookURL(path, base));
await settled(page, path); await settled(page, path);
const pane = await page.locator('.tree-pane').elementHandle(); const pane = await page.locator('.tree-pane').elementHandle();
await nodeAt(page, '/4-client.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click(); await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click();
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click(); await toggle(page).click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await nodeAt(page, '/').focus(); await nodeAt(page, '/').focus();
@@ -573,9 +575,11 @@ test('rapid navigation settles on the last file and repeated swaps do not duplic
await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown');
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused(); await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused();
expect(page.url()).toBe(currentURL); expect(page.url()).toBe(currentURL);
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click(); await toggle(page).click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await expect(toggle(page)).toHaveText('+'); await expect(toggle(page)).toHaveText('+');
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click(); await toggle(page).click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true');
await expect(toggle(page)).toHaveText('−'); await expect(toggle(page)).toHaveText('−');
+1 -1
View File
@@ -11,7 +11,7 @@ test('home and book share the usable tree without client JavaScript', async ({ p
await expect(page.locator('.root-mark')).toHaveAttribute('aria-selected', 'true'); await expect(page.locator('.root-mark')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('[data-selected-content]')).not.toContainText('1. コンセプト'); await expect(page.locator('[data-selected-content]')).not.toContainText('1. コンセプト');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
const root = await page.locator('.root-mark').boundingBox(); const root = await page.locator('.root-mark').boundingBox();
const spine = await page.locator('.branch-vector').first().evaluate((element) => { const spine = await page.locator('.branch-vector').first().evaluate((element) => {
const svg = element as SVGSVGElement; const svg = element as SVGSVGElement;
+367
View File
@@ -0,0 +1,367 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
interface View { textView: string; textViewTokens: { kind: string; text: string }[]; }
type Captures = { observe?: View; fetch_interface?: View };
async function capture(page: Page) {
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nfor (const method of ['observe','fetch_interface']) { const original = BrowserSession.prototype[method]; BrowserSession.prototype[method] = function(...args) { const response = original.apply(this,args); (window.__textViews ||= {})[method] = JSON.parse(response); return response; }; }` });
});
}
async function exactView(page: Page, view: Locator, method: keyof Captures) {
const expected = await page.evaluate(method => (window as unknown as { __textViews: Captures }).__textViews[method]!, method);
expect(expected.textViewTokens.length).toBeGreaterThan(0);
expect(expected.textViewTokens.map(token => token.text).join('')).toBe(expected.textView);
// DOM/range text preserves the final LF. Browser selection can omit an unpainted
// trailing newline, so compare its behavior with the same unhighlighted <pre>.
expect(await view.evaluate(element => {
const text = element.cloneNode(true) as HTMLElement;
text.querySelectorAll('[data-operation-control]').forEach(control => control.remove());
return text.textContent;
})).toBe(expected.textView);
const copied = await view.evaluate(element => {
const source = element.cloneNode(true) as HTMLElement; source.removeAttribute('id');
source.querySelectorAll('[data-operation-control]').forEach(control => control.remove());
source.querySelectorAll<HTMLElement>('*').forEach(node => { node.style.animation = 'none'; });
source.style.position = 'absolute'; element.after(source);
const range = document.createRange(); range.selectNodeContents(source);
const rangeText = range.toString();
const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range);
const highlighted = selection.toString();
const plain = source.cloneNode(false) as HTMLElement; plain.removeAttribute('id');
plain.textContent = source.textContent; source.after(plain);
range.selectNodeContents(plain); selection.removeAllRanges(); selection.addRange(range);
const unhighlighted = selection.toString(); selection.removeAllRanges(); plain.remove(); source.remove();
return { rangeText, highlighted, unhighlighted };
});
expect(copied.rangeText).toBe(expected.textView);
expect(copied.highlighted).toBe(copied.unhighlighted);
const semantic = expected.textViewTokens.filter(token => !['whitespace', 'newline', 'indentation', 'plain'].includes(token.kind));
expect(await view.locator('[data-text-kind]').evaluateAll(elements => elements.map(element => ({ kind: (element as HTMLElement).dataset.textKind, text: element.textContent })))).toEqual(semantic);
await expect(view.locator('img, script, a, style')).toHaveCount(0);
}
for (const scheme of ['light', 'dark'] as const) {
test(`real Text View token roles preserve content and use a readable ${scheme} palette`, async ({ page }, info) => {
await page.emulateMedia({ colorScheme: scheme }); await capture(page);
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
const object = page.getByLabel('Object Text View');
await expect(object.locator('[data-text-kind="keyword"]').first()).toHaveText('object');
await exactView(page, object, 'observe');
await page.getByRole('button', { name: '? /::document', exact: true }).click();
const signature = page.locator('[data-interface-view]:visible');
await exactView(page, signature, 'fetch_interface');
for (const kind of ['keyword', 'type-name', 'operation-name', 'field-name', 'reference', 'documentation', 'symbol']) {
expect(await signature.locator(`[data-text-kind="${kind}"]`).count()).toBeGreaterThan(0);
}
const palette = await page.locator('.protocol-signature [data-text-kind]').evaluateAll(elements => {
const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--paper)'; document.body.append(probe);
const background = getComputedStyle(probe).backgroundColor; probe.remove();
return { background, colors: elements.map(element => getComputedStyle(element).color) };
});
const luminance = (color: string) => {
const rgb = color.match(/[\d.]+/g)!.slice(0, 3).map(Number).map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return .2126 * rgb[0] + .7152 * rgb[1] + .0722 * rgb[2];
};
expect(new Set(palette.colors).size).toBeGreaterThanOrEqual(6);
for (const color of palette.colors) {
const a = luminance(color), b = luminance(palette.background);
expect((Math.max(a, b) + .05) / (Math.min(a, b) + .05)).toBeGreaterThanOrEqual(4.5);
}
const text = await signature.textContent();
const keyword = signature.locator('[data-text-kind="keyword"]').first();
const before = await keyword.evaluate(element => getComputedStyle(element).color);
await page.emulateMedia({ colorScheme: scheme === 'light' ? 'dark' : 'light' });
await expect(keyword).not.toHaveCSS('color', before);
expect(await signature.textContent()).toBe(text);
await page.getByRole('button', { name: 'v /::document', exact: true }).click();
await page.getByRole('button', { name: '> /::document', exact: true }).click();
await exactView(page, signature, 'fetch_interface');
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-interfaces', '1');
await page.emulateMedia({ colorScheme: scheme });
await page.screenshot({ path: info.outputPath(`text-view-${scheme}.png`), fullPage: true });
});
}
test('Unicode and markup remain literal text and Playground refresh keeps token styling', async ({ page }, info) => {
await capture(page);
await page.route('**/worldspace.json', async route => {
const response = await route.fetch(); const data = await response.json();
data.nodes[0].title = '日本語 🐈 <img src=x onerror="window.__highlightExecuted=1"> & \\ \n\t';
await route.fulfill({ response, json: data });
});
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
const object = page.getByLabel('Object Text View');
await expect(object.locator('[data-text-kind="string"]').last()).toContainText('日本語 🐈 <img');
await exactView(page, object, 'observe');
expect(await page.evaluate(() => '__highlightExecuted' in window)).toBe(false);
await page.locator('.demo-node[data-path="/playground"]').click();
await page.getByRole('button', { name: '? /::collection', exact: true }).click();
await expect(page.locator('[data-interface-view]:visible [data-text-kind="parameter-name"]').first()).toHaveText('`name`');
await page.locator('.demo-node[data-path="/playground/apple"]').click();
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
await expect(object.locator('[data-text-kind="string"]').last()).toHaveText('"An in-memory counter."');
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '4' });
await exactView(page, object, 'observe');
await expect(page.getByLabel('Operation result').locator('[data-text-kind]')).toHaveCount(0);
});
test('Interface lines fade in top-to-bottom at 10ms per line, including cached reopen', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' }); await capture(page);
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
const toggle = page.getByRole('button', { name: '? /::collection', exact: true });
await expect(toggle).toBeEnabled();
for (let attempt = 0; attempt < 2; attempt++) {
const timing = await page.locator('[data-interface-toggle]').filter({ hasText: '/::collection' }).evaluate(button => {
(button as HTMLButtonElement).click();
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
const lines = [...view.querySelectorAll('.text-view-line')];
const animations = view.getAnimations({ subtree: true });
const timings = lines.flatMap(line => line.getAnimations()).map(animation => {
const effect = animation.effect!;
return { delay: Number(effect.getTiming().delay), duration: effect.getComputedTiming().duration, end: effect.getComputedTiming().endTime };
});
animations.forEach(animation => { animation.pause(); animation.currentTime = 20; });
const opacity = lines.map(line => Number(getComputedStyle(line).opacity));
const source = view.cloneNode(true) as HTMLElement;
source.querySelectorAll('[data-operation-control]').forEach(control => control.remove());
const text = source.textContent;
animations.forEach(animation => animation.finish());
return { timings, opacity, lines: lines.map(line => line.textContent), text };
});
expect(timing.timings.length).toBe(timing.lines.length);
expect(timing.timings.length).toBeGreaterThan(2);
expect(timing.timings[0].delay).toBe(0);
expect(Math.max(...timing.timings.map(item => Number(item.end)))).toBeCloseTo(timing.lines.length * 10, 5);
expect(timing.timings.every(item => Number(item.duration) === 10)).toBe(true);
timing.timings.forEach((item, index) => expect(item.delay).toBe(index * 10));
expect(timing.opacity[0]).toBeGreaterThan(0);
expect(timing.opacity.at(-1)).toBe(0);
expect(timing.lines.join('\n') + '\n').toBe(timing.text);
await exactView(page, page.locator('[data-interface-view]:visible'), 'fetch_interface');
await page.getByRole('button', { name: 'v /::collection', exact: true }).click();
await expect(page.locator('[data-interface-view]:visible')).toHaveCount(0);
}
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-interfaces', '1');
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
test('Interface height grows from zero and pushes following rows down in sync with line fades', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
const toggle = page.locator('[data-interface-toggle]').first();
await expect(toggle).toBeEnabled();
for (let attempt = 0; attempt < 2; attempt++) {
const result = await toggle.evaluate(button => {
const panel = button.closest<HTMLElement>('[data-protocol-panel]')!;
const following = button.parentElement!.nextElementSibling!;
const top = () => following.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop;
const before = top();
(button as HTMLButtonElement).click();
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
const animations = view.getAnimations({ subtree: true });
const expansion = view.querySelector('.text-view-reveal')!.getAnimations()[0];
const duration = Number(expansion.effect!.getComputedTiming().duration);
const lineCount = view.querySelectorAll('.text-view-line').length;
animations.forEach(animation => animation.pause());
const frames = [0, .25, .5, .75, 1].map(progress => {
animations.forEach(animation => { animation.currentTime = progress * duration; });
return { height: view.getBoundingClientRect().height, following: top() };
});
animations.forEach(animation => animation.finish());
return { before, frames, duration, lineCount,
finalHeight: view.getBoundingClientRect().height,
contentHeight: view.querySelector('.text-view-lines')!.scrollHeight };
});
expect(result.duration).toBe(result.lineCount * 10);
expect(result.frames[0].height).toBe(0);
expect(result.frames[0].following).toBeCloseTo(result.before, 1);
for (let index = 1; index < result.frames.length; index++) {
expect(result.frames[index].height).toBeGreaterThan(result.frames[index - 1].height);
expect(result.frames[index].following).toBeGreaterThan(result.frames[index - 1].following);
expect(result.frames[index].following - result.before).toBeCloseTo(result.frames[index].height, 1);
}
expect(result.frames.at(-1)!.height).toBeCloseTo(result.finalHeight, 1);
expect(Math.abs(result.finalHeight - result.contentHeight)).toBeLessThan(1);
// No retained fixed height: wrapping after expansion must still size naturally.
const resized = await page.locator('[data-interface-view]:visible').evaluate(view => {
(view as HTMLElement).style.width = '16ch';
return { height: view.getBoundingClientRect().height, content: view.querySelector('.text-view-lines')!.scrollHeight };
});
expect(resized.height).toBeGreaterThan(result.finalHeight);
expect(Math.abs(resized.height - resized.content)).toBeLessThan(1);
await page.locator('[data-interface-view]:visible').evaluate(view => (view as HTMLElement).style.removeProperty('width'));
await toggle.click();
await expect(page.locator('[data-interface-view]:visible')).toHaveCount(0);
}
});
test('closing shrinks the current height and lifts following rows at 10ms per line', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
const toggle = page.locator('[data-interface-toggle]').first();
await expect(toggle).toBeEnabled();
for (const openingProgress of [1, .4]) {
const result = await toggle.evaluate((button, openingProgress) => {
(button as HTMLButtonElement).click();
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
const lineCount = view.querySelectorAll('.text-view-line').length;
view.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = openingProgress * lineCount * 10; });
const panel = button.closest<HTMLElement>('[data-protocol-panel]')!;
const following = button.parentElement!.nextElementSibling!;
const top = () => following.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop;
const before = { height: view.getBoundingClientRect().height, top: top(), opacity: getComputedStyle(view.querySelector('.text-view-line')!).opacity };
(button as HTMLButtonElement).click();
const animations = view.getAnimations({ subtree: true });
const duration = Number(view.querySelector('.text-view-reveal')!.getAnimations()[0].effect!.getComputedTiming().duration);
const lineTimings = [...view.querySelectorAll('.text-view-line')].map(line => line.getAnimations()[0].effect!.getTiming());
animations.forEach(animation => animation.pause());
const frames = [0, .25, .5, .75, 1].map(progress => {
animations.forEach(animation => { animation.currentTime = progress * duration; });
return { height: view.getBoundingClientRect().height, top: top(), opacity: Number(getComputedStyle(view.querySelector('.text-view-line')!).opacity) };
});
const inert = view.inert;
animations.forEach(animation => animation.finish());
return { before, frames, inert, expanded: button.getAttribute('aria-expanded'), duration, lineCount, lineTimings };
}, openingProgress);
result.lineTimings.forEach((timing, index) => {
expect(timing.duration).toBe(10);
expect(timing.delay).toBe((result.lineCount - index - 1) * 10);
});
expect(result.duration).toBe(result.lineCount * 10);
expect(result.inert).toBe(true);
expect(result.expanded).toBe('false');
expect(result.frames[0].height).toBeCloseTo(result.before.height, 1);
expect(result.frames[0].top).toBeCloseTo(result.before.top, 1);
expect(result.frames[0].opacity).toBeCloseTo(Number(result.before.opacity), 4);
for (let index = 1; index < result.frames.length; index++) {
expect(result.frames[index].height).toBeLessThan(result.frames[index - 1].height);
expect(result.frames[index].top).toBeLessThan(result.frames[index - 1].top);
expect(result.frames[index].opacity).toBeLessThanOrEqual(result.frames[index - 1].opacity);
expect(result.before.top - result.frames[index].top).toBeCloseTo(result.before.height - result.frames[index].height, 1);
}
expect(result.frames.at(-1)!.height).toBe(0);
expect(result.frames.at(-1)!.opacity).toBe(0);
await expect(page.locator('[data-interface-view]:not([hidden])')).toHaveCount(0);
await expect(page.locator('.text-view-reveal')).toHaveCount(0);
await expect(page.locator('[data-protocol-call]')).toHaveCount(0);
}
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-interfaces', '1');
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
});
test('reopening during collapse cannot be cleared by the old completion', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
const toggle = page.locator('[data-interface-toggle]').first();
await expect(toggle).toBeEnabled();
const state = await toggle.evaluate(async button => {
const click = () => (button as HTMLButtonElement).click();
click();
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
view.getAnimations({ subtree: true }).forEach(animation => animation.finish());
click();
const closing = view.getAnimations({ subtree: true });
closing.forEach(animation => { animation.pause(); animation.currentTime = 50; });
const settled = Promise.allSettled(closing.map(animation => animation.finished));
click();
view.getAnimations({ subtree: true }).forEach(animation => animation.finish());
await settled;
return { hidden: view.hidden, inert: view.inert, text: view.textContent, expanded: button.getAttribute('aria-expanded') };
});
expect(state.hidden).toBe(false);
expect(state.inert).toBe(false);
expect(state.text).toContain('interface');
expect(state.expanded).toBe('true');
await expect(page.locator('.text-view-closing')).toHaveCount(0);
await toggle.click();
await expect(page.locator('[data-interface-view]:not([hidden])')).toHaveCount(0);
});
test('reduced motion and print settle a running collapse without leaving content', async ({ page }, info) => {
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
const toggle = page.locator('[data-interface-toggle]').first();
await expect(toggle).toBeEnabled();
for (const preference of ['reduce', 'print']) {
await page.emulateMedia({ media: 'screen', reducedMotion: 'no-preference' });
await toggle.evaluate(button => {
(button as HTMLButtonElement).click();
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
view.getAnimations({ subtree: true }).forEach(animation => animation.finish());
(button as HTMLButtonElement).click();
view.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = 50; });
});
await expect(page.locator('.text-view-closing')).toHaveCount(1);
await page.emulateMedia(preference === 'reduce' ? { reducedMotion: 'reduce' } : { media: 'print' });
await expect(page.locator('.text-view-reveal')).toHaveCount(0);
await expect(page.locator('[data-interface-view]:not([hidden])')).toHaveCount(0);
}
});
test('rapid collapse and switching Interfaces cannot restore old animated text', async ({ page }, info) => {
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
await expect(page.getByRole('button', { name: '? /::collection', exact: true })).toBeEnabled();
await page.evaluate(() => {
const buttons = [...document.querySelectorAll<HTMLButtonElement>('[data-interface-toggle]')];
const collection = buttons.find(button => button.textContent?.includes('/::collection'))!;
const documentButton = buttons.find(button => button.textContent?.includes('/::document'))!;
collection.click();
document.getElementById(collection.getAttribute('aria-controls')!)!.getAnimations({ subtree: true }).forEach(animation => animation.pause());
collection.click(); documentButton.click();
});
const view = page.locator('[data-interface-view]:visible');
await expect(view).toContainText('interface "/::document"');
await expect.poll(() => view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
await expect(page.getByRole('button', { name: '> /::collection', exact: true })).toHaveAttribute('aria-expanded', 'false');
expect(await page.locator('[data-interface-view][hidden]').textContent()).toBe('');
await page.evaluate(() => {
document.querySelector<HTMLButtonElement>('[data-interface-toggle][aria-expanded="true"]')!.click();
document.querySelector<HTMLButtonElement>('[data-mode="read"]')!.click();
});
await expect(page.locator('.text-view-line')).toHaveCount(0);
});
test('reduced motion disables expansion and fades, including a live preference change', async ({ page }, info) => {
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
await page.getByRole('button', { name: '? /::document', exact: true }).click();
const view = page.locator('[data-interface-view]:visible');
expect(await view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
await expect(view.locator('.text-view-line').first()).toHaveCSS('opacity', '1');
const fullHeight = await view.evaluate(element => element.getBoundingClientRect().height);
expect(fullHeight).toBeGreaterThan(0);
await page.getByRole('button', { name: 'v /::document', exact: true }).click();
await page.emulateMedia({ reducedMotion: 'no-preference' });
const count = await page.getByRole('button', { name: '> /::document', exact: true }).evaluate(button => {
(button as HTMLButtonElement).click();
const animations = document.getElementById(button.getAttribute('aria-controls')!)!.getAnimations({ subtree: true });
animations.forEach(animation => { animation.pause(); animation.currentTime = 0; });
return animations.length;
});
expect(count).toBeGreaterThan(0);
expect(await page.locator('[data-interface-view]:not([hidden])').evaluate(element => element.getBoundingClientRect().height)).toBe(0);
await page.emulateMedia({ reducedMotion: 'reduce' });
await expect.poll(() => view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
await expect(view.locator('.text-view-line').last()).toHaveCSS('opacity', '1');
expect(await view.evaluate(element => element.getBoundingClientRect().height)).toBeCloseTo(fullHeight, 1);
await page.emulateMedia({ media: 'print', reducedMotion: 'no-preference' });
expect(await view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
});
for (const variant of ['missing', 'mismatch', 'unknown-kind'] as const) {
test(`token compatibility fallback is safe for ${variant}`, async ({ page }, info) => {
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const response = await route.fetch();
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(...args) { const value = JSON.parse(original.apply(this,args)); const variant = ${JSON.stringify(variant)}; if (variant === 'missing') delete value.textViewTokens; else value.textViewTokens = [{kind:'unknown" onclick="alert(1)',text:variant === 'mismatch' ? '<img src=x onerror=alert(1)>' : value.textView}]; window.__plainSignature = value.textView; return JSON.stringify(value); };` });
});
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
const object = page.getByLabel('Object Text View'); await expect(object).toBeVisible();
expect(await object.textContent()).toBe(await page.evaluate(() => (window as unknown as { __plainSignature: string }).__plainSignature));
await expect(object.locator('*')).toHaveCount(0);
await page.getByRole('button', { name: '? /::document', exact: true }).click();
await expect(page.locator('[data-interface-view]:visible [data-text-kind="keyword"]').first()).toHaveText('interface');
});
}
+6
View File
@@ -48,6 +48,11 @@ async function paint(locator: Locator, property = 'color') {
}, property); }, property);
} }
async function readable(locator: Locator, minimum = 4.5, property = 'color') { async function readable(locator: Locator, minimum = 4.5, property = 'color') {
// Assert the requested palette state, not a scheduler-dependent intermediate
// frame of the existing hover/focus color transition.
await locator.evaluate(async (element) => {
await Promise.all(element.getAnimations().map((animation) => animation.finished.catch(() => {})));
});
const colors = await paint(locator, property); const colors = await paint(locator, property);
expect(contrast(colors.color, colors.background), `${property} contrast for ${locator}`).toBeGreaterThanOrEqual(minimum); expect(contrast(colors.color, colors.background), `${property} contrast for ${locator}`).toBeGreaterThanOrEqual(minimum);
return colors; return colors;
@@ -256,6 +261,7 @@ test.describe('live CSS theme with JavaScript enabled', () => {
await expect(page).toHaveURL(bookURL('/3-protocol.md', testInfo.project.use.baseURL!)); await expect(page).toHaveURL(bookURL('/3-protocol.md', testInfo.project.use.baseURL!));
await complete(page); await complete(page);
await page.evaluate(() => document.fonts.ready.then(() => undefined)); await page.evaluate(() => document.fonts.ready.then(() => undefined));
await nodeAt(page, '/1-concept.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click(); await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false');
const content = await page.locator(article).elementHandle(); const content = await page.locator(article).elementHandle();
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
test('fold controls appear on row hover or keyboard focus without shifting the tree', async ({ page }) => {
await page.goto('./book/');
await expect(page.locator('.demo-tree')).toHaveAttribute('data-enhanced');
const node = page.locator('.demo-node[data-path="/book"]');
const toggle = page.locator('[data-toggle-path="/book"]');
await page.mouse.move(0, 0);
await expect(toggle).toHaveCSS('opacity', '0');
await expect(toggle).toHaveCSS('pointer-events', 'none');
const before = await node.boundingBox();
await node.hover();
await expect(toggle).toHaveCSS('opacity', '1');
expect(await node.boundingBox()).toEqual(before);
await toggle.click();
await expect(node).toHaveAttribute('aria-expanded', 'false');
await toggle.click();
await expect(node).toHaveAttribute('aria-expanded', 'true');
await page.locator('[data-selected-content]').focus();
await page.mouse.move(0, 0);
await expect(toggle).toHaveCSS('opacity', '0');
await node.focus();
await expect(toggle).toHaveCSS('opacity', '1');
await page.keyboard.press('ArrowLeft');
await expect(node).toHaveAttribute('aria-expanded', 'false');
await page.keyboard.press('ArrowRight');
await expect(node).toHaveAttribute('aria-expanded', 'true');
});
test.describe('touch without hover', () => {
test.use({ hasTouch: true, isMobile: true });
test('fold controls remain visible and tappable', async ({ page }) => {
await page.goto('./book/');
await expect(page.locator('.demo-tree')).toHaveAttribute('data-enhanced');
expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(true);
const toggle = page.locator('[data-toggle-path="/book"]');
await expect(toggle).toHaveCSS('opacity', '1');
await toggle.tap();
await expect(page.locator('.demo-node[data-path="/book"]')).toHaveAttribute('aria-expanded', 'false');
await toggle.tap();
await expect(page.locator('.demo-node[data-path="/book"]')).toHaveAttribute('aria-expanded', 'true');
});
});
+131
View File
@@ -0,0 +1,131 @@
import { expect, test } from '@playwright/test';
import { readFile } from 'node:fs/promises';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
async function probeAsset(name: string) {
return readFile(new URL(`../../test-assets/wip-wasm/${name}`, import.meta.url));
}
test('real WASM enforces manifest byte, node and depth bounds', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
await module.default();
const root = { path: '/', name: '', displayPath: '/', title: 'Root', href: '/', html: '<p>Root</p>' };
const attempt = (json: string) => {
try { new module.BrowserSession(json).free(); return { error: '', trap: false }; }
catch (error) { return { error: String(error), trap: error instanceof WebAssembly.RuntimeError }; }
};
// Both oversized manifests are otherwise valid; the controls prove that
// removing a limit cannot leave this test green via unrelated validation.
const byteLimit = 16 * 1024 * 1024;
const empty = JSON.stringify({ nodes: [{ ...root, html: '' }] });
const atBytes = JSON.stringify({ nodes: [{ ...root, html: 'x'.repeat(byteLimit - empty.length) }] });
const bytesAccepted = attempt(atBytes);
const bytes = attempt(atBytes.replace('"html":"', '"html":"x'));
const wide = [root, ...Array.from({ length: 4096 }, (_, i) => ({
path: `/n${i}`, name: `n${i}`, displayPath: `/n${i}`, parent: '/', title: `Node ${i}`, href: `/n${i}/`, html: '',
}))];
const nodesAccepted = attempt(JSON.stringify({ nodes: wide.slice(0, 4096) }));
const nodes = attempt(JSON.stringify({ nodes: wide }));
const deep: Record<string, string>[] = [root];
let parent = '/';
for (let i = 0; i < 32; i++) {
const path = `${parent === '/' ? '' : parent}/a`;
deep.push({ path, name: 'a', displayPath: path, parent, title: path, href: `${path}/`, html: '<p>Node</p>' });
parent = path;
}
const accepted = new module.BrowserSession(JSON.stringify({ nodes: deep }));
const deepest = JSON.parse(accepted.read(parent)).path;
accepted.free();
deep.push({ path: `${parent}/a`, name: 'a', displayPath: `${parent}/a`, parent, title: 'Too deep', href: `${parent}/a/`, html: '' });
const depth = attempt(JSON.stringify({ nodes: deep }));
return { bytes, nodes, depth, deepest, bytesAccepted, nodesAccepted, probePublished: 'panic_probe' in module.BrowserSession.prototype };
}, base);
expect(result.bytesAccepted).toEqual({ error: '', trap: false });
expect(result.nodesAccepted).toEqual({ error: '', trap: false });
expect(result.bytes).toEqual({ error: 'document JSON exceeds the input byte limit', trap: false });
expect(result.nodes).toEqual({ error: 'document node count must be between 1 and MAX_NODES', trap: false });
expect(result.depth).toEqual({ error: 'document hierarchy exceeds the depth limit', trap: false });
expect(result.deepest.split('/').length - 1).toBe(32);
expect(result.probePublished).toBe(false);
});
test('a controlled release Rust panic traps, not a Protocol failure; a fresh instance recovers', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.route('**/wip-probe/wip_browser*', async (route) => {
const wasm = new URL(route.request().url()).pathname.endsWith('.wasm');
await route.fulfill({ body: await probeAsset(wasm ? 'wip_browser_bg.wasm' : 'wip_browser.js'), contentType: wasm ? 'application/wasm' : 'text/javascript' });
});
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
const module = await import(new URL('wip-probe/wip_browser.js', base).href);
await module.default();
const session = new module.BrowserSession(manifest);
const before = JSON.parse(session.read('/')).path;
let trap = false, detail = '';
try { session.panic_probe(); } catch (error) {
trap = error instanceof WebAssembly.RuntimeError;
detail = String(error);
}
// Retain the aborted wrapper too: its generated GC finalizer enters WASM.
(window as unknown as Record<string, unknown>).__quarantinedSession = session;
// Do not call/free the aborted Rust object: a mutable borrow was interrupted.
// A distinct glue module creates a genuinely new WASM instance.
const fresh = await import(`${new URL('wip-probe/wip_browser.js', base).href}?fresh`);
await fresh.default();
const replacement = new fresh.BrowserSession(manifest);
const after = JSON.parse(replacement.read('/')).path;
replacement.free();
return { before, after, trap, detail };
}, base);
expect(result.before).toBe('/');
expect(result.trap).toBe(true);
expect(result.detail).toContain('RuntimeError');
expect(result.after).toBe('/');
});
test('the site retires a trapped WASM session and does not replay calls on later routes', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.route('**/wip-wasm/wip_browser.js', async (route) => {
const source = (await probeAsset('wip_browser.js')).toString();
await route.fulfill({ contentType: 'text/javascript', body: `${source}
BrowserSession.prototype.read = function () {
window.__panicReadCalls = (window.__panicReadCalls || 0) + 1;
window.__trappedSession = new WeakRef(this);
return this.panic_probe();
};
const originalFree = BrowserSession.prototype.free;
BrowserSession.prototype.free = function () {
window.__panicFreeCalls = (window.__panicFreeCalls || 0) + 1;
return originalFree.call(this);
};` });
});
await page.route('**/wip-wasm/*.wasm', async (route) => {
await route.fulfill({ contentType: 'application/wasm', body: await probeAsset('wip_browser_bg.wasm') });
});
await page.goto(base);
const stage = page.locator('[data-landing-stage]');
await expect(stage).toHaveAttribute('data-wip-state', 'fallback');
expect(await page.evaluate(() => (window as Window & { __panicReadCalls?: number }).__panicReadCalls)).toBe(1);
await page.locator('.demo-node[data-path="/book"]').click();
await expect(page).toHaveURL(new URL('book/', base).href);
await expect(stage).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator('[data-selected-content] h1')).toHaveText('Book');
expect(await page.evaluate(() => (window as Window & { __panicReadCalls?: number }).__panicReadCalls)).toBe(1);
// Force collection after continuations release their locals. The only test
// reference is weak, so this catches accidentally dropping the quarantine.
const cdp = await page.context().newCDPSession(page);
await cdp.send('HeapProfiler.collectGarbage');
expect(await page.evaluate(() => Boolean((window as unknown as { __trappedSession: WeakRef<object> }).__trappedSession.deref()))).toBe(true);
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false })));
await cdp.send('HeapProfiler.collectGarbage');
expect(await page.evaluate(() => ({
retained: Boolean((window as unknown as { __trappedSession: WeakRef<object> }).__trappedSession.deref()),
frees: (window as Window & { __panicFreeCalls?: number }).__panicFreeCalls || 0,
}))).toEqual({ retained: true, frees: 0 });
await cdp.detach();
});
+81
View File
@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true });
test('real WASM owns isolated Client/Host sessions and operates without a transport', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
// Use an asset page, so the site UI is not the source of this test's observations.
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
await module.default();
const session = new module.BrowserSession(manifest);
const input = JSON.parse(manifest);
const altered = structuredClone(input);
const document = altered.nodes.find((node: { path: string }) => node.path !== '/' && node.path !== '/book');
document.html = '<p>Isolated Worldspace content</p>';
document.title = 'Isolated title';
const other = new module.BrowserSession(JSON.stringify(altered));
// Asset loading has finished. All following interactions must be synchronous
// local calls: neither fetch nor a socket is a transport for WIP.
const originalFetch = window.fetch;
window.fetch = () => { throw new Error('Unexpected WIP network transport'); };
try {
const tree = JSON.parse(session.tree());
const bodies = tree.map((node: { path: string }) => JSON.parse(session.read(node.path)));
const first = JSON.parse(session.read(document.path));
const second = JSON.parse(other.read(document.path));
let missing = '', malformed = '', draft = '', operation = '';
const callsBeforeInvalid = JSON.parse(session.stats()).calls;
try { session.call(document.path, 'not-an-operation'); } catch (error) { operation = String(error); }
const callsAfterInvalid = JSON.parse(session.stats()).calls;
try { session.read('/not-published'); } catch (error) { missing = String(error); }
try { session.read('/book/../hidden'); } catch (error) { malformed = String(error); }
try { session.read('/draft/rust-api-model.md'); } catch (error) { draft = String(error); }
const stats = JSON.parse(session.stats());
const isolatedStats = JSON.parse(other.stats());
session.free();
other.free();
return { tree, bodies, input, first, second, missing, malformed, draft, operation, callsBeforeInvalid, callsAfterInvalid, stats, isolatedStats };
} finally { window.fetch = originalFetch; }
}, base);
expect(result.tree).toEqual(result.input.nodes.map(({ html, ...node }: { html: string }) => node));
expect(result.bodies).toEqual(result.input.nodes.map(({ path, displayPath, title, href, html }: Record<string, string>) => ({ path, displayPath, title, href, html })));
expect(result.first.html).not.toBe(result.second.html);
expect(result.second.html).toBe('<p>Isolated Worldspace content</p>');
expect(result.second.title).toBe('Isolated title');
expect(result.stats.observes).toBeGreaterThan(0);
expect(result.stats.interfaces).toBeGreaterThan(0);
expect(result.stats.calls).toBeGreaterThan(result.tree.length);
expect(result.isolatedStats.calls).toBe(1);
expect(result.missing).not.toBe('');
expect(result.malformed).not.toBe('');
expect(result.draft).not.toBe('');
expect(result.operation).not.toBe('');
expect(result.callsAfterInvalid).toBe(result.callsBeforeInvalid);
});
test('WASM rejects invalid initialization and remains usable after a handled input failure', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
await module.default();
const errors: string[] = [];
for (const input of ['{', '{"nodes":[]}', '{"nodes":null}']) {
try { new module.BrowserSession(input).free(); } catch (error) { errors.push(String(error)); }
}
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
const session = new module.BrowserSession(manifest);
const body = JSON.parse(session.read('/'));
session.free();
let disposed = false;
try { session.read('/'); } catch { disposed = true; }
return { errors, body, disposed };
}, base);
expect(result.errors).toHaveLength(3);
expect(result.body.path).toBe('/');
expect(result.body.html).toContain('data-root-page');
expect(result.disposed).toBe(true);
});
+46
View File
@@ -0,0 +1,46 @@
import { expect, test } from '@playwright/test';
import { load } from 'cheerio';
// Shared by dev and preview: a successful build alone cannot catch Vite's
// dynamic-import rewriting of generated modules served from public/.
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
test('generated WASM loads as a static asset and hydrates real WIP data', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const errors: string[] = [];
const assets: { url: string; status: number }[] = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('response', (response) => {
if (response.status() >= 500) errors.push(`${response.status()} ${response.url()}`);
if (new URL(response.url()).pathname.includes('/wip-wasm/')) {
assets.push({ url: response.url(), status: response.status() });
}
});
await page.route(base, async (route) => {
const response = await route.fetch();
const $ = load(await response.text());
$('[data-selected-content]').prepend('<p data-ssr-only>Not a WIP result</p>');
await route.fulfill({ response, body: $.html() });
});
await page.goto(base);
const stage = page.locator('[data-landing-stage]');
await expect(stage).toHaveAttribute('data-wip-state', 'ready');
await expect(page.locator('[data-ssr-only]')).toHaveCount(0);
await expect(page.locator('[data-selected-content]')).toHaveAttribute('data-wip-path', '/');
for (const counter of ['observes', 'interfaces', 'calls']) {
expect(Number(await stage.getAttribute(`data-wip-${counter}`))).toBeGreaterThan(0);
}
const calls = Number(await stage.getAttribute('data-wip-calls'));
await page.locator('.demo-node[data-path="/book"]').click();
await expect(page).toHaveURL(new URL('book/', base).href);
await expect(stage).toHaveAttribute('data-wip-state', 'ready');
await expect(page.locator('[data-selected-content]')).toHaveAttribute('data-wip-path', '/book');
expect(Number(await stage.getAttribute('data-wip-calls'))).toBe(calls + 1);
expect(assets.map(({ url }) => url).sort()).toEqual([
new URL('wip-wasm/wip_browser.js', base).href,
new URL('wip-wasm/wip_browser_bg.wasm', base).href,
].sort());
expect(assets.every(({ status }) => status === 200)).toBe(true);
await expect(page.locator('vite-error-overlay')).toHaveCount(0);
expect(errors).toEqual([]);
});
+460
View File
@@ -0,0 +1,460 @@
import { expect, test, type Page } from '@playwright/test';
import { load } from 'cheerio';
import { bookURL, documentPaths, draftPaths, treePaths, routeURL, catalogPaths } from './documents';
import { catalogs, type CatalogName } from '../../src/lib/catalog';
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}"]`);
async function ready(page: Page, path: string) {
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'ready');
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
const canonical = await nodeAt(page, path).getAttribute('data-wip-path');
expect(canonical).toBeTruthy();
await expect(page.locator(stage)).toHaveAttribute('data-wip-path', canonical!);
await expect(page.locator(article)).toHaveAttribute('data-wip-path', canonical!);
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
for (const counter of ['observes', 'interfaces', 'calls']) {
expect(Number(await page.locator(stage).getAttribute(`data-wip-${counter}`))).toBeGreaterThan(0);
}
}
async function useNestedArticleScroll(page: Page) {
await page.addInitScript(() => {
const constrain = (document: Document) => {
const article = document.querySelector<HTMLElement>('[data-selected-content]');
if (article) { article.style.maxHeight = '240px'; article.style.overflow = 'auto'; }
};
document.addEventListener('astro:page-load', () => constrain(document));
// Constrain incoming markup BEFORE Astro scrolls to a cross-page fragment.
// Changing overflow after that native scroll makes the mobile test race the
// layout instead of exercising the intended nested scrolling geometry.
document.addEventListener('astro:before-swap', (event) => {
constrain((event as Event & { newDocument: Document }).newDocument);
});
});
}
async function probeBridge(page: Page, base: string, displayPath: string) {
return page.evaluate(async ({ base, displayPath }) => {
const moduleURL = new URL('wip-wasm/wip_browser.js', base).href;
const module = await import(moduleURL);
await module.default();
const client = new module.BrowserSession(await (await fetch(new URL('worldspace.json', base))).text());
try {
const nodes = JSON.parse(client.tree()) as { path: string; displayPath: string; name: string; title: string; href: string }[];
const selected = nodes.find((node) => node.displayPath === displayPath)!;
const result = JSON.parse(client.read(selected.path));
const template = document.createElement('template');
template.innerHTML = result.html;
return { nodes, html: template.innerHTML, stats: JSON.parse(client.stats()) };
} finally { client.free(); }
}, { base, displayPath });
}
// Run this suite against both ordinary builds and SITE_BASE builds. All URLs
// derive from the project's baseURL; no root-only module or document shortcuts.
test('real WASM results replace poisoned SSR labels and article, rather than just observing', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const url = bookURL('/1-concept.md', base);
await page.route(url, async (route) => {
const response = await route.fetch();
const $ = load(await response.text());
$('.node-label').text('SSR-only wrong label');
$(article).prepend('<p data-ssr-only>SSR-only wrong body</p>');
await route.fulfill({ response, body: $.html() });
});
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
await page.route('**/wip-wasm/wip_browser.js', async (route) => { await gate; await route.continue(); });
const wasmResponses: string[] = [];
page.on('response', (response) => { if (new URL(response.url()).pathname.endsWith('.wasm') && response.ok()) wasmResponses.push(response.url()); });
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await expect(page.locator('[data-ssr-only]')).toBeVisible();
await expect(nodeAt(page, '/1-concept.md')).toHaveText('SSR-only wrong label');
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'loading');
const anchor = await nodeAt(page, '/1-concept.md').elementHandle();
release();
await ready(page, '/1-concept.md');
await expect(page.locator('[data-ssr-only]')).toHaveCount(0);
await expect(nodeAt(page, '/1-concept.md')).toHaveText('1-concept.md');
expect(await anchor!.evaluate((el) => el === document.querySelector('.demo-node[data-path="/1-concept.md"]'))).toBe(true);
expect(wasmResponses).toHaveLength(1);
expect(wasmResponses[0]).toContain(new URL('wip-wasm/', base).pathname);
// Independently read the same genuine Rust bridge, without exposing a test
// session on window. Compare actual data/HTML, not success markers alone.
const expected = await probeBridge(page, base, '/1-concept.md');
expect(await page.locator(article).innerHTML()).toBe(expected.html);
for (const node of expected.nodes) {
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('data-wip-path', node.path);
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('href', node.href);
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('title', node.title);
if (node.displayPath !== '/') {
await expect(nodeAt(page, node.displayPath)).toHaveText(node.name);
}
}
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('catalog root leaves share static and real WASM bodies, links and history', async ({ page, request }, testInfo) => {
const base = testInfo.project.use.baseURL!;
for (const path of catalogPaths) {
const name = path.slice(1) as CatalogName;
const response = await request.get(routeURL(path, base));
expect(response.ok()).toBe(true);
const $ = load(await response.text());
const staticBody = $(article);
expect(staticBody.find('h1').text()).toBe(catalogs[name].title);
expect(staticBody.find('.catalog-list > li').length).toBe(catalogs[name].entries.length);
const links = staticBody.find('a').map((_, el) => $(el).attr('href')).get();
expect(links).toEqual(catalogs[name].entries.flatMap((entry) => entry.links.map((link) => link.href)));
await page.goto(routeURL(path, base));
await ready(page, path);
const result = await probeBridge(page, base, path);
const body = load(result.html, {}, false);
expect(body.text().replace(/\s+/g, ' ').trim()).toBe(staticBody.text().replace(/\s+/g, ' ').trim());
expect(body('a').map((_, el) => body(el).attr('href')).get()).toEqual(links);
expect(await page.locator(article).innerHTML()).toBe(result.html);
await expect(nodeAt(page, path)).not.toHaveAttribute('aria-expanded');
await expect(nodeAt(page, path)).toHaveAttribute('aria-level', '2');
await expect(page.locator(`.node-toggle[data-toggle-path="${path}"]`)).toHaveCount(0);
await expect(page.locator(`.demo-node[data-path^="${path}/"]`)).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
}
await nodeAt(page, '/sources').click();
await ready(page, '/sources');
await nodeAt(page, '/packages').click();
await ready(page, '/packages');
await page.goBack();
await ready(page, '/sources');
await expect(page).toHaveURL(routeURL('/sources', base));
await page.goForward();
await ready(page, '/packages');
await expect(page).toHaveURL(routeURL('/packages', base));
});
test.describe('catalog static fallback', () => {
test.use({ javaScriptEnabled: false });
test('root catalog anchors and direct pages work without JavaScript', async ({ page }, testInfo) => {
await page.goto('./');
for (const path of catalogPaths) {
await nodeAt(page, path).click();
await expect(page).toHaveURL(routeURL(path, testInfo.project.use.baseURL!));
await expect(page.locator(article + ' h1')).toHaveText(catalogs[path.slice(1) as CatalogName].title);
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
await expect(nodeAt(page, path)).not.toHaveAttribute('aria-expanded');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
}
});
});
test('catalog bodies and native navigation remain readable when the WASM module fails', async ({ page }, testInfo) => {
await page.route('**/wip-wasm/wip_browser.js', (route) => route.abort());
await page.goto(routeURL('/sources', testInfo.project.use.baseURL!));
await expect(page.locator(article + ' h1')).toHaveText('Sources');
await nodeAt(page, '/packages').click();
await expect(page).toHaveURL(routeURL('/packages', testInfo.project.use.baseURL!));
await expect(page.locator(article + ' h1')).toHaveText('Packages');
});
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 nodeAt(page, '/1-concept.md').locator('..').hover();
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 nodeAt(page, '/book').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/book"]').click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await nodeAt(page, '/book').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/book"]').click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true');
await nodeAt(page, '/1-concept.md').focus();
await page.keyboard.press('ArrowRight'); // Expand exactly once, without navigation.
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'true');
await page.keyboard.press('ArrowDown');
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused();
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
await page.reload();
await ready(page, '/2-core-model.md');
const fresh = await probeBridge(page, base, '/2-core-model.md');
expect(Number(await page.locator(stage).getAttribute('data-wip-calls'))).toBe(fresh.stats.calls);
});
for (const leaveDocument of [false, true]) {
test(`fragment history restores separate article positions${leaveDocument ? ' after a document round trip' : ' without a swap'}`, async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const path = '/4.2-text-view.md';
const url = bookURL(path, base);
const assets: string[] = [];
page.on('request', (request) => {
if (/worldspace\.json|wip-wasm/.test(request.url())) assets.push(request.url());
});
// The normal mobile layout scrolls window. Force the nested reading pane on
// both projects so this cannot pass by checking Astro's window scroll alone.
await useNestedArticleScroll(page);
await page.goto(url);
await ready(page, path);
const calls = Number(await page.locator(stage).getAttribute('data-wip-calls'));
const initialBody = await page.locator(article).elementHandle();
const originalKey = await page.evaluate(() => history.state.wipReadingEntry);
const setWindowPosition = async (top: number) => {
const position = await page.evaluate((top) => { window.scrollTo(0, top); return scrollY; }, top);
// Let Astro save its own window-scroll state independently of our pane.
await expect.poll(() => page.evaluate(() => history.state.scrollY)).toBe(position);
return position;
};
const originalWindowPosition = await setWindowPosition(70);
await page.locator(article).evaluate((el) => { el.scrollTop = 180; });
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(180);
const fragment = page.locator(article).getByRole('link', { name: '名前と文字列', exact: true }).first();
const fragmentURL = new URL((await fragment.getAttribute('href'))!, url).href;
// Native anchor activation, without Playwright scrolling the link into view
// and silently replacing the controlled departure position before the push.
await fragment.evaluate((el) => (el as HTMLAnchorElement).click());
await expect(page).toHaveURL(fragmentURL);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBeGreaterThan(180);
const fragmentKey = await page.evaluate(() => history.state.wipReadingEntry);
expect(originalKey).toBeTruthy();
expect(fragmentKey).toBeTruthy();
expect(fragmentKey).not.toBe(originalKey);
expect(await initialBody!.evaluate((el) => el === document.querySelector('[data-selected-content]'))).toBe(true);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(calls));
// Restore the reader's latest position, not merely the fragment's anchor.
await page.locator(article).evaluate((el) => { el.scrollTop = 420; });
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(420);
const fragmentWindowPosition = await setWindowPosition(130);
let expectedCalls = calls;
if (leaveDocument) {
await nodeAt(page, '/2-core-model.md').evaluate((el) => (el as HTMLAnchorElement).click());
await ready(page, '/2-core-model.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
await page.goBack();
await ready(page, path);
await expect(page).toHaveURL(fragmentURL);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(420);
}
const body = await page.locator(article).elementHandle();
// Repeat traversals with new reading positions: each departure must update
// only its own entry, including the no-hash entry that shares this document.
for (const [originalPosition, fragmentPosition] of [[180, 420], [230, 470]]) {
await page.goBack();
await expect(page).toHaveURL(url);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(originalPosition);
expect(await page.evaluate(() => history.state.wipReadingEntry)).toBe(originalKey);
await expect.poll(() => page.evaluate(() => scrollY)).toBe(originalWindowPosition);
await page.locator(article).evaluate((el) => { el.scrollTop = 230; });
await page.goForward();
await expect(page).toHaveURL(fragmentURL);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(fragmentPosition);
expect(await page.evaluate(() => history.state.wipReadingEntry)).toBe(fragmentKey);
await expect.poll(() => page.evaluate(() => scrollY)).toBe(fragmentWindowPosition);
await page.locator(article).evaluate((el) => { el.scrollTop = 470; });
await ready(page, path);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(expectedCalls));
expect(await body!.evaluate((el) => el === document.querySelector('[data-selected-content]'))).toBe(true);
}
if (leaveDocument) {
await page.goForward();
await ready(page, '/2-core-model.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
await page.goBack();
await ready(page, path);
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(470);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', String(++expectedCalls));
}
for (const suffix of ['/worldspace.json', '/wip_browser.js', '.wasm']) {
expect(assets.filter((url) => new URL(url).pathname.endsWith(suffix))).toHaveLength(1);
}
});
}
test('WIP-rendered fragment and modifier links retain native navigation semantics', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.goto(bookURL('/4.2-text-view.md', base));
await ready(page, '/4.2-text-view.md');
const calls = await page.locator(stage).getAttribute('data-wip-calls');
const body = await page.locator(article).elementHandle();
const link = page.locator(article).getByRole('link', { name: '名前と文字列', exact: true }).first();
const href = await link.getAttribute('href');
await link.click();
await expect(page).toHaveURL(new URL(href!, bookURL('/4.2-text-view.md', base)).href);
await ready(page, '/4.2-text-view.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', calls!);
expect(await body!.evaluate((el) => el === document.querySelector('[data-selected-content]'))).toBe(true);
const originalURL = page.url();
const [tab] = await Promise.all([
page.context().waitForEvent('page'),
nodeAt(page, '/2-core-model.md').click({ modifiers: ['ControlOrMeta'] }),
]);
await ready(tab, '/2-core-model.md');
await expect(tab).toHaveURL(bookURL('/2-core-model.md', base));
expect(page.url()).toBe(originalURL);
await expect(page.locator(stage)).toHaveAttribute('data-wip-calls', calls!);
await tab.close();
});
test('slow WASM initialization cannot roll a newer Astro route back to a stale read', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
await page.route('**/wip-wasm/*.wasm', async (route) => { await gate; await route.continue(); });
try {
await page.goto(bookURL('/1-concept.md', base), { waitUntil: 'domcontentloaded' });
await expect(page.locator('.demo-tree')).toHaveAttribute('data-enhanced', 'true');
await page.evaluate(() => {
for (const path of ['/2-core-model.md', '/3-protocol.md', '/4-client.md']) {
document.querySelector<HTMLAnchorElement>(`.demo-node[data-path="${path}"]`)!.click();
}
});
await expect(page).toHaveURL(bookURL('/4-client.md', base));
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/4-client.md');
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'loading');
release();
await ready(page, '/4-client.md');
await expect(page.locator(article + ' h1')).toHaveText('4. クライアント');
// Only the still-live route is allowed to call read after shared init resolves.
const fresh = await probeBridge(page, base, '/4-client.md');
expect(Number(await page.locator(stage).getAttribute('data-wip-calls'))).toBe(fresh.stats.calls);
} finally {
release();
await page.unrouteAll({ behavior: 'wait' });
}
});
for (const asset of ['**/wip-wasm/wip_browser.js', '**/wip-wasm/*.wasm', '**/worldspace.json']) {
test(`failed ${asset} stays explicitly static and navigable`, async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.route(asset, (route) => route.abort());
await page.goto(bookURL('/1-concept.md', base));
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator(article + ' h1')).toHaveText('1. コンセプト');
await expect(page.locator(article)).not.toHaveAttribute('data-wip-path');
await nodeAt(page, '/2-core-model.md').click();
await expect(page).toHaveURL(bookURL('/2-core-model.md', base));
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator(article + ' h1')).toHaveText('2. コアモデル');
await expect(page.locator(stage)).not.toHaveAttribute('data-wip-calls');
});
}
test('unknown and draft routes remain 404 rather than reading the WIP home document', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
for (const url of [new URL('missing-wip-document/', base).href, ...draftPaths.map((path) => bookURL(path, base))]) {
const response = await page.goto(url);
expect(response!.status()).toBe(404);
await expect(page.locator(stage)).toHaveAttribute('data-wip-state', 'fallback');
await expect(page.locator(article + ' h1')).toHaveText('このページは見つかりません。');
await expect(page.locator(article)).not.toHaveAttribute('data-wip-path');
}
});
test.describe('no JavaScript', () => {
test.use({ javaScriptEnabled: false });
test('static public pages remain usable and never claim WIP execution', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
const assets: string[] = [];
page.on('request', (request) => { if (/worldspace\.json|wip-wasm/.test(request.url())) assets.push(request.url()); });
await page.goto('./');
for (const path of ['/book', ...documentPaths]) {
await nodeAt(page, path).click();
await expect(page).toHaveURL(routeURL(path, base));
await expect(page.locator(article + ' h1')).toBeVisible();
await expect(page.locator(stage)).not.toHaveAttribute('data-wip-state');
await expect(page.locator(article)).not.toHaveAttribute('data-wip-path');
}
expect(assets).toEqual([]);
});
});
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { catalogPages, catalogs } from '../src/lib/catalog.ts';
const rustRepository = 'https://gitea.hareworks.net/Hare/wip-rs';
test('Sources lists only the confirmed documentation and Rust repositories', () => {
assert.deepEqual(catalogs.sources.entries.map((entry) => [entry.name, entry.links]), [
['wip-reference', [{ label: 'Source', href: 'https://gitea.hareworks.net/Hare/wip-reference' }]],
['wip-rs', [{ label: 'Source', href: rustRepository }]],
]);
assert.deepEqual(catalogPages.map((page) => [page.path, page.title]), [['/sources', 'Sources'], ['/packages', 'Packages']]);
});
test('Packages retains the seven publishable Rust crates in README order with manifest descriptions', () => {
assert.deepEqual(catalogs.packages.entries.map((entry) => [entry.name, entry.description]), [
['wip-protocol', 'Transport-independent logical model and validation for WIP'],
['wip-interface-macros', 'Procedural macros for WIP interface declarations and DTO codecs'],
['wip-interface', 'Public Rust authoring API for WIP interfaces and operation value codecs'],
['wip-host', 'Host-side object publication and operation dispatch for WIP'],
['wip-http', 'Framework-independent standard WIP over HTTP v1 binding'],
['wip-client', 'Presentation-independent stateful WIP client runtime'],
['wip-text-view', 'Standalone Object and Interface Text View signatures for WIP'],
]);
for (const entry of catalogs.packages.entries) {
assert.deepEqual(entry.links, [
{ label: 'Source', href: `${rustRepository}/src/branch/master/crates/${entry.name}` },
{ label: 'Workspace README', href: `${rustRepository}/src/branch/master/README.md` },
]);
assert.equal('version' in entry, false);
assert.ok(!['wip-fs-server', 'wip-tui', 'wip-public-api-example', 'wip-site-worldspace', 'wip-browser'].includes(entry.name));
}
assert.match(catalogs.packages.description, /公開状況・公開バージョンを示すものではありません/);
assert.ok(!JSON.stringify(catalogs).includes('crates.io'));
assert.ok(!JSON.stringify(catalogs).includes('docs.rs'));
});
+28 -2
View File
@@ -3,7 +3,7 @@ import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { contentsRoot } from '../plugins/document-links.mjs'; import { contentsRoot } from '../plugins/document-links.mjs';
import { documentTree, chapterTree, bookTree, childrenOf, visibleNodes, treePrefix, serializeTree } from '../src/lib/landing-tree.ts'; import { documentTree, chapterTree, bookTree, publicTree, childrenOf, visibleNodes, treePrefix, serializeTree } from '../src/lib/landing-tree.ts';
async function sourceFiles(directory, prefix = '') { async function sourceFiles(directory, prefix = '') {
const entries = await readdir(directory, { withFileTypes: true }); const entries = await readdir(directory, { withFileTypes: true });
@@ -83,7 +83,7 @@ test('public book container wraps the numbered chapters without changing documen
const published = documents.filter((document) => !document.id.startsWith('draft/')); const published = documents.filter((document) => !document.id.startsWith('draft/'));
const display = bookTree(published); const display = bookTree(published);
assert.deepEqual(childrenOf(display, '/').map((node) => node.path), ['/book']); assert.deepEqual(childrenOf(display, '/').map((node) => node.path), ['/book']);
assert.equal(display[1].name, 'book/'); assert.equal(display[1].name, 'book');
assert.equal(display[1].depth, 2); assert.equal(display[1].depth, 2);
assert.equal(display.length, published.length + 2); assert.equal(display.length, published.length + 2);
assert.ok(!display.some((node) => node.path.startsWith('/draft'))); assert.ok(!display.some((node) => node.path.startsWith('/draft')));
@@ -98,6 +98,32 @@ test('public book container wraps the numbered chapters without changing documen
} }
}); });
test('public root catalogs and playground are leaves alongside the unchanged book subtree', () => {
const published = documents.filter((document) => !document.id.startsWith('draft/'));
for (const base of ['/', '/reference/']) {
const display = publicTree(published, base);
assert.deepEqual(display.slice(0, -3), bookTree(published));
assert.deepEqual(childrenOf(display, '/').map((node) => node.path), ['/book', '/sources', '/packages', '/playground']);
assert.deepEqual(visibleNodes(display, new Set(['/book'])).map((node) => node.path), ['/', '/book', '/sources', '/packages', '/playground']);
assert.deepEqual(visibleNodes(display, new Set(['/'])), [display[0]]);
for (const name of ['sources', 'packages', 'playground']) {
const leaf = display.find((node) => node.path === `/${name}`);
assert.equal(leaf.name, name);
assert.equal(leaf.depth, 2);
assert.equal(leaf.href, `${base}${name}/`);
assert.ok(leaf.title);
assert.deepEqual(childrenOf(display, leaf.path), []);
assert.deepEqual(visibleNodes(display, new Set([leaf.path])), display);
}
assert.equal(treePrefix(display.find((node) => node.path === '/book'), display), '├─ ');
assert.equal(treePrefix(display.find((node) => node.path === '/1.1-interaction-abstraction.md'), display), '│ │ ├─ ');
assert.equal(treePrefix(display.find((node) => node.path === '/packages'), display), '├─ ');
assert.equal(display.at(-1).path, '/playground');
assert.equal(treePrefix(display.at(-1), display), '└─ ');
assert.ok(!display.some((node) => node.path.includes('/draft')));
}
});
test('existing titles and hrefs pass through without embedding document bodies', () => { test('existing titles and hrefs pass through without embedding document bodies', () => {
for (const document of documents) { for (const document of documents) {
const node = nodes.find((node) => node.path === '/' + document.id + '.md'); const node = nodes.find((node) => node.path === '/' + document.id + '.md');
+163
View File
@@ -0,0 +1,163 @@
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 { publicTree } from '../src/lib/landing-tree.ts';
import { createWorldspaceData } from '../src/lib/worldspace-data.ts';
const pages = {
'/': { title: 'Worldspace Interface Protocol', href: '/', html: '<h1>Home</h1>' },
'/book': { title: 'Book', href: '/book/', html: '<h1>Book</h1>' },
'/sources': { title: 'Sources', href: '/sources/', html: '<h1>Sources</h1>' },
'/packages': { title: 'Packages', href: '/packages/', html: '<h1>Packages</h1>' },
'/playground': { title: 'Playground', href: '/playground/', html: '<h1>Playground</h1>' },
};
const source = (id) => ({ id, title: id, href: `/book/${id}/`, html: `<h1>${id}</h1>` });
async function sourceFiles(directory, prefix = '') {
return (await Promise.all((await readdir(directory, { withFileTypes: true })).map(async (entry) => {
const relative = prefix + entry.name;
return entry.isDirectory() ? sourceFiles(path.join(directory, entry.name), relative + '/')
: entry.name.endsWith('.md') ? [relative] : [];
}))).flat();
}
const files = await sourceFiles(contentsRoot);
const publicFiles = files.filter((file) => !file.startsWith('draft/'));
const processor = await createMarkdownProcessor({ remarkPlugins: [[documentLinks, { base: '/' }]], smartypants: false });
const documents = await Promise.all(publicFiles.map(async (file) => {
const body = await readFile(path.join(contentsRoot, file), 'utf8');
const result = await processor.render(body, { fileURL: new URL(`file://${path.join(contentsRoot, file)}`) });
return { ...source(file.slice(0, -3)), title: body.match(/^#\s+(.+)$/m)?.[1] ?? file, html: result.code };
}));
function checkIdentity(data) {
for (const field of ['path', 'displayPath', 'href']) {
assert.equal(new Set(data.nodes.map((node) => node[field])).size, data.nodes.length);
}
const root = data.nodes.find((node) => node.path === '/');
assert.equal(root.name, '');
assert.equal(root.parent, undefined);
for (const node of data.nodes.filter((node) => node.path !== '/')) {
assert.ok(data.nodes.some((parent) => parent.path === node.parent));
assert.equal(node.path, `${node.parent === '/' ? '' : node.parent}/${node.name}`);
assert.ok(node.name.length > 0 && !node.name.includes('/'));
assert.ok(!node.path.split('/').includes('draft'));
assert.ok(!node.displayPath.split('/').includes('draft'));
}
}
test('actual public documents keep Web and display identities but acquire real parent paths', () => {
const data = createWorldspaceData(documents, pages);
checkIdentity(data);
const display = publicTree(documents);
assert.equal(data.nodes.length, documents.length + 5);
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) ?? pages[node.displayPath]).html);
assert.match(node.html, /<h1/);
}
}
const section = data.nodes.find((node) => node.displayPath === '/1.1-interaction-abstraction.md');
assert.equal(section.path, '/book/1-concept.md/1.1-interaction-abstraction.md');
assert.equal(section.parent, '/book/1-concept.md');
assert.equal(section.href, '/book/1.1-interaction-abstraction/');
assert.equal(data.nodes[1].name, 'book');
assert.equal(display[1].name, 'book');
});
test('catalog and playground objects retain root identity, standalone URLs and no static descendants', () => {
const data = createWorldspaceData(documents, pages);
assert.deepEqual(data.nodes.filter((node) => node.parent === '/').map((node) => node.path), ['/book', '/sources', '/packages', '/playground']);
for (const path of ['/sources', '/packages', '/playground']) {
const leaf = data.nodes.find((node) => node.path === path);
assert.equal(leaf.displayPath, path);
assert.equal(leaf.href, `${path}/`);
assert.equal(leaf.html, pages[path].html);
assert.equal(data.nodes.filter((node) => node.parent === path).length, 0);
assert.ok(!data.nodes.some((node) => node.path.startsWith(`${path}/`)));
}
});
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/' },
'/sources': { ...pages['/sources'], href: '/reference/sources/' },
'/packages': { ...pages['/packages'], href: '/reference/packages/' },
'/playground': { ...pages['/playground'], href: '/reference/playground/' },
'/other': { title: 'other/', href: '/reference/book/other/', html: '<h1>other/</h1>' },
});
checkIdentity(data);
assert.equal(data.nodes.find((node) => node.displayPath === '/1.2.3-leaf.md').path,
'/book/1-parent.md/1.2-child.md/1.2.3-leaf.md');
assert.equal(data.nodes.find((node) => node.displayPath === '/other/1.1-child.md').path,
'/book/other/1-parent.md/1.1-child.md');
assert.ok(data.nodes.every((node) => node.href.startsWith('/reference/')));
});
test('invalid, duplicate and incomplete generated inputs fail rather than lose documents', () => {
assert.throws(() => createWorldspaceData([source('a'), source('a')], pages), /Duplicate document/);
for (const id of ['', '/absolute', 'a//b', 'a/../b', 'a/./b', 'a/draft/b']) {
assert.throws(() => createWorldspaceData([source(id)], pages), /Invalid public document id/);
}
assert.throws(() => createWorldspaceData([source('a')], {}), /Missing rendered page/);
assert.throws(() => createWorldspaceData([{ ...source('a'), href: '/book/' }], pages), /Duplicate Web URL/);
assert.throws(() => createWorldspaceData([{ ...source('a'), href: '//external.example/' }], pages), /Invalid page metadata/);
assert.throws(() => createWorldspaceData([{ ...source('a'), title: '' }], pages), /Invalid page metadata/);
assert.throws(() => createWorldspaceData([source('dir/a')], pages), /Missing rendered page: \/dir/);
});
test('root, book, catalog and playground content is supplied by their shared page renderers', () => {
const data = createWorldspaceData([], pages);
assert.deepEqual(data.nodes, [
{ path: '/', name: '', displayPath: '/', ...pages['/'] },
{ path: '/book', name: 'book', displayPath: '/book', parent: '/', ...pages['/book'] },
{ path: '/sources', name: 'sources', displayPath: '/sources', parent: '/', ...pages['/sources'] },
{ path: '/packages', name: 'packages', displayPath: '/packages', parent: '/', ...pages['/packages'] },
{ path: '/playground', name: 'playground', displayPath: '/playground', parent: '/', ...pages['/playground'] },
]);
});
// 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 + 5);
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'
: ['/sources', '/packages', '/playground'].includes(node.displayPath) ? `${node.displayPath.slice(1)}/index.html`
: `book/${node.displayPath.slice(1, -3)}/index.html`;
const page = load(await readFile(new URL(`../dist/${relative}`, import.meta.url), 'utf8'));
// Astro adds hydration/render markers outside the actual article fragment.
// Compare normalized markup, not generated comment/whitespace placement.
const fragment = load(node.html, {}, false);
const article = page('[data-selected-content]');
assert.equal(article.length, 1, `${relative} has a document body`);
assert.equal(fragment.text().replace(/\s+/g, ' ').trim(), article.text().replace(/\s+/g, ' ').trim(), node.path);
assert.deepEqual(fragment('a[href]').map((_, item) => fragment(item).attr('href')).get(),
article.find('a[href]').map((_, item) => page(item).attr('href')).get(), node.path);
}
});
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"extends": "astro/tsconfigs/strict", "extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"], "include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "node_modules"] "exclude": ["dist", "node_modules", "public/wip-wasm", "test-assets", "target"]
} }
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "wip-site-worldspace"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
publish = false
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wip-host = { path = "../../../wip-rs/crates/wip-host" }
wip-interface = { path = "../../../wip-rs/crates/wip-interface" }
wip-protocol = { path = "../../../wip-rs/crates/wip-protocol" }
+157
View File
@@ -0,0 +1,157 @@
# Public-document Worldspace
`wip-site-worldspace` is the site's in-memory Host provider: immutable documents
plus an opt-in session-local playground. It uses
`wip-host`, `wip-interface` and `wip-protocol`; it does not depend on the filesystem
server, HTTP, a browser, or a network runtime. The native and WASM callers use the
same `Worldspace` instance API.
## Generated data
The static Astro endpoint `src/pages/worldspace.json.ts` emits
`dist/worldspace.json` during the normal site build. It:
1. Reads `documents()` (the existing public-only content collection).
2. Renders each document with Astro's existing Markdown renderer, including the
configured document-link transform and syntax highlighting.
3. Renders the existing `RootPage` and `DirectoryIndex` components for `/`, `/book`
and any real public directory indexes. It does not copy their authored text.
4. Passes rendered bodies to `createWorldspaceData` in
`src/lib/worldspace-data.ts`, which uses `bookTree` and filters drafts again.
The input contract is:
```json
{"nodes":[
{"path":"/","name":"","displayPath":"/","title":"Worldspace Interface Protocol","href":"/","html":"..."},
{"path":"/book","name":"book","displayPath":"/book","parent":"/","title":"Book","href":"/book/","html":"..."},
{"path":"/book/1-concept.md","name":"1-concept.md","displayPath":"/1-concept.md","parent":"/book","title":"...","href":"/book/1-concept/","html":"..."},
{"path":"/book/1-concept.md/1.1-interaction-abstraction.md","name":"1.1-interaction-abstraction.md","displayPath":"/1.1-interaction-abstraction.md","parent":"/book/1-concept.md","title":"...","href":"/book/1.1-interaction-abstraction/","html":"..."}
]}
```
`path` is the only WIP request identity. `parent` is a WIP path and is absent only
for `/`. `displayPath` preserves the old explorer identity and selection state;
`href` preserves the existing Web route (including `SITE_BASE`). The UI can keep
its current `book/` label, but the actual Object name is `book`. No protocol name
has a trailing slash. Nodes retain `bookTree` order. The provider does not sort
children lexically. Every node, including home, book index, and a chapter that
also has sections, has a readable body.
`html` is a trusted, build-rendered fragment, not a full page or an untrusted user
submission. Consumers must retain the site's existing CSS, including home scoped
styles and code-block themes. Do not inject raw JSON into a script element without
escaping `<`; the generated endpoint is served as `application/json` instead.
There is no runtime loading by path: unpublished or guessed draft paths cannot
reach a source file. The Rust loader also rejects draft path segments, duplicate
path/displayPath/URL identities, inconsistent parent/name/path relationships,
missing parents, malformed input and unknown JSON fields.
## Rust API
```rust
use wip_site_worldspace::{Worldspace, DocumentContent};
use wip_site_worldspace::wip_protocol::{
ObserveRequest, FetchInterfaceRequest, CallOperationRequest, Target, InterfaceTarget,
};
use wip_interface::WipType;
# fn example(json: &str) {
let world = Worldspace::from_json(json).unwrap();
let observed = world.observe(ObserveRequest { path: "/".into(), depth: 4 }).unwrap();
let reference = observed.object.interfaces[0].clone();
let interface = world.fetch_interface(FetchInterfaceRequest { interface: reference.clone() }).unwrap();
let result = world.call_operation(CallOperationRequest {
target: Target { path: "/book/1-concept.md".into(), validator: None },
interface: InterfaceTarget {
reference, scope_ref: interface.scope_ref, validator: interface.validator,
},
operation: "read".into(),
arguments: Default::default(),
}).unwrap();
let document = DocumentContent::decode(result.result).unwrap();
assert_eq!(document.path, "/book/1-concept.md");
# }
```
Alternatively use `Worldspace::new(WorldspaceData { nodes })`. Construction returns
`Result<Worldspace, DataError>`. `observe(ObserveRequest)`,
`fetch_interface(FetchInterfaceRequest)` and `call_operation(CallOperationRequest)`
take requests by value and return canonical protocol response types or
`ProtocolError`. The crate re-exports `wip_protocol` for bridge consumers.
The registered root-scoped Interface is `{scope:"/",name:"document"}`. Its only
Operation, `read`, takes **no arguments** and returns the typed `DocumentContent`
record `{path,title,href,html}`. Host-generated Descriptors, scope association,
interface membership, request validation, argument/result validation and error
classification remain on the normal Host path. The provider exposes no alternate
body-fetch method. Documents are immutable, so no validators are advertised.
The recursive adapter calls `ObjectProvider::observe_object` for every node and
validates the assembled response against the request. At the requested depth,
`children` is `None`; above it, a visited leaf has `Some([])`. Unknown paths return
`NotFound` and malformed paths return `InvalidRequest`. Requests above
`MAX_DEPTH = 32` return `ResourceLimitExceeded` rather than silently truncating.
Construction allows at most `MAX_NODES = 4096`, with hierarchy depth at most 32.
`from_json` also caps input at 16 MiB. Together these bounds cap traversal work.
## Playground
A manifest document at `/playground` opts into mutable state. Manifests without
that node retain document-only behavior. Manifest descendants of `/playground`
are rejected, so mutable counters cannot shadow or delete document content.
- `/playground` keeps `/::document.read()` and also exposes `/::collection`:
`create(name: String) -> Unit` creates a zero-valued counter;
`remove(name: String) -> Unit` removes an existing counter.
- Children start as `apple = 3` and `orange = 0`, ordered lexically. Each exposes
only `/::counter`: `get() -> Integer`, `increment(amount: Integer) -> Integer`,
and `reset() -> Integer`. Increment returns the new value; reset returns zero.
- Names match `[A-Za-z0-9_-]{1,32}` and are case-sensitive. At most 32 counters
exist. Duplicate creation, missing removal, invalid names, and checked sums
outside Protocol Integer's range `[-9007199254740991, 9007199254740991]` fail
without mutation. Capacity errors are `ResourceLimitExceeded`; domain
rejections are `Internal` with an explicit not-committed Host guarantee.
Invalid typed arguments are rejected by standard Client/Host validation.
- Every counter lookup publishes a fresh Object snapshot whose `description`
is the current decimal integer and whose `name` remains the slug. All
generated interfaces bind to the same actual root ScopeObservation.
- State belongs to one Worldspace/BrowserSession. Navigation retains it; a new
session restores initial state. Documents are never mutated.
The browser's normal `tree()` includes only manifest display paths and therefore
never calls `document.read` on dynamic counters. Manual playground `observe`
uses the real Client's `refresh_observed` on every explicit retrieval (including
recovery from retained missing/stale observations); other documents retain
`ensure_observed` caching. This Client version has no public subtree-invalidation
API. A call does not observe, fetch, retry, or convert its result into an Object.
The UI explicitly observes `/playground` at depth 1, then the selected Object at
depth 1 after calls, and observes `/playground` on entry to Protocol mode.
## 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.
+436
View File
@@ -0,0 +1,436 @@
//! Site documents and an opt-in, session-local playground. All interactions use
//! the ordinary typed Host publication boundary.
mod playground;
pub use playground::{Collection, Counter, MAX_COUNTERS, PLAYGROUND_PATH};
use playground::{CollectionDefinition, CounterDefinition, Playground};
use std::collections::{BTreeMap, HashSet};
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::rc::Rc;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use wip_host::{
ExecutionContext, InterfaceRegistry, ObjectProvider, ProviderResult, PublishedObject,
RegisteredInterface, Scope, ScopeObservation, WipObject,
};
use wip_interface::{OperationResult, WipType, interface};
pub use wip_protocol;
use wip_protocol::{
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, FetchInterfaceResponse,
InterfaceReference, ObjectObservation, ObserveRequest, ObserveResponse, ProtocolError,
ProtocolErrorCode,
};
pub const DOCUMENT_INTERFACE: &str = "document";
pub const MAX_DEPTH: u32 = 32;
pub const MAX_NODES: usize = 4096;
pub const MAX_JSON_BYTES: usize = 16 * 1024 * 1024;
/// Build-generated input. Array order is also the direct-child observation order.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorldspaceData {
pub nodes: Vec<DocumentNode>,
}
/// Protocol identity, legacy UI identity and Web navigation are deliberately separate.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DocumentNode {
pub path: String,
pub name: String,
pub display_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub title: String,
pub href: String,
pub html: String,
}
/// A rendered public document fragment, including the root and directory indexes.
#[derive(Debug, Clone, PartialEq, WipType)]
pub struct DocumentContent {
pub path: String,
pub title: String,
pub href: String,
pub html: String,
}
/// Read the site's published document without network or filesystem access.
#[interface]
pub trait Document {
/// Returns the rendered HTML fragment and its existing Web URL.
fn read(&self) -> OperationResult<DocumentContent>;
}
/// Host-private implementation; no body lookup bypass is exposed to clients.
pub enum SiteObject {
Document(Arc<DocumentNode>, Option<Rc<Playground>>),
Counter { name: String, state: Rc<Playground> },
}
impl WipObject for SiteObject {
fn name(&self) -> &str {
match self {
Self::Document(node, _) => &node.name,
Self::Counter { name, .. } => name,
}
}
fn description(&self) -> Option<&str> {
Some(match self {
Self::Document(node, _) => &node.title,
Self::Counter { .. } => "An in-memory counter.",
})
}
}
impl Document for SiteObject {
fn read(&self) -> OperationResult<DocumentContent> {
let Self::Document(node, _) = self else {
return Err(wip_interface::HostError::internal(
"not a document",
wip_interface::EffectGuarantee::NotCommitted,
));
};
Ok(DocumentContent {
path: node.path.clone(),
title: node.title.clone(),
href: node.href.clone(),
html: node.html.clone(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataError(String);
impl Display for DataError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
impl Error for DataError {}
fn invalid(message: impl Into<String>) -> DataError {
DataError(message.into())
}
fn protocol_error(code: ProtocolErrorCode, message: impl Into<String>) -> ProtocolError {
ProtocolError {
code,
message: message.into(),
}
}
/// Immutable documents and isolated mutable playground state. This synchronous,
/// single-threaded provider publishes stable counter metadata; values are read by calls.
pub struct Worldspace {
registry: InterfaceRegistry,
objects: BTreeMap<String, PublishedObject<SiteObject>>,
children: BTreeMap<String, Vec<String>>,
playground: Option<Rc<Playground>>,
counter: Option<RegisteredInterface<CounterDefinition>>,
current_scope: ScopeObservation,
}
impl Worldspace {
pub fn from_json(json: &str) -> Result<Self, DataError> {
if json.len() > MAX_JSON_BYTES {
return Err(invalid("document JSON exceeds the input byte limit"));
}
let data = serde_json::from_str(json).map_err(|error| invalid(error.to_string()))?;
Self::new(data)
}
pub fn new(data: WorldspaceData) -> Result<Self, DataError> {
validate_data(&data)?;
let scope = Scope::new("/").map_err(|error| invalid(error.to_string()))?;
let mut registry = InterfaceRegistry::new();
let document = registry
.register(&scope, DOCUMENT_INTERFACE, DocumentDefinition, None)
.map_err(|error| invalid(error.to_string()))?;
let playground = data
.nodes
.iter()
.any(|node| node.path == PLAYGROUND_PATH)
.then(|| Rc::new(Playground::new()));
let (collection, counter) = if playground.is_some() {
// The mutable namespace cannot shadow or delete manifest documents.
if data
.nodes
.iter()
.any(|node| node.path.starts_with("/playground/"))
{
return Err(invalid(
"playground children are reserved for session counters",
));
}
(
Some(
registry
.register(&scope, "collection", CollectionDefinition, None)
.map_err(|error| invalid(error.to_string()))?,
),
Some(
registry
.register(&scope, "counter", CounterDefinition, None)
.map_err(|error| invalid(error.to_string()))?,
),
)
} else {
(None, None)
};
// 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::Document(Arc::clone(&root), None));
let mut objects = BTreeMap::new();
let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
for node in data.nodes {
let path = node.path.clone();
if let Some(parent) = &node.parent {
children
.entry(parent.clone())
.or_default()
.push(path.clone());
}
let object = SiteObject::Document(
if path == "/" {
Arc::clone(&root)
} else {
Arc::new(node)
},
if path == PLAYGROUND_PATH {
playground.clone()
} else {
None
},
);
let mut publication = PublishedObject::new(object)
.bind_scope(current_scope.clone())
.expose(&document)
.map_err(|error| invalid(error.to_string()))?;
if path == PLAYGROUND_PATH {
publication = publication
.expose(collection.as_ref().unwrap())
.map_err(|error| invalid(error.to_string()))?;
}
objects.insert(path, publication);
}
Ok(Self {
registry,
objects,
children,
playground,
counter,
current_scope,
})
}
/// Recursive Host observations. Boundary children are unknown (`None`), not empty.
/// A visited leaf above the boundary has a known empty child set (`Some(vec![])`).
pub fn observe(&self, request: ObserveRequest) -> Result<ObserveResponse, ProtocolError> {
request.validate().map_err(|error| {
protocol_error(ProtocolErrorCode::InvalidRequest, error.to_string())
})?;
if request.depth > MAX_DEPTH {
return Err(protocol_error(
ProtocolErrorCode::ResourceLimitExceeded,
"observation depth exceeds the site limit",
));
}
let response = self.observe_at(&request.path, request.depth)?;
request.validate_response(&response).map_err(|_| {
protocol_error(ProtocolErrorCode::Internal, "invalid provider observation")
})?;
Ok(response)
}
fn observe_at(&self, path: &str, depth: u32) -> Result<ObjectObservation, ProtocolError> {
let object =
ObjectProvider::observe_object(self, path).map_err(|error| error.protocol_error())?;
let children = if depth == 0 {
None
} else {
Some(
self.child_paths(path)
.iter()
.map(|child| self.observe_at(child, depth - 1))
.collect::<Result<Vec<_>, _>>()?,
)
};
Ok(ObjectObservation { object, children })
}
fn child_paths(&self, path: &str) -> Vec<String> {
if path == PLAYGROUND_PATH
&& let Some(state) = &self.playground
{
return state
.counters
.borrow()
.keys()
.map(|name| format!("{PLAYGROUND_PATH}/{name}"))
.collect();
}
self.children.get(path).cloned().unwrap_or_default()
}
pub fn fetch_interface(
&self,
request: FetchInterfaceRequest,
) -> Result<FetchInterfaceResponse, ProtocolError> {
ObjectProvider::fetch_interface(self, &self.registry, &request.interface)?
.protocol_response()
}
pub fn call_operation(
&self,
request: CallOperationRequest,
) -> Result<CallOperationResponse, ProtocolError> {
ObjectProvider::call_operation(self, request).map_err(|error| error.protocol_error())
}
pub fn document_interface() -> InterfaceReference {
InterfaceReference {
scope: "/".into(),
name: DOCUMENT_INTERFACE.into(),
}
}
}
impl ObjectProvider for Worldspace {
type Object = SiteObject;
fn with_object<R>(
&self,
_context: &ExecutionContext,
path: &str,
operation: impl FnOnce(&PublishedObject<Self::Object>) -> R,
) -> ProviderResult<R> {
if let (Some(state), Some(counter), Some(name)) = (
&self.playground,
&self.counter,
path.strip_prefix("/playground/"),
) {
if !state.counters.borrow().contains_key(name) {
return ProviderResult::NotFound;
}
// Resolve existence only; counter values are exposed by typed operations.
// Rc/RefCell keeps lookup/dispatch synchronous and single-threaded,
// with no external writer between resolution and call.
let publication = PublishedObject::new(SiteObject::Counter {
name: name.into(),
state: Rc::clone(state),
})
.bind_scope(self.current_scope.clone())
.expose(counter);
return match publication {
Ok(publication) => ProviderResult::Found(operation(&publication)),
Err(error) => ProviderResult::Failure(wip_interface::HostError::internal(
error.to_string(),
wip_interface::EffectGuarantee::NotCommitted,
)),
};
}
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(())
}
+136
View File
@@ -0,0 +1,136 @@
//! Bounded in-memory state behind generated, typed Interface dispatch.
use std::cell::RefCell;
use std::collections::BTreeMap;
use wip_interface::{EffectGuarantee, HostError, OperationResult, interface};
use wip_protocol::{MAX_SAFE_INTEGER, MIN_SAFE_INTEGER};
use crate::SiteObject;
pub const PLAYGROUND_PATH: &str = "/playground";
pub const MAX_COUNTERS: usize = 32;
#[interface]
pub trait Collection {
/// Creates a zero-valued counter with a unique ASCII slug (1..=32 characters).
fn create(&self, name: String) -> OperationResult<()>;
/// Removes an existing counter.
fn remove(&self, name: String) -> OperationResult<()>;
}
#[interface]
pub trait Counter {
/// Returns the current counter value.
fn get(&self) -> OperationResult<i64>;
/// Adds a signed amount, failing without mutation outside Protocol Integer range.
fn increment(&self, amount: i64) -> OperationResult<i64>;
/// Resets the counter to zero and returns zero.
fn reset(&self) -> OperationResult<i64>;
}
pub struct Playground {
pub(crate) counters: RefCell<BTreeMap<String, i64>>,
}
impl Playground {
pub fn new() -> Self {
Self {
counters: RefCell::new(BTreeMap::from([("apple".into(), 3), ("orange".into(), 0)])),
}
}
}
fn rejected(message: &str) -> HostError {
HostError::internal(message, EffectGuarantee::NotCommitted)
}
fn validate_name(name: &str) -> OperationResult<()> {
if !(1..=32).contains(&name.len())
|| !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
{
return Err(rejected(
"counter names must be ASCII slugs of length 1..=32",
));
}
Ok(())
}
impl Collection for SiteObject {
fn create(&self, name: String) -> OperationResult<()> {
validate_name(&name)?;
let Self::Document(_, Some(state)) = self else {
return Err(rejected("not a collection"));
};
let mut counters = state.counters.borrow_mut();
if counters.contains_key(&name) {
return Err(rejected("counter already exists"));
}
if counters.len() >= MAX_COUNTERS {
return Err(HostError::resource_limit(
"counter limit reached",
EffectGuarantee::NotCommitted,
));
}
counters.insert(name, 0);
Ok(())
}
fn remove(&self, name: String) -> OperationResult<()> {
validate_name(&name)?;
let Self::Document(_, Some(state)) = self else {
return Err(rejected("not a collection"));
};
let mut counters = state.counters.borrow_mut();
if !counters.contains_key(&name) {
return Err(rejected("counter does not exist"));
}
counters.remove(&name);
Ok(())
}
}
impl Counter for SiteObject {
fn get(&self) -> OperationResult<i64> {
let Self::Counter { name, state, .. } = self else {
return Err(rejected("not a counter"));
};
state
.counters
.borrow()
.get(name)
.copied()
.ok_or_else(|| rejected("counter does not exist"))
}
fn increment(&self, amount: i64) -> OperationResult<i64> {
let Self::Counter { name, state, .. } = self else {
return Err(rejected("not a counter"));
};
let mut counters = state.counters.borrow_mut();
let value = counters
.get_mut(name)
.ok_or_else(|| rejected("counter does not exist"))?;
// Validate before commit, including the generated codec's result range.
// Otherwise dispatch could fail to encode a result after changing state.
let next = value
.checked_add(amount)
.filter(|next| (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(next))
.ok_or_else(|| rejected("counter exceeds Protocol Integer range"))?;
*value = next;
Ok(next)
}
fn reset(&self) -> OperationResult<i64> {
let Self::Counter { name, state, .. } = self else {
return Err(rejected("not a counter"));
};
let mut counters = state.counters.borrow_mut();
let value = counters
.get_mut(name)
.ok_or_else(|| rejected("counter does not exist"))?;
*value = 0;
Ok(0)
}
}
+414
View File
@@ -0,0 +1,414 @@
use std::collections::BTreeMap;
use wip_site_worldspace::wip_protocol::{
CallOperationRequest, CallOperationResponse, FetchInterfaceRequest, InterfaceReference,
InterfaceTarget, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, ObjectObservation, ObserveRequest,
ProtocolError, ProtocolErrorCode, Target, TypeExpr, Value,
};
use wip_site_worldspace::{MAX_COUNTERS, Worldspace};
fn world() -> Worldspace {
Worldspace::from_json(r#"{"nodes":[
{"path":"/","name":"","displayPath":"/","title":"Root","href":"/","html":"<p>root</p>"},
{"path":"/book","name":"book","parent":"/","displayPath":"/book","title":"Book","href":"/book/","html":"<p>book</p>"},
{"path":"/playground","name":"playground","parent":"/","displayPath":"/playground","title":"Playground","href":"/playground/","html":"<p>playground</p>"}
]}"#).unwrap()
}
fn reference(name: &str) -> InterfaceReference {
InterfaceReference {
scope: "/".into(),
name: name.into(),
}
}
fn call(
world: &Worldspace,
path: &str,
interface: &str,
operation: &str,
arguments: BTreeMap<String, Value>,
) -> Result<CallOperationResponse, ProtocolError> {
world.call_operation(CallOperationRequest {
target: Target {
path: path.into(),
validator: None,
},
interface: InterfaceTarget {
reference: reference(interface),
scope_ref: None,
validator: None,
},
operation: operation.into(),
arguments,
})
}
fn collection(
world: &Worldspace,
operation: &str,
name: &str,
) -> Result<CallOperationResponse, ProtocolError> {
call(
world,
"/playground",
"collection",
operation,
BTreeMap::from([("name".into(), Value::String(name.into()))]),
)
}
fn increment(world: &Worldspace, amount: i64) -> Result<CallOperationResponse, ProtocolError> {
call(
world,
"/playground/apple",
"counter",
"increment",
BTreeMap::from([("amount".into(), Value::Integer(amount))]),
)
}
fn get(world: &Worldspace, name: &str) -> Value {
call(
world,
&format!("/playground/{name}"),
"counter",
"get",
BTreeMap::new(),
)
.unwrap()
.result
}
fn assert_initial_values(world: &Worldspace, observation: &ObjectObservation) {
for child in observation.children.as_ref().unwrap() {
let name = &child.object.name;
assert_eq!(
get(world, name),
Value::Integer(if name == "apple" { 3 } else { 0 }),
"counter {name} must remain unchanged"
);
}
}
fn observe(world: &Worldspace, path: &str, depth: u32) -> ObjectObservation {
world
.observe(ObserveRequest {
path: path.into(),
depth,
})
.unwrap()
}
#[test]
fn typed_descriptors_root_scope_and_initial_snapshots() {
let world = world();
let collection = world
.fetch_interface(FetchInterfaceRequest {
interface: reference("collection"),
})
.unwrap();
let counter = world
.fetch_interface(FetchInterfaceRequest {
interface: reference("counter"),
})
.unwrap();
assert_eq!(
collection
.descriptor
.operations
.iter()
.map(|op| op.name.as_str())
.collect::<Vec<_>>(),
["create", "remove"]
);
for op in &collection.descriptor.operations {
assert_eq!(op.parameters.len(), 1);
assert_eq!(op.parameters[0].name, "name");
assert_eq!(op.parameters[0].r#type, TypeExpr::String);
assert_eq!(op.returns.r#type, TypeExpr::Unit);
}
assert_eq!(
counter
.descriptor
.operations
.iter()
.map(|op| op.name.as_str())
.collect::<Vec<_>>(),
["get", "increment", "reset"]
);
assert_eq!(
counter.descriptor.operations[1].parameters[0].name,
"amount"
);
assert_eq!(
counter.descriptor.operations[1].parameters[0].r#type,
TypeExpr::Integer
);
assert!(
counter
.descriptor
.operations
.iter()
.all(|op| op.returns.r#type == TypeExpr::Integer)
);
let initial = observe(&world, "/playground", 2);
assert_eq!(
initial.object.interfaces,
[reference("document"), reference("collection")]
);
let children = initial.children.unwrap();
assert_eq!(
children
.iter()
.map(|c| (c.object.name.as_str(), c.object.description.as_deref()))
.collect::<Vec<_>>(),
[
("apple", Some("An in-memory counter.")),
("orange", Some("An in-memory counter.")),
]
);
assert!(
children
.iter()
.all(|c| c.object.interfaces == [reference("counter")] && c.children == Some(vec![]))
);
assert_eq!(get(&world, "apple"), Value::Integer(3));
assert_eq!(get(&world, "orange"), Value::Integer(0));
assert_eq!(
call(
&world,
"/playground/apple",
"document",
"read",
BTreeMap::new()
)
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceMismatch
);
assert_eq!(get(&world, "apple"), Value::Integer(3));
assert_eq!(
call(&world, "/book", "collection", "create", BTreeMap::new())
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceMismatch
);
assert_eq!(get(&world, "apple"), Value::Integer(3));
}
#[test]
fn mutations_preserve_metadata_and_publish_values_and_children_without_mutating_documents() {
let world = world();
let before = observe(&world, "/playground/apple", 1);
let parent_before = observe(&world, "/playground", 2);
let documents: Vec<_> = ["/", "/book", "/playground"]
.into_iter()
.map(|path| {
(
path,
call(&world, path, "document", "read", BTreeMap::new())
.unwrap()
.result,
)
})
.collect();
assert_eq!(increment(&world, 7).unwrap().result, Value::Integer(10));
assert_eq!(get(&world, "apple"), Value::Integer(10));
assert_eq!(observe(&world, "/playground/apple", 1), before);
assert_eq!(observe(&world, "/playground", 2), parent_before);
assert_eq!(increment(&world, -12).unwrap().result, Value::Integer(-2));
assert_eq!(get(&world, "apple"), Value::Integer(-2));
assert_eq!(
call(
&world,
"/playground/apple",
"counter",
"reset",
BTreeMap::new()
)
.unwrap()
.result,
Value::Integer(0)
);
assert_eq!(get(&world, "apple"), Value::Integer(0));
assert_eq!(observe(&world, "/playground/apple", 1), before);
assert_eq!(observe(&world, "/playground", 2), parent_before);
assert_eq!(
collection(&world, "create", "New_counter-2")
.unwrap()
.result,
Value::Unit
);
assert_eq!(
observe(&world, "/playground/New_counter-2", 1)
.object
.description
.as_deref(),
Some("An in-memory counter.")
);
assert_eq!(get(&world, "New_counter-2"), Value::Integer(0));
collection(&world, "remove", "apple").unwrap();
assert_eq!(
call(
&world,
"/playground/apple",
"counter",
"get",
BTreeMap::new()
)
.unwrap_err()
.code,
ProtocolErrorCode::NotFound
);
assert!(
!observe(&world, "/playground", 1)
.children
.unwrap()
.iter()
.any(|child| child.object.name == "apple")
);
collection(&world, "create", "apple").unwrap();
assert_eq!(observe(&world, "/playground/apple", 1), before);
assert_eq!(get(&world, "apple"), Value::Integer(0));
for (path, expected) in documents {
assert_eq!(
call(&world, path, "document", "read", BTreeMap::new())
.unwrap()
.result,
expected
);
}
}
#[test]
fn invalid_names_duplicates_missing_and_capacity_fail_without_mutation() {
let world = world();
let before = observe(&world, "/", 3);
let counters_before = observe(&world, "/playground", 1);
for name in [
"",
".",
"..",
"a/b",
"a b",
"a\nb",
"<script>",
"é",
"a.b",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
] {
for operation in ["create", "remove"] {
assert!(
collection(&world, operation, name).is_err(),
"{operation} {name:?}"
);
assert_eq!(observe(&world, "/", 3), before);
assert_initial_values(&world, &counters_before);
}
}
assert!(collection(&world, "create", "apple").is_err());
assert_eq!(observe(&world, "/", 3), before);
assert_initial_values(&world, &counters_before);
assert!(collection(&world, "remove", "missing").is_err());
assert_eq!(observe(&world, "/", 3), before);
assert_initial_values(&world, &counters_before);
collection(&world, "create", &"a".repeat(32)).unwrap();
for index in 3..MAX_COUNTERS {
collection(&world, "create", &format!("counter-{index}")).unwrap();
}
let full = observe(&world, "/playground", 1);
assert_eq!(full.children.as_ref().unwrap().len(), MAX_COUNTERS);
assert_eq!(
collection(&world, "create", "over-limit").unwrap_err().code,
ProtocolErrorCode::ResourceLimitExceeded
);
assert_eq!(observe(&world, "/playground", 1), full);
assert_initial_values(&world, &full);
collection(&world, "remove", "orange").unwrap();
collection(&world, "create", "over-limit").unwrap();
}
#[test]
fn integer_and_typed_argument_failures_are_atomic() {
let world = world();
assert_eq!(
increment(&world, MAX_SAFE_INTEGER - 3).unwrap().result,
Value::Integer(MAX_SAFE_INTEGER)
);
assert_eq!(get(&world, "apple"), Value::Integer(MAX_SAFE_INTEGER));
let maximum = observe(&world, "/playground/apple", 1);
for amount in [1, MAX_SAFE_INTEGER, i64::MAX, i64::MIN] {
assert!(increment(&world, amount).is_err());
assert_eq!(observe(&world, "/playground/apple", 1), maximum);
assert_eq!(get(&world, "apple"), Value::Integer(MAX_SAFE_INTEGER));
}
assert_eq!(
call(
&world,
"/playground/apple",
"counter",
"reset",
BTreeMap::new(),
)
.unwrap()
.result,
Value::Integer(0)
);
assert_eq!(get(&world, "apple"), Value::Integer(0));
assert_eq!(observe(&world, "/playground/apple", 1), maximum);
assert_eq!(
increment(&world, MIN_SAFE_INTEGER).unwrap().result,
Value::Integer(MIN_SAFE_INTEGER)
);
assert_eq!(get(&world, "apple"), Value::Integer(MIN_SAFE_INTEGER));
let minimum = observe(&world, "/playground/apple", 1);
assert_eq!(minimum, maximum);
assert!(increment(&world, -1).is_err());
assert_eq!(get(&world, "apple"), Value::Integer(MIN_SAFE_INTEGER));
for args in [
BTreeMap::new(),
BTreeMap::from([("amount".into(), Value::String("1".into()))]),
BTreeMap::from([
("amount".into(), Value::Integer(1)),
("extra".into(), Value::Unit),
]),
] {
assert_eq!(
call(&world, "/playground/apple", "counter", "increment", args)
.unwrap_err()
.code,
ProtocolErrorCode::InvalidArguments
);
assert_eq!(observe(&world, "/playground/apple", 1), minimum);
assert_eq!(get(&world, "apple"), Value::Integer(MIN_SAFE_INTEGER));
}
}
#[test]
fn new_worldspace_resets_state_and_manifest_documents_cannot_be_shadowed() {
let first = world();
assert_eq!(increment(&first, 12).unwrap().result, Value::Integer(15));
assert_eq!(get(&first, "apple"), Value::Integer(15));
collection(&first, "remove", "orange").unwrap();
collection(&first, "create", "pear").unwrap();
let fresh = world();
assert_eq!(
observe(&fresh, "/playground/apple", 1),
observe(&first, "/playground/apple", 1)
);
assert_eq!(get(&fresh, "apple"), Value::Integer(3));
assert_eq!(get(&fresh, "orange"), Value::Integer(0));
assert_eq!(get(&first, "apple"), Value::Integer(15));
assert_eq!(
observe(&fresh, "/playground", 1)
.children
.unwrap()
.iter()
.map(|child| child.object.name.clone())
.collect::<Vec<_>>(),
["apple", "orange"]
);
assert!(Worldspace::from_json(r#"{"nodes":[
{"path":"/","name":"","displayPath":"/","title":"Root","href":"/","html":""},
{"path":"/playground","name":"playground","parent":"/","displayPath":"/playground","title":"Playground","href":"/playground/","html":""},
{"path":"/playground/apple","name":"apple","parent":"/playground","displayPath":"/playground/apple","title":"Document","href":"/apple/","html":""}
]}"#).is_err());
}
+476
View File
@@ -0,0 +1,476 @@
use std::collections::BTreeMap;
use wip_interface::WipType;
use wip_site_worldspace::wip_protocol::{
CallOperationRequest, FetchInterfaceRequest, InterfaceReference, InterfaceTarget,
ObserveRequest, ProtocolErrorCode, Target, Value,
};
use wip_site_worldspace::{
DocumentContent, DocumentNode, MAX_DEPTH, MAX_NODES, Worldspace, WorldspaceData,
};
fn node(path: &str, parent: Option<&str>, display: &str, href: &str) -> DocumentNode {
DocumentNode {
path: path.into(),
name: if path == "/" {
""
} else {
path.rsplit('/').next().unwrap()
}
.into(),
display_path: display.into(),
parent: parent.map(str::to_owned),
title: format!("Title {display}"),
href: href.into(),
html: format!("<h1>{display}</h1>"),
}
}
fn data() -> WorldspaceData {
WorldspaceData {
nodes: vec![
node("/", None, "/", "/"),
node("/book", Some("/"), "/book", "/book/"),
node(
"/book/1-chapter.md",
Some("/book"),
"/1-chapter.md",
"/book/1-chapter/",
),
node(
"/book/1-chapter.md/1.1-section.md",
Some("/book/1-chapter.md"),
"/1.1-section.md",
"/book/1.1-section/",
),
// This intentional order differs from lexical order.
node(
"/book/10-last.md",
Some("/book"),
"/10-last.md",
"/book/10-last/",
),
node(
"/book/2-second.md",
Some("/book"),
"/2-second.md",
"/book/2-second/",
),
node("/sources", Some("/"), "/sources", "/sources/"),
node("/packages", Some("/"), "/packages", "/packages/"),
],
}
}
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(), 3);
assert_eq!(children[0].object.name, "book");
assert_eq!(children[1].object.name, "sources");
assert_eq!(children[2].object.name, "packages");
assert!(children.iter().all(|child| child.children.is_none()));
for path in ["/sources", "/packages"] {
assert!(observe(path, 0).children.is_none());
assert_eq!(observe(path, 1).children, Some(vec![]));
let leaf = observe(path, 1);
assert_eq!(
leaf.object.interfaces,
vec![Worldspace::document_interface()]
);
let content =
DocumentContent::decode(world.call_operation(read(path)).unwrap().result).unwrap();
assert_eq!(content.path, path);
assert_eq!(content.href, format!("{path}/"));
assert_eq!(content.html, format!("<h1>{path}</h1>"));
}
let root = observe("/", 3);
let book = &root.children.as_ref().unwrap()[0];
let chapters = book.children.as_ref().unwrap();
assert_eq!(
chapters
.iter()
.map(|node| node.object.name.as_str())
.collect::<Vec<_>>(),
vec!["1-chapter.md", "10-last.md", "2-second.md"]
);
assert!(chapters[0].children.as_ref().unwrap()[0].children.is_none());
assert_eq!(chapters[1].children, Some(vec![]));
assert_eq!(
observe("/book/1-chapter.md/1.1-section.md", 1).children,
Some(vec![])
);
assert!(observe("/", MAX_DEPTH).children.is_some());
assert_eq!(
world
.observe(ObserveRequest {
path: "/".into(),
depth: MAX_DEPTH + 1
})
.unwrap_err()
.code,
ProtocolErrorCode::ResourceLimitExceeded
);
}
#[test]
fn canonical_host_failures_are_not_replaced_by_direct_body_lookup() {
let world = Worldspace::new(data()).unwrap();
for path in ["relative", "/book/", "/book//x", "/book/../x", "/./book"] {
assert_eq!(
world
.observe(ObserveRequest {
path: path.into(),
depth: 0
})
.unwrap_err()
.code,
ProtocolErrorCode::InvalidRequest
);
assert_eq!(
world.call_operation(read(path)).unwrap_err().code,
ProtocolErrorCode::InvalidRequest
);
}
for path in [
"/missing",
"/1-chapter.md",
"/draft/private.md",
"/book/draft/private.md",
"/sources/repository",
"/packages/crate",
] {
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()
.iter()
.map(|node| node.object.name.as_str())
.collect::<Vec<_>>(),
vec!["book", "sources", "packages", "playground"]
);
for path in ["/sources", "/packages"] {
assert_eq!(
world
.observe(ObserveRequest {
path: path.into(),
depth: 1,
})
.unwrap()
.children,
Some(vec![])
);
}
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
);
}
}