Compare commits
7
Commits
b7cef01afd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4c8ba97f | ||
|
|
41e88e3c40 | ||
|
|
75a36f2298 | ||
|
|
b82c1d0a70 | ||
|
|
1297d86140 | ||
|
|
c480b67301 | ||
|
|
ad08037f4f |
@@ -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:
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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`を設定すると成果物へ反映します。ホスティングへのアップロードは、その公開先を決めてから別途接続してください。
|
|
||||||
|
|||||||
Generated
+245
@@ -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"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["worldspace", "browser"]
|
||||||
|
resolver = "3"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
@@ -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' } },
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Generated
+7
@@ -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,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"repository": "https://gitea.hareworks.net/Hare/wip-rs",
|
||||||
|
"revision": "eb92878e3c696c768f1d2294c3718ecd04cf6a87",
|
||||||
|
"toolchain": "1.97.1",
|
||||||
|
"wasmBindgen": "0.2.121"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.97.1"
|
||||||
|
targets = ["wasm32-unknown-unknown"]
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
|
profile = "minimal"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Build the browser runtime from source; no prebuilt local WASM is required.
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const site = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
const rust = fileURLToPath(new URL('../../../wip-rs/', import.meta.url));
|
||||||
|
const source = JSON.parse(readFileSync(new URL('../rust-source.json', import.meta.url), 'utf8'));
|
||||||
|
const revision = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: rust, encoding: 'utf8' });
|
||||||
|
const status = spawnSync('git', ['status', '--porcelain'], { cwd: rust, encoding: 'utf8' });
|
||||||
|
if (revision.status !== 0 || revision.stdout.trim() !== source.revision || status.status !== 0 || status.stdout.trim()) {
|
||||||
|
if (process.env.WIP_ALLOW_DIRTY_SOURCE !== '1') {
|
||||||
|
throw new Error(`Expected clean sibling wip-rs at ${source.revision}. See rust-source.json; use WIP_ALLOW_DIRTY_SOURCE=1 only for local Rust development.`);
|
||||||
|
}
|
||||||
|
console.warn('Building with an explicitly unpinned local Rust source; this is not a reproducible release build.');
|
||||||
|
}
|
||||||
|
function run(command, args) {
|
||||||
|
const result = spawnSync(command, args, { cwd: site, stdio: 'inherit' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
|
function available(command) {
|
||||||
|
return spawnSync(command, ['--version'], { stdio: 'ignore' }).status === 0;
|
||||||
|
}
|
||||||
|
const testProbes = process.argv.includes('--test-probes');
|
||||||
|
const output = testProbes ? 'test-assets/wip-wasm' : 'public/wip-wasm';
|
||||||
|
const cargo = ['build', '--locked', '--manifest-path', 'browser/Cargo.toml', '--target', 'wasm32-unknown-unknown', '--release', ...(testProbes ? ['--features', 'test-probes'] : [])];
|
||||||
|
if (available('cargo')) run('cargo', cargo);
|
||||||
|
else run('nix', ['shell', '--inputs-from', rust, 'nixpkgs#lld', '--command', 'nix', 'develop', rust, '--no-write-lock-file', '--command', 'cargo', ...cargo]);
|
||||||
|
mkdirSync(new URL(`../${output}/`, import.meta.url), { recursive: true });
|
||||||
|
const bindgen = ['target/wasm32-unknown-unknown/release/wip_browser.wasm', '--target', 'web', '--out-dir', output, '--out-name', 'wip_browser'];
|
||||||
|
if (available('wasm-bindgen')) run('wasm-bindgen', bindgen);
|
||||||
|
else run('nix', ['shell', '--inputs-from', rust, 'nixpkgs#wasm-bindgen-cli', '--command', 'wasm-bindgen', ...bindgen]);
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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` },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,4 +1,3 @@
|
|||||||
@import './fonts.css';
|
|
||||||
@import './theme.css';
|
@import './theme.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; } }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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('−');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
|
||||||
|
|
||||||
|
async function probeAsset(name: string) {
|
||||||
|
return readFile(new URL(`../../test-assets/wip-wasm/${name}`, import.meta.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('real WASM enforces manifest byte, node and depth bounds', async ({ page }, testInfo) => {
|
||||||
|
const base = testInfo.project.use.baseURL!;
|
||||||
|
await page.goto(new URL('worldspace.json', base).href);
|
||||||
|
const result = await page.evaluate(async (base) => {
|
||||||
|
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
|
||||||
|
await module.default();
|
||||||
|
const root = { path: '/', name: '', displayPath: '/', title: 'Root', href: '/', html: '<p>Root</p>' };
|
||||||
|
const attempt = (json: string) => {
|
||||||
|
try { new module.BrowserSession(json).free(); return { error: '', trap: false }; }
|
||||||
|
catch (error) { return { error: String(error), trap: error instanceof WebAssembly.RuntimeError }; }
|
||||||
|
};
|
||||||
|
// Both oversized manifests are otherwise valid; the controls prove that
|
||||||
|
// removing a limit cannot leave this test green via unrelated validation.
|
||||||
|
const byteLimit = 16 * 1024 * 1024;
|
||||||
|
const empty = JSON.stringify({ nodes: [{ ...root, html: '' }] });
|
||||||
|
const atBytes = JSON.stringify({ nodes: [{ ...root, html: 'x'.repeat(byteLimit - empty.length) }] });
|
||||||
|
const bytesAccepted = attempt(atBytes);
|
||||||
|
const bytes = attempt(atBytes.replace('"html":"', '"html":"x'));
|
||||||
|
const wide = [root, ...Array.from({ length: 4096 }, (_, i) => ({
|
||||||
|
path: `/n${i}`, name: `n${i}`, displayPath: `/n${i}`, parent: '/', title: `Node ${i}`, href: `/n${i}/`, html: '',
|
||||||
|
}))];
|
||||||
|
const nodesAccepted = attempt(JSON.stringify({ nodes: wide.slice(0, 4096) }));
|
||||||
|
const nodes = attempt(JSON.stringify({ nodes: wide }));
|
||||||
|
const deep: Record<string, string>[] = [root];
|
||||||
|
let parent = '/';
|
||||||
|
for (let i = 0; i < 32; i++) {
|
||||||
|
const path = `${parent === '/' ? '' : parent}/a`;
|
||||||
|
deep.push({ path, name: 'a', displayPath: path, parent, title: path, href: `${path}/`, html: '<p>Node</p>' });
|
||||||
|
parent = path;
|
||||||
|
}
|
||||||
|
const accepted = new module.BrowserSession(JSON.stringify({ nodes: deep }));
|
||||||
|
const deepest = JSON.parse(accepted.read(parent)).path;
|
||||||
|
accepted.free();
|
||||||
|
deep.push({ path: `${parent}/a`, name: 'a', displayPath: `${parent}/a`, parent, title: 'Too deep', href: `${parent}/a/`, html: '' });
|
||||||
|
const depth = attempt(JSON.stringify({ nodes: deep }));
|
||||||
|
return { bytes, nodes, depth, deepest, bytesAccepted, nodesAccepted, probePublished: 'panic_probe' in module.BrowserSession.prototype };
|
||||||
|
}, base);
|
||||||
|
expect(result.bytesAccepted).toEqual({ error: '', trap: false });
|
||||||
|
expect(result.nodesAccepted).toEqual({ error: '', trap: false });
|
||||||
|
expect(result.bytes).toEqual({ error: 'document JSON exceeds the input byte limit', trap: false });
|
||||||
|
expect(result.nodes).toEqual({ error: 'document node count must be between 1 and MAX_NODES', trap: false });
|
||||||
|
expect(result.depth).toEqual({ error: 'document hierarchy exceeds the depth limit', trap: false });
|
||||||
|
expect(result.deepest.split('/').length - 1).toBe(32);
|
||||||
|
expect(result.probePublished).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a controlled release Rust panic traps, not a Protocol failure; a fresh instance recovers', async ({ page }, testInfo) => {
|
||||||
|
const base = testInfo.project.use.baseURL!;
|
||||||
|
await page.route('**/wip-probe/wip_browser*', async (route) => {
|
||||||
|
const wasm = new URL(route.request().url()).pathname.endsWith('.wasm');
|
||||||
|
await route.fulfill({ body: await probeAsset(wasm ? 'wip_browser_bg.wasm' : 'wip_browser.js'), contentType: wasm ? 'application/wasm' : 'text/javascript' });
|
||||||
|
});
|
||||||
|
await page.goto(new URL('worldspace.json', base).href);
|
||||||
|
const result = await page.evaluate(async (base) => {
|
||||||
|
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
|
||||||
|
const module = await import(new URL('wip-probe/wip_browser.js', base).href);
|
||||||
|
await module.default();
|
||||||
|
const session = new module.BrowserSession(manifest);
|
||||||
|
const before = JSON.parse(session.read('/')).path;
|
||||||
|
let trap = false, detail = '';
|
||||||
|
try { session.panic_probe(); } catch (error) {
|
||||||
|
trap = error instanceof WebAssembly.RuntimeError;
|
||||||
|
detail = String(error);
|
||||||
|
}
|
||||||
|
// Retain the aborted wrapper too: its generated GC finalizer enters WASM.
|
||||||
|
(window as unknown as Record<string, unknown>).__quarantinedSession = session;
|
||||||
|
// Do not call/free the aborted Rust object: a mutable borrow was interrupted.
|
||||||
|
// A distinct glue module creates a genuinely new WASM instance.
|
||||||
|
const fresh = await import(`${new URL('wip-probe/wip_browser.js', base).href}?fresh`);
|
||||||
|
await fresh.default();
|
||||||
|
const replacement = new fresh.BrowserSession(manifest);
|
||||||
|
const after = JSON.parse(replacement.read('/')).path;
|
||||||
|
replacement.free();
|
||||||
|
return { before, after, trap, detail };
|
||||||
|
}, base);
|
||||||
|
expect(result.before).toBe('/');
|
||||||
|
expect(result.trap).toBe(true);
|
||||||
|
expect(result.detail).toContain('RuntimeError');
|
||||||
|
expect(result.after).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the site retires a trapped WASM session and does not replay calls on later routes', async ({ page }, testInfo) => {
|
||||||
|
const base = testInfo.project.use.baseURL!;
|
||||||
|
await page.route('**/wip-wasm/wip_browser.js', async (route) => {
|
||||||
|
const source = (await probeAsset('wip_browser.js')).toString();
|
||||||
|
await route.fulfill({ contentType: 'text/javascript', body: `${source}
|
||||||
|
BrowserSession.prototype.read = function () {
|
||||||
|
window.__panicReadCalls = (window.__panicReadCalls || 0) + 1;
|
||||||
|
window.__trappedSession = new WeakRef(this);
|
||||||
|
return this.panic_probe();
|
||||||
|
};
|
||||||
|
const originalFree = BrowserSession.prototype.free;
|
||||||
|
BrowserSession.prototype.free = function () {
|
||||||
|
window.__panicFreeCalls = (window.__panicFreeCalls || 0) + 1;
|
||||||
|
return originalFree.call(this);
|
||||||
|
};` });
|
||||||
|
});
|
||||||
|
await page.route('**/wip-wasm/*.wasm', async (route) => {
|
||||||
|
await route.fulfill({ contentType: 'application/wasm', body: await probeAsset('wip_browser_bg.wasm') });
|
||||||
|
});
|
||||||
|
await page.goto(base);
|
||||||
|
const stage = page.locator('[data-landing-stage]');
|
||||||
|
await expect(stage).toHaveAttribute('data-wip-state', 'fallback');
|
||||||
|
expect(await page.evaluate(() => (window as Window & { __panicReadCalls?: number }).__panicReadCalls)).toBe(1);
|
||||||
|
await page.locator('.demo-node[data-path="/book"]').click();
|
||||||
|
await expect(page).toHaveURL(new URL('book/', base).href);
|
||||||
|
await expect(stage).toHaveAttribute('data-wip-state', 'fallback');
|
||||||
|
await expect(page.locator('[data-selected-content] h1')).toHaveText('Book');
|
||||||
|
expect(await page.evaluate(() => (window as Window & { __panicReadCalls?: number }).__panicReadCalls)).toBe(1);
|
||||||
|
// Force collection after continuations release their locals. The only test
|
||||||
|
// reference is weak, so this catches accidentally dropping the quarantine.
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
await cdp.send('HeapProfiler.collectGarbage');
|
||||||
|
expect(await page.evaluate(() => Boolean((window as unknown as { __trappedSession: WeakRef<object> }).__trappedSession.deref()))).toBe(true);
|
||||||
|
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: false })));
|
||||||
|
await cdp.send('HeapProfiler.collectGarbage');
|
||||||
|
expect(await page.evaluate(() => ({
|
||||||
|
retained: Boolean((window as unknown as { __trappedSession: WeakRef<object> }).__trappedSession.deref()),
|
||||||
|
frees: (window as Window & { __panicFreeCalls?: number }).__panicFreeCalls || 0,
|
||||||
|
}))).toEqual({ retained: true, frees: 0 });
|
||||||
|
await cdp.detach();
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.use({ javaScriptEnabled: true });
|
||||||
|
|
||||||
|
test('real WASM owns isolated Client/Host sessions and operates without a transport', async ({ page }, testInfo) => {
|
||||||
|
const base = testInfo.project.use.baseURL!;
|
||||||
|
// Use an asset page, so the site UI is not the source of this test's observations.
|
||||||
|
await page.goto(new URL('worldspace.json', base).href);
|
||||||
|
const result = await page.evaluate(async (base) => {
|
||||||
|
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
|
||||||
|
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
|
||||||
|
await module.default();
|
||||||
|
const session = new module.BrowserSession(manifest);
|
||||||
|
const input = JSON.parse(manifest);
|
||||||
|
const altered = structuredClone(input);
|
||||||
|
const document = altered.nodes.find((node: { path: string }) => node.path !== '/' && node.path !== '/book');
|
||||||
|
document.html = '<p>Isolated Worldspace content</p>';
|
||||||
|
document.title = 'Isolated title';
|
||||||
|
const other = new module.BrowserSession(JSON.stringify(altered));
|
||||||
|
// Asset loading has finished. All following interactions must be synchronous
|
||||||
|
// local calls: neither fetch nor a socket is a transport for WIP.
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = () => { throw new Error('Unexpected WIP network transport'); };
|
||||||
|
try {
|
||||||
|
const tree = JSON.parse(session.tree());
|
||||||
|
const bodies = tree.map((node: { path: string }) => JSON.parse(session.read(node.path)));
|
||||||
|
const first = JSON.parse(session.read(document.path));
|
||||||
|
const second = JSON.parse(other.read(document.path));
|
||||||
|
let missing = '', malformed = '', draft = '', operation = '';
|
||||||
|
const callsBeforeInvalid = JSON.parse(session.stats()).calls;
|
||||||
|
try { session.call(document.path, 'not-an-operation'); } catch (error) { operation = String(error); }
|
||||||
|
const callsAfterInvalid = JSON.parse(session.stats()).calls;
|
||||||
|
try { session.read('/not-published'); } catch (error) { missing = String(error); }
|
||||||
|
try { session.read('/book/../hidden'); } catch (error) { malformed = String(error); }
|
||||||
|
try { session.read('/draft/rust-api-model.md'); } catch (error) { draft = String(error); }
|
||||||
|
const stats = JSON.parse(session.stats());
|
||||||
|
const isolatedStats = JSON.parse(other.stats());
|
||||||
|
session.free();
|
||||||
|
other.free();
|
||||||
|
return { tree, bodies, input, first, second, missing, malformed, draft, operation, callsBeforeInvalid, callsAfterInvalid, stats, isolatedStats };
|
||||||
|
} finally { window.fetch = originalFetch; }
|
||||||
|
}, base);
|
||||||
|
expect(result.tree).toEqual(result.input.nodes.map(({ html, ...node }: { html: string }) => node));
|
||||||
|
expect(result.bodies).toEqual(result.input.nodes.map(({ path, displayPath, title, href, html }: Record<string, string>) => ({ path, displayPath, title, href, html })));
|
||||||
|
expect(result.first.html).not.toBe(result.second.html);
|
||||||
|
expect(result.second.html).toBe('<p>Isolated Worldspace content</p>');
|
||||||
|
expect(result.second.title).toBe('Isolated title');
|
||||||
|
expect(result.stats.observes).toBeGreaterThan(0);
|
||||||
|
expect(result.stats.interfaces).toBeGreaterThan(0);
|
||||||
|
expect(result.stats.calls).toBeGreaterThan(result.tree.length);
|
||||||
|
expect(result.isolatedStats.calls).toBe(1);
|
||||||
|
expect(result.missing).not.toBe('');
|
||||||
|
expect(result.malformed).not.toBe('');
|
||||||
|
expect(result.draft).not.toBe('');
|
||||||
|
expect(result.operation).not.toBe('');
|
||||||
|
expect(result.callsAfterInvalid).toBe(result.callsBeforeInvalid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WASM rejects invalid initialization and remains usable after a handled input failure', async ({ page }, testInfo) => {
|
||||||
|
const base = testInfo.project.use.baseURL!;
|
||||||
|
await page.goto(new URL('worldspace.json', base).href);
|
||||||
|
const result = await page.evaluate(async (base) => {
|
||||||
|
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
|
||||||
|
await module.default();
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const input of ['{', '{"nodes":[]}', '{"nodes":null}']) {
|
||||||
|
try { new module.BrowserSession(input).free(); } catch (error) { errors.push(String(error)); }
|
||||||
|
}
|
||||||
|
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
|
||||||
|
const session = new module.BrowserSession(manifest);
|
||||||
|
const body = JSON.parse(session.read('/'));
|
||||||
|
session.free();
|
||||||
|
let disposed = false;
|
||||||
|
try { session.read('/'); } catch { disposed = true; }
|
||||||
|
return { errors, body, disposed };
|
||||||
|
}, base);
|
||||||
|
expect(result.errors).toHaveLength(3);
|
||||||
|
expect(result.body.path).toBe('/');
|
||||||
|
expect(result.body.html).toContain('data-root-page');
|
||||||
|
expect(result.disposed).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { load } from 'cheerio';
|
||||||
|
|
||||||
|
// Shared by dev and preview: a successful build alone cannot catch Vite's
|
||||||
|
// dynamic-import rewriting of generated modules served from public/.
|
||||||
|
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
|
||||||
|
|
||||||
|
test('generated WASM loads as a static asset and hydrates real WIP data', async ({ page }, testInfo) => {
|
||||||
|
const base = testInfo.project.use.baseURL!;
|
||||||
|
const errors: string[] = [];
|
||||||
|
const assets: { url: string; status: number }[] = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.status() >= 500) errors.push(`${response.status()} ${response.url()}`);
|
||||||
|
if (new URL(response.url()).pathname.includes('/wip-wasm/')) {
|
||||||
|
assets.push({ url: response.url(), status: response.status() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.route(base, async (route) => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const $ = load(await response.text());
|
||||||
|
$('[data-selected-content]').prepend('<p data-ssr-only>Not a WIP result</p>');
|
||||||
|
await route.fulfill({ response, body: $.html() });
|
||||||
|
});
|
||||||
|
await page.goto(base);
|
||||||
|
const stage = page.locator('[data-landing-stage]');
|
||||||
|
await expect(stage).toHaveAttribute('data-wip-state', 'ready');
|
||||||
|
await expect(page.locator('[data-ssr-only]')).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-selected-content]')).toHaveAttribute('data-wip-path', '/');
|
||||||
|
for (const counter of ['observes', 'interfaces', 'calls']) {
|
||||||
|
expect(Number(await stage.getAttribute(`data-wip-${counter}`))).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
const calls = Number(await stage.getAttribute('data-wip-calls'));
|
||||||
|
await page.locator('.demo-node[data-path="/book"]').click();
|
||||||
|
await expect(page).toHaveURL(new URL('book/', base).href);
|
||||||
|
await expect(stage).toHaveAttribute('data-wip-state', 'ready');
|
||||||
|
await expect(page.locator('[data-selected-content]')).toHaveAttribute('data-wip-path', '/book');
|
||||||
|
expect(Number(await stage.getAttribute('data-wip-calls'))).toBe(calls + 1);
|
||||||
|
expect(assets.map(({ url }) => url).sort()).toEqual([
|
||||||
|
new URL('wip-wasm/wip_browser.js', base).href,
|
||||||
|
new URL('wip-wasm/wip_browser_bg.wasm', base).href,
|
||||||
|
].sort());
|
||||||
|
expect(assets.every(({ status }) => status === 200)).toBe(true);
|
||||||
|
await expect(page.locator('vite-error-overlay')).toHaveCount(0);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "wip-site-worldspace"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.88"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
wip-host = { path = "../../../wip-rs/crates/wip-host" }
|
||||||
|
wip-interface = { path = "../../../wip-rs/crates/wip-interface" }
|
||||||
|
wip-protocol = { path = "../../../wip-rs/crates/wip-protocol" }
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user