Compare commits
10
Commits
6e9e675c70
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db4c8ba97f | ||
|
|
41e88e3c40 | ||
|
|
75a36f2298 | ||
|
|
b82c1d0a70 | ||
|
|
1297d86140 | ||
|
|
c480b67301 | ||
|
|
ad08037f4f | ||
|
|
b7cef01afd | ||
|
|
839eb2a6a4 | ||
|
|
f6905074b1 |
@@ -10,6 +10,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout pinned Rust source
|
||||
run: |
|
||||
git clone https://gitea.hareworks.net/Hare/wip-rs ../wip-rs
|
||||
git -C ../wip-rs checkout "$(python3 -c 'import json; print(json.load(open("site/rust-source.json"))["revision"])')"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: '1.97.1'
|
||||
targets: wasm32-unknown-unknown
|
||||
components: rustfmt, clippy
|
||||
- name: Install matching WASM bindings tool
|
||||
run: cargo install wasm-bindgen-cli --version 0.2.121 --locked
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
@@ -21,16 +32,25 @@ jobs:
|
||||
- name: Check and build
|
||||
working-directory: site
|
||||
run: npm run verify
|
||||
- name: Validate generated Worldspace and Rust bridge
|
||||
working-directory: site
|
||||
run: |
|
||||
WIP_TEST_GENERATED=1 node --test tests/worldspace-data.test.mjs
|
||||
cargo fmt --all -- --check
|
||||
cargo test --workspace --locked -- --include-ignored
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
- name: Browser smoke tests
|
||||
working-directory: site
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
npm run test:browser
|
||||
npm run test:dev
|
||||
- name: Verify subpath deployment
|
||||
working-directory: site
|
||||
run: |
|
||||
SITE_BASE=/reference SITE_URL=https://example.test npm run verify
|
||||
SITE_BASE=/reference npm run test:browser
|
||||
SITE_BASE=/reference SITE_URL=https://example.test npm run test:browser
|
||||
SITE_BASE=/reference SITE_URL=https://example.test npm run test:dev
|
||||
- name: Build deployment artifact
|
||||
working-directory: site
|
||||
env:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
site/node_modules/
|
||||
site/target/
|
||||
site/test-assets/
|
||||
site/public/wip-wasm/
|
||||
site/dist/
|
||||
site/.astro/
|
||||
site/test-results/
|
||||
|
||||
@@ -1,91 +1,148 @@
|
||||
# WIP Reference
|
||||
|
||||
Web Interface Protocolの仕様文書と、その文書を読むための静的サイトです。
|
||||
Worldspace Interface Protocolの仕様文書と、その文書を実際のWIPで読むサイトです。
|
||||
|
||||
## 構成
|
||||
|
||||
- [`contents/`](contents/) — 仕様の正本。番号付きMarkdownを章順に読みます。
|
||||
- [`contents/draft/`](contents/draft/) — API設計・投影方針などの草案。正本とは区別します。
|
||||
- [`site/`](site/) — AstroによるトップページとBook。文書のコピーは持たず、`contents/**/*.md`をビルド時に読み込みます。
|
||||
- [`contents/draft/`](contents/draft/) — 検討中の草案。公開サイトとWIPの生成データには含めません。
|
||||
- [`site/`](site/) — Astroの静的サイトと、ブラウザ内で動くRust Client / Host。
|
||||
|
||||
[第1章:コンセプト](contents/1-concept.md) / [Core Model](contents/2-core-model.md) / [Protocol](contents/3-protocol.md) / [Client](contents/4-client.md) / [WIP over HTTP](contents/5.1-wip-over-http.md)
|
||||
|
||||
以前の直下Markdownと`draft/`は`contents/`へ移動しました。文書の内容・ファイル名・相互の相対リンクは維持しています。外部からraw Markdownを参照する場合はパスへ`contents/`を追加してください。
|
||||
## ブラウザ内のWIP
|
||||
|
||||
```text
|
||||
ツリー・本文UI (TypeScript)
|
||||
→ BrowserSession (WASM公開API)
|
||||
→ wip-client::direct::Client
|
||||
→ 型付き要求・応答の直接呼び出し
|
||||
→ サイト用Worldspace / wip-host
|
||||
```
|
||||
|
||||
ClientとHostは同じWASM内にあります。三つのinteraction(observe、fetch_interface、call_operation)にHTTP binding、JSON wire envelope、socketは使いません。WASMと`worldspace.json`の取得、Astroによる静的ページ遷移は通常のasset/page配信です。
|
||||
|
||||
公開文書と既存のhome/indexコンポーネントから`worldspace.json`をビルド時に生成します。本文の別コピーを編集する必要はありません。Rust Providerはそれを変更不可の文書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
|
||||
# このrepositoryのrootで実行
|
||||
# 既存の隣接checkoutがある場合はcloneせず、その作業を保全してください。
|
||||
git clone https://gitea.hareworks.net/Hare/wip-rs ../wip-rs
|
||||
git -C ../wip-rs checkout "$(node -p 'require("./site/rust-source.json").revision')"
|
||||
rustup toolchain install 1.97.1 --component rustfmt --component clippy --target wasm32-unknown-unknown
|
||||
cargo +1.97.1 install wasm-bindgen-cli --version 0.2.121 --locked
|
||||
cd site
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
開発サーバーは通常 `http://localhost:4321/` です。
|
||||
`npm run dev`と`npm run build`は先にWASMをsourceから生成します。Rustとwasm-bindgenがPATHにないNix環境では、隣接`wip-rs/flake.lock`を使って開発環境・LLD・wasm-bindgenを起動します。Cargo.lockとwasm-bindgenのバージョンは固定し、rustup環境では`site/rust-toolchain.toml`を使います。通常のWASMビルドはRust sourceのrevisionとclean状態を確認します。Rust側を同時編集するローカル開発に限り`WIP_ALLOW_DIRTY_SOURCE=1`で明示的に解除できますが、release検証では解除しません。生成物`site/public/wip-wasm/`と`site/target/`はGit管理しません。
|
||||
|
||||
```sh
|
||||
npm run verify # unit tests → Astro/TypeScript check → production build → link checks
|
||||
npm run verify # unit tests → Astro check → WASM/build → link checks
|
||||
WIP_TEST_GENERATED=1 node --test tests/worldspace-data.test.mjs
|
||||
cargo test --workspace --locked -- --include-ignored # build後の実文書テストも実施
|
||||
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||
npx playwright install chromium
|
||||
npm run test:browser
|
||||
npm run test:dev # dev serverでも実WASMの読み込み・本文反映とフォント配信を確認
|
||||
# インストール済みChromeを使う場合:CHROME_PATH=/path/to/chrome npm run test:browser
|
||||
npm run preview
|
||||
```
|
||||
|
||||
`/`がトップページ、`/book/`が目次です。各章は`/book/3-protocol/`のように元のファイル名を保持します。草案は`/book/draft/rust-api-model/`などに分離し、草案である旨を表示します。サイドバー、章内目次、前後の章へのリンクはsourceから生成します。
|
||||
Nixでnativeテストを行う場合は`nix develop ../../wip-rs --command cargo test --workspace --locked -- --include-ignored`のように実行します。テスト用previewは`127.0.0.1:4322`、`test:dev`は別のdev serverを4323で起動し、通常のdev server(4321)を再利用・停止しません。`SITE_BASE=/reference npm run test:dev`でサブパスのWASM・全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
|
||||
npx playwright install chromium
|
||||
npm run test:browser
|
||||
# インストール済みChromeを使う場合:CHROME_PATH=/path/to/chrome npm run test:browser
|
||||
```
|
||||
ブラウザテストはPC/mobile、no-JS、実WASMの独立session・三つのinteraction、WIP結果によるUI更新、失敗fallback、履歴、初回演出、ライト/ダークを検証します。`wasm.spec.ts`は静的asset取得後にfetchを禁止して直接呼び出しを確認し、`wip.spec.ts`は壊した初期HTMLを実WIP結果が修復することまで確認します。
|
||||
|
||||
Bookの閲覧テストはJavaScript無効、ランディングのテストは有効にして実行します。スクリーンショットは`site/test-results/`に保存され、ランディングは動きが重なる途中フレームをデスクトップ/モバイルで記録し、移動・縮小・幹伸長の同時進行と、移動中のSVG/罫線の接続位置を検証します。サブパスbuildの検証時は同じ`SITE_BASE`を設定してください。テスト用previewは`127.0.0.1:4322`を使用し、4321のdev serverとは別に起動します。
|
||||
## 表示と文書編集
|
||||
|
||||
### ランディング
|
||||
`/`が独立したホーム、`/book/`が目次です。各章の既存Web URLを維持し、共通ツリーでは章の下に節を表示します。初回ホームの導入演出は入力で開始し、EscapeやSkipで完了できます。`prefers-reduced-motion`では演出を省略します。配色はOSの`prefers-color-scheme`へ追従し、JavaScriptなしでも有効です。
|
||||
|
||||
トップはSVGのルートマークから始まります。コンテの2はホバー専用の「Tap」表現で、ポインタを離すと消え、待っていても本編には進みません。クリック・ホイール/タッチスクロール・キー入力のいずれか一回で、全体を約3.9秒で最後まで再生します。ホバーは再生の前提ではなく、追加入力で段階を進めたり、再生を重ねたりもしません。Escapeまたは画面右下のスキップで直ちにデモへ移れます。ブラウザの修飾キー付きショートカットは妨げません。
|
||||
|
||||
ホバーの「Tap」はルートと同じSVG座標系に置き、スラッシュの上辺・下辺(`y=4`〜`60`)に一致するマスクで切り抜きます。左右の文字列は斜辺と平行に逆方向へ流れ、上下端は水平に揃います。
|
||||
|
||||
ルートは中間位置で停止せず、一つの弧状の軌道でツリーへ移動します。円周文字の上端接線への流出、ルートの縮小、直下への幹伸長を重ね、着地を待たずに右向きの枝とパネルのglitchを始めます。円の半径・曲率は変えません。移動中の枝もルートと同じ座標変換に乗せ、縦幹全体を`├`・`│`・`└`へずれなく引き継ぎます。主幹は連続したまま保ち、枝先とラベルにglitchを適用します。ページのヘッダーや周辺要素も末尾の収束に重ねて現れます。
|
||||
|
||||
ツリーとパネルはASCII glitchから確定した文字・罫線へ収束します。ノイズは局所的なセル置換を10Hzで更新し、全画面の点滅は使いません。空白・改行・日本語は保持し、完了/スキップ時には元の文字列を完全に復元します。
|
||||
|
||||
- `src/components/Landing.astro` — SVGと、最終的に操作するツリー/パネルの静的HTML。
|
||||
- `src/components/BoxFrame.astro` — `┌─┐`・`│`・`└─┘`によるText View/Call resultの枠。CSS borderやSVGで枠を代用せず、幅・高さに合わせて罫線素片をクリップします。JavaScript無効時も表示されます。
|
||||
- `src/lib/landing-ascii.mjs` — セル幅を保つ決定的なglitch文字置換。
|
||||
- `src/scripts/landing.ts` — 単一の`requestAnimationFrame`時計、役割別の速度曲線、固定円周+上端接線の文字経路、幹の伸長から罫線への接続、ツリー操作。
|
||||
- `src/lib/landing-demo.ts` — ブラウザ内だけで使うサンプル。外部Hostへの接続、変更操作、通信はありません。Object signatureとInterface signatureを分け、本文は明示的なCallで表示します。
|
||||
- `src/styles/landing.css` — トップ全体を14pxのmonoフォント・24pxの行高で構成。フレーム幅を4ch単位に揃え、列・ガター・パディングもセルと行の整数倍にします。ツリーはSVGの線ではなく`├──`・`└──`・`│`などの罫線素片を使い、JavaScript無効時も同じ構造を表示します。モバイルはツリーとパネルを縦に配置します。
|
||||
|
||||
`prefers-reduced-motion: reduce`では演出を省略し、操作可能なデモを表示します。JavaScript無効時は静的なサンプルと説明・Bookリンクを表示し、モジュール取得失敗時も導入画面に閉じ込めません。再確認はページのリロードで行えます。
|
||||
|
||||
## 文書の編集
|
||||
|
||||
文書は`contents/`だけを編集します。各文書の先頭の`#`見出しをタイトルに使用するため、frontmatterは不要です。番号付き文書は番号順、草案は別グループで表示します。
|
||||
|
||||
Markdown上の相対`.md`リンクはリポジトリ上でもそのまま読めます。サイト生成時にremark pluginがBookのURLへ変換し、queryとfragmentを維持します。コードブロックや外部URLは変換しません。`npm run check:links`は生成ページ、文書の欠落、重複ID、内部リンク・画像・CSS/JSと見出しanchorを検証します。
|
||||
文書は`contents/`だけを編集します。先頭の`#`見出しをタイトルに使うためfrontmatterは不要です。Markdownの相対`.md`リンクはビルド時にBook URLへ変換し、query/fragmentを維持します。draftへのリンクは公開リンクにしません。`npm run check:links`で生成ページ・内部リンク・見出しanchorを検証します。
|
||||
|
||||
## サイト公開
|
||||
|
||||
静的出力の`site/dist/`を公開します。SSR、常駐Nodeサーバー、データベースは不要です。ホスティング先や公開ドメインはまだ固定していません。**このリポジトリへのpushだけでインターネット上へ自動デプロイされる設定ではありません。**
|
||||
静的出力の`site/dist/`を公開します。常駐WIPサーバー、Nodeサーバー、データベースは不要です。このrepositoryへのpushだけでインターネットへ自動デプロイする設定ではありません。
|
||||
|
||||
```sh
|
||||
cd site
|
||||
npm ci
|
||||
SITE_URL=https://your-domain.example npm run verify
|
||||
```
|
||||
|
||||
サブパスへ公開する場合:
|
||||
|
||||
```sh
|
||||
# サブパス配信の検証
|
||||
SITE_URL=https://your-domain.example SITE_BASE=/wip-reference npm run verify
|
||||
SITE_URL=https://your-domain.example SITE_BASE=/wip-reference npm run test:browser
|
||||
```
|
||||
|
||||
- `SITE_URL`:公開サイトのorigin(例:`https://your-domain.example`)。path prefixは`SITE_BASE`へ分離します。設定時にcanonical URLを生成し、未指定時に仮ドメインは埋め込みません。
|
||||
- `SITE_BASE`:公開path prefix。既定は`/`。サブパスの場合は、そのprefixへ`dist/`の内容を配置します。
|
||||
- 本番ビルドはリポジトリ全体をcheckoutし、作業directoryを`site/`、build commandを`npm ci && npm run verify`、output directoryを`site/dist/`(作業directory基準では`dist/`)にします。
|
||||
- Static hostではディレクトリの`index.html`を配信し、未知のURLへ`404.html`を返す設定を使用します。SPA fallbackは不要です。
|
||||
- 文書は静的HTML、コードの色付けはビルド時のShiki、モバイルの目次はHTMLの`details`です。閲覧にクライアント側JavaScriptを必要としません。
|
||||
|
||||
Gitea Actionsの`.gitea/workflows/site.yml`で、通常ビルドとサブパスビルドを検証し、公開用の`wip-reference-site` artifactを作成します。GiteaのActionsと対応runnerが必要です。Repository variablesの`SITE_URL`/`SITE_BASE`を設定すると成果物へ反映します。ホスティングへのアップロードは、その公開先を決めてから別途接続してください。
|
||||
- `SITE_URL`はorigin、`SITE_BASE`はpath prefixです。既定のbaseは`/`です。
|
||||
- Static hostはディレクトリの`index.html`を配信し、未知URLに`404.html`を返します。SPA fallbackは不要です。
|
||||
- `.wasm`は`application/wasm`で配信してください。モジュール、WASM、manifest、HTMLを同じbuildの成果物として公開します。
|
||||
- Gitea Actionsは固定revisionのRust sourceとサイトを組み合わせ、通常/サブパスの検証後に`wip-reference-site` artifactを作ります。Rust revisionの更新時は両repositoryのテストを実施し、参照commitを取得可能にしてからCIを動かしてください。
|
||||
|
||||
+7
-74
@@ -1,79 +1,12 @@
|
||||
# 1. コンセプト
|
||||
|
||||
## 背景
|
||||
WIP(Worldspace Interface Protocol)は、人間・AI・システムがアプリケーションを操作するための共通インタフェースを目指す。
|
||||
|
||||
現在のLLMエージェントは、Filesystem、検索、Issue Tracker、データベース、外部API、他のAgentなどを、それぞれ別個のToolとして扱うことが多い。統合先が増えるほど、モデルに提示するTool SchemaとAction Spaceも増えていく。
|
||||
同じアプリケーションでも、人間は画面、AIはTool、システムはAPIを通して利用することが多い。接点の違いは必要だが、その下にある操作対象や利用可能な操作まで、利用者の種類ごとに別のモデルとして扱う必要があるのだろうか。
|
||||
|
||||
Unix filesystemは、異なる資源を単一の名前空間へ配置し、少数の操作で探索できる強い抽象化を持つ。一方、file / directoryとread / write中心の操作だけでは、`Issue.close()`、`PullRequest.merge()`、`Agent.ask()`のような意味的操作を自然に表現しにくい。
|
||||
WIPでは、対象を発見し、その対象で何ができるかを知り、操作するという共通モデルを定める。人間向けのView、AI向けのTools、プログラム向けのAPIは、そのモデルをそれぞれの用途に合わせて提示する。
|
||||
|
||||
## 中心アイデア
|
||||
|
||||
WIPは、任意のデジタル環境を一つの**Worldspace**として公開する。
|
||||
|
||||
WorldspaceはObjectのTreeとして構成される。本書では、Tree上に配置され、Pathで到達できるObjectを**エントリ**と呼ぶ。Objectとエントリは別スキーマではない。
|
||||
|
||||
Objectは、短いdescription、利用可能なOperation、子Object、必要に応じて`ref`を公開できる。本文やmetadataなどのドメインデータは常時露出せず、Operationを通して取得・操作することを基本とする。
|
||||
|
||||
## Tool DiscoveryではなくWorld Discovery
|
||||
|
||||
一般的なTool Callingでは、モデルは提示されたTool一覧から操作を選ぶ。
|
||||
|
||||
WIPでは、モデルは既知のWorldspaceを辿り、目的に関連する対象を見つけ、公開されたOperationを確認して実行する。Operation Resultの`entry`型から未知のpathを発見し、必要なものを取得してClientのKnown Spaceへ追加できる。
|
||||
|
||||
中心となる問題を**Tool SelectionからWorld Explorationへ移す**ことがWIPの狙いである。
|
||||
|
||||
## Worldspaceの構造
|
||||
|
||||
Pathはidentityではなく、現在のWorldspace上の配置位置である。同一Objectが複数のエントリとして現れてもよい。
|
||||
|
||||
親子関係は所有・内包を表す。所有関係にない意味的な関連は、それを表す名前付きOperationから関連エントリのpathを返し、Treeへ無理に押し込まない。
|
||||
|
||||
Worldspace全体を事前にmaterializeする必要はない。Hostはlazy / virtualなTreeとして、必要な範囲だけを動的に公開できる。
|
||||
|
||||
## 操作中心のモデル
|
||||
|
||||
WIPではObjectを受動的なJSON recordとして扱わない。
|
||||
|
||||
本文を読む、metadataを取得する、検索する、状態を変更するといった行為はいずれもOperationとして表現する。例えばIssueであれば、`read_body()`、`get_metadata()`、`close(reason)`などを公開できる。
|
||||
|
||||
Operationには、名前や型に加えて、用途を判断するための短いdescriptionを持たせる。長文documentationや大量のドメインデータをInterfaceへ埋め込むことは想定しない。
|
||||
|
||||
ObjectはOperation定義を直接埋め込まず、実装コード上のInterface Declarationから生成された0個以上のInterface Descriptorを参照する。各Interfaceに宣言された関数の集合が再利用可能なcapability unitとなり、複数のObjectが同じInterfaceを共有できる。Objectは現在公開するInterface集合を持ち、Operation callではそのうち一つを明示してOperationを実行する。
|
||||
|
||||
## 発見と既知世界
|
||||
|
||||
Clientは、これまでに発見したエントリを既知のWorldspaceとして保持できる。既知のエントリはClient側で自由に辿り、Operation結果から得たエントリを追加して、さらに探索できる。
|
||||
|
||||
機械的な広域探索の対象は、Hostがindexableとして公開した範囲に限定できる。これにより、大量の検索結果や動的Objectが自動的に探索indexへ流入することを防ぐ。
|
||||
|
||||
## Operation Resultと新規発見
|
||||
|
||||
OperationはHost背後の環境を変更し得るが、WIPはその状態遷移や変更差分をClientへ同期するprotocolではない。
|
||||
|
||||
Operation Resultは型付きの値であり、Interface Descriptorの`entry`型として、未観測だったエントリのpathを返せる。例えばRAG検索からResult群を発見したり、Agentへの問い合わせからConversationを発見したりできる。
|
||||
|
||||
Clientは必要に応じて返されたpathを取得し、変更後の状態を改めて観測する。
|
||||
|
||||
## Narrow Waist
|
||||
|
||||
WIPの目的は、Filesystem、GitHub、RAG、DBなどを一つの巨大APIへ統合することではない。
|
||||
|
||||
任意のデジタル環境を共通のWorldspaceとして投影し、発見・観測・Operation実行という小さなInteraction Modelへ収束させる。Host内部のAPI、DB、RPC、永続化方式は規定せず、TransportもProtocolのlogical data modelから分離する。
|
||||
|
||||
## 状態管理の非目標
|
||||
|
||||
WIPは、Host上の任意状態をClientへ同期するdata store protocolではない。Object propertyのreplication、change feed、reactive subscription、分散transaction、domain event deliveryはCore Modelの規定対象外とする。
|
||||
|
||||
Clientが保持するKnown SpaceとDiscoverable Spaceは、過去の観測から構成されたcacheであり、Host上の現在状態を同期したreplicaではない。HostがClientのcache状態を追跡または更新することを要求しない。Operationのauthorizationと実行条件はHostが実行時に評価する。
|
||||
|
||||
## Post-trainingとの仮説
|
||||
|
||||
Tool Useが事後学習の対象になるなら、個別Tool名ではなくWorldspace上のinteraction policyを学習できる可能性がある。
|
||||
|
||||
1. 既知の世界を探索する。
|
||||
2. 対象の意味とOperationを理解する。
|
||||
3. 必要ならOperationから未知のエントリを発見する。
|
||||
4. Operationを実行する。
|
||||
5. 必要ならOperation Resultやentry pathをもとに対象を再観測する。
|
||||
|
||||
最も強い仮説は、**未知のToolを使えるモデルではなく、未知のDigital Environmentを探索できるモデルを作れるのではないか**という点にある。
|
||||
- [1.1. アプリケーション操作の抽象化](1.1-interaction-abstraction.md)
|
||||
- [1.2. 既存の抽象化と、その範囲](1.2-existing-abstractions.md)
|
||||
- [1.3. WIPの設計方針](1.3-design-principles.md)
|
||||
- [1.4. WIPの概念と適用範囲](1.4-concepts-and-scope.md)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# 1.1. アプリケーション操作の抽象化
|
||||
|
||||
## 利用者が違っても、操作する対象は共通する
|
||||
|
||||
Issueを閉じるという操作は、人間が画面から行っても、AIがToolを呼んでも、別のシステムがAPIを呼んでも、アプリケーション上ではIssueに対する操作である。利用者によって入力の仕方や結果の見せ方は変わるが、対象の意味まで変わるわけではない。
|
||||
|
||||
それでも、各接点では操作を異なる形で記述する。画面にはボタンや入力欄を設け、AIにはTool名と引数のSchemaを提示し、プログラムにはendpointやmethodを提供する。内部で同じ処理を呼んでいても、外部からは、それぞれの接点に合わせて対象と操作を理解する必要がある。
|
||||
|
||||
WIPの出発点は、この違いの下に、アプリケーションを操作するための共通モデルを置けないかという問いである。AIのためだけにToolを整理したいのではない。人間もAIもシステムも利用できる、操作インタフェースそのものを抽象化したい。
|
||||
|
||||
## 共通化するのは操作モデルである
|
||||
|
||||
アプリケーションを使う主体には、少なくとも、操作する対象、その対象が何を表すか、利用可能な操作、操作に必要な入力、返される結果を知る手段が必要になる。これらを共通の契約として公開できれば、Clientは用途ごとの接点を、その契約に基づいて構成できる。
|
||||
|
||||
人間には対象の一覧や操作フォームとして、AIには対象の説明とTool呼び出しとして、プログラムには型付きのAPIとして提示できる。同じモデルを使うことは、全員に同じ表示や呼び出し構文を使わせることではない。
|
||||
|
||||
また、共通のモデルからUIやToolを作れることと、用途に最適なUIや判断手順まで自動的に得られることは別である。画面の構成、操作の選び方、結果の解釈には、それぞれのClientやアプリケーションに固有の設計が残る。共有したいのは、それらの設計が参照する対象と操作の契約である。
|
||||
|
||||
## 呼び出し方だけでは足りない
|
||||
|
||||
操作を呼び出す形式が揃っていても、利用者が呼出先と引数をすでに知っていることを前提にすると、未知のアプリケーションを扱う手順は共通にならない。何を対象にできるか、必要な機能がどこにあるか、どの操作が使えるかを、別の文書や個別の組み込み知識から得る必要がある。
|
||||
|
||||
プログラムが既知のAPIを呼ぶ場合には、その前提で足りることもある。一方、人間やAIが環境を調べながら操作する場合や、システムが公開された機能を動的に扱う場合には、対象と操作を発見する手段も必要になる。
|
||||
|
||||
そのためWIPでは、実行だけでなく、発見と観測も操作モデルに含める。どの対象へ作用するかを決め、その対象の公開情報を確認してから操作する。対象を知ることと操作を呼ぶことを、別々の接続方式へ分散させずに扱いたい。
|
||||
|
||||
## 共通の契約と、主体ごとの権限
|
||||
|
||||
人間・AI・システムが同じモデルを使っても、同じ対象や操作をすべての主体へ公開する必要はない。公開する範囲も、実行を認める条件も、主体によって異なり得る。
|
||||
|
||||
Issueを見つけられたことだけでは、そのIssueを閉じる権限があるとは限らない。共通インタフェースは、こうした区別をなくすものではない。公開された対象と操作を同じ形式で理解できるようにしながら、可視性と実行時の認可はHostが判断する。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 1.2. 既存の抽象化と、その範囲
|
||||
|
||||
## HTTP API
|
||||
|
||||
HTTPを使えば、異なる言語や環境で実装されたClientとサーバーが、共通のrequest / response形式で通信できる。Method、URI、status、header、representationには共通の意味があり、アプリケーションごとに通信の基本から設計し直す必要はない。
|
||||
|
||||
ただし、HTTPを採用しただけでは、アプリケーションの操作モデルまでは揃わない。どのURIが操作対象を表すのか、どの操作を公開するのか、引数と結果をどう表現するのかは、個々のAPIの設計に依存する。呼び出し方を知るために、個別のAPI仕様を参照する場面は残る。
|
||||
|
||||
OpenAPIなどで操作やSchemaを機械可読に記述することもできる。それでも、APIを記述できることと、実行時に存在する対象を共通の方法で辿り、その対象が公開する操作を取得できることは同じではない。WIPでは、通信形式やAPI定義に加え、対象の発見から操作までを共通のモデルとして扱いたい。
|
||||
|
||||
HTTPが不足しているというのは、その用途に対して通信機能が足りないという意味ではない。HTTPの契約だけでは決まらない、アプリケーション操作の契約が必要だということである。WIPも、通信にはHTTPを利用できる。
|
||||
|
||||
## REST
|
||||
|
||||
RESTは、通信上の呼び出しを並べるだけの方式ではない。資源の識別、representationを通した操作、自己記述的なメッセージ、hypermediaによる状態遷移などを、統一インタフェースの制約として扱う。リンクや操作の手掛かりを辿って利用を進める考え方も含んでいる。
|
||||
|
||||
したがって、WIPの動機を「RESTでは対象を発見できない」「RESTでは意味的操作を表現できない」と説明するのは適切ではない。Issueを閉じることも、資源の状態変更や関連する資源への操作として表現できる。
|
||||
|
||||
一方、その表現をClientがどう理解するかには、media typeやドメイン固有の契約が必要になる。RESTの制約を採用することだけで、任意の対象に対するOperation名、引数と結果の型、操作の説明、それらを取得する手順まで、一つの共通モデルに決まるわけではない。
|
||||
|
||||
WIPでは、この部分をObjectとInterfaceの契約として明示したい。資源をどうHTTPへ対応付けるかに先立って、何が操作対象で、どの操作を持ち、その宣言をどう取得するかを定める。RESTで同様の仕組みを実装できないのではなく、WIPが求める共通化には、その仕組み自体の定義が必要になる。
|
||||
|
||||
## システムコール
|
||||
|
||||
システムコールは、プロセスからOSの機能を利用するための共通インタフェースを提供する。同じOSの契約に従えば、アプリケーション内部の実装が違っても、ファイル、プロセス、メモリなどを共通の操作で扱える。
|
||||
|
||||
この抽象化の対象は、OSが提供する機能である。Issueを閉じる、Pull Requestをmergeする、Agentへ問い合わせるといったアプリケーション固有の操作まで、共通のシステムコールとして公開されるわけではない。システムコールを使って通信やプロセス間連携を実装しても、その先の操作の意味は別途定める必要がある。
|
||||
|
||||
WIPで共通化したいのは、OS資源へのアクセスより上の、アプリケーションが公開する対象と操作である。対象の実装が同じプロセス内にあるか、別のserviceにあるかによらず、Clientから同じモデルで扱えることを目指す。
|
||||
|
||||
## Filesystem
|
||||
|
||||
Filesystemは、異なる資源を単一の名前空間へ配置し、少数の操作で探索するためのモデルになる。Pathを辿って対象へ到達できることは、対象ごとの呼出先を最初からすべて知っている方式とは異なる。
|
||||
|
||||
しかし、file / directoryとread / writeを中心にしたモデルだけでは、アプリケーション固有の操作を自然に表しにくい。Issueの内容をfileとして読めても、それだけでは`Issue.close()`の引数や意味は分からない。特殊なファイルへの書き込みなどで操作を表現することはできるが、その規約を別途理解する必要がある。
|
||||
|
||||
WIPは、名前空間を辿って対象を見つける性質を使いつつ、対象の操作をread / writeに限定しない。`Issue.close()`、`PullRequest.merge()`、`Agent.ask()`のような操作を、対象が公開するInterfaceの中に、それぞれの名前と型を持つOperationとして表す。
|
||||
|
||||
## WIPが共通化する範囲
|
||||
|
||||
HTTP API、REST、システムコール、Filesystemは、それぞれ異なる範囲を抽象化している。WIPはそれらをまとめて置き換えるのではなく、アプリケーションの操作対象、配置、利用可能な操作、引数と結果の型を、ClientとHostの間の共通契約にする。
|
||||
|
||||
Hostは、その背後でHTTP APIを呼んでも、Databaseへ問い合わせても、Filesystemへアクセスしてもよい。Clientに対しては、それらの内部構成とは独立に、対象の発見・観測・Operation実行を公開する。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 1.3. WIPの設計方針
|
||||
|
||||
## 対象を見つけ、その対象の操作を知る
|
||||
|
||||
WIPでは、アプリケーションが公開する対象を辿り、各対象で利用できる操作を確認してから実行する。操作の一覧だけを独立して渡すのではなく、操作がどの対象に属するかも共通のモデルで表す。
|
||||
|
||||
対象の配置を表すTreeをWorldspace、その中で観測・操作できる対象をObjectと呼ぶ。Objectは公開するInterfaceへの参照を持ち、自身の短い説明も公開できる。InterfaceにはOperationとその引数・結果の型を宣言し、用途を判断するための説明を添えられる。説明の公開は必須ではない。
|
||||
|
||||
この分離によって、対象を探索するときには配置と公開情報を使い、操作を選ぶときには必要なInterfaceの宣言を使える。すべての対象の本文や、すべてのOperationの詳細を、探索の開始時にまとめて取得する必要はない。
|
||||
|
||||
## ドメイン固有の操作を残す
|
||||
|
||||
共通化するために、すべての操作をread / writeへ変換するわけではない。Issueを閉じるなら`close(reason)`というOperationを公開できる。本文の取得は`read_body()`、metadataの取得は`get_metadata()`として表せる。
|
||||
|
||||
これらのOperationの業務上の意味は、アプリケーションが定める。WIPが共通にするのは、Operationを宣言し、取得し、型に従って呼び出し、結果を受け取る方法である。Filesystem、Issue Tracker、Databaseなどの全操作を、単一の巨大な標準APIへ収めることは目指さない。
|
||||
|
||||
同じ操作定義を複数のObjectで使う場合は、Interfaceとして共有する。一つのObjectが複数のInterfaceを公開することもできる。対象ごとに操作定義を重複させず、かつ、その対象がどの機能を公開するかを明示できる。
|
||||
|
||||
## 共通モデルと提示方法を分ける
|
||||
|
||||
Hostは、対象とInterfaceを共通のProtocol dataとして公開する。Clientはそれを取得・検証し、人間向けのView、AI向けのTools、プログラム向けのAPIへ投影する。
|
||||
|
||||
人間向けには対象の配置や操作を画面へ表示し、AI向けには説明とsignatureを提示し、プログラム向けにはmethod呼び出しとして扱うことができる。表示の形が違っても、参照するObjectとInterfaceの契約は共有できる。
|
||||
|
||||
観測のcache、対象の同一性の追跡、観測時と操作時の対応関係の検証はClientが担う。利用者にProtocolのvalidatorを取得・更新させることを前提にしない。共通化のために、通信や整合性管理の詳細まで各利用者へ直接見せる必要はない。
|
||||
|
||||
## 環境全体を事前に列挙しない
|
||||
|
||||
アプリケーションが扱う対象は、実行時に増減し得る。Worldspace全体を事前にmaterializeしたり、すべてのObjectを常駐させたりすることは要求しない。Hostは必要な範囲をlazy / virtualなTreeとして公開できる。
|
||||
|
||||
ただし、遅延して列挙できることと、その集合をTreeへ展開するのが適切であることは別である。機械的な探索で辿る範囲は、Hostがindexableとして公開した親子関係に限る。大量の検索結果や動的Objectは、Operationの結果から発見できる。
|
||||
|
||||
Operation Resultには、型付きの値として未知の対象のpathを含められる。Clientは必要な対象を選んで観測し、保持できる。探索を始める前にすべての対象を知っている必要も、発見した対象をすべて即時に取得する必要もない。
|
||||
|
||||
## AIのTool利用に現れる課題
|
||||
|
||||
個別の機能をそれぞれToolとして登録する方式では、統合先や公開機能が増えると、提示するTool SchemaとAction Spaceも増えていく。Tool一覧を提示するだけでは、その環境にどの対象が存在し、何に対して操作するのかを把握する手段が別に必要になる。
|
||||
|
||||
WIPのAI向けインターフェースでは、配置を見る`tree`、対象の説明と操作を確認する`inspect`、選んだ操作を実行する`invoke`を使う。新しいOperationを発見しても、それを別のToolとして登録するのではなく、同じ`invoke`から呼び出す。
|
||||
|
||||
この方式でも、個々のOperationの意味や引数を理解する必要は残る。基礎Toolの数を固定するだけで、操作の選択や判断が自動的に正しくなるわけではない。違いは、操作定義を最初から一括して提示することを前提にせず、対象を探索しながら必要な操作を取得できる点にある。
|
||||
|
||||
Tool SelectionからWorld Explorationへ問題の扱い方を変えることは、この共通モデルをAIへ提示したときの狙いである。WIP全体の動機は、AI専用のToolを減らすことよりも広い。人間やシステムも同じ対象と操作の契約を使えるようにする。
|
||||
@@ -0,0 +1,67 @@
|
||||
# 1.4. WIPの概念と適用範囲
|
||||
|
||||
## WorldspaceとObject
|
||||
|
||||
WIPは、任意のデジタル環境を、ObjectのTreeであるWorldspaceとして公開する。Tree上に配置され、Pathで到達できるObjectをエントリと呼ぶ。Objectとエントリは、別のスキーマではない。
|
||||
|
||||
各Objectは、短いdescription、Interfaceへの参照、必要に応じて`ref`を公開できる。Tree上では子Objectを持つこともできる。本文やmetadataなどのドメインデータは常時公開するのではなく、Operationを通して取得・操作することを基本とする。
|
||||
|
||||
Pathは、現在のWorldspace上の配置位置を表す。同一Objectが複数のエントリとして現れてもよいため、PathをObjectのidentityとはみなさない。Hostは、同じ主体に公開した一つのWorldspace内での同一性を、optionalな`ref`によって示せる。
|
||||
|
||||
Treeの親子関係は所有・内包を表す。所有関係にない意味的な関連には、その関連を表す名前付きOperationを使い、関連エントリのpathを返す。これらの関連を、Treeの親子関係へ無理に押し込まない。
|
||||
|
||||
## InterfaceとOperation
|
||||
|
||||
ObjectはOperation定義を直接埋め込まず、0個以上のInterfaceを参照する。実装コード上のInterface Declarationから、機械可読なInterface Descriptorを生成する。各Interfaceに宣言された関数の集合が、再利用可能なcapability unitになる。
|
||||
|
||||
複数のObjectが同じInterfaceを共有でき、一つのObjectも複数のInterfaceを組み合わせて公開できる。Operation callでは、対象Objectが現在公開している集合から一つのInterfaceを明示して実行する。
|
||||
|
||||
Operationには、名前や型に加え、用途を判断するための短い説明を任意で添えられる。長文documentationや大量のドメインデータをInterfaceへ埋め込むことは想定しない。Objectも、受動的なJSON recordとしては扱わない。本文の取得、検索、状態変更はいずれもOperationとして表す。
|
||||
|
||||
## ClientとHost
|
||||
|
||||
Hostは、主体に応じたWorldspaceを公開し、Operationを実行する。Clientは、Hostから得たObjectとInterfaceを、View、Tool、APIなどとして利用者へ提示する。同じモデルを使っても、どの情報をどの順序で表示するか、どの取得を先行して行うかは、用途に応じて選べる。
|
||||
|
||||
Protocolの基本的なinteractionは、Objectを観測する`observe`、Interface Descriptorを取得する`fetch_interface`、Operationを実行する`call_operation`である。これらの具体的なrequest / responseは[3. Protocol](3-protocol.md)、上位の提示やcacheの責務は[4. クライアント](4-client.md)に従う。
|
||||
|
||||
AI向けの`tree` / `inspect` / `invoke`は、Clientが提供する上位インターフェースであり、Protocol interactionをそのままTool名にしたものではない。他のClientが同じ呼び出し構文を使う必要はない。
|
||||
|
||||
## 発見と既知世界
|
||||
|
||||
Clientは、発見して取得したエントリをKnown Spaceとして保持できる。既知のエントリをClient側で辿り、Operation結果から得たエントリを追加すれば、さらに探索を進められる。
|
||||
|
||||
ただし、保持したエントリすべてを、機械的な広域探索の対象にするわけではない。Hostがindexableとして公開した親子関係を観測して得た範囲を、Discoverable Spaceとして扱う。Operationから発見したpathだけを根拠に、Treeの親子関係を追加しない。
|
||||
|
||||
この分離によって、大量の検索結果や動的Objectが自動的に探索indexへ流入するのを防ぐ。HostはWorldspace全体を事前にmaterializeせず、必要な範囲を動的に公開できる。
|
||||
|
||||
## Operation Resultと再観測
|
||||
|
||||
RAG検索のResult群や、Agentへの問い合わせで得られたConversationは、Operation Resultから発見できる。Resultは型付きの値であり、Interface Descriptorの`entry`型として、未観測だったエントリのpathを返せる。
|
||||
|
||||
一方、OperationがHost背後の環境を変更しても、その状態遷移や変更差分がClientへ同期されるわけではない。変更後の状態が必要なら、Clientは返されたpathを取得するなどして改めて観測する。
|
||||
|
||||
観測したことと、現在の操作が許可されることも別である。Operationのauthorizationと実行条件は、Hostが実行時に評価する。
|
||||
|
||||
## Narrow Waist
|
||||
|
||||
Filesystem、GitHub、RAG、DBを同じWorldspaceへ投影しても、それらの内部APIまで一つにする必要はない。WIPは、発見・観測・Operation実行という小さなInteraction Modelを共通化する。
|
||||
|
||||
Host内部のAPI、DB、RPC、永続化方式は規定しない。TransportもProtocolのlogical data modelから分離する。人間向けの画面、AI向けのTools、プログラム向けのAPIと、Host内部の実装の間に、共通の対象・操作モデルを置く。
|
||||
|
||||
## 状態管理の非目標
|
||||
|
||||
Known SpaceとDiscoverable Spaceは、過去の観測から構成されたcacheである。Host上の現在状態を同期したreplicaではなく、HostにClientのcache状態を追跡・更新することも要求しない。
|
||||
|
||||
WIPは、Host上の任意状態をClientへ同期するdata store protocolではない。Object propertyのreplication、change feed、reactive subscription、分散transaction、domain event deliveryは、Core Modelの規定対象外とする。
|
||||
|
||||
## Post-trainingとの仮説
|
||||
|
||||
共通の操作モデルをAIへ提示することには、学習上の仮説もある。Tool Useが事後学習の対象になるなら、個別Tool名の使い方に代えて、Worldspace上でのinteraction policyを学習できる可能性がある。
|
||||
|
||||
1. 既知の世界を探索する。
|
||||
2. 対象の意味とOperationを理解する。
|
||||
3. 必要ならOperationから未知のエントリを発見する。
|
||||
4. Operationを実行する。
|
||||
5. 必要ならOperation Resultやentry pathをもとに対象を再観測する。
|
||||
|
||||
この探索の仕方を学習できれば、未知のToolを使うだけでなく、未知のDigital Environmentを探索できるモデルを作れるのではないか。ただし、これは学習に関する仮説であり、WIPを採用しただけで得られる能力の保証ではない。人間・AI・システムに共通の操作モデルを提供するという目的は、この仮説の成立を前提にしない。
|
||||
+33
-58
@@ -1,80 +1,66 @@
|
||||
# 2. コアモデル
|
||||
|
||||
WIP Core Modelは、ClientがHostの提供するWorldspaceを観測し、Objectを発見し、Interfaceに従って操作するための意味論を定義する。
|
||||
Clientは、Hostが提供するWorldspaceからObjectを発見し、Interfaceに従って操作する。WIP Core Modelは、その観測と操作の意味論を定義する。
|
||||
|
||||
具体的なrequest / response structは[3. Protocol](3-protocol.md)、Transport Bindingの共通要件は[5. Transport Bindings](5-transports.md)、HTTPとJSONへの対応付けは[5.1. WIP over HTTP](5.1-wip-over-http.md)で定義する。
|
||||
|
||||
## 基本構造
|
||||
|
||||
Worldspaceは、Clientが探索できる**ObjectのTree**として公開される。
|
||||
Worldspaceは、Clientが探索できるObjectのTreeである。Treeの途中にあるNodeも操作対象になってよい。例えば、`articles`というObjectに集合を扱う`query`等のOperationをInterfaceとして公開し、その配下へ個別Articleを配置できる。
|
||||
|
||||
WIPではObjectとエントリを別の構造として定義しない。**ObjectをWorldspaceのTree上の位置として扱うとき、そのObjectをエントリと呼ぶ。**
|
||||
|
||||
したがって、ObjectはTree上で親子関係を持ち得るし、途中のNodeもそのまま操作対象になってよい。例えば`articles`は対象の集合を扱うObjectとして、そのInterfaceに`query`等を宣言し、配下に個別Articleを配置できる。
|
||||
ObjectをTree上の位置として扱うとき、そのObjectを**エントリ**と呼ぶ。Objectとエントリを別の構造として定義するわけではない。
|
||||
|
||||
## ClientとHost
|
||||
|
||||
WIP上の主要な実装主体は**Client**と**Host**である。
|
||||
Worldspaceを提供するのがHostであり、それをユーザーまたはAIへ提示するのがClientである。
|
||||
|
||||
### Client
|
||||
|
||||
Clientは、Hostが提供するWorldspaceをユーザーまたはAIへ提示する。
|
||||
|
||||
Hostから取得したObjectとInterface Descriptorをcacheし、`ref`によるObject同一性の追跡、観測時と操作時の対応関係の検証、GUI、AI用Tools、PTC / REPL向けAPI等への投影を行う。
|
||||
ClientはHostから取得したObjectとInterface Descriptorをcacheする。その観測を使って、`ref`によるObject同一性の追跡、観測時と操作時の対応関係の検証、GUI、AI用Tools、PTC / REPL向けAPI等への投影を行う。
|
||||
|
||||
### Host
|
||||
|
||||
Hostは、Clientに対して一つのWorldspaceを提供する。
|
||||
Hostは、Clientに対して一つのWorldspaceを提供する。どのObject、エントリ、Interface、可視性を公開するかを決め、そのClientから見えるWorldspaceを構成する。
|
||||
|
||||
どのObject、エントリ、Interface、可視性を公開するかを決定し、そのClientから見えるWorldspaceを構成する。
|
||||
|
||||
Host内部でGitHub API、Database、Filesystem、RAG、他Agent、別protocol等をどう接続、集約、永続化するかはWIPの規定対象外とする。
|
||||
|
||||
ClientとHostの境界は通信境界であると同時に、ある主体へどのWorldspaceを公開するかを決めるtrust boundaryである。
|
||||
内部でGitHub API、Database、Filesystem、RAG、他Agent、別protocol等をどう接続、集約、永続化するかは、WIPの規定対象外である。ClientとHostの境界は通信境界であると同時に、ある主体へどのWorldspaceを公開するかを決めるtrust boundaryでもある。
|
||||
|
||||
## Object / エントリ
|
||||
|
||||
**Object**は、Worldspace上で観測および操作できる対象のprotocol-level projectionである。
|
||||
Objectは、Worldspace上で観測・操作できる対象をProtocolとして表現したものである。探索に必要な公開情報とInterface参照の集合を持つが、Host内部のdomain entityやApplication dataをそのまま公開する必要はない。本文、状態、domain metadata等は、現在公開しているInterfaceのOperationを通して取得・変更する。
|
||||
|
||||
Objectは、探索に必要な公開情報とInterface参照の集合を持つ。Host内部のdomain entityやApplication dataをそのままObjectとして公開する必要はない。Objectの本文、状態、domain metadata等は、現在公開されているいずれかのInterfaceに宣言されたOperationを通して取得または変更する。
|
||||
そのObjectをTree上の位置として見たときの呼び名が、エントリである。Protocolの交換形式に「Objectへの参照を別途持つエントリ」という二重構造を要求しない。ただし、Client内部のcacheでは、Objectをflat storeへnormalizeし、Entry Treeから内部keyで参照してよい。
|
||||
|
||||
**エントリ**は、そのObjectをTree上の位置として見たときの呼び名である。
|
||||
Tree上の位置を示すpathは、rootから各Objectの`name`を順に`/`で連結して構成する。同じ親の直下では`name`は一意であり、非root Objectの`name`は空であってはならない。
|
||||
|
||||
エントリがObjectへの参照を別途持つ、という二重構造はProtocolの交換形式として前提としない。Clientはcache実装上、Objectをflat storeへnormalizeし、Entry Treeから内部keyで参照してよい。
|
||||
|
||||
Tree上の位置はpathで表される。Pathはrootから各Objectの`name`を順に`/`で連結して構成する。Pathは恒久的なIdentityではなく、その時点でのnavigation coordinateである。
|
||||
|
||||
同じ親の直下ではObjectの`name`は一意であり、非root Objectの`name`は空であってはならない。同じObjectが複数の位置に現れる場合、そのObjectは同じ`name`を持ち、異なる親を経由する複数のpathで観測され得る。Objectの公開情報、name、path、Tree上の配置については[2.1. Object tree / Schema](2.1-object-tree-schema.md)で扱う。
|
||||
同じObjectが複数の位置に現れる場合、そのObjectは同じ`name`を持ちながら、異なる親を経由する複数のpathで観測され得る。Pathは恒久的なidentityではなく、その時点でのnavigation coordinateである。公開情報、name、path、配置の詳細は[2.1. Object tree / Schema](2.1-object-tree-schema.md)で定義する。
|
||||
|
||||
## Interface
|
||||
|
||||
ObjectはOperation定義を直接持たず、0個以上のInterface参照をordered setとして持つ。
|
||||
ObjectはOperation定義を直接持たず、0個以上のInterface参照をordered setとして持つ。Interfaceは、Operation、parameter、return type、documentationをまとめた、再利用可能なcapability unitである。
|
||||
|
||||
各Interfaceは、Objectで利用できるOperation、parameter、return type、documentationを定義する再利用可能なcapability unitである。Interface実装者はRustのtraitやTypeScriptのdefinition API等を使って実装コード上でInterfaceを宣言し、Libraryはその宣言からmachine-readableなInterface Descriptorを生成する。
|
||||
実装者はRustのtraitやTypeScriptのdefinition API等でInterfaceを宣言し、Libraryがその宣言からmachine-readableなInterface Descriptorを生成する。複数のObjectが同じInterfaceを共有でき、一つのObjectも複数のInterfaceを組み合わせて公開できる。Operation定義を各Objectへ重複して持たせずに済み、ClientもDescriptorを参照単位でcacheできる。
|
||||
|
||||
複数のObjectが同じInterfaceを共有でき、一つのObjectも複数のInterfaceを組み合わせて公開できる。ObjectとInterfaceを分離することで、Operation定義を各Objectへ重複して持たせず、ClientがInterface Descriptorを参照単位でcacheできる。
|
||||
Interface集合のmembershipはset semanticsを持ち、同じ参照を重複して含めない。列挙順はOperation解決には影響しないが、Hostが公開した順序としてUI表示に使える。
|
||||
|
||||
Interface集合のmembershipはset semanticsを持ち、同じ参照を重複して含めない。列挙順はOperation解決には影響しないが、HostがInterfaceを公開した順序としてUI表示に使用できる。
|
||||
|
||||
InterfaceとOperationの意味論は[2.2. Operation and Interface](2.2-operation-and-interface.md)、Descriptorのlogical structは[3. Protocol](3-protocol.md)、AI向け操作は[4.1. AI Tools](4.1-ai-tools.md)、人間とAIへのsignature表記は[4.2. Text View](4.2-text-view.md)で扱う。
|
||||
InterfaceとOperationの意味論は[2.2. Operation and Interface](2.2-operation-and-interface.md)、Descriptorのlogical structは[3. Protocol](3-protocol.md)、AI向け操作は[4.1. AI Tools](4.1-ai-tools.md)、人間とAIへのsignature表記は[4.2. Text View](4.2-text-view.md)で定義する。
|
||||
|
||||
## Core interaction
|
||||
|
||||
Clientは、次の3操作によってWorldspaceを観測および操作する。
|
||||
Worldspaceの観測と操作には、次の3操作を使う。
|
||||
|
||||
- `observe(path, depth)` — 指定pathのObjectと、指定した深さまでのindexableな子Objectを一つの観測として取得する。`depth = 0`では対象Objectだけを取得する。
|
||||
- `fetch_interface(interface)` — Interface参照から現在のInterface Descriptorとoptionalなvalidatorを取得する。
|
||||
- `call_operation(target, interface, operation, arguments)` — `path`とoptionalなObject validatorを持つtarget、およびObjectが現在公開する一つのInterface observationを指定してOperationを実行する。
|
||||
|
||||
この節は各操作の意味だけを定める。Parameterとreturn valueの具体的なstructはProtocolで定義する。
|
||||
各parameterとreturn valueの具体的なstructは、Protocolで定義する。
|
||||
|
||||
複数の`observe`や`fetch_interface`は、それぞれ独立したCore interactionである。Clientは必要なinteractionを個別に並行実行でき、Transport実装は複数のexchangeを一つのconnection上でmultiplexできる。並行実行やmultiplexingによって新しいCore interaction、request間のdata dependency、共通snapshot、または暗黙の先読みを導入しない。
|
||||
複数の`observe`や`fetch_interface`は、それぞれ独立したCore interactionである。Clientは個別に並行実行でき、Transport実装は複数のexchangeを一つのconnection上でmultiplexできる。ただし、並行実行やmultiplexingによって、新たなCore interaction、request間のdata dependency、共通snapshot、暗黙の先読みを導入しない。
|
||||
|
||||
## `ref`
|
||||
|
||||
Objectは、同じ主体に公開された一つのWorldspace内でObjectの同一性を示すために、optionalな`ref`を公開できる。
|
||||
異なるpathで観測したObjectが同じ対象を表す場合、Hostはoptionalな`ref`でその同一性を示せる。有効範囲は、同じ主体に公開された一つのWorldspace内である。
|
||||
|
||||
`ref`はopaque stringであり、Objectのprimary key、永続的なUID、navigation coordinateではない。Backendに適切なUIDがある場合、Hostはそれを直接または変換して使用できるが、WIPはbackend data形式から得られるUIDの公開を要求しない。
|
||||
`ref`はopaque stringであり、Objectのprimary key、永続的なUID、navigation coordinateではない。Backendに適切なUIDがあれば、Hostは直接または変換して使用できるが、WIPはbackend dataから得られるUIDの公開を要求しない。
|
||||
|
||||
- 同じWorldspace内で同じ有効な`ref`を持つObjectは、同じObjectを表す。
|
||||
- Hostは同じ有効な`ref`を異なるObjectへ付与してはならない。
|
||||
@@ -85,19 +71,15 @@ Objectは、同じ主体に公開された一つのWorldspace内でObjectの同
|
||||
- Session、再接続、Host restart、Worldspace再構築をまたぐ永続性を要求しない。
|
||||
- `ref`からObjectを取得するinteractionは提供しない。Objectの取得にはpathを使用する。
|
||||
|
||||
`ref`は完全なidentity systemではなく、Hostが明示した場合だけ複数の観測またはエントリを同一Objectとしてcorrelateするためのmarkerである。
|
||||
つまり、`ref`によって複数の観測やエントリを同一Objectとして対応付けられるのは、Hostがその同一性を明示した場合だけである。完全なidentity systemではない。
|
||||
|
||||
## 観測整合性
|
||||
|
||||
エントリ自体をObjectとは別実体として扱う必要はない。ただし、あるpath上のObjectを観測した後、Operationを実行するまでの間に、そのpathが別のObjectを指すことはあり得る。
|
||||
AIが`/issues/current`を観測したときにはIssue Aだったのに、`invoke`時にはIssue Bへ差し替わっていることがある。そのまま実行すれば、Issue Aを操作するつもりでIssue Bへ作用してしまう可能性がある。
|
||||
|
||||
その場合、Client / Runtimeは、以前のObjectを対象としたOperationを新しいObjectへ黙って適用してはならない。実行前に観測時の対応関係を検証し、変化していればOperationを中止する。この性質を**observation consistency**と呼ぶ。
|
||||
Client / Runtimeは、以前のObjectを対象としたOperationを、新しいObjectへ黙って適用してはならない。実行前に観測時の対応関係を検証し、変化していれば中止する。この性質を**observation consistency**と呼ぶ。エントリをObjectとは別実体にする必要はないが、観測から実行までの変化は扱う必要がある。
|
||||
|
||||
例えば、AIが`/issues/current`を観測した時点ではIssue Aだったが、`invoke`時にはIssue Bへ差し替わっていた場合、AIはIssue Aへ作用するつもりでIssue Bを操作してしまう可能性がある。
|
||||
|
||||
この観測整合性は利用者に追跡させるのではなく、Clientの責務とする。
|
||||
|
||||
処理の流れは次の通り。
|
||||
観測整合性の管理は利用者ではなくClientの責務である。
|
||||
|
||||
1. Clientがpath上のObjectを観測する。
|
||||
2. Objectがvalidatorを持つ場合、ClientはObjectとrequest pathを対応付けて保持する。
|
||||
@@ -105,33 +87,26 @@ Objectは、同じ主体に公開された一つのWorldspace内でObjectの同
|
||||
4. 同じObjectへ条件付きで操作する場合、Clientは`path`とObjectの`validator`をtargetとしてHostへ渡す。
|
||||
5. Hostは現在のvalidatorとの一致確認とOperation実行をatomicに行う。
|
||||
|
||||
ValidatorはClientとHostの間で扱い、AIやユーザーに直接管理させない。Transport Bindingはvalidatorを対応するProtocol fieldとして保持し、HTTP ETag等のrepresentation metadataで置き換えない。
|
||||
ValidatorはClientとHostの間で扱い、AIやユーザーに直接管理させない。Transport Bindingも対応するProtocol fieldとして保持し、HTTP ETag等のrepresentation metadataで置き換えない。
|
||||
|
||||
Validatorが一致しない場合、Hostは`validator_mismatch`を返してOperationを実行しない。同じOperationを再試行するなら、先に新しい観測を取得しなければならない。
|
||||
Validatorが一致しなければ、Hostは`validator_mismatch`を返し、Operationを実行しない。同じOperationを再試行するには、先に新しい観測を取得しなければならない。
|
||||
|
||||
## 同一性と観測整合性の分離
|
||||
|
||||
WIPでは次の二つを分けて扱う。
|
||||
`ref`は、複数の観測またはエントリが同じObjectを表すことを明示するmarkerである。一方、validatorは、条件付きOperationにおいてHostが定める現在状態との一致を検証するための値である。
|
||||
|
||||
- **Object identity (`ref`)** — 一つのClient-facing Worldspace内で、複数の観測またはエントリが同じObjectを表すことを明示するoptionalなmarker。
|
||||
- **Observation consistency** — `ref`の有無にかかわらず、観測したpath上のObjectと操作対象のObjectが一致していることを保証する仕組み。
|
||||
|
||||
したがって、`ref`を持たないObjectであっても安全な操作対象になり得る。`ref`は同一性を公開する仕組みであり、validatorはHostが定める現在状態との一致を条件付きOperationで検証する仕組みである。
|
||||
この二つを区別することで、`ref`のないObjectも安全な操作対象になり得る。Object identityの公開はoptionalであり、観測したpath上のObjectと操作対象のObjectの一致を保証するobservation consistencyは、`ref`の有無とは別に扱う。
|
||||
|
||||
## Application data
|
||||
|
||||
WIPは、Host上の任意状態をClientへ同期するdata store protocolではない。Object propertyのreplication、change feed、reactive subscription、分散transaction、domain event deliveryはCore Modelの規定対象外とする。
|
||||
Operationが交換するApplication dataについて、WIPが規定するのは、Interfaceに宣言された型、wire encoding、boundary validationである。値のdomain上の意味、保存方法、business validation、Client内部での利用方法はApplicationの責務とする。
|
||||
|
||||
Operationが交換するApplication dataについて、WIPはInterfaceに宣言された型、wire encoding、boundary validationを規定する。値のdomain上の意味、保存方法、business validation、Client内部での利用方法はApplicationの責務である。
|
||||
ClientのKnown SpaceとDiscoverable Spaceは、過去の観測から構成されたcacheであり、Hostの現在状態を同期したreplicaではない。HostにClientのcache状態を追跡・更新することは要求しない。
|
||||
|
||||
Clientが保持するKnown SpaceとDiscoverable Spaceは、過去の観測から構成されたcacheであり、Host上の現在状態を同期したreplicaではない。HostがClientのcache状態を追跡または更新することを要求しない。
|
||||
|
||||
この責務境界はObject、Interface、Operationの各節で一貫して適用する。
|
||||
WIPは任意状態を同期するdata store protocolではないため、Object propertyのreplication、change feed、reactive subscription、分散transaction、domain event deliveryもCore Modelの規定対象外とする。この責務の分担は、Object、Interface、Operationに共通する。
|
||||
|
||||
## Transportとの分離
|
||||
|
||||
Core ModelとProtocol structは、HTTP、TCP、WebSocket等の特定transportを要求しない。
|
||||
Core ModelとProtocol structは、HTTP、TCP、WebSocket等の特定transportを要求しない。通信方式、concrete encoding、endpointへの対応付けはTransport Bindingとして定義する。
|
||||
|
||||
通信方式、concrete encoding、endpointへの対応付けはTransport Bindingとして定義する。共通要件は[5. Transport Bindings](5-transports.md)、標準bindingは[5.1. WIP over HTTP](5.1-wip-over-http.md)で扱う。
|
||||
|
||||
この分離により、Worldspaceの意味論、Client / Host間のlogical contract、通信方式を独立して進化させる。
|
||||
共通要件は[5. Transport Bindings](5-transports.md)、標準bindingは[5.1. WIP over HTTP](5.1-wip-over-http.md)に定める。Worldspaceの意味論、Client / Host間のlogical contract、通信方式を分け、それぞれを独立して進化させる。
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## Objectの公開情報
|
||||
|
||||
Objectは、Worldspace上の対象を発見し、理解し、操作するためのprotocol-level projectionである。
|
||||
Objectは、Worldspace上の対象をprotocol-level projectionとして公開するものである。Clientはその公開情報を使って対象を発見し、理解し、操作する。
|
||||
|
||||
Objectは少なくとも次の公開情報を持つ。
|
||||
少なくとも次の情報がObjectの公開情報に含まれる。
|
||||
|
||||
- **name** — 親Objectの下でのpath segment。
|
||||
- **description** — Objectが何を表すかを説明する情報。
|
||||
@@ -12,32 +12,32 @@ Objectは少なくとも次の公開情報を持つ。
|
||||
- **ref** — 同じClient-facing Worldspace内で複数の観測またはエントリが同じObjectを表すことを示すoptionalなmarker。
|
||||
- **validator** — HostがObjectの現在状態へ付与する任意のopaque value。
|
||||
|
||||
`interfaces`のmembershipはset semanticsを持ち、同じInterface参照を重複して含めてはならない。集合は空でもよい。列挙順はOperation解決には影響せず、HostがInterfaceを立項した順序としてUI表示に使用できる。
|
||||
`interfaces`のmembershipにはset semanticsを適用し、同じInterface参照を重複して含めてはならない。空の集合も許可する。列挙順はOperation解決には影響しないが、HostがInterfaceを立項した順序としてUI表示に使用できる。
|
||||
|
||||
具体的な型、必須性、resource limitは[3. Protocol](3-protocol.md)で定義する。
|
||||
各情報の具体的な型、必須性、resource limitは[3. Protocol](3-protocol.md)で定義する。
|
||||
|
||||
Objectの本文、状態、domain metadata等のApplication dataを、共通のproperty bagとしてObjectへ格納しない。それらはInterfaceに宣言されたOperationを通して取得または変更する。
|
||||
Objectの本文、状態、domain metadata等のApplication dataは、Interfaceに宣言されたOperationを通して取得または変更する。Objectへ共通のproperty bagとして格納しない。
|
||||
|
||||
`description`は探索時に対象を理解するための短い公開情報であり、長い本文やdomain entityの完全な表現を格納する場所ではない。
|
||||
`description`も、長い本文やdomain entityの完全な表現を格納する場所ではない。探索時に対象を理解するための短い公開情報である。
|
||||
|
||||
### 同じ`ref`を持つObject
|
||||
|
||||
同じClient-facing Worldspace内で同じ`ref`を持つObjectは、一つの共有された現在状態を持つ。同じObjectを異なるpathへ配置したことを理由に、path固有の`name`、`description`、Interface集合を返してはならない。
|
||||
同じClient-facing Worldspace内で同じ`ref`を持つObjectには、一つの共有された現在状態がある。同じObjectを異なるpathへ配置しても、path固有の`name`、`description`、Interface集合を返してはならない。
|
||||
|
||||
- 同じ`ref`と同じvalidator valueを持つObjectは、validatorが存在する場合、`name`、`description`、Interface集合を含むObject representationが一致しなければならない。
|
||||
- 同じ`ref`と異なるvalidatorを持つObjectは、同一Objectの異なる状態として扱う。
|
||||
- Hostは異なるpathから同じ`ref`を観測した場合も、一つの現在状態を一貫して返す。
|
||||
- Clientは最後に成功した観測を、その`ref`を持つObjectの現在状態として扱う。
|
||||
|
||||
同じObjectを異なる親の下へ配置する場合もObjectの`name`は共通である。Pathは親から各Objectの`name`を辿って構成するため、同じObjectは異なる親を経由して異なるpathに現れ得る。
|
||||
異なる親の下へ配置した場合も、同じObjectの`name`は共通である。Pathは親から各Objectの`name`を辿って構成するので、異なる親を経由すれば、同じObjectが異なるpathに現れ得る。
|
||||
|
||||
## Interface参照
|
||||
|
||||
Objectは0個以上のInterfaceを参照する。各Interface Descriptorは、Objectに対して実行可能なOperationの一つのcapability namespaceを宣言する。
|
||||
Objectは0個以上のInterfaceを参照する。Interface Descriptorは、Objectに対して実行可能なOperationを宣言するものであり、一つのcapability namespaceを定義する。
|
||||
|
||||
Operation名は一つのInterface Descriptor内で一意であればよく、異なるInterface間で同じOperation名を使用できる。Operation callはscopeとlocal nameからなるInterface参照を明示するため、Protocol上の解決は曖昧にならない。人間・AI向けのqualified表記は[Text Viewの参照表記](4.2-text-view.md#参照表記)に従う。
|
||||
Operation名は、一つのInterface Descriptor内で一意であればよい。異なるInterface間では同じOperation名を使用できる。Operation callではscopeとlocal nameからなるInterface参照を明示するため、Protocol上の解決は曖昧にならない。人間・AI向けのqualified表記は[Text Viewの参照表記](4.2-text-view.md#参照表記)に従う。
|
||||
|
||||
Interfaceは次を定義する。
|
||||
Interfaceには次の定義を含める。
|
||||
|
||||
- Operation名
|
||||
- Operationの説明
|
||||
@@ -45,31 +45,31 @@ Interfaceは次を定義する。
|
||||
- Return type
|
||||
- Named typeとdocumentation
|
||||
|
||||
Interface実装者は実装コード上でInterfaceを宣言し、Libraryはその宣言からInterface Descriptorを生成する。ClientはObjectの各Interface参照を使ってDescriptorを取得し、検証、cache、表示、Operation callのvalidationに利用する。
|
||||
Interface実装者が実装コード上でInterfaceを宣言すると、Libraryはその宣言からInterface Descriptorを生成する。ClientはObjectの各Interface参照でDescriptorを取得し、検証、cache、表示、Operation callのvalidationに利用する。
|
||||
|
||||
同じInterfaceを持つObjectは同じDescriptor参照を共有できる。
|
||||
同じInterfaceを持つObjectは、同じDescriptor参照を共有できる。
|
||||
|
||||
## Interface scope
|
||||
|
||||
Objectをpath `P`で公開するとき、そのInterface参照のscopeは`P`自身またはその祖先でなければならない。Self-scopeを許可し、rootから`P`までの各pathを含む。Path segmentの完全一致で判定し、単純な文字列prefixで判定しない。本仕様でInterface scopeの「祖先制約」と呼ぶ場合もself-scopeを含む。
|
||||
Objectをpath `P`で公開する場合、Interface参照のscopeは`P`自身か、その祖先でなければならない。Self-scopeも許可するので、rootから`P`までの各pathが候補となる。祖先かどうかはpath segmentの完全一致で判定し、単純な文字列prefixでは判定しない。本仕様のInterface scopeに関する「祖先制約」にはself-scopeも含む。
|
||||
|
||||
例えば`/github/my-rep/issues`は、scope `/`、`/github`、`/github/my-rep`、`/github/my-rep/issues`のInterfaceを公開できる。`/gitlab`、`/github/other-rep`、`/github/my-rep/issues/123`のscopeは使用できない。`/github`は`/github-extra`の祖先ではない。Root Objectはscope `/`のInterfaceを公開できる。
|
||||
例えば、`/github/my-rep/issues`で公開できるのは、scopeが`/`、`/github`、`/github/my-rep`、`/github/my-rep/issues`であるInterfaceである。Scopeが`/gitlab`、`/github/other-rep`、`/github/my-rep/issues/123`であるInterfaceは使用できない。文字列の先頭が一致しても、`/github`は`/github-extra`の祖先にはならない。Root Objectはscope `/`のInterfaceを公開できる。
|
||||
|
||||
この制約は配置pathに基づき、indexableかどうかには依存しない。非indexableなObjectを直接観測した場合も同じ規則を適用する。Scope Objectを先に観測することも、そのObject自身が同じInterfaceを公開することも要求しない。
|
||||
この制約の基準は配置pathであり、Objectがindexableかどうかではない。非indexableなObjectを直接観測した場合にも同じ規則を適用する。Scope Objectを先に観測する必要はなく、そのObject自身が同じInterfaceを公開する必要もない。
|
||||
|
||||
Scopeの適合と`Object.interfaces`への明示的なmembershipは別の条件である。祖先scopeのInterfaceを自動継承せず、scopeをauthorizationの証明として扱わない。
|
||||
Scopeが適合していても、`Object.interfaces`への明示的なmembershipは別に必要である。祖先scopeのInterfaceは自動継承せず、scopeをauthorizationの証明にも使わない。
|
||||
|
||||
ScopeはそのpathにあるObjectに紐づき、Objectとは独立して存続するnamespaceではない。Scope Objectがそのpathから消えれば、そのscopeのInterfaceの公開も終了する。消えたObjectの配下を、親Objectの存在しない孤立したsubtreeとして公開し続けない。別のObjectを同じpathへ公開し直すことはできる。
|
||||
Scopeは、そのpathにあるObjectに紐づく。Objectとは独立して存続するnamespaceではないため、scope Objectがそのpathから消えると、そのscopeのInterfaceの公開も終了する。消えたObjectの配下を、親Objectの存在しない孤立したsubtreeとして公開し続けない。同じpathへ別のObjectを公開し直すことはできる。
|
||||
|
||||
Scope Objectに`ref`があれば、ClientはそのObjectとの紐付きを確認できる。`ref`はoptionalであり、存在しない場合は同じpathでの差し替えをidentityによって追跡できない。これは許可された公開形態であり、Hostに`ref`の生成を強制したり、Clientが代替identityを推測したりしない。参照の有効期間、呼出時の確認、移動とcacheの扱いは[Operation and Interface](2.2-operation-and-interface.md#scope-objectと有効期間)および[Protocol](3-protocol.md#scope-objectの紐付き)に従う。
|
||||
Scope Objectに`ref`があれば、ClientはそのObjectとの紐付きを確認できる。一方、`ref`はoptionalなので、存在しない場合には、同じpathでの差し替えをidentityによって追跡できない。この公開形態も許可されており、Hostに`ref`の生成を強制したり、Clientが代替identityを推測したりしない。参照の有効期間、呼出時の確認、移動とcacheの扱いは[Operation and Interface](2.2-operation-and-interface.md#scope-objectと有効期間)および[Protocol](3-protocol.md#scope-objectの紐付き)に従う。
|
||||
|
||||
## Object / エントリ / path
|
||||
|
||||
WIPでは、Objectとエントリを別のdomain entityとして定義しない。ObjectをTree上の位置として扱うとき、そのObjectをエントリと呼ぶ。
|
||||
ObjectをTree上の位置として扱うときは、そのObjectをエントリと呼ぶ。WIPでは、Objectとエントリを別のdomain entityとしては定義しない。
|
||||
|
||||
### `name`
|
||||
|
||||
Objectの`name`は表示用labelではなく、親Objectの下でそのObjectを識別するpath segmentである。
|
||||
Objectの`name`は、親Objectの下でそのObjectを識別するpath segmentである。表示用labelとは区別し、次の条件を適用する。
|
||||
|
||||
- Root Objectの`name`は空文字列とする。
|
||||
- 非root Objectの`name`は空であってはならない。
|
||||
@@ -80,11 +80,11 @@ Objectの`name`は表示用labelではなく、親Objectの下でそのObjectを
|
||||
- Nameの比較はUnicode code point sequenceの完全一致で行う。
|
||||
- 同じ親の直接のchildrenは、互いに異なる`name`を持たなければならない。
|
||||
|
||||
`name`を人間向けの自由なtitleとして扱わない。探索時の短い説明には`description`を使い、domain上のtitle等はOperationからApplication dataとして取得する。
|
||||
このようにpath segmentとして使う`name`を、人間向けの自由なtitleとして扱わない。探索時の短い説明は`description`で公開し、domain上のtitle等はOperationからApplication dataとして取得する。
|
||||
|
||||
### Pathの構成
|
||||
|
||||
Rootのpathは`/`とする。非root Objectのpathは、rootからそのObjectまでの各`name`を`/`で連結して構成する。
|
||||
Rootのpathは`/`とする。非root Objectのpathは、rootからそのObjectまでの各`name`を`/`で連結する。
|
||||
|
||||
```text
|
||||
root.name = ""
|
||||
@@ -97,18 +97,18 @@ example.name = "example"
|
||||
example.path = "/projects/example"
|
||||
```
|
||||
|
||||
Pathには空segment、`.`、`..`を含めない。Root以外のpathは末尾に`/`を持たない。Nameに`/`を許可しないため、Core Modelではsegment escapeを定義しない。Transport BindingがPathをURL componentへ格納する場合のURL encodingは、WIP Pathの構成とは別に扱う。
|
||||
Pathには空segment、`.`、`..`を含めず、root以外のpathの末尾には`/`を付けない。Nameに`/`を許可しないため、Core Modelではsegment escapeを定義しない。Transport BindingでPathをURL componentへ格納するときのURL encodingは、WIP Pathの構成とは別に扱う。
|
||||
|
||||
非root pathについては、最後のsegmentと取得したObjectの`name`が一致しなければならない。
|
||||
また、非root pathの最後のsegmentは、取得したObjectの`name`と一致しなければならない。
|
||||
|
||||
```text
|
||||
observe("/projects/example", depth = 0)
|
||||
-> ObjectObservation.object.name == "example"
|
||||
```
|
||||
|
||||
同じObjectが複数のpathへ配置される場合、同じ`ref`と`name`を持ち、異なる親を経由して異なるpathに現れる。Objectをrenameすると、そのObjectを参照するすべての配置とdescendantのpathが変化する。Pathは恒久的なObject identityとして使用しない。
|
||||
同じObjectを複数のpathへ配置するときは、各配置で同じ`ref`と`name`を持ち、異なる親を経由して異なるpathに現れる。Renameすると、同じObjectを参照するすべての配置とdescendantのpathが変化する。このため、pathを恒久的なObject identityとして使用しない。
|
||||
|
||||
`ref`、path、Interface参照は異なる役割を持つ。
|
||||
`ref`、path、Interface参照は、それぞれ次の役割を持つ。
|
||||
|
||||
| 値 | 役割 |
|
||||
| --- | --- |
|
||||
@@ -116,54 +116,54 @@ observe("/projects/example", depth = 0)
|
||||
| `ref` | 複数の観測にまたがる任意のObject identity |
|
||||
| Interface参照 | Objectで利用可能なcapability definition |
|
||||
|
||||
通常のstringを内容だけからpathまたは`ref`と推測しない。Operation value内でWorldspace上の位置を返す場合は、Descriptorで`entry`型として宣言する。
|
||||
Operation value内でWorldspace上の位置を返す場合は、Descriptorで`entry`型として宣言する。通常のstringを、内容だけからpathや`ref`と推測しない。
|
||||
|
||||
## Tree
|
||||
|
||||
Objectは子Objectを持つことができる。
|
||||
|
||||
Parent / child関係はWorldspaceにおける**所有または内包**を表す。
|
||||
Objectは子Objectを持つことができる。このparent / child関係は、Worldspaceにおける**所有または内包**を表す。
|
||||
|
||||
- Filesystem: Directory / File
|
||||
- GitHub: Repository / Issue
|
||||
- Database: Database / Table
|
||||
- RAG: Index / Document
|
||||
|
||||
関連、依存、参照等の非所有関係を、Treeの親子関係として濫用しない。そのような関係はOperationのresultとして`entry`を返し、Clientが必要に応じて移動できるようにする。
|
||||
これに対し、関連、依存、参照等の非所有関係をTreeの親子関係として濫用しない。そのような関係はOperationのresultとして`entry`を返し、Clientが必要に応じて移動できるようにする。
|
||||
|
||||
### `children`の意味
|
||||
|
||||
`children`はApplication dataではなく、Objectを指定pathで観測した時点におけるWorldspace上のprojectionである。
|
||||
`children`は、指定pathでObjectを観測した時点のWorldspace上のprojectionである。Application dataには含めない。
|
||||
|
||||
一つのObjectが複数pathへ配置される場合、そのObjectから見えるchildrenは配置ごとに異なり得る。したがって、childrenを`ref`が示すObject identityの一部とはみなさない。
|
||||
一つのObjectを複数pathへ配置した場合、見えるchildrenは配置ごとに異なり得る。そのため、childrenは`ref`が示すObject identityの一部とはみなさない。
|
||||
|
||||
Protocolでは`Object`自体にchildrenとpathを含めず、`observe` responseの`ObjectObservation`としてObjectとoptionalなchildren observationをまとめる。ValidatorはObjectに含まれる。各nodeのpathは、request pathを起点にObjectの`name`を辿って導出する。このwrapperはObjectとは別のdomain entityを導入するものではなく、指定depthまでの観測結果を表すresponse shapeである。
|
||||
Protocolでは、`Object`自体にchildrenとpathを含めない。`observe` responseの`ObjectObservation`で、Objectとoptionalなchildren observationをまとめる。ValidatorはObjectに含め、各nodeのpathはrequest pathを起点にObjectの`name`を辿って導出する。
|
||||
|
||||
`children` fieldが省略されているnodeではchildrenを観測していない。`children` fieldが存在する場合、そのarrayはその主体へ公開された直接のindexable childrenをすべて含み、空arrayは観測時点でindexable childが存在しなかったことを表す。
|
||||
`ObjectObservation`は、指定depthまでの観測結果を表すresponse shapeである。このwrapperによって、Objectとは別のdomain entityを導入するわけではない。
|
||||
|
||||
Nodeの`children` fieldを省略した場合、そのnodeのchildrenは観測していない。Fieldが存在する場合、そのarrayには、その主体へ公開された直接のindexable childrenをすべて含める。空arrayは、観測時点でindexable childが存在しなかったことを表す。
|
||||
|
||||
## Objectの可視性
|
||||
|
||||
Hostは、主体に認識させるObjectだけでClient-facing Worldspaceを構成する。ObjectをWorldspaceへ公開することは、そのpathとObjectの公開情報を主体が認識できることを意味する。
|
||||
Client-facing Worldspaceは、Hostがその主体に認識させるObjectだけで構成する。Objectを公開すると、そのpathとObjectの公開情報を主体が認識できる。
|
||||
|
||||
主体に認識させないObjectは、そのWorldspaceに存在しないものとして扱う。
|
||||
反対に、主体に認識させないObjectは、そのWorldspaceには存在しないものとして扱う。
|
||||
|
||||
- `observe`では、そのObjectと、そのObjectだけを経由して到達するsubtreeをresponseから省略する。
|
||||
- 省略されたObjectやchild数を示すredaction markerは返さない。
|
||||
- 非公開pathを起点とする`observe`には、Host内部での存在理由にかかわらず`NotFound`を返す。Object観測に対する`PermissionDenied`は使用しない。
|
||||
|
||||
Objectを公開しながら、特定Interfaceを集合から除外すること、公開したInterfaceから一部のOperationを除外すること、公開されたOperationの実行を認可時に拒否することはできる。Operationの可視性と`PermissionDenied`の使い分けは[2.2. Operation and Interface](2.2-operation-and-interface.md)で定義する。
|
||||
Objectを公開する場合でも、特定Interfaceを集合から除外したり、公開したInterfaceから一部のOperationを除外したりできる。公開されたOperationの実行を認可時に拒否することもできる。Operationの可視性と`PermissionDenied`の使い分けは[2.2. Operation and Interface](2.2-operation-and-interface.md)で定義する。
|
||||
|
||||
Objectの`description`には、その主体へ公開できる説明だけを含める。安全な説明を公開できない場合はoptionalな`description`を省略できるが、Protocolはfield-level redaction markerを定義しない。Objectの本文やdomain dataの部分的な開示は、各Operationのresult schemaとdomain semanticsで表現する。
|
||||
`description`に含めるのは、その主体へ公開できる説明だけである。安全な説明を公開できない場合は、optionalな`description`を省略できる。ただし、Protocolはfield-level redaction markerを定義しない。本文やdomain dataを部分的に開示する場合は、各Operationのresult schemaとdomain semanticsで表現する。
|
||||
|
||||
## `observe`
|
||||
|
||||
`observe(path, depth)`は、指定pathのObjectと、そのObjectからindexable child edgeを指定depthまで辿った範囲を一つの観測として取得する。
|
||||
`observe(path, depth)`は、指定pathのObjectを起点に、indexable child edgeを指定depthまで辿った範囲を一つの観測として取得する。
|
||||
|
||||
```text
|
||||
observe(path, depth) -> ObjectObservation
|
||||
```
|
||||
|
||||
起点Objectをdepth `0`とする。`depth = 0`では起点Objectだけを返し、そのnodeの`children` fieldを省略する。成功responseはrequested depthまでの公開されたindexable childrenを完全に含み、requested depthより深いObjectを含めない。
|
||||
起点Objectのdepthは`0`である。`depth = 0`では起点Objectだけを返し、そのnodeの`children` fieldを省略する。成功responseには、requested depthまでの公開されたindexable childrenを完全に含め、それより深いObjectは含めない。
|
||||
|
||||
- Nodeのdepthがrequestの`depth`より小さい場合、`children` fieldを必ず含める。
|
||||
- 観測した直接のindexable childが存在しない場合、`children`は空arrayとする。
|
||||
@@ -171,7 +171,9 @@ observe(path, depth) -> ObjectObservation
|
||||
- 同じparent nodeのchildrenは、互いに異なる`name`を持つ。
|
||||
- Clientはrequest pathとchildrenの`name`から各nodeのpathを導出する。
|
||||
|
||||
各ObjectはHostが発行したvalidatorを持ち得るため、Clientは導出したpathとvalidatorから条件付きOperationのtargetを構築できる。Responseを部分的な成功として返す`limit`や`truncated`は定義しない。Requested depthまでの完全な観測をresource budget内で構築できない場合、Hostはsuccess responseの代わりに`ResourceLimitExceeded`を返す。
|
||||
各ObjectはHostが発行したvalidatorを持ち得る。Clientは、導出したpathとvalidatorから条件付きOperationのtargetを構築できる。
|
||||
|
||||
Responseを部分的な成功として返すための`limit`や`truncated`は定義しない。Requested depthまでの完全な観測をresource budget内で構築できない場合、Hostはsuccess responseを返さず、`ResourceLimitExceeded`を返す。
|
||||
|
||||
## Indexable children
|
||||
|
||||
@@ -181,9 +183,9 @@ Tree上のchildrenとして自然に列挙できるのは、**indexableなObject
|
||||
- 比較的安価に取得できる。
|
||||
- 所有または内包関係として自然に理解できる。
|
||||
|
||||
全件列挙が高コストまたは不自然な集合は、Treeへ直接展開しない。対象の集合を扱うObjectのOperationを使って検索、filter、paginationを行い、resultとして関連する`entry`を返す。
|
||||
全件列挙が高コストまたは不自然な集合は、Treeへ直接展開しない。その集合を扱うObjectのOperationで検索、filter、paginationを行い、関連する`entry`をresultとして返す。
|
||||
|
||||
例えば、大規模なIssue collectionは次のように公開できる。参照はscope `/`を含む短縮表記で示す。
|
||||
例えば、大規模なIssue collectionは次の形で公開できる。参照には、scope `/`を含む短縮表記を使う。
|
||||
|
||||
```text
|
||||
/issues
|
||||
@@ -191,12 +193,14 @@ Tree上のchildrenとして自然に列挙できるのは、**indexableなObject
|
||||
operation: /::IssueCollection#query(filter, cursor, limit) -> IssueQueryResult
|
||||
```
|
||||
|
||||
そのquery resultでは、itemsを`entry`のarrayとして返す。
|
||||
|
||||
```text
|
||||
IssueQueryResult.items: [entry]
|
||||
```
|
||||
|
||||
## 複数の取得
|
||||
|
||||
複数のObjectやInterface Descriptorが必要な場合も、各`observe`と`fetch_interface`は独立したCore interactionである。Clientは必要なrequestを個別に並行実行でき、Transport実装はconnection reuseやmultiplexingを利用できる。
|
||||
複数のObjectやInterface Descriptorを取得する場合でも、各`observe`と`fetch_interface`は独立したCore interactionである。Clientは必要なrequestを個別に並行実行できる。Transport実装ではconnection reuseやmultiplexingを利用できる。
|
||||
|
||||
WIPは複数のinteractionを包む共通request envelope、自動的な全量取得、暗黙の再帰展開、Clientが要求していない先読みを定義しない。
|
||||
WIPは、複数のinteractionを包む共通request envelope、自動的な全量取得、暗黙の再帰展開、Clientが要求していない先読みを定義しない。
|
||||
|
||||
@@ -2,52 +2,56 @@
|
||||
|
||||
## Interface
|
||||
|
||||
ObjectはOperation定義を直接持たず、0個以上のInterface参照をordered setとして持つ。各InterfaceはObjectが公開する一つのcapability namespaceである。
|
||||
Objectは、Operation定義を直接持つ代わりに、0個以上のInterface参照をordered setとして持つ。各Interfaceは、Objectが公開する一つのcapability namespaceであり、利用できるOperationと各Operationのparameter、return type、documentationを定義する。
|
||||
|
||||
Interfaceは、Objectで利用できるOperationと、各Operationのparameter、return type、documentationを定義する。複数のObjectが同じInterfaceを共有できるため、Operation定義をObjectごとに重複して送る必要はない。
|
||||
複数のObjectで同じInterfaceを共有できるため、ObjectごとにOperation定義を重複して送る必要はない。ObjectのInterface集合にはset semanticsを適用し、同じ参照を重複して含めない。列挙順はOperation解決には影響しないが、Hostの立項順としてUI表示に使用できる。
|
||||
|
||||
ObjectのInterface集合はset semanticsを持ち、同じ参照を重複して含めない。列挙順はOperation解決には影響しないが、Hostの立項順としてUI表示に使用できる。Operation callは集合から一つのInterfaceを明示して、そのDescriptor内のOperationを実行する。
|
||||
Operation callでは、この集合から一つのInterfaceを明示し、そのDescriptor内のOperationを実行する。
|
||||
|
||||
Interface実装者は、RustのtraitやTypeScriptのdefinition API等を使って実装コード上でInterfaceを宣言する。Libraryはその宣言から、typed handler、runtime validation、およびmachine-readableなInterface Descriptorを生成する。
|
||||
Interface実装者は、RustのtraitやTypeScriptのdefinition API等を使い、実装コード上でInterfaceを宣言する。Libraryはこの宣言から、typed handler、runtime validation、machine-readableなInterface Descriptorを生成する。
|
||||
|
||||
Interface Descriptorは人間が手書きするschema fileではない。Hostが`fetch_interface`で公開し、Clientが参照単位でcacheするprotocol dataである。
|
||||
したがって、Interface Descriptorは人間が手書きするschema fileではない。Hostが`fetch_interface`で公開し、Clientが参照単位でcacheするprotocol dataである。
|
||||
|
||||
## Interfaceの識別とscope
|
||||
|
||||
Interface参照は、Worldspace上の絶対pathである`scope`と、そのscope内のlocal nameである`name`を別fieldとして持つ構造化データである。同じClient-facing Worldspace内の同じ有効な`(scope, name)`は同じInterfaceを表す。
|
||||
Interface参照は、`scope`と`name`を別fieldとして持つ構造化データである。`scope`はWorldspace上の絶対path、`name`はそのscope内のlocal nameを表す。同じClient-facing Worldspace内では、同じ有効な`(scope, name)`が同じInterfaceを表す。
|
||||
|
||||
```text
|
||||
{ scope: "/github", name: "issue.read" }
|
||||
{ scope: "/gitlab", name: "issue.read" }
|
||||
```
|
||||
|
||||
この二つは同じlocal nameを持つが、異なるInterface参照である。Local nameの`.`等に階層やOperationとの区切りの意味を持たせない。同じscope内の異なるInterfaceへ同じnameを割り当てない。複数Providerを同じscopeへ登録する場合も、Hostが名前の衝突を検出・解消する。
|
||||
この二つの参照はlocal nameが同じでも、scopeが異なるため別のInterface参照となる。Local nameの`.`等には、階層やOperationとの区切りの意味を持たせない。同じscope内の異なるInterfaceに同じnameを割り当てない。複数Providerを同じscopeへ登録する場合にも、Hostが名前の衝突を検出・解消する。
|
||||
|
||||
ScopeはClientに対してHostが公開するWorldspaceのrootを基準とする。Backendのpath、filesystem root、HTTP endpointのprefixではなく、相対pathは認めない。異なるWorldspace間で同じ`(scope, name)`を同一Interfaceとして扱わない。
|
||||
Scopeの基準は、HostがClientへ公開するWorldspaceのrootである。Backendのpath、filesystem root、HTTP endpointのprefixを基準にはせず、相対pathも認めない。異なるWorldspaceにある同じ`(scope, name)`を、同一Interfaceとして扱わない。
|
||||
|
||||
Objectが利用できるscopeは、そのObjectの配置pathとの[祖先関係](2.1-object-tree-schema.md#interface-scope)に従う。この制約はInterfaceの自動継承ではない。Objectは使用するInterfaceを明示的に公開し、scopeだけを根拠にOperationを追加したり、実行を認可したりしない。
|
||||
Objectが利用できるscopeは、そのObjectの配置pathとの[祖先関係](2.1-object-tree-schema.md#interface-scope)に従う。ただし、祖先関係だけでInterfaceを自動継承するわけではない。使用するInterfaceはObjectで明示的に公開し、scopeだけを根拠にOperationを追加したり、実行を認可したりしない。
|
||||
|
||||
構造とvalidationは[3. Protocol](3-protocol.md#interface-reference)、`/github::issue.read#read`等の人間・AI向け短縮表記は[4.2. Text View](4.2-text-view.md#参照表記)で定義する。Interface参照とOperation名を連結した表示文字列をProtocolの参照fieldへ格納しない。
|
||||
Protocolの参照fieldへは、Interface参照とOperation名を連結した表示文字列を格納しない。構造とvalidationは[3. Protocol](3-protocol.md#interface-reference)に従う。`/github::issue.read#read`等の人間・AI向け短縮表記は[4.2. Text View](4.2-text-view.md#参照表記)で定義する。
|
||||
|
||||
Hostによるproxy・Worldspace合成では、Hostがscopeを公開先の配置へ変換する。Path、typed `entry`、identity、validatorとの一貫性は[Worldspace composition](3-protocol.md#worldspace-composition)に従う。
|
||||
HostがproxyやWorldspace合成を行う場合は、scopeを公開先の配置へ変換する。その際のpath、typed `entry`、identity、validatorとの一貫性は[Worldspace composition](3-protocol.md#worldspace-composition)に従う。
|
||||
|
||||
## Scope Objectと有効期間
|
||||
|
||||
Scopeは、そのpathにあるObject(scope Object)に紐づく。Object自身のpathをscopeにするself-scopeを許可し、root Objectもscope `/`を利用できる。Scope Object自身がそのscopeの全Interfaceを`interfaces`へ公開する必要はない。
|
||||
Scopeは、そのpathにあるObject(scope Object)に紐づく。Object自身のpathをscopeにするself-scopeも許可し、root Objectはscope `/`を利用できる。Scope Object自身の`interfaces`に、そのscopeの全Interfaceを公開する必要はない。
|
||||
|
||||
Interfaceの識別は`(scope, name)`のままであり、scope Objectの`ref`はその識別子の第三成分ではない。`ref`がある場合は観測したObjectとの紐付きを確認するために用いる。Objectの状態を確認するvalidatorや、Descriptorの版を確認するInterface validatorとは役割が異なる。Scope Objectの通常の状態更新だけを理由に、scopeの紐付きを無効にしない。
|
||||
Scope Objectの`ref`がある場合は、観測したObjectとの紐付きを確認するために用いる。Interfaceの識別子はあくまで`(scope, name)`であり、`ref`を第三成分にはしない。
|
||||
|
||||
Scope Objectがそのpathから削除・移動・差し替えられると、旧Objectに紐づくInterface参照の有効期間は終了する。同じpathへ新しいObjectとInterfaceを公開することは許可するが、旧Objectの登録を暗黙に引き継がない。移動先では新しいscopeによる別の参照となり、同じ`ref`を観測してもClientにInterface参照の移動追跡やcacheの付け替えを要求しない。
|
||||
この`ref`は、Objectの状態を確認するvalidatorとも、Descriptorの版を確認するInterface validatorとも役割が異なる。Scope Objectの通常の状態更新だけを理由に、scopeの紐付きを無効にしない。
|
||||
|
||||
Scope Objectの`ref`はoptionalである。`ref`のないObjectが同じpathで差し替えられても、そのidentityを追跡できないことは仕様上許容される。これを不正なHostとしたり、差し替えの検出を必須にしたりしない。ただし、現在のObjectへのInterface membership、scopeの適合、指定されたvalidator、authorizationは引き続き検証する。存続中の同じInterfaceについて、`ref`がないことを理由にDescriptorのimmutable / validator規則を弱めない。
|
||||
一方、scope Objectがそのpathから削除・移動・差し替えられると、旧Objectに紐づくInterface参照の有効期間は終了する。同じpathへ新しいObjectとInterfaceを公開することは許可するが、旧Objectの登録を暗黙には引き継がない。移動先では新しいscopeによる別の参照となる。同じ`ref`を観測しても、ClientにInterface参照の移動追跡やcacheの付け替えを要求しない。
|
||||
|
||||
Clientはscope Objectがそのpathから消えたことを確認した場合、そのscopeのInterface cacheを破棄できる。Client-localなcache evictionやindexable edgeの消失だけをObject削除の証拠にしない。実行中callは固定済みの観測を使用し、結果検証が終わるまで必要なDescriptorを保持する。削除通知やClientによる常時監視は要求しない。
|
||||
Scope Objectの`ref`はoptionalである。`ref`のないObjectが同じpathで差し替えられた場合、そのidentityを追跡できないことは仕様上許容される。そのHostを不正とみなしたり、差し替えの検出を必須にしたりしない。
|
||||
|
||||
ただし、`ref`の有無にかかわらず、現在のObjectへのInterface membership、scopeの適合、指定されたvalidator、authorizationは検証する。存続中の同じInterfaceでは、`ref`がないことを理由にDescriptorのimmutable / validator規則を弱めない。
|
||||
|
||||
Clientは、scope Objectがそのpathから消えたと確認した場合、そのscopeのInterface cacheを破棄できる。Client-localなcache evictionやindexable edgeの消失だけを、Object削除の証拠にはしない。実行中callでは固定済みの観測を使い、結果検証が終わるまで必要なDescriptorを保持する。削除通知やClientによる常時監視は要求しない。
|
||||
|
||||
具体的な紐付きの観測とcall preconditionは[Protocol](3-protocol.md#scope-objectの紐付き)に従う。
|
||||
|
||||
## Interface Descriptor
|
||||
|
||||
一つのInterface Descriptorは一つのInterfaceを定義し、少なくとも次を保持する。
|
||||
一つのInterface Descriptorは一つのInterfaceを定義し、少なくとも次の情報を保持する。
|
||||
|
||||
- Format version
|
||||
- Interface documentation
|
||||
@@ -56,15 +60,15 @@ Clientはscope Objectがそのpathから消えたことを確認した場合、
|
||||
- Operationごとのparameterとreturn type
|
||||
- Type、field、parameter、case等のdocumentation
|
||||
|
||||
Descriptorのlogical structは[3. Protocol](3-protocol.md)で定義する。Code-firstな生成は本章、人間とAIへのInterface signatureは[4.2. Text View](4.2-text-view.md)で扱う。
|
||||
Descriptorのlogical structは[3. Protocol](3-protocol.md)で定義する。実装コードの宣言から生成するcode-firstの形をとり、人間とAIへのInterface signatureは[4.2. Text View](4.2-text-view.md)に従う。
|
||||
|
||||
Clientは取得したDescriptorを構造検証および意味検証してからcacheする。Named typeの参照解決、Operation callのvalidation、表示生成には検証済みのInterfaceだけを使用する。
|
||||
Clientは、取得したDescriptorの構造と意味を検証してからcacheする。Named typeの参照解決、Operation callのvalidation、表示生成に使うのは、検証済みのInterfaceだけである。
|
||||
|
||||
## Operation declaration
|
||||
|
||||
Operationは、名前付きparameter、return type、documentationを持つ。
|
||||
Operationには、名前付きparameter、return type、documentationを宣言する。
|
||||
|
||||
人間とAIへ提示する場合は、Interface DescriptorからIDL風のInterface signatureを生成できる。以下はOperation部分の表示例である。
|
||||
人間とAIへ提示するときは、Interface DescriptorからIDL風のInterface signatureを生成できる。Operation部分は、例えば次のように表示する。
|
||||
|
||||
```text
|
||||
/// "Returns items related to this Object."
|
||||
@@ -77,61 +81,61 @@ operation get_related_items(
|
||||
};
|
||||
```
|
||||
|
||||
Interface signatureはInterfaceの交換形式や入力言語ではなく、検証済みDescriptorから生成する表示である。Object signatureとInterface signatureの表記は[4.2. Text View](4.2-text-view.md)で定義する。
|
||||
このInterface signatureは、検証済みDescriptorから生成する表示であり、Interfaceの交換形式や入力言語ではない。Object signatureとInterface signatureの表記は[4.2. Text View](4.2-text-view.md)で定義する。
|
||||
|
||||
Parameterは常に名前付きとし、位置引数は用いない。引数を持たないOperationは空のparameter listとして表し、`call_operation`には空のrecordを渡す。
|
||||
Parameterは常に名前付きとし、位置引数は用いない。引数を持たないOperationは空のparameter listで表し、`call_operation`には空のrecordを渡す。
|
||||
|
||||
```wip
|
||||
operation refresh() -> unit;
|
||||
```
|
||||
|
||||
Return typeにはDescriptorで定義される任意のvalue typeを使用できる。Evolution compatibilityが必要なOperationでは、fieldを追加できるrecordをreturn typeの基本とする。
|
||||
Return typeには、Descriptorで定義される任意のvalue typeを使用できる。Evolution compatibilityが必要なOperationでは、fieldを追加できるrecordをreturn typeの基本とする。
|
||||
|
||||
Operationの用途は短いdocumentationで説明する。長文documentationや大量のApplication dataをInterface Descriptorへ埋め込まない。
|
||||
Operationの用途は短いdocumentationで説明し、長文documentationや大量のApplication dataをInterface Descriptorへ埋め込まない。
|
||||
|
||||
Operation名は一つのInterface Descriptor内で一意とする。異なるInterface間では同じOperation名を使用でき、`call_operation`は構造化Interface参照とOperation名の組で解決する。表示用のqualified表記は[Text Viewの参照表記](4.2-text-view.md#参照表記)に従い、Protocolでは参照とOperation名を別fieldとして扱う。
|
||||
Operation名は、一つのInterface Descriptor内で一意とする。異なるInterface間では同じOperation名を使用できる。`call_operation`は構造化Interface参照とOperation名の組で解決し、Protocolでは両者を別fieldとして扱う。表示用のqualified表記は[Text Viewの参照表記](4.2-text-view.md#参照表記)に従う。
|
||||
|
||||
## `fetch_interface`
|
||||
|
||||
`fetch_interface(interface)`は、Interface参照に対応する現在のInterface Descriptor、optionalなscope Objectの`ref`(`scope_ref`)、optionalなInterface validatorを一つの観測として取得する。
|
||||
`fetch_interface(interface)`は、Interface参照に対応する現在のInterface Descriptorを取得する。同時に、optionalなscope Objectの`ref`(`scope_ref`)とoptionalなInterface validatorを、一つの観測として取得する。
|
||||
|
||||
```text
|
||||
fetch_interface(interface) -> Interface Descriptor, scope_ref?, validator?
|
||||
```
|
||||
|
||||
ObjectはInterface参照のordered setだけを持ち、Descriptorを先行同梱しない。ClientはInterface cacheと各参照を比較し、未知のDescriptorを取得する。Interface validator mismatch等で更新が必要になった参照は再取得する。Hostは主体へ公開したObjectの各Interface参照について、現在のDescriptorをその主体から取得可能にしなければならない。
|
||||
Objectが持つのはInterface参照のordered setだけであり、Descriptorは先行同梱しない。Clientは各参照をInterface cacheと比較して未知のDescriptorを取得し、Interface validator mismatch等で更新が必要になった参照を再取得する。Hostは、主体へ公開したObjectの各Interface参照について、現在のDescriptorをその主体から取得可能にしなければならない。
|
||||
|
||||
同じ有効期間内のInterface参照とvalidatorの組は同じDescriptorを表す。Validatorを返さないInterface参照は、その参照の有効期間中immutableでなければならない。`scope_ref`はこの観測の紐付きmetadataとして保持し、参照の識別子へ組み込まない。
|
||||
同じ有効期間内では、同じInterface参照とvalidatorの組は同じDescriptorを表す。Validatorを返さないInterface参照は、その有効期間中immutableでなければならない。`scope_ref`は、観測の紐付きmetadataとして保持し、参照の識別子へは組み込まない。
|
||||
|
||||
HostはClientのcache状態を推測して、要求されていないDescriptorをObject responseへ追加しない。複数のDescriptorが必要なClientは、それぞれの`fetch_interface`を個別に並行実行できる。
|
||||
HostはClientのcache状態を推測し、要求されていないDescriptorをObject responseへ追加しない。複数のDescriptorが必要なら、Clientはそれぞれの`fetch_interface`を個別に並行実行できる。
|
||||
|
||||
## Operationの可視性
|
||||
|
||||
Hostは、主体の権限やrole / capabilityに基づいて、その主体へ公開するInterface参照の集合を決定してよい。同じObjectでも、主体ごとに異なるInterface集合を返してよい。
|
||||
Hostは、主体の権限やrole / capabilityに基づいて、その主体へ公開するInterface参照の集合を決定してよい。同じObjectに対しても、主体ごとに異なるInterface集合を返してよい。
|
||||
|
||||
Operationを主体に認識させない場合、Hostは次のいずれかを選べる。
|
||||
Operationの存在を主体に認識させない場合には、次のいずれかを選べる。
|
||||
|
||||
- そのOperationをcapabilityとしてまとめたInterface参照をObjectの集合から除外する。
|
||||
- そのOperationを宣言しない別のInterface Descriptorを参照する。
|
||||
|
||||
ClientがObjectで公開されていないInterface参照を指定した場合は`InterfaceMismatch`、公開されているInterfaceに宣言されていないOperation名を指定した場合は`OperationNotFound`を返す。
|
||||
ClientがObjectで公開されていないInterface参照を指定すると、`InterfaceMismatch`を返す。公開されているInterfaceに宣言されていないOperation名を指定すると、`OperationNotFound`を返す。
|
||||
|
||||
Operationの存在を公開しつつ実行だけを認めない設計も可能である。この場合はOperationをいずれかのInterfaceに宣言し、`call_operation`時のauthorizationで拒否して`PermissionDenied`を返す。
|
||||
これとは別に、Operationの存在を公開し、実行だけを認めない設計も可能である。その場合は、OperationをいずれかのInterfaceに宣言したうえで、`call_operation`時のauthorizationで拒否し、`PermissionDenied`を返す。
|
||||
|
||||
したがって、Hostは主体に対して次を独立に選択できる。
|
||||
Hostは、主体に対する公開と実行の条件を次のように独立に選択できる。
|
||||
|
||||
- Object自体を公開せず、そのObjectへの`observe`とOperation callを`NotFound`にする。
|
||||
- Objectを公開するが、特定Interfaceを集合から除外する。
|
||||
- Objectを公開し、特定Operationを含まないInterfaceを使用する。
|
||||
- Object、Interface、Operationを公開するが、そのOperationの実行を`PermissionDenied`にする。
|
||||
|
||||
Interface集合と各Interface内のOperation可視性は、頻繁に変化する実行可否やdomain stateを表すものではなく、**その主体に対してどのcapabilityを公開するか**というprojectionとして扱う。
|
||||
Interface集合と各Interface内のOperation可視性は、**その主体に対してどのcapabilityを公開するか**を表すprojectionである。頻繁に変化する実行可否やdomain stateを表すものとしては扱わない。
|
||||
|
||||
WIPはOperationの`available` / `disabled`のような揮発的状態をInterface集合へ同期することを前提としない。現在状態を判断する必要がある場合は、必要に応じてdomain固有のOperationで観測する。公開されたOperationについても、最終的なauthorizationと実行条件の判定は`call_operation`時にHostが行う。
|
||||
WIPは、Operationの`available` / `disabled`のような揮発的状態をInterface集合へ同期することを前提としない。現在状態の判断が必要なら、必要に応じてdomain固有のOperationで観測する。公開されたOperationでも、最終的なauthorizationと実行条件は、`call_operation`時にHostが判定する。
|
||||
|
||||
## `call_operation`
|
||||
|
||||
`call_operation`は、指定pathにあるObjectに対して、Interfaceに宣言されたOperationを実行する。
|
||||
`call_operation`は、指定pathにあるObjectに対し、Interfaceに宣言されたOperationを実行する。
|
||||
|
||||
```text
|
||||
call_operation(target, interface, operation, arguments) -> result, validator?
|
||||
@@ -147,7 +151,7 @@ call_operation(target, interface, operation, arguments) -> result, validator?
|
||||
- `result`はOperationのreturn typeに従うvalue。
|
||||
- Responseの`validator`はOperation実行後のObject状態に対してHostが発行したoptionalな値。
|
||||
|
||||
HostはOperationを実行する前に、次を検証する。
|
||||
Hostは、Operationを実行する前に次の条件を検証する。
|
||||
|
||||
1. `target.path`が存在する。
|
||||
2. `target.validator`が指定されている場合、現在のObject validatorと一致する。
|
||||
@@ -157,26 +161,28 @@ HostはOperationを実行する前に、次を検証する。
|
||||
6. Argumentsがparameter declarationに従っている。
|
||||
7. ClientがOperationを実行できる。
|
||||
|
||||
対象Objectが現在のClient-facing Worldspaceへ公開されていない場合は`not_found`、指定Interfaceが対象Objectの現在の`interfaces`に含まれない場合や、指定された`scope_ref`の紐付きを確認できない場合は`interface_mismatch`、Operationが指定Interfaceに宣言されていない場合は`operation_not_found`、宣言されたOperationの実行が認められない場合は`permission_denied`を返す。`scope_ref`を持たない観測とcallも許可し、その省略だけでは拒否しない。
|
||||
対象Objectが現在のClient-facing Worldspaceへ公開されていなければ、`not_found`を返す。指定InterfaceがObjectの現在の`interfaces`に含まれない場合、または指定された`scope_ref`の紐付きを確認できない場合は、`interface_mismatch`を返す。指定InterfaceにOperationが宣言されていなければ`operation_not_found`、宣言されたOperationの実行が認められなければ`permission_denied`を返す。`scope_ref`を持たない観測とcallも許可し、省略したことだけを理由には拒否しない。
|
||||
|
||||
Object validator、Interface membership、scope Objectの紐付きと指定された`scope_ref`、Interface validator、Descriptor、Operation implementation、backend対象の確認と固定は、Operation実行に対して一貫したpreconditionとして扱う。Object validatorが一致しない場合は`validator_mismatch`、要求されるObject validatorがない場合は`validator_required`を返す。Mutable Interfaceのvalidatorがない場合は`interface_validator_required`、一致しない場合は`interface_validator_mismatch`を返す。いずれの場合もHostはOperationを実行しない。
|
||||
Object validator、Interface membership、scope Objectの紐付きと指定された`scope_ref`、Interface validator、Descriptor、Operation implementation、backend対象の確認と固定は、Operation実行に対して一貫したpreconditionとして扱う。
|
||||
|
||||
Argumentsに未知のfieldがある場合、必須parameterがない場合、またはvalueが宣言型に一致しない場合、HostはOperationを実行せず`invalid_arguments`を返す。
|
||||
Object validatorが一致しない場合は`validator_mismatch`を、要求されるObject validatorがない場合は`validator_required`を返す。Mutable Interfaceのvalidatorがない場合は`interface_validator_required`を、一致しない場合は`interface_validator_mismatch`を返す。いずれの場合も、HostはOperationを実行しない。
|
||||
|
||||
ClientはHostから受け取ったsuccess responseのresultをreturn typeに従って検証する。Hostが宣言と異なるresultを返した場合はHostが返したProtocol failureではなく`InvalidResponse`として扱い、そのvalueをApplicationへ渡さない。Host Runtimeがinvalidなimplementation resultを送信前に検出した場合は、effectがcommitされていないと保証できるときだけ`Internal`を返し、保証できない場合は`OperationOutcomeUnknown`を返す。
|
||||
Argumentsに未知のfieldがある場合、必須parameterがない場合、valueが宣言型に一致しない場合にも、HostはOperationを実行せず、`invalid_arguments`を返す。
|
||||
|
||||
Clientは、Hostから受け取ったsuccess responseのresultをreturn typeに従って検証する。宣言と異なるresultが返された場合は、Hostが返したProtocol failureではなく`InvalidResponse`として扱い、そのvalueをApplicationへ渡さない。
|
||||
|
||||
Host Runtimeが送信前にinvalidなimplementation resultを検出した場合は、effectがcommitされていないと保証できるときだけ`Internal`を返す。保証できない場合は`OperationOutcomeUnknown`を返す。
|
||||
|
||||
## Application data
|
||||
|
||||
Operation argumentsとresultは、Applicationが定義し、利用するdataである。
|
||||
|
||||
WIPは次を規定する。
|
||||
Operation argumentsとresultは、Applicationが定義して利用するdataである。WIPが規定する内容は次のとおりである。
|
||||
|
||||
- Descriptor上の型表現
|
||||
- Transport上のencoding
|
||||
- ClientとHostのboundary validation
|
||||
- `entry`等のWIP固有型の意味
|
||||
|
||||
WIPは次を規定しない。
|
||||
一方、次の内容はWIPでは規定しない。
|
||||
|
||||
- Dataのdomain上の意味
|
||||
- 永続化方法
|
||||
@@ -184,19 +190,19 @@ WIPは次を規定しない。
|
||||
- Business rule
|
||||
- Clientが取得したdataを利用する方法
|
||||
|
||||
Wire schema上正しいことと、domain上有効であることは別である。例えばargumentsが型に従っていても、現在のdomain stateが操作を許可しない場合、Operation implementationはdomain failureを返し得る。
|
||||
例えば、argumentsが型に従っていても、現在のdomain stateが操作を許可しない場合には、Operation implementationはdomain failureを返し得る。Wire schema上の正しさと、domain上の有効性は別の条件である。
|
||||
|
||||
## Operation Resultとエントリの発見
|
||||
|
||||
Operation Resultには、Descriptorの`entry`型として新しいエントリのpathが含まれ得る。
|
||||
Operation Resultには、Descriptorの`entry`型として、新しいエントリのpathが含まれ得る。`entry`は、同じWorldspace内のcanonical absolute pathである。
|
||||
|
||||
`entry`は同じWorldspace内のcanonical absolute pathである。Clientはreturn typeに従って`entry`型の位置を認識する。通常の`string`やopaqueな`json`に含まれるpathらしい文字列を、エントリとして推測してはならない。
|
||||
Clientは、return typeに従って`entry`型の位置を認識する。通常の`string`やopaqueな`json`に含まれるpathらしい文字列を、エントリとして推測してはならない。
|
||||
|
||||
Clientは発見したpathを参照として利用でき、Objectの公開情報が必要になったときに、必要な深さで`observe`する。発見したすべてのpathの即時取得は要求しない。Operation ResultへObject公開情報やInterface Descriptorを先行同梱しない。
|
||||
発見したpathは参照として利用できる。Clientは、Objectの公開情報が必要になったときに、必要な深さで`observe`する。すべてのpathの即時取得は要求せず、Operation ResultへObject公開情報やInterface Descriptorを先行同梱しない。
|
||||
|
||||
Operationの実行対象と、結果として発見されたエントリのTree上の位置は独立している。発見されたエントリは実行対象の子である必要はなく、親、兄弟、またはWorldspace内の任意の位置にあってよい。
|
||||
発見されたエントリは、実行対象の子である必要はない。親、兄弟、Worldspace内の任意の位置にあってよい。Operationの実行対象と、結果として発見されたエントリのTree上の位置は独立している。
|
||||
|
||||
Operationがpathを返したこと自体から、実行対象との間に親子関係や意味的関係を推論しない。親子関係はTree上の配置で表し、非内包の意味的関係はOperation名、result field名、またはApplication固有のrelation型で表現する。
|
||||
また、pathが返されたこと自体から、実行対象との親子関係や意味的関係を推論しない。親子関係はTree上の配置で表し、非内包の意味的関係はOperation名、result field名、Application固有のrelation型で表現する。
|
||||
|
||||
## Protocol failureとdomain failure
|
||||
|
||||
@@ -217,9 +223,9 @@ Protocol failureは、WIPのrequest、対象、Interface、validation、validato
|
||||
- `internal`
|
||||
- `operation_outcome_unknown`
|
||||
|
||||
`InvalidResponse`はClientが受信したsuccess responseまたはerror responseのProtocol違反を表すlocal classificationであり、Hostが返すProtocol failureではない。`UnsupportedDescriptorFormat`もClient-localであり、validだが未対応のDescriptorをProtocol違反として扱わない。
|
||||
これに対し、`InvalidResponse`は、Clientが受信したsuccess responseまたはerror responseのProtocol違反を表すlocal classificationである。Hostが返すProtocol failureには含めない。`UnsupportedDescriptorFormat`もClient-localであり、validだが未対応のDescriptorをProtocol違反としては扱わない。
|
||||
|
||||
`call_operation`が`operation_outcome_unknown`以外のProtocol failureを返した場合、HostはOperationのeffectがcommitされていないことを保証する。`operation_outcome_unknown`またはresponse不達の場合、ClientはOperationを自動再実行せず、対象を再観測するかdomain固有の照会手段を利用する。
|
||||
`call_operation`が`operation_outcome_unknown`以外のProtocol failureを返した場合、HostはOperationのeffectがcommitされていないことを保証する。`operation_outcome_unknown`またはresponse不達の場合、ClientはOperationを自動再実行しない。対象を再観測するか、domain固有の照会手段を利用する。
|
||||
|
||||
Domain failureは、型として正しいOperationをApplicationが実行した結果として生じる失敗である。
|
||||
|
||||
@@ -227,4 +233,4 @@ Domain failureは、型として正しいOperationをApplicationが実行した
|
||||
- `payment_declined`
|
||||
- `merge_conflict`
|
||||
|
||||
Domain failureをProtocol failureへ混在させない。現在のProtocolでは、Domain failureをOperation return typeのUnion caseとして宣言する。専用のfailure declarationは定義しない。
|
||||
これらのDomain failureをProtocol failureへ混在させない。現在のProtocolでは、Operation return typeのUnion caseとして宣言し、専用のfailure declarationは定義しない。
|
||||
|
||||
+143
-119
@@ -1,19 +1,19 @@
|
||||
# 3. Protocol
|
||||
|
||||
WIP Protocolは、ClientとHostの間で交換するlogical data modelと、Core interactionのrequest / responseを定義する。
|
||||
WIP Protocolは、ClientとHostが交換するlogical data modelと、Core interactionのrequest / responseを定義する。
|
||||
|
||||
この章のstructはRust風の記法を使用するが、Rust固有のmemory layoutやserialization libraryを要求しない。TypeScriptを含む他言語でも同じ型、必須性、validation semanticsを実装できなければならない。
|
||||
Structの記法はRust風だが、Rust固有のmemory layoutやserialization libraryは要求しない。TypeScriptを含む他言語でも、同じ型、必須性、validation semanticsを実装できなければならない。
|
||||
|
||||
HTTP endpoint、JSON fieldへのencoding、status codeはTransport Bindingの責務とし、共通要件を[5. Transport Bindings](5-transports.md)、標準bindingを[5.1. WIP over HTTP](5.1-wip-over-http.md)で扱う。
|
||||
HTTP endpoint、JSON fieldへのencoding、status codeはTransport Bindingの責務である。共通要件は[5. Transport Bindings](5-transports.md)、標準bindingの定義は[5.1. WIP over HTTP](5.1-wip-over-http.md)にある。
|
||||
|
||||
## Protocol dataの分類
|
||||
|
||||
WIPがfirst-classなprotocol dataとして定義し、Clientが取得およびcacheするものは次の二つである。
|
||||
Clientが取得してcacheするfirst-classなprotocol dataは、次の二つである。
|
||||
|
||||
- `Object`
|
||||
- `InterfaceDescriptor`
|
||||
|
||||
Operationのargumentsとresultには、Interface Descriptorで宣言された`Value`を使用する。Application dataは独立したWIP resourceではなく、Operation callの入力または出力として交換される。
|
||||
一方、Operationのargumentsとresultには、Interface Descriptorで宣言された`Value`を使用する。Application dataは独立したWIP resourceにはせず、Operation callの入力または出力として交換する。
|
||||
|
||||
```text
|
||||
Protocol data
|
||||
@@ -25,11 +25,11 @@ Operation value
|
||||
`-- result
|
||||
```
|
||||
|
||||
Path、Object参照、Interface参照、validator、error等の意味は、それらが現れるfieldまたはenvelopeによって決まる。Protocol上のnamed scalarとしては定義しない。
|
||||
Path、Object参照、Interface参照、validator、error等は、現れるfieldやenvelopeに応じて意味が決まる。Protocol上のnamed scalarとしては定義しない。
|
||||
|
||||
## 記法
|
||||
|
||||
この章では次の記法を使用する。
|
||||
Logical structの型とfieldは、次の記法で表す。
|
||||
|
||||
```rust
|
||||
struct T { ... } // 名前付きfieldを持つrecord
|
||||
@@ -39,13 +39,13 @@ Vec<T> // 順序付きlist
|
||||
BTreeMap<String, T> // string keyを持つlogical map
|
||||
```
|
||||
|
||||
`Option<T>`はfieldの省略を表し、`null`を表さない。Transport Bindingは、省略されたfieldをwire valueへ出力してはならない。
|
||||
`Option<T>`はfieldの省略を表し、`null`を表すものではない。Transport Bindingは、省略されたfieldをwire valueへ出力してはならない。
|
||||
|
||||
`BTreeMap`はstring-keyed mapをRust風に示すための記法であり、keyのiteration orderをProtocol semanticsには含めない。
|
||||
`BTreeMap`はstring-keyed mapをRust風に示す記法にすぎない。Keyのiteration orderはProtocol semanticsに含めない。
|
||||
|
||||
## Protocol primitives
|
||||
|
||||
Protocol structは、次のprimitiveおよびcompositeだけで表す。
|
||||
Protocol structを表すprimitiveとcompositeは、次のものだけに限定する。
|
||||
|
||||
```text
|
||||
unit
|
||||
@@ -58,9 +58,9 @@ list
|
||||
record
|
||||
```
|
||||
|
||||
Path、Interface参照、Object identity、validator、Operation名等を独立したProtocol scalarにはしない。同じprimitiveを使用するfieldでも意味は交換可能ではなく、各fieldの定義とvalidation ruleに従って解釈する。
|
||||
Path、Interface参照、Object identity、validator、Operation名等を独立したProtocol scalarにはしない。ただし、同じprimitiveを使うfieldでも、その意味を交換可能とはしない。各fieldの定義とvalidation ruleに従って解釈する。
|
||||
|
||||
Rust SDKは利便性や安全性のために`Path`等のnewtypeを公開してよく、TypeScript SDKはbrand等を利用してよい。それらは言語別SDKのAPIであり、Protocol structの一部ではない。
|
||||
Rust SDKは、利便性や安全性のために`Path`等のnewtypeを公開してよい。TypeScript SDKもbrand等を利用してよい。これらは言語別SDKのAPIであり、Protocol structの一部ではない。
|
||||
|
||||
## Path
|
||||
|
||||
@@ -75,11 +75,11 @@ Pathとして宣言されたfieldのvalueは、同じWorldspace内のObjectを
|
||||
- Segmentの比較はUnicode code point sequenceの完全一致で行う。
|
||||
- Navigationに使用できるが、恒久的なObject identityとして使用しない。
|
||||
|
||||
Nameに`/`を許可しないため、Protocolはpath segment escapeを定義しない。Transport BindingによるURL encodingはWIP Pathの構成とは別に扱う。
|
||||
Nameに`/`を許可しないため、Protocolはpath segment escapeを定義しない。Transport Bindingが行うURL encodingは、WIP Pathの構成とは別に扱う。
|
||||
|
||||
## Interface Reference
|
||||
|
||||
Interface参照は、scopeとlocal nameからなるrecordとして表す。新しいfirst-class resourceやnamed scalarではなく、Objectやinteractionが使用する参照のstructである。
|
||||
Interface参照は、scopeとlocal nameを持つrecordである。Objectやinteractionで使う参照のstructであり、新しいfirst-class resourceやnamed scalarではない。
|
||||
|
||||
```rust
|
||||
struct InterfaceReference {
|
||||
@@ -94,15 +94,15 @@ struct InterfaceReference {
|
||||
- `Object.interfaces`の重複判定、membership、request / responseの参照一致、Interface cacheのkeyにはこの組を使う。Object member順や短縮表記の引用・escapeの違いは参照の同一性へ影響しない。
|
||||
- Interface Descriptorはscopeやnameを自身のfieldとして持たない。同じ構造のDescriptorであることから参照の同一性を推測しない。
|
||||
|
||||
`Object.interfaces`の各要素、`FetchInterfaceRequest.interface`、`FetchInterfaceResponse.interface`、`InterfaceTarget.reference`はすべてこのstructを使用する。Opaque stringや`scope::name`という連結stringを代替として受理しない。Operation名は`CallOperationRequest.operation`に独立して保持する。
|
||||
このstructは、`Object.interfaces`の各要素、`FetchInterfaceRequest.interface`、`FetchInterfaceResponse.interface`、`InterfaceTarget.reference`で共通して使用する。Opaque stringや`scope::name`を連結したstringを代わりに受理しない。Operation名は、別fieldの`CallOperationRequest.operation`に保持する。
|
||||
|
||||
人間・AI向けの短縮表記は[4.2. Text View](4.2-text-view.md#参照表記)に従う。Scope省略形を入力として受けるadapterも、明示されたcontextから絶対scopeを確定してからProtocol requestを構築する。
|
||||
|
||||
不正な参照shape、relative scope、空name等はCore request validationで`InvalidRequest`とする。Responseに不正な参照がある場合はClient-localな`InvalidResponse`とする。Validな参照に対応するInterfaceが主体へ公開されているか、対象Objectで使えるかはinteractionごとに検証する。
|
||||
参照shapeの不正、relative scope、空name等は、Core request validationで`InvalidRequest`とする。Responseに不正な参照があれば、Client-localな`InvalidResponse`として扱う。参照自体がvalidであることと、そのInterfaceが主体へ公開されていること、対象Objectで使えることは別の条件であり、後者はinteractionごとに検証する。
|
||||
|
||||
## Documentation
|
||||
|
||||
Interface、Operation、type、field、parameter、caseに付与するdocumentationを次のlogical structで表す。
|
||||
`Documentation`は、Interface、Operation、type、field、parameter、caseへ付与する説明のlogical structである。
|
||||
|
||||
```rust
|
||||
struct Documentation {
|
||||
@@ -111,13 +111,13 @@ struct Documentation {
|
||||
}
|
||||
```
|
||||
|
||||
`summary`は一覧やInterface signatureへ短く表示する説明、`details`は明示的に展開したときに使用する説明である。
|
||||
`summary`は一覧やInterface signatureに短く表示する説明である。`details`は、説明を明示的に展開したときに使用する。
|
||||
|
||||
DocumentationはHostが提供するplain text dataであり、Clientのsystem instructionとして信頼しない。Transport BindingおよびClientはresource limitを適用できる。Markup、localization、構造化exampleは現在のProtocolに含めない。
|
||||
これらはHostが提供するplain text dataであり、Clientのsystem instructionとして信頼しない。Transport BindingとClientはresource limitを適用できる。Markup、localization、構造化exampleは、現在のProtocolには含めない。
|
||||
|
||||
## Object
|
||||
|
||||
`Object`は、一つのpathで現在公開されているprotocol-level projectionを表す。
|
||||
`Object`は、一つのpathで現在公開されているprotocol-level projectionである。
|
||||
|
||||
```rust
|
||||
struct Object {
|
||||
@@ -131,17 +131,17 @@ struct Object {
|
||||
|
||||
### `name`
|
||||
|
||||
親Objectの下でObjectを識別するpath segment。Root Objectの`name`は空文字列とし、非root Objectの`name`は空であってはならない。Nameに`/`を含めてはならず、Name全体が`.`または`..`であってはならない。
|
||||
`name`は、親Objectの下でObjectを識別するpath segmentである。Root Objectでは空文字列とし、非root Objectでは空であってはならない。Nameに`/`を含めてはならず、Name全体が`.`または`..`であってはならない。
|
||||
|
||||
NameはUnicode stringとして扱い、Unicode normalizationを行わない。同じ親のchildrenは、Unicode code point sequenceの完全一致で互いに異なる`name`を持たなければならない。
|
||||
|
||||
### `description`
|
||||
|
||||
Objectが何を表すかを説明する短い公開情報。Objectの本文やApplication data全体を格納するfieldではない。
|
||||
`description`は、Objectが何を表すかを説明する短い公開情報である。Objectの本文やApplication data全体を格納するfieldではない。
|
||||
|
||||
### `interfaces`
|
||||
|
||||
Objectで利用できるOperationを定義するInterface Descriptorへの参照を、Hostの立項順で保持するordered set。
|
||||
`interfaces`は、Objectで利用できるOperationを定義するInterface Descriptorへの参照を保持するordered setである。順序はHostの立項順とする。
|
||||
|
||||
- 集合は空でもよい。
|
||||
- 同じInterface参照を重複して含めてはならない。Clientは重複を含むObject responseをHostから返されたProtocol failureではなく`InvalidResponse`として扱う。
|
||||
@@ -149,13 +149,13 @@ Objectで利用できるOperationを定義するInterface Descriptorへの参照
|
||||
- Clientは列挙順をInterfaceおよびOperationのUI表示順として使用してよい。
|
||||
- Operation callは集合から一つのInterface参照を明示する。
|
||||
|
||||
異なるInterfaceでは同じOperation名を宣言できる。Protocolでは構造化参照とOperation名を別fieldとして扱い、表示用の短縮表記は[4.2. Text View](4.2-text-view.md#参照表記)に従う。
|
||||
異なるInterfaceでは、同じOperation名を宣言できる。Protocol上は構造化参照とOperation名を別fieldで扱い、表示用の短縮表記は[4.2. Text View](4.2-text-view.md#参照表記)に従う。
|
||||
|
||||
Hostはvalidatorを発行することで、同じInterface参照のDescriptorを時間とともに変更できる。Validatorを発行しない参照のDescriptorは、その参照が有効な間は変更しない。Clientは`fetch_interface`でDescriptorとvalidatorを一つの観測として取得する。
|
||||
同じInterface参照のDescriptorを時間とともに変更するHostは、validatorを発行する。Validatorを発行しない参照のDescriptorは、その参照が有効な間は変更しない。Clientは`fetch_interface`でDescriptorとvalidatorを一つの観測として取得する。
|
||||
|
||||
### `ref`
|
||||
|
||||
同じ主体に公開された一つのWorldspace内で、複数の観測またはエントリが同じObjectを表すことを示すoptionalなopaque string。
|
||||
`ref`はoptionalなopaque stringであり、同じ主体に公開された一つのWorldspace内で、複数の観測やエントリが同じObjectを表すことを示す。
|
||||
|
||||
- 同じ有効な`ref`は同じObjectを表す。
|
||||
- Hostは同じ有効な`ref`を異なるObjectへ付与してはならない。
|
||||
@@ -164,19 +164,19 @@ Hostはvalidatorを発行することで、同じInterface参照のDescriptorを
|
||||
- Session、再接続、Host restart、Worldspace再構築をまたぐ永続性を要求しない。
|
||||
- Navigationには使用せず、`ref`からObjectを取得するinteractionは提供しない。
|
||||
|
||||
Backendに適切なUIDがある場合、Hostはそれを直接または変換して使用できるが、Protocolはbackend UIDの存在または公開を要求しない。
|
||||
Backendに適切なUIDがある場合、Hostは直接、または変換して`ref`に使用できる。ただし、Protocolはbackend UIDの存在も公開も要求しない。
|
||||
|
||||
### `validator`
|
||||
|
||||
HostがそのObjectの現在状態へ付与するoptionalなopaque bytes。Clientは内容を解釈せず、観測に基づくOperationを条件付きで実行するときに、pathとともに`target.validator`へコピーする。
|
||||
`validator`は、HostがObjectの現在状態に付与するoptionalなopaque bytesである。Clientは内容を解釈しない。観測に基づくOperationを条件付きで実行する場合は、pathとともに`target.validator`へコピーする。
|
||||
|
||||
Hostはvalidatorの生成方法を自由に選べる。Generation、content hash、database revision等を使用してよい。ValidatorはObject identityとHostが保護する現在状態の両方へ結び付け、target pathが別Objectを指す場合は、内容が同じでも一致させてはならない。Content hashを使用する場合はObject identityをhashへ含めるか、Hostがidentityを別途検証する。
|
||||
生成方法はHostが自由に選べる。Generation、content hash、database revision等を使用してよい。ただし、validatorはObject identityとHostが保護する現在状態の両方に結び付ける。Target pathが別Objectを指す場合は、内容が同じでも一致させてはならない。Content hashを使う場合は、Object identityをhashに含めるか、Hostがidentityを別途検証する。
|
||||
|
||||
Hostは、どの状態変化によってObject validatorを無効にするかについて、そのObjectに対して一貫した方針を適用する。Object validatorはpathに固有ではなく、同じ`ref`を持つObjectの現在状態に付与する。
|
||||
どの状態変化によってvalidatorを無効にするかは、HostがそのObjectに対して一貫した方針を適用する。Object validatorはpathに固有のものではなく、同じ`ref`を持つObjectの現在状態に付与する。
|
||||
|
||||
Object validatorはAIやユーザーへ公開するApplication dataではない。Client runtimeがObject observationとともに保持するprotocol metadataである。
|
||||
Object validatorは、Client runtimeがObject observationとともに保持するprotocol metadataである。AIやユーザーへ公開するApplication dataではない。
|
||||
|
||||
ここでいうObjectの「現在状態」は、Hostがvalidatorによってobservation consistencyを保護する対象を指す。WIP Objectはprotocol-level projectionであり、Application stateを格納する共通containerではない。WIPは状態遷移modelを定義せず、OperationをObject stateに対するread / writeとして分類しない。
|
||||
Objectの「現在状態」とは、Hostがvalidatorによってobservation consistencyを保護する対象を指す。WIP Objectはprotocol-level projectionであり、Application stateを格納する共通containerではない。WIPは状態遷移modelを定義せず、OperationをObject stateに対するread / writeとして分類しない。
|
||||
|
||||
### 同じ`ref`を持つObjectの整合性
|
||||
|
||||
@@ -187,15 +187,15 @@ Object validatorはAIやユーザーへ公開するApplication dataではない
|
||||
- HostはObjectが配置されたpathを理由に、同じ`ref`へ異なる`name`、`description`、`interfaces`を同時に公開してはならない。
|
||||
- Clientは最後に成功した観測を、その`ref`を持つObjectの現在状態として扱う。
|
||||
|
||||
同じObjectを複数のpathへ配置する場合、各配置は同じObjectの`name`を使用する。異なる親を経由することでpathが異なる。
|
||||
同じObjectを複数のpathへ配置する場合も、各配置には同じObjectの`name`を使用する。Pathの違いは、異なる親を経由することで生じる。
|
||||
|
||||
`Object`はchildrenとpathを直接持たない。これらはObject identityに固有とは限らず、Objectを観測したentryのcontextに属するため、request pathと`ObjectObservation`で表す。
|
||||
`Object`自体はchildrenとpathを直接持たない。これらはObject identityに固有とは限らず、Objectを観測したentryのcontextに属する。そのため、request pathと`ObjectObservation`で表す。
|
||||
|
||||
Objectの意味とApplication dataとの境界は[2. Core Model](2-core-model.md)と[2.1. Object tree / Schema](2.1-object-tree-schema.md)で扱う。
|
||||
Objectの意味とApplication dataとの境界については、[2. Core Model](2-core-model.md)と[2.1. Object tree / Schema](2.1-object-tree-schema.md)に定義がある。
|
||||
|
||||
## Interface Descriptor
|
||||
|
||||
`InterfaceDescriptor`は、実装コード上のInterface Declarationから生成されるmachine-readable metadataである。
|
||||
`InterfaceDescriptor`は、実装コード上のInterface Declarationから生成するmachine-readable metadataである。
|
||||
|
||||
```rust
|
||||
struct InterfaceDescriptor {
|
||||
@@ -206,9 +206,9 @@ struct InterfaceDescriptor {
|
||||
}
|
||||
```
|
||||
|
||||
Interface実装者がこのstructのJSON encodingを手書きすることは想定しない。Rust macro、TypeScript definition API、code generator等が生成し、Hostが公開する。
|
||||
生成にはRust macro、TypeScript definition API、code generator等を用い、Hostが公開する。Interface実装者がこのstructのJSON encodingを手書きすることは想定しない。
|
||||
|
||||
`types`と`operations`のdeclaration順は表示時に保持してよいが、名前解決とvalidationの結果には影響しない。
|
||||
`types`と`operations`のdeclaration順は、表示時に保持してよい。ただし、名前解決やvalidationの結果には影響しない。
|
||||
|
||||
### Type declaration
|
||||
|
||||
@@ -220,7 +220,7 @@ struct TypeDeclaration {
|
||||
}
|
||||
```
|
||||
|
||||
一つのDescriptor内でType名は一意でなければならない。
|
||||
Type名は、一つのDescriptor内で一意でなければならない。
|
||||
|
||||
### Type expression
|
||||
|
||||
@@ -257,7 +257,7 @@ enum TypeExpr {
|
||||
}
|
||||
```
|
||||
|
||||
`Named`は同じInterface Descriptor内の`TypeDeclaration`を参照する。External type importとrecursive type referenceは定義しない。Descriptorの意味検証では、存在しないTypeへの参照と参照cycleを拒否する。
|
||||
`Named`の参照先は、同じInterface Descriptor内の`TypeDeclaration`である。External type importとrecursive type referenceは定義しない。Descriptorの意味検証では、存在しないTypeへの参照と参照cycleを拒否する。
|
||||
|
||||
### Record
|
||||
|
||||
@@ -270,9 +270,9 @@ struct FieldDeclaration {
|
||||
}
|
||||
```
|
||||
|
||||
一つのRecord内でfield名は一意でなければならない。`required = false`はfieldの省略を許可するが、`null`を許可する意味ではない。
|
||||
Field名は、一つのRecord内で一意でなければならない。`required = false`で許可するのはfieldの省略であり、`null`ではない。
|
||||
|
||||
Record valueに未宣言fieldが存在する場合はvalidation failureとする。
|
||||
また、Record valueに未宣言fieldがある場合はvalidation failureとする。
|
||||
|
||||
### Enum
|
||||
|
||||
@@ -283,7 +283,7 @@ struct EnumCase {
|
||||
}
|
||||
```
|
||||
|
||||
Enum caseはpayloadを持たない。一つのEnum内でcase名は一意でなければならない。
|
||||
Enum caseはpayloadを持たない。Case名は、一つのEnum内で一意でなければならない。
|
||||
|
||||
### Union
|
||||
|
||||
@@ -295,7 +295,7 @@ struct UnionCase {
|
||||
}
|
||||
```
|
||||
|
||||
Union caseは、caseごとに0個または1個のpayloadを持つ。一つのUnion内でcase名は一意でなければならない。
|
||||
Union caseのpayloadは、caseごとに0個または1個とする。Case名は、一つのUnion内で一意でなければならない。
|
||||
|
||||
### Operation declaration
|
||||
|
||||
@@ -320,13 +320,13 @@ struct ReturnDeclaration {
|
||||
}
|
||||
```
|
||||
|
||||
一つのDescriptor内でOperation名は一意でなければならない。一つのOperation内でparameter名は一意でなければならない。
|
||||
Operation名は、一つのDescriptor内で一意でなければならない。Parameter名は、一つのOperation内で一意でなければならない。
|
||||
|
||||
Interface Descriptorの生成と意味論は[2.2. Operation and Interface](2.2-operation-and-interface.md)、人間とAIへのInterface signatureは[4.2. Text View](4.2-text-view.md)で扱う。
|
||||
Interface Descriptorの生成と意味論は[2.2. Operation and Interface](2.2-operation-and-interface.md)、人間とAIへのInterface signatureは[4.2. Text View](4.2-text-view.md)に従う。
|
||||
|
||||
## Value
|
||||
|
||||
Operation argumentsとresultは、schema上の意味を持たない次のlogical valueで表す。
|
||||
`Value`は、Operation argumentsとresultを表すlogical valueである。Schema上の意味は、この型自体には含めない。
|
||||
|
||||
```rust
|
||||
enum Value {
|
||||
@@ -341,7 +341,7 @@ enum Value {
|
||||
}
|
||||
```
|
||||
|
||||
`Value`はHostとClientが交換するprimitiveおよびcompositeだけを表す。Path、enum case、union case等のsemantic variantを持たない。既知のInterfaceに対するgenerated bindingは、言語固有DTOと`Value`のcodecを提供できる。
|
||||
`Value`が表すのは、HostとClientが交換するprimitiveとcompositeだけである。Path、enum case、union case等のsemantic variantは持たない。既知のInterfaceに対するgenerated bindingは、言語固有DTOと`Value`のcodecを提供できる。
|
||||
|
||||
### Primitive semantics
|
||||
|
||||
@@ -356,13 +356,13 @@ enum Value {
|
||||
| `Record` | String keyを持つvalueの集合 | Key順に意味を持たない |
|
||||
| `List` | 順序付きvalue | — |
|
||||
|
||||
`integer`の範囲はRustとTypeScript間でlosslessに相互運用できる範囲へ制限する。
|
||||
`integer`の範囲は、RustとTypeScriptの間でlosslessに相互運用できる範囲に制限する。
|
||||
|
||||
`number`を受信したruntimeは、有限値であることを検証する。
|
||||
|
||||
### Descriptorによる解釈
|
||||
|
||||
Interface Descriptorの`TypeExpr`は、rawな`Value`に意味と追加validationを与える。
|
||||
Rawな`Value`の意味と追加validationは、Interface Descriptorの`TypeExpr`によって決まる。
|
||||
|
||||
| TypeExpr | 対応するValue | 追加の解釈 |
|
||||
| --- | --- | --- |
|
||||
@@ -379,15 +379,15 @@ Interface Descriptorの`TypeExpr`は、rawな`Value`に意味と追加validation
|
||||
| `Enum` | `Value::String` | 宣言されたcase名として検証する |
|
||||
| `Union` | `Value::Record` | Discriminatorとpayloadをcase declarationに従って検証する |
|
||||
|
||||
`Json`が受け付けるのはJSONのnull、boolean、number、string、array、objectに対応するValueだけであり、`Bytes`は含まない。JSON内の整数と数値にも`Value::Integer`と`Value::Number`と同じ範囲および有限性の制約を適用する。
|
||||
`Json`が受け付けるのは、JSONのnull、boolean、number、string、array、objectに対応するValueだけである。`Bytes`は含まない。JSON内の整数と数値にも、`Value::Integer`と`Value::Number`と同じ範囲および有限性の制約を適用する。
|
||||
|
||||
`Entry`と`String`は同じprimitiveで交換する。ClientはDescriptor上で`Entry`と宣言された位置だけをWorldspace pathとして扱い、通常のStringを内容からEntryへ変換しない。
|
||||
`Entry`と`String`は、同じprimitiveで交換する。Worldspace pathとして扱うのは、Descriptor上で`Entry`と宣言された位置だけである。Clientは通常のStringを、その内容からEntryへ変換しない。
|
||||
|
||||
EnumもString、UnionもRecordとして交換する。SDKまたはruntimeは、Descriptorによるvalidation後の内部表現としてtyped enum、union、Path等を構築してよい。それらはProtocolの送受信形式ではない。
|
||||
同様に、EnumはString、UnionはRecordとして交換する。SDKやruntimeは、Descriptorによるvalidationを終えた内部表現としてtyped enum、union、Path等を構築してよい。ただし、これらはProtocolの送受信形式ではない。
|
||||
|
||||
### Value validation
|
||||
|
||||
Valueは、Operation declarationのTypeExprと照合して検証する。
|
||||
Valueの検証には、Operation declarationのTypeExprを使用する。
|
||||
|
||||
- `Named`を参照先Typeへ解決する。
|
||||
- Required fieldまたはparameterの欠落を拒否する。
|
||||
@@ -400,17 +400,17 @@ Valueは、Operation declarationのTypeExprと照合して検証する。
|
||||
|
||||
## Core request validation
|
||||
|
||||
Transport Bindingがframing、media type、authentication等を受理した後、Hostはinteraction固有のObject / Interface解決より前にlogical requestを検証する。
|
||||
Hostは、Transport Bindingがframing、media type、authentication等を受理した後にlogical requestを検証する。この検証は、interaction固有のObject / Interface解決より前に行う。
|
||||
|
||||
1. Request structとして解釈できない、required fieldがない、未知fieldがある、Path等のfield semanticsに違反する場合は`InvalidRequest`。
|
||||
2. Field長、nesting、collection件数、`observe`のdepth等がHostのboundedな上限を超える場合は`ResourceLimitExceeded`。
|
||||
3. 検証済みrequestについて、各interaction固有のtarget resolutionとpreconditionを評価する。
|
||||
|
||||
`InvalidArguments`は`call_operation` request自体がvalidであり、固定したOperation declarationに対して`arguments`が適合しない場合だけに使用する。Transport route、authentication、media type、framing等のfailureはCore interactionのProtocol failureではない。
|
||||
`InvalidArguments`を使用するのは、`call_operation` request自体はvalidであり、固定したOperation declarationに`arguments`が適合しない場合だけである。Transport route、authentication、media type、framing等のfailureは、Core interactionのProtocol failureとして扱わない。
|
||||
|
||||
## `observe`
|
||||
|
||||
一つのpathで現在公開されているObjectと、そのObjectから指定depthまでのindexable childrenを一つの観測として取得する。
|
||||
`observe`は、一つのpathで現在公開されているObjectと、そこから指定depthまでのindexable childrenを、一つの観測として取得する。
|
||||
|
||||
```rust
|
||||
struct ObserveRequest {
|
||||
@@ -426,7 +426,7 @@ struct ObjectObservation {
|
||||
type ObserveResponse = ObjectObservation;
|
||||
```
|
||||
|
||||
起点Objectをdepth `0`とする。`depth = 0`は対象Objectだけを観測する通常の形であり、root nodeの`children`を省略する。
|
||||
起点Objectのdepthを`0`とする。通常の`depth = 0`では対象Objectだけを観測し、root nodeの`children`を省略する。
|
||||
|
||||
- Root nodeのObjectはrequest pathが指すObjectである。Root pathが`/`の場合はObjectの`name`が空文字列でなければならず、非root pathでは最後のsegmentとObjectの`name`が一致しなければならない。
|
||||
- Request pathのObjectがその主体へ公開されていない場合は`NotFound`を返す。
|
||||
@@ -437,23 +437,23 @@ type ObserveResponse = ObjectObservation;
|
||||
- Requested depthより深いnodeをresponseへ含めない。
|
||||
- その主体へ公開されていないObjectはresponseへ含めず、存在や省略を示すmarkerも返さない。
|
||||
|
||||
Success responseはrequested depthまで完全である。Node数によるpartial success、`limit`、`truncated`は定義しない。Hostが完全なresponseをresource budget内で構築できない場合は`ResourceLimitExceeded`を返す。
|
||||
Success responseは、requested depthまで完全でなければならない。Node数によるpartial success、`limit`、`truncated`は定義しない。Hostがresource budget内で完全なresponseを構築できなければ、`ResourceLimitExceeded`を返す。
|
||||
|
||||
各nodeのObjectがvalidatorを持つ場合、Clientはrequest pathとchildrenの`name`から導出したpathに対応付けてcacheし、条件付きOperationの`target`を構築できる。
|
||||
|
||||
HostとClientは各nodeの配置pathに対して[Interface scopeの祖先制約](2.1-object-tree-schema.md#interface-scope)を検証する。Self-scopeを含み、root Objectではscope `/`を使用できる。制約に違反する参照を含むObject responseは`InvalidResponse`であり、そのObjectを操作へ使用しない。
|
||||
HostとClientは、各nodeの配置pathに対して[Interface scopeの祖先制約](2.1-object-tree-schema.md#interface-scope)を検証する。Self-scopeを含み、root Objectではscope `/`を使用できる。制約に違反する参照を含むObject responseは`InvalidResponse`とし、そのObjectを操作に使用しない。
|
||||
|
||||
## `fetch_interface`
|
||||
|
||||
Interface参照に対応する現在のInterface Descriptorを取得する。
|
||||
`fetch_interface`は、Interface参照に対応する現在のInterface Descriptorを取得する。
|
||||
|
||||
### Interface参照とvalidator
|
||||
|
||||
`Object.interfaces`の各要素、`FetchInterfaceRequest.interface`、`FetchInterfaceResponse.interface`、`InterfaceTarget.reference`は`InterfaceReference`である。同じWorldspace context内の同じ有効な`(scope, name)`は同じInterfaceを表すが、Hostはvalidatorを使用してDescriptorを時間とともに変更できる。
|
||||
`Object.interfaces`の各要素、`FetchInterfaceRequest.interface`、`FetchInterfaceResponse.interface`、`InterfaceTarget.reference`には、いずれも`InterfaceReference`を使用する。同じWorldspace context内の同じ有効な`(scope, name)`は、同じInterfaceを表す。ただし、Hostはvalidatorを使用してDescriptorを時間とともに変更できる。
|
||||
|
||||
`FetchInterfaceResponse.validator`は、HostがそのInterface Descriptorの現在状態へ付与するoptionalなopaque bytesである。Clientは内容を解釈せず、観測したDescriptorに基づいてOperationを実行するときに、Interface参照とともに`InterfaceTarget`へコピーする。
|
||||
`FetchInterfaceResponse.validator`は、HostがInterface Descriptorの現在状態に付与するoptionalなopaque bytesである。Clientは内容を解釈しない。観測したDescriptorに基づいてOperationを実行するときは、Interface参照とともに`InterfaceTarget`へコピーする。
|
||||
|
||||
同じ有効期間内のInterface参照とvalidatorには次の規則を適用する。有効期間とscope Objectの紐付きは次節に従う。
|
||||
Interface参照とvalidatorには、同じ有効期間内で次の規則を適用する。有効期間はscope Objectとの紐付きに依存する。
|
||||
|
||||
- 同じInterface参照と同じvalidator valueを持つ観測は、validatorが存在する場合、同じInterface Descriptorを表す。
|
||||
- 同じInterface参照と異なるvalidatorを持つ観測は、同じInterfaceの異なる状態を表す。
|
||||
@@ -462,25 +462,33 @@ Interface参照に対応する現在のInterface Descriptorを取得する。
|
||||
- Validator valueはInterface参照と組み合わせて比較する。異なるInterface参照で同じopaque bytesを使用してもよい。
|
||||
- Validatorから状態の順序、新旧、Descriptor内容を推測してはならない。
|
||||
|
||||
HostはInterface validatorの生成方法を自由に選べる。Generation、content hash、database revision等を使用してよい。Clientは生成方法を解釈せず、Interface参照とvalidator valueの組をOperation executionのpreconditionとして使用する。
|
||||
生成方法はHostが自由に選べる。Generation、content hash、database revision等を使用してよい。Clientはその方法を解釈せず、Interface参照とvalidator valueの組をOperation executionのpreconditionとして使用する。
|
||||
|
||||
Interface validatorはAIやユーザーへ公開するApplication dataではない。Client runtimeがInterface Descriptor observationとともに保持するprotocol metadataである。
|
||||
Interface validatorは、Client runtimeがInterface Descriptor observationとともに保持するprotocol metadataである。AIやユーザーへ公開するApplication dataではない。
|
||||
|
||||
### Scope Objectの紐付き
|
||||
|
||||
Interface scopeはそのpathのObjectに紐づく。Scope Objectがそのpathから消えれば、そのObjectに紐づくInterface参照の有効期間は終了し、未公開のInterfaceへの`fetch_interface`は`InterfaceNotFound`となる。削除されたObject自身と消えた配下への`observe` / `call_operation`は`NotFound`となる。同じpathへ別Objectが公開された場合は現在の公開状態で解決する。
|
||||
Interface scopeは、そのpathのObjectに紐づく。Scope Objectがpathから消えると、そのObjectに紐づくInterface参照の有効期間が終了する。未公開のInterfaceへの`fetch_interface`は`InterfaceNotFound`、削除されたObject自身と消えた配下への`observe` / `call_operation`は`NotFound`となる。同じpathへ別Objectが公開された場合は、現在の公開状態で解決する。
|
||||
|
||||
`FetchInterfaceResponse.scope_ref`は、scope Objectが同じ主体へ公開する`Object.ref`である。Hostはその`ref`があれば同じstringを返し、なければfieldを省略する。別のscope専用identityやvalidatorを生成するfieldではない。Scope Objectとの紐付き、Descriptor、Interface validatorは一つの整合した観測として取得し、別々の時点の値を組み合わせない。Clientにscope Objectやその祖先の追加取得を必須にしない。
|
||||
`FetchInterfaceResponse.scope_ref`は、scope Objectが同じ主体へ公開する`Object.ref`である。Hostはその`ref`があれば同じstringを返し、なければfieldを省略する。別のscope専用identityやvalidatorを生成するfieldではない。
|
||||
|
||||
Clientは`scope_ref`をInterface observationとともに保持し、その観測を使うcallの`InterfaceTarget.scope_ref`へ存在する値をコピーする。Hostは指定された`scope_ref`を、現在のscope Objectの`ref`とUnicode code point sequenceの完全一致で比較する。一致しない場合、または現在のObjectが`ref`を持たない場合、その紐付きは確認できず`InterfaceMismatch`とする。異なる`ref`から別Objectだと断定するものではなく、指定された同一性条件を満たせないことを表す。Clientの事前確認だけで済ませず、Hostがcallの他のpreconditionと一貫して確認する。
|
||||
Scope Objectとの紐付き、Descriptor、Interface validatorは、一つの整合した観測として取得する。別々の時点の値を組み合わせず、Clientにscope Objectやその祖先の追加取得を必須にしない。
|
||||
|
||||
`scope_ref`は参照の識別子に含めない。Interfaceの識別とmembershipは引き続き`(scope, name)`であり、通常の表示も変更しない。Scope Objectの状態validatorはこの確認に使わず、通常の状態更新でscopeの紐付きを無効にしない。Descriptorの変更検出には独立したInterface validatorを使う。
|
||||
Clientは`scope_ref`をInterface observationとともに保持し、その観測を使うcallでは、存在する値を`InterfaceTarget.scope_ref`へコピーする。Hostは指定された`scope_ref`と、現在のscope Objectの`ref`をUnicode code point sequenceの完全一致で比較する。一致しない場合、または現在のObjectが`ref`を持たない場合は、紐付きを確認できないため`InterfaceMismatch`とする。
|
||||
|
||||
`scope_ref`が省略されたcallにはscope Objectのidentity preconditionを適用しない。Scope Objectの`ref`はoptionalであり、`ref`なしで同じpathのObjectが差し替わったことをClientが追跡できないのは仕様上許容される。Hostは`ref`がないことやidentityを追跡できないことだけを理由に拒否せず、現在の公開状態と残るpreconditionに従う。Hostにref生成、Clientに疑似refやscope validatorの生成を要求しない。
|
||||
この不一致は、異なる`ref`から別Objectだと断定するものではない。指定された同一性条件を満たせないことを表す。確認はClientの事前確認だけで済ませず、Hostがcallの他のpreconditionと一貫して行う。
|
||||
|
||||
Scopeの削除・移動・差し替えによる有効期間終了を確認したClientは、旧Interface observationを新しいcallへ再利用せず、必要なら現在の参照を再取得する。同じpathへ再公開された参照の観測と、終了した有効期間のcacheは区別する。`scope_ref`の相違や欠落により以前の紐付きを確認できなくなった場合も、旧観測をそのまま付け替えない。異なる有効期間のDescriptorを、同じvalidatorであることだけから同一とみなさない。
|
||||
`scope_ref`は、Interface参照の識別子には含めない。識別とmembershipには引き続き`(scope, name)`を使用し、通常の表示も変更しない。Scope Objectの状態validatorは紐付きの確認に使わず、通常の状態更新で紐付きを無効にしない。Descriptorの変更検出には、独立したInterface validatorを使う。
|
||||
|
||||
`ref`も削除の観測もない場合、有効期間の切り替わりを検出できるとは限らない。Clientは得られた情報で通常のvalidationを行うが、検出不能な差し替えを防ぐ保証はない。この許容は、存続中の同じInterfaceのDescriptor整合性を弱めない。Scopeが別pathへ移動すれば別参照として扱い、Clientは移動を追跡しない。実行開始済みcallが固定した観測と結果検証用Descriptorの保持は、有効期間終了後も維持する。
|
||||
Callで`scope_ref`が省略された場合は、scope Objectのidentity preconditionを適用しない。Scope Objectの`ref`はoptionalであり、`ref`なしではClientが同じpathのObjectの差し替えを追跡できないことも、仕様上許容される。
|
||||
|
||||
Hostは、`ref`がないことやidentityを追跡できないことだけを理由に拒否せず、現在の公開状態と残るpreconditionに従う。Hostにref生成を要求せず、Clientにも疑似refやscope validatorの生成を要求しない。
|
||||
|
||||
Clientがscopeの削除・移動・差し替えによる有効期間終了を確認した場合は、旧Interface observationを新しいcallへ再利用しない。必要なら現在の参照を再取得し、同じpathへ再公開された参照の観測と、終了した有効期間のcacheを区別する。`scope_ref`の相違や欠落によって以前の紐付きを確認できなくなった場合も、旧観測をそのまま付け替えない。異なる有効期間のDescriptorを、同じvalidatorであることだけから同一とみなさない。
|
||||
|
||||
一方、`ref`も削除の観測もなければ、有効期間の切り替わりを検出できるとは限らない。Clientは得られた情報で通常のvalidationを行うが、検出不能な差し替えを防ぐ保証はない。この許容によって、存続中の同じInterfaceのDescriptor整合性が弱まることはない。
|
||||
|
||||
Scopeが別pathへ移動した場合は別参照として扱い、Clientは移動を追跡しない。実行開始済みcallが固定した観測と、結果検証用Descriptorは、有効期間終了後も保持する。
|
||||
|
||||
### Requestとresponse
|
||||
|
||||
@@ -497,31 +505,35 @@ struct FetchInterfaceResponse {
|
||||
}
|
||||
```
|
||||
|
||||
Clientは次を検証する。
|
||||
Clientはresponseに対して、次の検証を行う。
|
||||
|
||||
- Responseの`interface`がrequestと一致する。
|
||||
- Descriptorが構造検証および意味検証に成功する。
|
||||
- Descriptorの`format`がClientの対応するversionである。
|
||||
- 同じ有効期間のInterface参照と同じvalidator valueを持つ既存cache entryがある場合、validatorが存在する観測ではDescriptorがそのentryと一致する。Scopeの紐付きが確認できなくなった旧cacheとの比較は、前節の規則に従う。
|
||||
|
||||
ResponseまたはDescriptorがProtocol contractに違反する場合、ClientはHostが返したProtocol failureではなく`InvalidResponse`として扱う。Descriptorが構造的にはvalidだが、その`format`をClientが実装していない場合は`UnsupportedDescriptorFormat`として扱い、`InvalidResponse`と混在させない。
|
||||
ResponseやDescriptorがProtocol contractに違反した場合は、Hostが返したProtocol failureとはせず、Client-localな`InvalidResponse`として扱う。Descriptorが構造的にはvalidでも、その`format`をClientが実装していない場合は`UnsupportedDescriptorFormat`とする。この二つのclassificationを混在させない。
|
||||
|
||||
Clientはvalidatorが存在するInterface observationを、同じ有効期間内の`(interface, validator)`の組でcacheし、`scope_ref`も観測metadataとして保持する。Interface参照ごとに最後に成功した観測を現在のcache entryとしてよいが、実行中のOperationが参照する観測はresponseのvalidationが完了するまで保持する。次の規則は同じ有効期間の観測に適用し、終了・紐付きの不一致を確認した旧cacheは新しいcallに使わない。
|
||||
Validatorが存在するInterface observationは、同じ有効期間内の`(interface, validator)`をkeyとしてcacheする。Clientは`scope_ref`も観測metadataとして保持する。Interface参照ごとに最後に成功した観測を現在のcache entryとしてよいが、実行中のOperationが参照する観測は、responseのvalidationが完了するまで保持する。
|
||||
|
||||
同じ有効期間の観測には、次の規則を適用する。終了や紐付きの不一致を確認した旧cacheは、新しいcallには使用しない。
|
||||
|
||||
- 同じInterface参照でvalidatorが異なる場合は、同じInterfaceの新しい状態としてlatest observationを更新する。
|
||||
- Validatorが存在しないInterface参照はimmutableなDescriptorとして、Interface参照だけをcache keyに使用できる。
|
||||
- 同じInterface参照と同じvalidator valueで異なるDescriptorを受け取った場合は`InvalidResponse`として扱い、そのDescriptorをOperationへ使用しない。
|
||||
- 新しい観測を得ても、異なるvalidatorを使用して実行中のrequestに必要なDescriptorをresponse validation前に破棄しない。
|
||||
|
||||
`fetch_interface`は現在のDescriptorを観測するinteractionであり、requestにvalidatorを含めない。Responseにvalidatorが存在する場合、Clientは後続の`call_operation`で、そのDescriptorに基づくOperation実行の必須preconditionとして使用する。Responseにvalidatorが存在しない場合、そのInterface参照のDescriptorはimmutableであり、`call_operation`の`interface.validator`を省略する。
|
||||
`fetch_interface`は現在のDescriptorを観測するinteractionであり、requestにvalidatorを含めない。Responseにvalidatorがあれば、Clientは後続の`call_operation`で、そのDescriptorに基づくOperation実行の必須preconditionとして使用する。Validatorがなければ、そのInterface参照のDescriptorはimmutableであり、`call_operation`の`interface.validator`を省略する。
|
||||
|
||||
Hostは、主体へ公開したObjectの`interfaces`に含まれる各参照について、現在のDescriptorをその主体の`fetch_interface`で取得可能にしなければならない。現在公開されていないInterface参照には`InterfaceNotFound`を返し、Descriptor取得の認可失敗として`PermissionDenied`は使用しない。
|
||||
Hostは、主体へ公開したObjectの`interfaces`に含まれる各参照について、その主体が`fetch_interface`で現在のDescriptorを取得できるようにしなければならない。現在公開されていないInterface参照には`InterfaceNotFound`を返す。Descriptor取得の認可失敗として`PermissionDenied`を使用しない。
|
||||
|
||||
`fetch_interface`は完全な`(scope, name)`を受け取るため、Clientに祖先Objectを順に取得するlookupを要求しない。Hostはscope Objectと、そのObjectに紐づくInterfaceの現在の公開状態を解決し、近い祖先の同名Interfaceへのfallbackは行わない。Requestに対象Objectのpathはなく、特定Objectで利用できるかの祖先制約(self-scopeを含む)は`observe`と`call_operation`で検証する。Scope外のObjectで使えないことと、Interface単独の取得可否は同一視しない。
|
||||
`fetch_interface`には完全な`(scope, name)`を指定するため、Clientに祖先Objectを順に取得するlookupは要求しない。Hostはscope Objectと、それに紐づくInterfaceの現在の公開状態を解決する。近い祖先の同名Interfaceへfallbackしない。
|
||||
|
||||
このrequestは、対象Objectのpathを持たない。特定Objectで利用できるかを判定する祖先制約(self-scopeを含む)は、`observe`と`call_operation`で検証する。Scope外のObjectでは使えないことと、Interface単独の取得可否は同一視しない。
|
||||
|
||||
## `call_operation`
|
||||
|
||||
指定pathにあるObjectへOperationを実行する。
|
||||
`call_operation`は、指定pathにあるObjectへOperationを実行する。
|
||||
|
||||
```rust
|
||||
struct Target {
|
||||
@@ -548,15 +560,15 @@ struct CallOperationResponse {
|
||||
}
|
||||
```
|
||||
|
||||
`target.path`はOperationを実行するObjectのpathである。`target.validator`は、Clientが観測したObjectのvalidatorを条件として指定する。Validatorを持たないObjectまたは無条件実行では省略する。
|
||||
`target.path`には、Operationを実行するObjectのpathを指定する。`target.validator`には、Clientが観測したObjectのvalidatorを条件として指定する。Objectがvalidatorを持たない場合や、無条件実行の場合は省略する。
|
||||
|
||||
`interface.reference`は、ClientがObjectの`interfaces`から選択し、そのOperation schemaを取得するために使用したInterface参照である。`interface.validator`は、その参照への`fetch_interface`で観測したvalidatorである。`fetch_interface` responseにvalidatorが存在する場合は必ず指定し、validatorが存在しないimmutableなInterface参照では省略する。
|
||||
`interface.reference`には、ClientがObjectの`interfaces`から選択し、Operation schemaを取得するために使ったInterface参照を指定する。`interface.validator`は、その参照への`fetch_interface`で観測したvalidatorである。Responseにvalidatorが存在する場合は必ず指定し、validatorが存在しないimmutableなInterface参照では省略する。
|
||||
|
||||
`interface.scope_ref`は、そのDescriptor observationで得たscope Objectのoptionalな`ref`である。観測に存在する場合はコピーし、存在しない場合は省略する。Scope Objectを別途観測して得た新しい`ref`だけを古いDescriptorへ付け替えてはならない。紐付きが確認できなくなった場合はInterface observationを取得し直す。
|
||||
`interface.scope_ref`は、そのDescriptor observationで得たscope Objectのoptionalな`ref`である。観測に存在する場合はコピーし、存在しない場合は省略する。Scope Objectを別途観測して得た新しい`ref`だけを、古いDescriptorへ付け替えてはならない。紐付きが確認できなくなった場合は、Interface observationを取得し直す。
|
||||
|
||||
`arguments`はOperation parameterに対応する名前付きrecordである。引数がない場合は空のmapを送る。
|
||||
`arguments`は、Operation parameterに対応する名前付きrecordである。引数がない場合も、空のmapを送る。
|
||||
|
||||
Hostは、現在その主体へ公開しているObjectとInterfaceに基づいてOperationを解決する。複数のfailure条件が同時に成立する場合は、次の順で最初のcodeだけを返す。
|
||||
Hostは、現在その主体へ公開しているObjectとInterfaceに基づいてOperationを解決する。複数のfailure条件が同時に成立する場合も、次の順で最初に該当するcodeだけを返す。
|
||||
|
||||
1. `target.path`のObjectが公開されていない場合は`NotFound`。
|
||||
2. 指定された`target.validator`が現在のObject validatorと一致しない場合は`ValidatorMismatch`。
|
||||
@@ -568,27 +580,27 @@ Hostは、現在その主体へ公開しているObjectとInterfaceに基づい
|
||||
8. `arguments`が現在のOperation declarationに適合しない場合は`InvalidArguments`。
|
||||
9. 宣言されたOperationの存在を公開しつつ、その主体による実行を認めない場合は`PermissionDenied`。
|
||||
|
||||
指定されたObject validatorまたはInterface validatorの不一致は、現在のDescriptorに基づくOperation lookupとarguments validationより先に判定する。これによりClientは、古いObjectまたはschemaに基づくrequestを`OperationNotFound`や`InvalidArguments`として扱わず、対応する観測を更新できる。
|
||||
指定されたObject validatorやInterface validatorの不一致は、現在のDescriptorに基づくOperation lookupとarguments validationより先に判定する。古いObjectやschemaに基づくrequestを`OperationNotFound`や`InvalidArguments`として扱わず、Clientが対応する観測を更新できるようにするためである。
|
||||
|
||||
Hostは一つのlinearization pointで、現在のObject identityと状態、Object validator、Objectが公開するInterface参照、scope Objectとの紐付きと指定された`scope_ref`、Interface validator、Interface Descriptor、Operation implementation、実行対象を解決し、各preconditionを確認して、そのcallが使用する観測を固定する。ObjectまたはInterfaceが後から変化しても、callの途中で別のscope Object、Descriptor、Operation implementation、実行対象へ解決し直してはならない。
|
||||
Hostは一つのlinearization pointで、現在のObject identityと状態、Object validator、Objectが公開するInterface参照、scope Objectとの紐付きと指定された`scope_ref`、Interface validator、Interface Descriptor、Operation implementation、実行対象を解決する。同じ時点で各preconditionを確認し、そのcallが使用する観測を固定する。
|
||||
|
||||
Argumentsとresultのvalidationは、固定したInterface Descriptorの同じOperation declarationを使用する。Authorizationも固定したObject、Interface、主体のcontextに対して評価する。
|
||||
その後にObjectやInterfaceが変化しても、callの途中で別のscope Object、Descriptor、Operation implementation、実行対象へ解決し直してはならない。Argumentsとresultのvalidationには、固定したInterface Descriptorの同じOperation declarationを使用する。Authorizationも、固定したObject、Interface、主体のcontextに対して評価する。
|
||||
|
||||
Hostが`target.validator`をbackendへのcommit条件として使用する場合、validatorの比較とcommitをatomicに行う。Responseの`validator`は、Operation完了後にHostがそのObjectについて発行する現在のvalidatorである。Operationが何を行ったか、またはApplication dataを変更したかをvalidatorの有無や値から推測しない。Object validatorを提供しない場合は省略する。
|
||||
Hostが`target.validator`をbackendへのcommit条件として使用する場合は、validatorの比較とcommitをatomicに行う。Responseの`validator`は、Operation完了後にHostがそのObjectについて発行する現在のvalidatorであり、Object validatorを提供しない場合は省略する。Validatorの有無や値から、Operationが何を行ったか、Application dataを変更したかを推測しない。
|
||||
|
||||
Validatorの比較はopaque bytesの完全一致で行う。Clientはvalidatorの生成方法または内容に基づいて分岐してはならない。
|
||||
Validatorはopaque bytesの完全一致で比較する。Clientは生成方法や内容に基づいて分岐してはならない。
|
||||
|
||||
HostはOperationをInterfaceから除外して存在を非公開にすることも、Interfaceへ含めたうえで実行時に`PermissionDenied`として拒否することもできる。
|
||||
Hostは、OperationをInterfaceから除外して存在を非公開にしてもよい。Interfaceに含めたうえで、実行時に`PermissionDenied`として拒否することもできる。
|
||||
|
||||
Clientはresultを、requestの`interface`が指定し、Hostがpreconditionを確認して固定したOperation return typeに従って検証する。Validationに失敗したsuccess responseはHostから返されたProtocol failureではなく`InvalidResponse`として扱い、そのvalueをApplicationへ渡さない。
|
||||
Clientはresultを、requestの`interface`が指定し、Hostがprecondition確認時に固定したOperation return typeに従って検証する。Success responseがvalidationに失敗した場合は、Hostから返されたProtocol failureとはせず、`InvalidResponse`として扱う。そのvalueをApplicationへ渡さない。
|
||||
|
||||
Host Runtimeがimplementation resultのencodeまたはvalidation failureを送信前に検出し、Operationのeffectがcommitされていないと保証できる場合は`Internal`を返す。Operationが未実行またはrollback済みであることを保証できず、validなsuccess responseも返せない場合は`OperationOutcomeUnknown`を返す。
|
||||
Host Runtimeがimplementation resultのencodeやvalidationのfailureを送信前に検出した場合は、effectのcommit状況に応じてerrorを返す。Operationのeffectがcommitされていないと保証できる場合は`Internal`とする。未実行またはrollback済みであることを保証できず、validなsuccess responseも返せない場合は`OperationOutcomeUnknown`とする。
|
||||
|
||||
## Worldspace composition
|
||||
|
||||
Clientが扱う絶対pathとInterface scopeは、常に接続先Hostが公開するClient-facing Worldspaceのrootに基づく。Proxyや合成によってUpstream Worldspaceの公開位置が変わる場合、Hostが公開先とUpstreamの座標を対応付ける。Upstreamのrootやmount構成をClientに追跡・補完させない。
|
||||
Clientが扱う絶対pathとInterface scopeは、常に接続先Hostが公開するClient-facing Worldspaceのrootを基準にする。Proxyや合成によってUpstream Worldspaceの公開位置が変わる場合は、Hostが公開先とUpstreamの座標を対応付ける。Upstreamのrootやmount構成を、Clientに追跡・補完させない。
|
||||
|
||||
Upstreamのrootを`/github`へ配置する例:
|
||||
Upstreamのrootを`/github`へ配置する場合、座標は次のように対応する。
|
||||
|
||||
| 項目 | Upstream | 公開先 |
|
||||
| --- | --- | --- |
|
||||
@@ -598,11 +610,13 @@ Upstreamのrootを`/github`へ配置する例:
|
||||
| Repository scope | `/my-rep` | `/github/my-rep` |
|
||||
| `entry` | `/my-rep/issues/123` | `/github/my-rep/issues/123` |
|
||||
|
||||
別のUpstreamを`/gitlab`へ配置した場合、その`{ scope: "/", name: "issue.read" }`は`{ scope: "/gitlab", name: "issue.read" }`となる。同じlocal nameでもscopeによって区別される。さらに全体を`/services`へ配置するHostは、`/github`を`/services/github`へ変換する。Worldspaceの配置を変えないHTTP proxyはscopeを変更する必要がない。HTTP endpointのpath prefixとWorldspace pathを混同しない。
|
||||
別のUpstreamを`/gitlab`へ配置した場合、その`{ scope: "/", name: "issue.read" }`は`{ scope: "/gitlab", name: "issue.read" }`となる。同じlocal nameでも、scopeによって区別する。さらに全体を`/services`へ配置するHostは、`/github`を`/services/github`へ変換する。
|
||||
|
||||
一方、Worldspaceの配置を変えないHTTP proxyは、scopeを変更する必要がない。HTTP endpointのpath prefixとWorldspace pathは混同しない。
|
||||
|
||||
### 座標の変換
|
||||
|
||||
合成Hostは次の対応を一貫させる。
|
||||
合成Hostは、次の対応を一貫させる。
|
||||
|
||||
- `observe`のrequest pathをUpstreamへ変換する。ResponseのObjectの`name`とchildrenは公開先pathの規則を満たすようにする。特にUpstream rootの空の`name`を、非rootのmount先へそのまま公開しない。
|
||||
- `Object.interfaces`の各構造化参照のscopeを公開先へ変換し、公開先での祖先制約と名前の一意性を保つ。
|
||||
@@ -611,9 +625,11 @@ Upstreamのrootを`/github`へ配置する例:
|
||||
- `scope_ref`は公開先scope Objectの`ref`と一致させる。Upstreamのscope Objectが持つ`ref`との対応をHostが保持し、公開先で指定されたpreconditionを検証してからUpstreamへ対応付ける。Opaqueな`ref`へpath prefixを機械的に加えたり、指定された条件を転送時に落としたりしない。公開先scope Objectが`ref`を持たない形態も許可する。
|
||||
- Descriptorで`entry`と宣言されたargumentsをUpstreamへ、resultを公開先へ変換する。Named type、record、list、unionの内部にある`entry`も同じ規則で扱う。
|
||||
|
||||
通常の`string`、opaqueな`json`、documentation内のpathらしい文字列を推測して書き換えない。Application固有の別の座標系はApplication adapterの責務とする。
|
||||
通常の`string`、opaqueな`json`、documentation内のpathらしい文字列は、推測して書き換えない。Application固有の別の座標系は、Application adapterが扱う。
|
||||
|
||||
公開subtree外のscopeや`entry`に対応付けがない場合、Upstream pathをそのまま公開したり、scopeを削除・rootへ付け替えたりしてはならない。部分合成の対応付け方針と詳細な拒否条件は未確定だが、失敗時も既存のProtocol failure契約を守る。特にUpstreamでeffectがcommit済みのOperationのresultを変換できない場合、未実行を保証するerrorへ置き換えない。Validなsuccessを返せず、未実行またはrollbackを保証できない場合は`OperationOutcomeUnknown`として扱う。
|
||||
公開subtree外のscopeや`entry`に対応付けがない場合、Upstream pathをそのまま公開したり、scopeを削除したりrootへ付け替えたりしてはならない。部分合成の対応付け方針と詳細な拒否条件は未確定だが、失敗時にも既存のProtocol failure契約を守る。
|
||||
|
||||
特に、Upstreamでeffectがcommit済みのOperationについてresultを変換できない場合は、未実行を保証するerrorへ置き換えない。Validなsuccessを返せず、未実行またはrollbackを保証できない場合は`OperationOutcomeUnknown`として扱う。
|
||||
|
||||
### 同一性と観測整合性
|
||||
|
||||
@@ -623,11 +639,13 @@ Upstreamのrootを`/github`へ配置する例:
|
||||
- Upstream validatorをそのまま使用するか、Host側で対応付け・wrapするかは、これらの保証を満たす範囲でHostが決める。
|
||||
- Scopeが変わればProtocol上は別のInterface参照である。Clientに移動前後の同一性を推測させない。
|
||||
|
||||
同じUpstream Objectを複数のscope配下へ配置すると、公開するInterface集合が配置ごとに変わり得る。異なる公開representationへ同じ`ref`とvalidatorを無条件に引き継げない。共通祖先scopeでの共有やprojectionごとのidentity割当等の選択は[未解決事項](draft/unresolved-issues.md)に残す。Renameやmount変更では新しいscopeを別参照として扱い、同じpathでのscope Objectの差し替えには上述のoptionalな`scope_ref`の確認を適用する。Clientによる移動追跡は要求しない。これらは既存のvalidator・実行中callの一貫性を弱めない。
|
||||
同じUpstream Objectを複数のscope配下へ配置すると、公開するInterface集合は配置ごとに変わり得る。このため、異なる公開representationへ同じ`ref`とvalidatorを無条件には引き継げない。共通祖先scopeでの共有や、projectionごとのidentity割当等の選択は、[未解決事項](draft/unresolved-issues.md)に残る。
|
||||
|
||||
Renameやmount変更では、新しいscopeを別参照として扱う。同じpathでscope Objectが差し替わる場合は、optionalな`scope_ref`の確認を適用する。Clientによる移動追跡は要求しない。これらの扱いによって、既存のvalidatorと実行中callの一貫性を弱めない。
|
||||
|
||||
## Protocol responseとfailure
|
||||
|
||||
Core interactionは、成功responseまたはProtocol failureを返す。
|
||||
Core interactionの結果は、成功responseまたはProtocol failureである。
|
||||
|
||||
```rust
|
||||
enum ProtocolResult<T> {
|
||||
@@ -658,9 +676,9 @@ enum ProtocolErrorCode {
|
||||
}
|
||||
```
|
||||
|
||||
`message`は人間向けのboundedな診断であり、Clientが分岐に使用する安定した値ではない。Clientは`code`を使用する。Hostは秘密情報、非公開Object、backend path、credential、内部stack traceをmessageへ含めない。
|
||||
Clientが分岐に使用するのは`code`である。`message`は人間向けのboundedな診断であり、分岐用の安定した値ではない。Hostは秘密情報、非公開Object、backend path、credential、内部stack traceをmessageへ含めない。
|
||||
|
||||
各interactionが返してよいcodeは次に限定する。
|
||||
返してよいcodeは、interactionごとに次のものへ限定する。
|
||||
|
||||
| Code | `observe` | `fetch_interface` | `call_operation` |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -679,9 +697,9 @@ enum ProtocolErrorCode {
|
||||
| `Internal` | ✓ | ✓ | ✓ |
|
||||
| `OperationOutcomeUnknown` | — | — | ✓ |
|
||||
|
||||
Hostがinteractionに許可されていないcodeを返した場合、Clientはそのerror responseを`InvalidResponse`として扱う。
|
||||
Hostがそのinteractionに許可されていないcodeを返した場合、Clientはerror responseを`InvalidResponse`として扱う。
|
||||
|
||||
Validatorおよび観測関連errorの回復対象は次のとおりである。
|
||||
Validatorと観測に関するerrorの回復対象は、次のとおりである。
|
||||
|
||||
- `NotFound`では、Clientは対象Entryをstaleとして扱い、必要に応じて親を再観測する。
|
||||
- `InterfaceNotFound`では、ClientはそのInterface observationを使用せず、参照元Objectを再観測できる。
|
||||
@@ -689,28 +707,30 @@ Validatorおよび観測関連errorの回復対象は次のとおりである。
|
||||
- `InterfaceMismatch`では、Clientは対象Objectを再観測し、現在公開されているInterface参照の集合を取得する。Scopeの紐付きが変わった可能性もあるため、使用したInterface observationを新しいcallへ再利用せず、必要なら`fetch_interface`で再取得する。
|
||||
- `InterfaceValidatorRequired`または`InterfaceValidatorMismatch`では、Clientは同じInterface参照を`fetch_interface`で再観測し、現在のDescriptorに基づいてOperationを再構築する。
|
||||
|
||||
同じOperationを再試行するなら、先に新しい観測を取得しなければならない。
|
||||
同じOperationを再試行する場合は、先に新しい観測を取得しなければならない。
|
||||
|
||||
- `InvalidRequest`または`InvalidArguments`では、Clientは同じrequestを変更せずに再送しない。
|
||||
- `ResourceLimitExceeded`では、Clientはlogical request shapeやobservation depth等を削減できるが、`call_operation`を自動再実行しない。
|
||||
|
||||
`call_operation`が`OperationOutcomeUnknown`以外のProtocol failureを返した場合、HostはOperationのeffectがcommitされていないことを保証する。`OperationOutcomeUnknown`は、HostがOperationの未実行またはrollbackを保証できず、validなsuccess responseも返せないことを示す。Clientは対象Objectをstaleとして扱い、Operationを自動再実行せず、再観測またはdomain固有の照会手段を利用者へ提示する。
|
||||
`call_operation`が`OperationOutcomeUnknown`以外のProtocol failureを返した場合、HostはOperationのeffectがcommitされていないことを保証する。`OperationOutcomeUnknown`を返すのは、未実行またはrollbackを保証できず、validなsuccess responseも返せない場合である。
|
||||
|
||||
`Internal`はHost内部のfailureであり、`call_operation`ではeffectがcommitされていないと保証できる場合だけ返す。Clientは`Internal`を理由にOperationを自動再実行しない。Transport timeoutやconnection lossでresponse自体を受け取れない場合もOperation outcomeは不明になり得るが、それはProtocol failureではなくTransport failureとして扱う。
|
||||
`OperationOutcomeUnknown`を受けたClientは、対象Objectをstaleとして扱い、Operationを自動再実行しない。再観測またはdomain固有の照会手段を、利用者へ提示する。
|
||||
|
||||
Objectを主体へ公開しない場合、HostはそのObjectをClient-facing Worldspaceに存在しないものとして扱い、`observe`と`call_operation`へ`NotFound`を返す。Objectの存在を秘匿する目的で`PermissionDenied`を返してはならない。
|
||||
`Internal`はHost内部のfailureであり、`call_operation`ではeffectがcommitされていないと保証できる場合だけ返す。Clientは`Internal`を理由にOperationを自動再実行しない。Transport timeoutやconnection lossでresponse自体を受け取れない場合も、Operation outcomeは不明になり得る。ただし、これはProtocol failureではなくTransport failureとして扱う。
|
||||
|
||||
`PermissionDenied`は、現在公開されているInterfaceに宣言されたOperationについて、その存在を公開しつつ実行を認めない場合に使用する。Operation自体をInterfaceから除外している場合は`OperationNotFound`を返す。Operation argumentが参照するdomain resourceの不存在やbusiness rule failureを、対象Objectの`NotFound`へ変換しない。
|
||||
Objectを主体へ公開しない場合、HostはそのObjectをClient-facing Worldspaceに存在しないものとして扱う。`observe`と`call_operation`には`NotFound`を返し、存在を秘匿する目的で`PermissionDenied`を返してはならない。
|
||||
|
||||
`InvalidResponse`と`UnsupportedDescriptorFormat`はClient-localなclassificationであり、`ProtocolErrorCode`ではない。前者はsuccess responseまたはerror responseがProtocol contractに違反した場合、後者はvalidなDescriptor formatをClientが実装していない場合に使用する。
|
||||
`PermissionDenied`は、現在公開されているInterfaceに宣言されたOperationの存在を公開しつつ、実行を認めない場合に使用する。Operation自体をInterfaceから除外している場合は、`OperationNotFound`を返す。Operation argumentが参照するdomain resourceの不存在やbusiness rule failureを、対象Objectの`NotFound`へ変換しない。
|
||||
|
||||
Protocol failureとdomain failureを混在させない。現在のProtocolでは、Domain failureをApplicationのOperation resultに含まれるUnion caseとして明示する。専用のfailure envelopeは定義しない。
|
||||
`InvalidResponse`と`UnsupportedDescriptorFormat`は、いずれもClient-localなclassificationであり、`ProtocolErrorCode`には含めない。Success responseまたはerror responseがProtocol contractに違反した場合は前者、validなDescriptor formatをClientが実装していない場合は後者を使用する。
|
||||
|
||||
Transport BindingはProtocol Resultをstatus code、response envelope、stream termination等へ対応付ける。Authentication、route、media type、framing、response不達等のTransport failureをProtocol errorへ変換しない。
|
||||
Protocol failureとdomain failureは混在させない。現在のProtocolでは、domain failureをApplicationのOperation resultに含まれるUnion caseとして明示する。専用のfailure envelopeは定義しない。
|
||||
|
||||
Transport Bindingは、Protocol Resultをstatus code、response envelope、stream termination等へ対応付ける。Authentication、route、media type、framing、response不達等のTransport failureを、Protocol errorへ変換しない。
|
||||
|
||||
## Resource limit
|
||||
|
||||
HostとClientは、untrusted inputによるresource exhaustionを避けるため、少なくとも次へ上限を設ける。
|
||||
HostとClientは、untrusted inputによるresource exhaustionを避けるため、少なくとも次の対象へ上限を設ける。
|
||||
|
||||
- Pathとして解釈するstring、validator、name、description、documentationの長さ。
|
||||
- Interface Descriptor内のdeclaration、documentation、field等の総量。
|
||||
@@ -721,13 +741,17 @@ HostとClientは、untrusted inputによるresource exhaustionを避けるため
|
||||
- `observe`のdepthとresponse node数。
|
||||
- Protocol error messageの長さ。
|
||||
|
||||
各実装はこれらにboundedな上限を設定する。Request fieldだけで判定できる上限違反は、interaction固有のtarget resolutionまたはOperation dispatchより前に`ResourceLimitExceeded`として拒否する。Hostが取得responseを構築する途中でresponse node数やlogical collection size等の上限超過を検出した場合も、success responseの代わりに`ResourceLimitExceeded`を返してよい。
|
||||
各実装は、これらにboundedな上限を設定する。Request fieldだけで判定できる上限違反は、interaction固有のtarget resolutionやOperation dispatchより前に、`ResourceLimitExceeded`として拒否する。
|
||||
|
||||
`call_operation`では、dispatch後にresultのlogical size、validation、backend budget等の問題を検出しても、effectがcommitされていないと保証できる場合だけ`ResourceLimitExceeded`または`Internal`を返す。未実行またはrollbackを保証できない場合は`OperationOutcomeUnknown`とする。Transport implementationまたはdeploymentのmessage-size上限、response不達等はProtocol failureへ変換せず、Transport failureとして扱う。
|
||||
Hostが取得responseを構築する途中で、response node数やlogical collection size等の上限超過を検出した場合も、success responseの代わりに`ResourceLimitExceeded`を返してよい。
|
||||
|
||||
`call_operation`では、dispatch後にresultのlogical size、validation、backend budget等の問題を検出した場合、effectがcommitされていないと保証できるときだけ`ResourceLimitExceeded`または`Internal`を返す。未実行またはrollbackを保証できない場合は`OperationOutcomeUnknown`とする。
|
||||
|
||||
Transport implementationやdeploymentのmessage-size上限、response不達等は、Protocol failureへ変換しない。これらはTransport failureとして扱う。
|
||||
|
||||
## Transport Bindingの要件
|
||||
|
||||
Transport Bindingは、この章のlogical structについて次を定義する。
|
||||
Transport Bindingは、これらのlogical structに対して次を定義する。
|
||||
|
||||
- Requestとresponseのframing。
|
||||
- Protocol primitiveのconcrete encoding。
|
||||
@@ -736,6 +760,6 @@ Transport Bindingは、この章のlogical structについて次を定義する
|
||||
- Protocol failureのmapping。
|
||||
- Object validator、Interface validator、Operation preconditionのmapping。
|
||||
|
||||
Transport固有のmessage size、timeout、concurrency、security、connection policyはTransport実装とdeploymentの責務であり、BindingがWIP Protocolとして値またはnegotiationを定義する必要はない。複数のinteractionを並行実行またはmultiplexする場合も、それぞれは独立したrequestとresponseを持つ。
|
||||
Transport固有のmessage size、timeout、concurrency、security、connection policyは、Transport実装とdeploymentの責務である。BindingがWIP Protocolとして値やnegotiationを定義する必要はない。複数のinteractionを並行実行またはmultiplexする場合も、それぞれは独立したrequestとresponseを持つ。
|
||||
|
||||
Transport Bindingは、logical typeの区別またはvalidation semanticsを失ってはならない。
|
||||
Transport Bindingは、logical typeの区別やvalidation semanticsを失ってはならない。
|
||||
|
||||
+67
-65
@@ -2,57 +2,55 @@
|
||||
|
||||
## 役割
|
||||
|
||||
Clientは、Hostが提供するWorldspaceを利用する実装主体である。WIP Protocolを利用しつつ、Objectが参照するInterface集合を解決し、用途に応じたView、Tool、API等の上位インターフェースへ投影する。
|
||||
Clientは、Hostが提供するWorldspaceを利用する実装主体である。WIP ProtocolでObjectを観測し、そのObjectが参照するInterface集合を解決して、用途に応じたView、Tool、API等へ投影する。
|
||||
|
||||
ClientはHostから得た情報をinteractionごとに破棄するだけでなく、rootからindexable edgeを辿って構成したエントリツリーと、必要に応じて選択したエントリを保持し、上位層にとっての**既知の空間**を構成する。
|
||||
取得した情報は、interactionごとに捨てる必要はない。Rootからindexable edgeを辿って得たエントリツリーや、必要に応じて選んだエントリを保持すれば、上位層はそれを既知の空間として利用できる。
|
||||
|
||||
## Known Space
|
||||
|
||||
Known Spaceは、Clientが**現在保持しているエントリの集合**である。過去に発見したすべてのエントリを単調に蓄積する集合ではない。
|
||||
Known Spaceは、Clientが現在保持しているエントリの集合である。過去に発見したすべてのエントリを、増え続ける集合として蓄積するわけではない。
|
||||
|
||||
Known SpaceとDiscoverable SpaceはClientが過去の観測から構築したcacheであり、Host上の現在状態を同期したreplicaではない。HostはClientのcache状態を追跡または更新しない。
|
||||
Known SpaceとDiscoverable Spaceは、過去の観測から構築したcacheである。Hostの現在状態を同期したreplicaではなく、HostはClientのcache状態を追跡・更新しない。
|
||||
|
||||
Clientは、観測結果が同じeffective Worldspace viewに属する範囲でcacheを共有する。接続先、認証主体、tenant、その他のsecurity contextによって異なるWorldspaceが公開され得る場合、それらのKnown Space、Object observation、Interface cacheを混在させない。具体的なcache namespaceの構成はClientおよびTransport Bindingに委ねる。
|
||||
Cacheを共有できるのは、観測結果が同じeffective Worldspace viewに属する範囲に限られる。接続先、認証主体、tenant、その他のsecurity contextによって異なるWorldspaceが公開され得る場合、それらのKnown Space、Object observation、Interface cacheを混在させない。具体的なcache namespaceの構成は、ClientとTransport Bindingに委ねる。
|
||||
|
||||
次のような経路で得られたエントリをKnown Spaceへ追加できる。
|
||||
Known Spaceへ追加できるのは、例えば次のエントリである。
|
||||
|
||||
- `observe`で取得したエントリ
|
||||
- Operation結果の`entry`型pathを`depth = 0`で`observe`して取得したエントリ
|
||||
|
||||
Operationを通して発見されたpathは、必ずしもKnown Spaceへ追加する必要はない。Clientは現在の結果として一時的に扱うことも、取得後に必要なエントリだけを選択して保持することもできる。
|
||||
|
||||
既知エントリ間の移動や参照はClient側で処理できる。既知の空間を辿るたびにHostへ問い合わせる必要はない。
|
||||
Operationで発見したpathを、すべてKnown Spaceへ追加する必要はない。現在の結果として一時的に扱っても、取得後に必要なエントリだけを保持してもよい。保持したエントリ間の移動や参照はClient側で処理でき、辿るたびにHostへ問い合わせる必要はない。
|
||||
|
||||
## 取得・保持・提示の分離
|
||||
|
||||
ユーザーやAIがpathを知っていること、ClientがObjectを観測・保持していること、Clientがindexable edgeも観測・保持していることは別の状態である。
|
||||
Pathを知っていても、ClientがそのObjectを観測しているとは限らない。Objectを観測・保持していても、そこへ至るindexable edgeを観測・保持しているとは限らない。この三つは別の状態である。
|
||||
|
||||
ClientはKnown Spaceにない有効なpathも取得対象にできる。過去に観測していないことだけを理由に取得を拒否せず、現在のHostの公開状態に従って`observe`する。取得前に仮のObjectをKnown Spaceへ登録する必要はない。Pathを知っていること自体は公開可視性や実行権限を保証しない。
|
||||
Clientは、Known Spaceにない有効なpathも取得対象にできる。未観測であることだけを理由に拒否せず、現在のHostの公開状態に従って`observe`する。取得前に仮のObjectをKnown Spaceへ登録する必要はない。ただし、pathを知っていることは、公開可視性や実行権限の保証にはならない。
|
||||
|
||||
観測したObjectのどの情報を提示するかは、上位インターフェースの責務である。ClientがObject情報を保持していても、配置だけを提示する場面ですべての説明やInterfaceを展開する必要はない。提示済みの情報とcache内容を同一視しない。
|
||||
観測した情報のうち何を提示するかは、上位インターフェースが決める。配置だけを提示する場面では、保持している説明やInterfaceをすべて展開しなくてもよい。提示済みの情報とcache内容を同一視しない。
|
||||
|
||||
Objectを観測・保持したことだけでは、そのpathを既知のtreeへ接続するindexable edgeを得たことにはならない。Treeの構成には観測済みのedgeを使い、pathのprefixや階層関係からedgeを推測しない。
|
||||
同様に、Objectを保持していることだけを根拠に、既知のtreeへ接続してはならない。Treeは観測済みのindexable edgeから構成し、pathのprefixや階層関係からedgeを推測しない。
|
||||
|
||||
## Entry TreeとObject observation
|
||||
|
||||
Clientは、観測したObjectをcanonical path上のEntryへ対応付け、観測済みのindexable child edgeとともに保持できる。具体的なcache表現は規定しない。Clientは`ObjectObservation`に近い入れ子構造またはforestを保持しても、path index、flatなObject store、Entry Treeへnormalizeしてもよい。
|
||||
Clientは、観測したObjectをcanonical path上のEntryに対応付け、観測済みのindexable child edgeとともに保持できる。Cacheの表現は規定しない。`ObjectObservation`に近い入れ子構造やforestを使っても、path index、flatなObject store、Entry Treeへnormalizeしてもよい。
|
||||
|
||||
EntryのpathとchildrenはWorldspace上の配置contextであり、Object identityそのものには含まれない。同じObjectが複数のEntryから観測される場合、Clientはshared record、複製したsnapshot、その他の内部表現を使用できる。ただし、保存形式によって`ref`、validator、Object representationのconsistencyを変えてはならない。
|
||||
Entryのpathとchildrenは配置contextであり、Object identityには含まれない。同じObjectが複数のEntryから観測される場合、shared record、複製したsnapshot、その他の内部表現を使える。ただし、保存形式を変えても、`ref`、validator、Object representationのconsistencyは変えてはならない。
|
||||
|
||||
- 同じ有効な`ref`を持つ観測は、同じWorldspace内の同じObjectを表す。Clientは一つのObject stateを共有しても、Entryごとにsnapshotを保持してもよい。
|
||||
- `ref`がないこと、または異なることから、複数のEntryが同じObjectか異なるObjectかを判断しない。Clientはrepresentationが同じという理由だけでObject identityを推測しない。
|
||||
- Objectの`interfaces`はmembershipをsetとして扱い、同じ参照の重複を`InvalidResponse`として拒否する。ClientはHostの立項順を保持し、InterfaceやOperationの提示順に使用してよい。
|
||||
- 同じ`ref`と同じvalidator valueを持つObjectを再観測したとき、validatorが存在するにもかかわらずObject representationが異なる場合、Clientはresponseを`InvalidResponse`として扱う。同じ`ref`でvalidatorが異なる場合、またはvalidatorが存在しない場合は、同一Objectの新しい観測として扱い、最後に受理した成功観測をその`ref`の現在状態とする。
|
||||
|
||||
このcache表現はClient内部の実装であり、Objectと別のWIP resourceまたはProtocol上のEntry resourceを導入するものではない。
|
||||
Entry TreeはClient内部のcache表現であり、Objectとは別のWIP resourceやProtocol上のEntry resourceを追加するものではない。
|
||||
|
||||
## Interface cache
|
||||
|
||||
Interface参照は`{ scope, name }`のrecordであり、同じeffective Worldspace view内の`(scope, name)`の完全一致で比較する。Local nameだけ、表示用の短縮表記、JSON serializationのbyte列をcache keyにしない。Scopeが異なれば別参照であり、Host内部のUpstream座標へ読み替えない。
|
||||
Interface参照は`{ scope, name }`というrecordである。比較には、同じeffective Worldspace view内の`(scope, name)`の完全一致を使う。Local nameだけ、表示用の短縮表記、JSON serializationのbyte列をcache keyにしない。Scopeが異なれば別参照であり、Host内部のUpstream座標へ読み替えない。
|
||||
|
||||
Clientは各Objectの配置pathに対して[scopeの祖先制約](2.1-object-tree-schema.md#interface-scope)を検証する。参照の解決には完全なscopeとnameを使い、祖先Objectの順次取得、同名Interfaceの探索、shadowingを導入しない。Scopeの適合だけでInterface membershipやauthorizationを推測しない。
|
||||
Clientは各Objectの配置pathに対して[scopeの祖先制約](2.1-object-tree-schema.md#interface-scope)を検証する。解決には完全なscopeとnameを使い、祖先Objectの順次取得、同名Interfaceの探索、shadowingを導入しない。Scopeが適合しても、それだけでInterface membershipやauthorizationがあるとは判断しない。
|
||||
|
||||
Objectの公開情報はOperation定義を直接含まず、Interface参照のordered setを持つ。Clientは必要な参照を`fetch_interface`で解決し、取得したInterface Descriptorとoptionalなvalidatorを観測としてcacheする。
|
||||
Objectの公開情報には、Operation定義そのものではなく、Interface参照のordered setが含まれる。Clientは必要な参照を`fetch_interface`で解決し、Descriptorとoptionalなvalidatorを観測としてcacheする。
|
||||
|
||||
```rust
|
||||
struct CachedInterface {
|
||||
@@ -62,91 +60,95 @@ struct CachedInterface {
|
||||
}
|
||||
```
|
||||
|
||||
Clientは同じ有効期間内でvalidatorが存在する観測を`(interface, validator)`の組で識別し、Interface参照ごとに最後に成功した観測をcurrent entryとして保持できる。Validatorが存在しないInterface参照は、その参照が有効な間Descriptorがimmutableなので、参照だけをcache keyに使用できる。
|
||||
Validatorがある観測は、同じ有効期間内で`(interface, validator)`の組により識別する。ClientはInterface参照ごとに、最後に成功した観測をcurrent entryとして保持できる。Validatorがない場合、その参照が有効な間はDescriptorがimmutableなので、参照だけをcache keyに使える。
|
||||
|
||||
`scope_ref`は`fetch_interface`が返すscope Objectのoptionalな`ref`であり、参照の第三成分ではなく観測metadataとして保持する。観測に存在する場合はcallの`interface.scope_ref`へコピーし、なければ省略する。Clientはscope Objectや祖先を追加取得する必要がない。Scope Objectを別途観測して紐付きを確認できなくなった場合は、古いDescriptorの`scope_ref`だけを更新せず、Interface observationを取得し直す。
|
||||
`scope_ref`は、`fetch_interface`が返すscope Objectのoptionalな`ref`である。参照の第三成分ではなく、観測metadataとして保持する。存在する場合はcallの`interface.scope_ref`へコピーし、なければ省略する。Scope Objectや祖先を追加取得する必要はない。
|
||||
|
||||
Scope Objectがそのpathから消えたことを確認したClientは、そのscopeのInterface cacheを破棄できる。Scope Objectの`ref`が以前の紐付きを確認できない値へ変わった場合や`ref`がなくなった場合も、旧観測を新しいcallへ使用しない。有効期間が終了した旧観測と、同じpathへ再公開された新しい観測を区別する。Scopeの移動先を追跡してcacheを付け替えない。
|
||||
Scope Objectを別途観測した結果、以前の紐付きを確認できなくなった場合は、Interface observationを取得し直す。古いDescriptorの`scope_ref`だけを更新してはならない。
|
||||
|
||||
`ref`のないscopeが同じpathで差し替わる場合、Clientがそのidentityや有効期間の切り替わりを追跡できないことは仕様上許容される。欠落を不正なresponseとしたり、疑似identityを生成したりしない。得られた情報によるDescriptor・validatorの検証は引き続き行うが、検出不能な差し替えの防止は保証しない。
|
||||
Scope Objectがそのpathから消えたことを確認したClientは、そのscopeのInterface cacheを破棄できる。`ref`が以前の紐付きを確認できない値に変わった場合や、`ref`がなくなった場合も、旧観測を新しいcallに使わない。有効期間が終了した旧観測と、同じpathへ再公開された新しい観測を区別し、scopeの移動先を追跡してcacheを付け替えない。
|
||||
|
||||
削除の確認とClient-localなcache evictionを混同しない。Indexable edgeが一覧から消えただけでは、直接観測可能な非indexable Objectとして存続している可能性があり、scope Objectの削除を断定しない。削除の即時検出、監視、全Interfaceの先行再取得は要求しない。破棄可能なcacheであっても、実行中callの結果検証に必要なDescriptorは完了まで保持する。
|
||||
一方、`ref`のないscopeが同じpathで差し替わる場合、Clientがidentityや有効期間の切り替わりを追跡できないことは許容される。`ref`の欠落を不正なresponseとしたり、疑似identityを生成したりしない。得られた情報によるDescriptorとvalidatorの検証は続けるが、検出不能な差し替えの防止までは保証しない。
|
||||
|
||||
一つのInterface Descriptorに宣言されたOperationの集合が、そのInterfaceのOperation一覧である。Objectは複数のInterfaceを組み合わせられ、複数のObjectも同じInterfaceを共有できる。
|
||||
Indexable edgeが一覧から消えても、scope Objectが削除されたとは限らない。直接観測可能な非indexable Objectとして存続している可能性があるためである。削除の確認と、Client-localなcache evictionを混同しない。削除の即時検出、監視、全Interfaceの先行再取得も要求しない。
|
||||
|
||||
ClientはDescriptorが必要なInterface参照とcacheを比較し、未知のInterfaceだけを取得する。Objectの`interfaces`からDescriptor interestを先行して導出しても、Operationを選択した時点で必要な参照だけを取得してもよい。Interface validator mismatch等で更新が必要になった参照は再取得する。複数の`fetch_interface`が必要な場合、それぞれを個別のrequestとして並行実行できる。
|
||||
Objectは複数のInterfaceを組み合わせられ、複数のObjectも同じInterfaceを共有できる。それぞれのOperation一覧は、そのInterface Descriptorに宣言されたOperationの集合である。
|
||||
|
||||
異なるInterfaceで同じOperation名が存在しても、Clientは構造化Interface参照とOperation名の組で識別する。UIやAI Toolのqualified表示は[Text Viewの参照表記](4.2-text-view.md#参照表記)に従う。Operation callではsignature全体をparseせず、Interface参照とOperation名を別fieldとして送る。
|
||||
Clientは、必要なInterface参照とcacheを比較し、未知のInterfaceだけを取得する。Objectの`interfaces`からDescriptor interestを先行して導出しても、Operationを選択した時点で必要な参照だけを取得してもよい。Interface validator mismatch等で更新が必要になった参照は再取得する。複数の`fetch_interface`は、それぞれ個別のrequestとして並行実行できる。
|
||||
|
||||
実行中のOperationが使用するDescriptor observationは、resultのvalidationが完了するまで保持する。新しいvalidatorのDescriptorを観測しても、古いvalidatorで開始済みのcallに必要なDescriptorを早期に破棄しない。
|
||||
同名Operationが異なるInterfaceに存在しても、Clientは構造化Interface参照とOperation名の組で識別する。UIやAI Toolのqualified表示は[Text Viewの参照表記](4.2-text-view.md#参照表記)に従う。Callではsignature全体をparseせず、Interface参照とOperation名を別fieldとして送る。
|
||||
|
||||
実行中のOperationが使うDescriptor observationは、resultのvalidationが終わるまで保持する。Cacheを破棄できる場合や、新しいvalidatorのDescriptorを観測した場合も、開始済みのcallが必要とするDescriptorを早期に捨てない。
|
||||
|
||||
## Discoverable Space
|
||||
|
||||
Clientは、Hostから`observe`で取得したindexableなエントリツリーをDiscoverable Spaceとして保持する。Discoverable Spaceとして扱う間、Clientはその観測済みtreeの構造を保持する必要がある。
|
||||
Discoverable Spaceは、Clientが`observe`で取得・保持したindexableなエントリツリーである。Worldspace rootまたはClientが選んだ起点から、観測済みのindexable edgeで到達できるmaterialize済みの範囲を指す。
|
||||
|
||||
Discoverable SpaceはKnown Spaceの部分集合であり、AI向けの`tree`など、indexableな配置の探索に利用する。Worldspace rootまたはClientが選択した起点からindexable edgeで到達できる、materialize済みの範囲によって構成される。
|
||||
Known Spaceの部分集合であり、AI向けの`tree`など、indexableな配置の探索に使う。Discoverable Spaceとして扱う間は、観測済みtreeの構造を保持する必要がある。
|
||||
|
||||
Clientが`observe`結果をmergeし、`children`が存在するparent nodeから以前のindexable edgeが返されなくなった場合、その子エントリをDiscoverable Spaceから除外する。その結果、保持しているどの起点からも到達不能になったsubtreeは、その場で破棄できる。ただし、別の経路から到達できるエントリ、Clientがpinしたエントリ、実行中のOperationが参照しているエントリは引き続き保持してよい。`children`が省略されたnodeはedge削除の根拠にしない。
|
||||
Clientが`observe`結果をmergeしたとき、`children`のあるparent nodeから以前のindexable edgeが返されなくなった場合は、その子エントリをDiscoverable Spaceから除外する。保持するどの起点からも到達不能になったsubtreeは、その場で破棄できる。ただし、別経路から到達できるエントリ、pinしたエントリ、実行中のOperationが参照するエントリは保持してよい。`children`が省略されたnodeは、edge削除の根拠にしない。
|
||||
|
||||
Operationを通して発見されたpathは、それだけではDiscoverable Spaceへ追加しない。そのエントリを起点として`depth > 0`で`observe`した場合に、そのindexableな範囲をDiscoverable Spaceへ追加できる。
|
||||
Operationからpathを発見しても、それだけではDiscoverable Spaceへ追加しない。そのエントリを起点に`depth > 0`で`observe`した場合に、取得したindexableな範囲を追加できる。
|
||||
|
||||
## 一時的な発見とpin
|
||||
|
||||
Clientは、Operation Resultの`entry`型として発見したpathを、そのObjectを即時に観測せず、現在の結果として提示・利用できる。結果のpathを提示することと、Objectを取得することは別であり、発見したすべてのpathの即時取得は要求しない。
|
||||
Operation Resultの`entry`型pathは、Objectを即時に観測しなくても、現在の結果として提示・利用できる。Pathの提示とObjectの取得は別であり、発見した全pathの即時取得は要求しない。
|
||||
|
||||
Objectの公開情報が必要になった時点で、Clientは必要なpathを観測できる。対象Objectだけが必要なら`depth = 0`を使う。Client policyとして先行取得してもよく、その場合はOperationのreturn typeからentry pathを抽出して重複を除去し、保持済みの観測と比較して必要な取得を行う。複数の`observe`が必要な場合、それぞれを個別のrequestとして並行実行できる。
|
||||
公開情報が必要になった時点で、必要なpathを観測できる。対象Objectだけなら`depth = 0`を使う。Client policyとして先行取得してもよい。その場合はOperationのreturn typeからentry pathを抽出し、重複を除去して、保持済みの観測と比較したうえで必要な取得を行う。複数の`observe`は、それぞれ個別のrequestとして並行実行できる。
|
||||
|
||||
一時的に取得したエントリは、結果の利用が終わった時点で破棄してよい。
|
||||
一時的に取得したエントリは、結果を使い終えた時点で破棄してよい。後続のInteractionでも必要なら、Client側でpinしてKnown Spaceに保持できる。PinはClient内部の保持方針であり、Host上のObject lifetimeやWIP Protocol上の状態を変えない。
|
||||
|
||||
後続のInteractionでも必要なエントリは、Client側でpinしてKnown Spaceへ保持できる。PinはClient内部の保持方針であり、Host上のObject lifetimeやWIP Protocol上の状態を変更しない。
|
||||
|
||||
単純なClientは発見したエントリをすべて保持してもよいが、WIPはそれを必須としない。具体的な容量制限やeviction algorithmはClient実装に委ねる。
|
||||
単純なClientは、発見したエントリをすべて保持してもよい。ただし必須ではなく、容量制限やeviction algorithmはClient実装に委ねる。
|
||||
|
||||
## Objectとchildrenの観測
|
||||
|
||||
Clientは`observe(path, depth)`を使い、対象Objectだけが必要なら`depth = 0`、indexableな配下も必要なら正のdepthを指定する。一つのinteractionでrequested depthまでを取得するため、Clientが各nodeについてdata-dependentな取得requestを逐次発行する必要はない。
|
||||
`observe(path, depth)`では、対象Objectだけが必要なら`depth = 0`、indexableな配下も必要なら正のdepthを指定する。一つのinteractionでrequested depthまで取得するため、Clientが各nodeに対してdata-dependentなrequestを逐次発行する必要はない。
|
||||
|
||||
Response nodeの`children`が省略されている場合、そのnodeのchildrenは未観測であり、空であるとは扱わない。`children`が存在する場合、その一覧は完全であり、空arrayならindexable childが存在しない。Clientは起点pathと各Objectの`name`からchild pathを導出する。`children`が存在するnodeをcacheへmergeするときは、そのEntryで観測済みのdirect child edgeをresponseの完全な一覧で置き換える。`children`が省略されたnodeは構造について新しい情報を持たないため、既存edgeを削除したり、観測済みの一覧を未観測へ戻したりしない。
|
||||
Response nodeの`children`が省略されていれば、そのchildrenは未観測である。存在する場合は完全な一覧であり、空arrayならindexable childが存在しない。未観測と空を同じ状態として扱わない。
|
||||
|
||||
件数の非常に多い集合の個々の要素は、通常indexableな子エントリとして露出しない。個別要素は対象の集合を扱うObjectの`query`や`list`等のOperationを通して発見することを想定する。
|
||||
Clientは起点pathと各Objectの`name`からchild pathを導出する。`children`があるnodeをcacheへmergeするときは、そのEntryの観測済みdirect child edgeを、responseの完全な一覧で置き換える。省略されたnodeは構造について新しい情報を持たないため、既存edgeを削除したり、観測済み一覧を未観測に戻したりしない。
|
||||
|
||||
件数の非常に多い集合では、通常、個々の要素をindexableな子エントリとして公開しない。集合を扱うObjectの`query`や`list`等のOperationを通して、個別要素を発見することを想定する。
|
||||
|
||||
## Interestと取得計画
|
||||
|
||||
Clientの上位層は、特定pathについて必要な観測depth、または特定Interface参照についてDescriptorが必要であることを、Client-localな**interest**として示せる。InterestはProtocol dataではなく、Hostはその状態を追跡しない。Interestの追加または削除は、保持済み観測のProtocol上の意味や有効性を変更しない。Interestがなくなった観測を保持するかevictするかはClient policyに委ねる。
|
||||
Interestは、上位層がClientへ示す取得上の要求である。特定pathに必要な観測depthや、特定Interface参照のDescriptorが必要であることを示せる。Client-localな情報であり、Protocol dataではなく、Hostも追跡しない。
|
||||
|
||||
Clientはinterestを、現在有効なObject observation、children coverage、Interface Descriptor observation、および進行中の取得requestと比較し、不足するlogical interactionだけを発行できる。要求されたdepthまでのcoverageがすでに存在する場合、または進行中のrequestによって満たされる場合、同じinterestの調整処理は重複requestを発行しない。Depth境界で`children`が省略されたnodeは、その配下に対する正のdepthのinterestを満たさない。
|
||||
Interestを追加・削除しても、保持済み観測のProtocol上の意味や有効性は変わらない。Interestがなくなった観測を保持するかevictするかは、Client policyに委ねる。
|
||||
|
||||
Clientは、interestを満たすためのidempotentな調整と、既存の観測を置き換える明示的な再観測を区別できる。明示的な再観測は、現在有効な観測または同じ対象の進行中requestがあっても新しいrequestを発行し、それ以前のrequestをsupersedeしてよい。
|
||||
Clientはinterestを、現在有効なObject observation、children coverage、Interface Descriptor observation、進行中の取得requestと比較し、不足するlogical interactionだけを発行できる。要求depthまでのcoverageがすでにある場合や、進行中requestで満たされる場合、同じinterestの調整処理は重複requestを発行しない。Depth境界で`children`が省略されたnodeは、その配下への正のdepthのinterestを満たさない。
|
||||
|
||||
Clientは、保持する観測、進行中request、並列度、request rateへboundedな上限を設ける。上限によって新しい取得を開始できない場合、interest自体をProtocol failureとして扱わず、別のrequestの完了、容量の解放、またはinterestの変更後に再評価できる。具体的な優先順位、並行実行、prefetch範囲、fairness、cache容量、eviction algorithmはClient実装に委ねる。
|
||||
このidempotentな調整と、既存観測を置き換える明示的な再観測は区別できる。明示的な再観測では、現在有効な観測や同じ対象の進行中requestがあっても、新たなrequestを発行して、それ以前のrequestをsupersedeしてよい。
|
||||
|
||||
Clientは、上位層から直接示されたinterestに加えて、観測したObjectのInterface参照やindexable child edgeから次のinterestを先行して導出してよい。WIPは全量取得、暗黙の再帰展開、または特定のprefetch範囲を要求しない。取得計画とevictionは、interestの変更や明示的な再観測がないまま同じ観測を繰り返し破棄して再取得する、自己維持的なloopを作ってはならない。
|
||||
Clientは、保持する観測、進行中request、並列度、request rateに上限を設ける。上限のために取得を開始できなくても、interest自体をProtocol failureにはしない。別requestの完了、容量の解放、interestの変更後に再評価できる。優先順位、並行実行、prefetch範囲、fairness、cache容量、eviction algorithmはClient実装に委ねる。
|
||||
|
||||
複数の取得requestを並行実行するClientは、各responseを元のrequestとそのeffective Worldspace viewへ対応付ける。Clientがある観測状態のownershipを後続requestへ移した場合、supersedeされたrequestのresponseでその状態を上書きしない。Overlapping observationを部分的にmergeする場合も、後続requestが所有するObjectまたはchild coverageを以前のresponseで置き換えない。Success、Protocol failure、Transport failure、またはlocal cancellationによって終了したrequestを、進行中として残してはならない。
|
||||
上位層から直接示されたinterestに加え、観測したObjectのInterface参照やindexable child edgeから、次のinterestを先行して導出してもよい。WIPは全量取得、暗黙の再帰展開、特定のprefetch範囲を要求しない。取得計画とevictionは、interestの変更や明示的な再観測がないまま、同じ観測を繰り返し破棄・再取得する自己維持的なloopを作ってはならない。
|
||||
|
||||
`observe`または`fetch_interface`の失敗を自動的に再試行するかはClient policyに委ねるが、retry回数、間隔、並列度はboundedでなければならない。取得requestのretry policyを`call_operation`の再実行へ適用せず、OperationについてはProtocolのfailure semanticsに従う。
|
||||
並行取得では、各responseを元のrequestとそのeffective Worldspace viewに対応付ける。ある観測状態のownershipを後続requestへ移した場合、supersedeされたrequestのresponseでその状態を上書きしない。Overlapping observationを部分的にmergeする場合も、後続requestが所有するObjectやchild coverageを以前のresponseで置き換えない。
|
||||
|
||||
Success、Protocol failure、Transport failure、local cancellationで終了したrequestは、進行中として残してはならない。`observe`や`fetch_interface`の失敗を自動retryするかはClient policyに委ねるが、回数、間隔、並列度には上限が必要である。取得requestのretry policyを`call_operation`の再実行へ流用せず、OperationにはProtocolのfailure semanticsを適用する。
|
||||
|
||||
## Objectの同一性
|
||||
|
||||
`ref`は、同じ主体に公開された一つのWorldspace内で、複数の観測またはエントリが同じObjectを表すことを示すoptionalなopaque markerである。
|
||||
`ref`は、複数の観測やエントリが同じObjectを表すことを示すoptionalなopaque markerである。同じ主体に公開された一つのWorldspace内で、同じ有効な`ref`があれば、同じObjectであることを保証する。
|
||||
|
||||
同じ有効な`ref`は同じObjectを保証する。異なるまたは欠落した`ref`からObjectが異なるとは判断しない。Clientは異なるWorldspaceの`ref`を比較せず、`ref`を永続的なprimary keyまたはNavigationに使用しない。
|
||||
逆に、`ref`が異なる、あるいは欠けていることから、Objectが異なるとは判断しない。異なるWorldspace間の`ref`も比較しない。Clientは`ref`を永続的なprimary keyやNavigationに使わない。
|
||||
|
||||
Backend UIDが適している場合、Hostはそれを直接または変換して`ref`に利用できる。ただし、Clientは`ref`がbackend UIDであること、またはsessionやHost restartをまたいで安定することを仮定しない。
|
||||
適したbackend UIDがあれば、Hostは直接または変換して`ref`に利用できる。ただしClientは、それがbackend UIDであることや、session・Host restartをまたいで安定することを仮定しない。
|
||||
|
||||
## 観測整合性
|
||||
|
||||
Clientは、Hostが提供した`ref`とvalidatorの保証範囲で、観測とOperation実行の整合性を管理する。Scopeの`ref`や対象Objectのvalidatorがない場合に、pathだけから観測時と同じObjectであると保証してはならない。Optional metadataの欠落自体は有効な公開形態であり、identity追跡不能を理由にProtocol違反としない。
|
||||
Clientが観測とOperation実行の整合性を管理できるのは、Hostから得た`ref`とvalidatorの保証範囲である。Scopeの`ref`や対象Objectのvalidatorがない場合、pathだけで観測時と同じObjectだと保証してはならない。Optional metadataの欠落は有効な公開形態であり、identityを追跡できないことをProtocol違反にはしない。
|
||||
|
||||
Objectが`validator`を持つ場合、Clientは観測したpathとvalidatorを対応付ける。条件付きOperationでは両者をtargetとしてHostへ渡し、validatorの比較と実行をHost上でatomicに行わせる。ValidatorはAIやユーザーに直接管理させず、Transport BindingでもProtocol struct上のfieldとして保持する。HTTP ETag等のrepresentation metadataをWIP validatorの代用として解釈しない。
|
||||
Objectに`validator`があれば、Clientは観測したpathと対応付ける。条件付きOperationでは両者をtargetとしてHostへ渡し、validatorの比較と実行をHost上でatomicに行わせる。AIやユーザーにvalidatorを直接管理させず、Transport BindingでもProtocol structのfieldとして保持する。HTTP ETag等のrepresentation metadataをWIP validatorの代用にしない。
|
||||
|
||||
ClientはOperationに選択したInterface参照と、`fetch_interface`で観測したoptionalな`scope_ref`およびInterface validatorもrequestへ含める。MutableなInterfaceがvalidatorを返す場合は必須preconditionとして使用し、`InterfaceValidatorMismatch`ではInterfaceだけを再観測する。`InterfaceMismatch`ではObjectを再観測して現在のInterface集合を取得し、scopeの紐付きが変わった可能性もあるため、使用したInterface observationも無効化して必要なら再取得する。
|
||||
Interfaceについても、選択した参照と、`fetch_interface`で観測したoptionalな`scope_ref`およびInterface validatorをrequestへ含める。MutableなInterfaceがvalidatorを返す場合は、必須preconditionとして使う。`InterfaceValidatorMismatch`ではInterfaceだけを再観測する。
|
||||
|
||||
Operation responseが新しいObject validatorを返した場合、Clientは対応するObject observationへ反映する。
|
||||
`InterfaceMismatch`では、Objectを再観測して現在のInterface集合を取得する。Scopeの紐付きが変わった可能性もあるため、使ったInterface observationも無効化し、必要なら再取得する。Operation responseが新しいObject validatorを返した場合は、対応するObject observationへ反映する。
|
||||
|
||||
## Failure handling
|
||||
|
||||
ClientはHostが返すProtocol errorの`code`で回復actionを決定し、人間向け`message`やTransport statusからcodeを推測しない。各interactionで許可されていないcode、schemaに違反するsuccess / error response、同じvalidatorに矛盾するrepresentationは`InvalidResponse`として拒否する。Validだが未対応のInterface Descriptor formatは`UnsupportedDescriptorFormat`として区別する。
|
||||
回復actionは、Hostが返すProtocol errorの`code`で決める。人間向け`message`やTransport statusからcodeを推測しない。Interactionで許されないcode、schemaに違反するsuccess / error response、同じvalidatorに矛盾するrepresentationは、`InvalidResponse`として拒否する。有効だが未対応のInterface Descriptor formatは、`UnsupportedDescriptorFormat`として区別する。
|
||||
|
||||
- `NotFound`では対象Entryをstaleとして扱い、必要に応じて親を再観測する。
|
||||
- `InterfaceNotFound`では参照元Objectを再観測できる。
|
||||
@@ -154,32 +156,32 @@ ClientはHostが返すProtocol errorの`code`で回復actionを決定し、人
|
||||
- `InterfaceValidatorRequired`または`InterfaceValidatorMismatch`では対象Interfaceを再観測する。
|
||||
- `ValidatorRequired`または`ValidatorMismatch`では対象Objectを再観測する。
|
||||
|
||||
同じOperationを再試行するなら、先に新しい観測を取得しなければならない。
|
||||
同じOperationを再試行するには、先に新しい観測を取得しなければならない。
|
||||
|
||||
- `InvalidRequest`または`InvalidArguments`では同じrequestを変更せずに再送しない。
|
||||
- `ResourceLimitExceeded`ではlogical request shapeやobservation depth等を削減できるが、Operationを自動再実行しない。
|
||||
- `PermissionDenied`では公開済みOperationが実行拒否されたことを示し、Object自体が閲覧禁止で存在するとは推測しない。
|
||||
|
||||
`OperationOutcomeUnknown`では、Operationが未実行またはrollback済みであることが保証されない。ClientはOperationを自動再実行せず、対象Objectをstaleとして再観測するか、domain固有の照会手段を提示する。`Internal`、Transport timeout、connection lossの場合もOperationを自動再実行しない。Response不達はProtocol errorへ変換せず、Transport failureとOperation outcomeが不明な状態をClient内部で保持する。WIPはOperationをObject stateへのeffectで分類してretry可否を推測しない。
|
||||
`OperationOutcomeUnknown`は、Operationの未実行やrollback済みを保証しない。自動再実行せず、対象Objectをstaleとして再観測するか、domain固有の照会手段を提示する。`Internal`、Transport timeout、connection lossでも、Operationを自動再実行しない。
|
||||
|
||||
ClientがOperation結果の`entry`を付随的に取得する場合、その取得の成否は元のOperationの成否と分けて扱う。参照先の`observe`が失敗しても、成功済みOperationを失敗扱いしたり、そのOperationを再実行したりしてはならない。取得失敗は、その参照先への別のinteractionの失敗として提示・処理する。
|
||||
Responseが届かなかった場合は、Protocol errorへ変換せず、Transport failureとOperation outcomeが不明な状態をClient内部に保持する。OperationをObject stateへのeffectで分類し、retry可否を推測することもしない。
|
||||
|
||||
HTTP authentication failureとWorldspace endpoint全体のaccess denialはTransport layerのfailureであり、WIPの`PermissionDenied`へ変換しない。
|
||||
Operation結果の`entry`を付随的に取得するときは、その取得と元のOperationの成否を分ける。参照先の`observe`が失敗しても、成功済みOperationを失敗扱いしたり、再実行したりしてはならない。その参照先に対する別interactionの失敗として提示・処理する。
|
||||
|
||||
HTTP authentication failureとWorldspace endpoint全体のaccess denialは、Transport layerのfailureである。WIPの`PermissionDenied`へ変換しない。
|
||||
|
||||
## 想定実装
|
||||
|
||||
Clientは用途に応じて複数の上位インターフェースを提供できる。
|
||||
Clientは、用途に応じて複数の上位インターフェースを提供できる。
|
||||
|
||||
- GUI上のWorldspace View
|
||||
- [AI Tools](4.1-ai-tools.md)
|
||||
- PTC / REPL向けスクリプティングAPI
|
||||
|
||||
これらは同一のKnown Space、Discoverable Space、Object identity、観測整合性管理を共有してよい。
|
||||
|
||||
PTC / REPL bindingでは、Operationを通常のメソッド呼び出しへ投影してよい。
|
||||
これらは同じKnown Space、Discoverable Space、Object identity、観測整合性管理を共有してよい。PTC / REPL bindingでは、Operationを通常のメソッド呼び出しとして提示することもできる。
|
||||
|
||||
```python
|
||||
issue.close(reason="duplicate")
|
||||
```
|
||||
|
||||
この場合もClientがInterfaceとOperationを解決して`call_operation`を利用し、Object identityと観測整合性を管理する。AI Toolsの3操作は、そのAI向けインターフェースの契約であり、PTC / REPLの呼び出し構文を制限しない。
|
||||
この呼び方でも、ClientがInterfaceとOperationを解決して`call_operation`を使い、Object identityと観測整合性を管理する。AI Toolsの3操作はAI向けインターフェースの契約であり、PTC / REPLの呼び出し構文を制限しない。
|
||||
|
||||
+26
-31
@@ -2,9 +2,9 @@
|
||||
|
||||
## 目的と適用範囲
|
||||
|
||||
AI Toolsは、WIP ClientがAIへ提供する、対象の配置を見て、できることを理解し、操作するための上位インターフェースである。本章で定義するAI向けの基礎ツールは **tree / inspect / invoke** の3操作に固定する。
|
||||
AIがWIPの対象を操作するには、その配置を見つけ、何ができるかを確認し、操作を選ぶ必要がある。Clientはこのための基礎ツールとして、**tree / inspect / invoke**の3操作を提供する。
|
||||
|
||||
Protocol interactionをそのままToolとして公開するものではない。Protocolの`observe` / `fetch_interface` / `call_operation`は[3. Protocol](3-protocol.md)、取得・cache・観測整合性の責務は[4. Client](4-client.md)に従う。GUIやPTC / REPLなど、他のClientインターフェースの構成は本章で制限しない。
|
||||
この3操作は、Protocolの`observe` / `fetch_interface` / `call_operation`をそのまま公開したものではない。必要な取得、cache、観測整合性の管理はClientが引き受ける。Protocol interactionは[3. Protocol](3-protocol.md)、Clientの責務は[4. Client](4-client.md)に従う。GUIやPTC / REPLなど、他のClientインターフェースの構成は、この3操作に制限しない。
|
||||
|
||||
## 基本的な利用モデル
|
||||
|
||||
@@ -14,18 +14,17 @@ Protocol interactionをそのままToolとして公開するものではない
|
||||
| `inspect(path)` | 対象の説明とできることを知る | Object観測、Interface集合の解決、signature生成 |
|
||||
| `invoke(target, interface, operation, arguments)` | 選択した操作を名前付き引数で呼ぶ | 引数検証、観測との対応、validator、Protocol requestと結果の処理 |
|
||||
|
||||
これらはProtocol interactionとの一対一対応ではない。特に`inspect`の対象はInterface参照ではなく、Objectを指すpathである。必要な`observe`と複数の`fetch_interface`はClient内部で行う。
|
||||
例えば`inspect`が受け取るのは、Interface参照ではなくObjectのpathである。Clientはそのpathに必要な`observe`と複数の`fetch_interface`を内部で行う。AI向け操作とProtocol interactionは、一対一には対応しない。
|
||||
|
||||
Inspectで見つけたOperationも`invoke`から呼び出し、Operationごとに新しいToolを登録しない。
|
||||
Inspectで見つけたOperationは、`invoke`で呼ぶ。Operationごとに新しいToolを登録することはない。
|
||||
|
||||
大量のObjectの発見は、各ドメインのObjectが公開する`list` / `search` / `query`等のOperationで行う。件数制限、検索条件、ページング等は、そのドメインに応じて各Operationが管理する。AIはこれらのOperationも`inspect`で確認し、`invoke`で呼び出す。発見方法が増えても基礎ツールは追加・変更しない。
|
||||
大量のObjectを発見する場合も、各ドメインのObjectが公開する`list` / `search` / `query`等をinspectし、invokeする。件数制限、検索条件、ページング等は、それぞれのOperationがドメインに応じて管理する。発見方法が増えても、基礎ツールは3操作のままである。
|
||||
|
||||
## `tree`: indexableな配置を見る
|
||||
|
||||
`tree(path, depth)`は、起点をdepth 0として、指定depth内のindexableなツリーを全量取得・表示する。
|
||||
`tree(path, depth)`は、起点をdepth 0として、指定depth内のindexableなツリーを全量取得・表示する。「全量」の範囲は、指定した起点・depth・主体に公開されたindexableな範囲であり、Worldspace全体ではない。
|
||||
|
||||
- 必要なcoverageを持つ観測を再利用できる。不足する場合はClientが`observe`で取得する。
|
||||
- 全量とは、指定した起点・depth・主体に公開されたindexableな範囲の全量であり、Worldspace全体ではない。
|
||||
- 名前、path、親子構造を中心に提示する。すべてのObjectのDescriptorやドメインデータを展開しない。
|
||||
- Protocolのdepth境界にある未観測のchildrenと、観測済みの空のindexable childrenを混同しない。
|
||||
- 指定depth内の一部を黙って省略して完全なtreeとして返さない。既存のresource limit内で成立しない場合は、その失敗を伝える。
|
||||
@@ -35,17 +34,17 @@ HostがTreeへ公開する範囲は、[Indexable childrenの条件](2.1-object-t
|
||||
|
||||
## `inspect`: 対象の説明とできることを知る
|
||||
|
||||
`inspect(path)`は、そのpathで公開されているObjectの説明と操作を返す。
|
||||
`inspect(path)`は、そのpathで公開されているObjectの説明と操作を返す。AIは、treeでまだ見ていないpathも直接指定できる。
|
||||
|
||||
AIがそのpathをtree、会話、Operation結果のいずれから知ったかは、事前条件にしない。ClientがまだObjectを観測していなくても、有効なpathを直接指定できる。Known Spaceにないことだけを理由に拒否せず、現在のHostの公開状態に従って観測する。
|
||||
Pathをtree、会話、Operation結果のどこから知ったかは事前条件にしない。Clientが未観測であっても、Known Spaceにないことだけを理由に拒否せず、現在のHostの公開状態に従って観測する。
|
||||
|
||||
Clientは次の情報を保持済みの観測から解決し、不足するものを取得する。
|
||||
Clientは、保持済みの観測を使い、不足する情報を取得する。
|
||||
|
||||
1. 対象pathのObject。対象だけが必要なら`observe`の`depth = 0`を使用する。
|
||||
2. Objectが公開するInterface参照のordered set。
|
||||
3. 表示対象のInterfaceに対応する、取得・検証済みのDescriptor。
|
||||
|
||||
Clientはこれらの情報から、Objectの説明とInterfaceごとの操作を提示する。AIに「先にObjectをobserveして、Interface参照を抜き出し、一つずつfetchする」という手順を要求しない。一方で、Objectの本文やドメインmetadataまで暗黙に読み込むわけではない。それらの取得は対象のOperationとして扱う。
|
||||
この情報から、Objectの説明とInterfaceごとの操作を提示する。AIにObjectのobserve、Interface参照の抽出、個別のfetchという手順を管理させない。ただし、Objectの本文やドメインmetadataまで暗黙に読み込むことはなく、それらは対象のOperationで取得する。
|
||||
|
||||
### 表示に必要な情報
|
||||
|
||||
@@ -54,47 +53,43 @@ Clientはこれらの情報から、Objectの説明とInterfaceごとの操作
|
||||
- Operation名、短い用途説明、名前付き引数、必須性、引数・結果の型。
|
||||
- 操作を理解するために必要な名前付き型・複合型の定義。
|
||||
|
||||
生のDescriptor JSONを返すだけではなく、検証済みの情報から読みやすいObject signatureとInterface signatureを生成する。各signatureの表記は[4.2. Text View](4.2-text-view.md)に従う。同名Operationが複数Interfaceに存在しても、一つへ平坦化したり、出所を失ったりしない。
|
||||
Clientは生のDescriptor JSONを返すだけでなく、検証済み情報からObject signatureとInterface signatureを生成する。表記は[4.2. Text View](4.2-text-view.md)に従う。同名Operationが複数Interfaceにあっても、一つへ平坦化したり、出所を失ったりしない。
|
||||
|
||||
Invokeへ渡すInterface参照とOperation名は区別して保持し、表示textを再parseして呼出先を決めない。Interfaceの未展開や取得失敗を「操作が存在しない」と誤表示しない。
|
||||
Invokeへ渡すInterface参照とOperation名は、表示とは別に保持する。表示textを再parseして呼出先を決めず、Interfaceの未展開や取得失敗も「操作が存在しない」と誤表示しない。
|
||||
|
||||
Signatureの配置や、詳細表示・表示対象を選択する具体的なTool引数は本章で固定しない。個々のsignatureを生成するときの全宣言の表示規則はText Viewに従い、表示側でInterface内部を切り詰めない。
|
||||
Signatureの配置、詳細表示、表示対象を選ぶ具体的なTool引数は固定しない。一方、個々のsignatureはText Viewの全宣言の表示規則に従う。表示側でInterface内部を切り詰めない。
|
||||
|
||||
## `invoke`: 選んだ操作を実行する
|
||||
|
||||
`invoke(target, interface, operation, arguments)`は、対象ObjectのInterfaceとOperationを明示して呼び出す。`target`は対象Objectのpathを指定するものであり、AIにProtocolのvalidator付きTargetを組み立てさせるものではない。
|
||||
`invoke(target, interface, operation, arguments)`は、対象ObjectのInterfaceとOperationを明示して呼び出す。同じObjectの複数Interfaceに同名Operationがあり得るため、Interfaceも選ぶ必要がある。
|
||||
|
||||
`arguments`はOperationのparameter declarationに従う名前付きfieldのrecordであり、位置引数ではない。引数を持たないOperationには空のrecordを渡す。
|
||||
`target`で指定するのは対象Objectのpathである。Protocolのvalidator付きTargetをAIに組み立てさせるものではない。`arguments`はparameter declarationに従う名前付きfieldのrecordであり、位置引数ではない。引数のないOperationには空のrecordを渡す。
|
||||
|
||||
Interfaceを明示するのは、同じObjectの複数Interfaceに同名Operationがあり得るためである。Interfaceの選択をAIへ示すことと、Descriptorの取得手順をAIに管理させることは別である。
|
||||
Interfaceの選択はAIへ示すが、Descriptorの取得手順はAIに管理させない。選択するInterfaceは、絶対scopeとlocal nameの組で識別する。Adapterが[Text Viewの短縮表記](4.2-text-view.md#参照表記)を入力として受ける場合は、入力境界で構造化参照へ変換し、Operation名を独立して保持する。Scopeの省略には明示contextが必要であり、対象pathからの推測や相対scopeの解決はしない。短縮表記のstringをProtocolへそのまま送らない。
|
||||
|
||||
選択するInterfaceは絶対scopeとlocal nameの組で識別する。Adapterが[Text Viewの短縮表記](4.2-text-view.md#参照表記)を入力として受ける場合も、入力境界で構造化参照へ変換し、Operation名は独立して保持する。Scope省略には明示contextが必要であり、対象pathからの推測や相対scopeの解決はしない。Protocolへ短縮表記のstringをそのまま送らない。
|
||||
ClientはObjectとDescriptorの観測を保持して引数を検証し、path、Object validator、Interface参照とvalidator、観測に存在する`scope_ref`をProtocol requestへ設定する。AIにvalidatorやscope Objectの`ref`を取得・コピー・更新させず、同一性管理やvalidatorをsignatureにも含めない。`ref`のないscopeではidentityによる差し替え検出ができないことを許容し、AIへ代替identityを要求しない。
|
||||
|
||||
ClientはObjectとDescriptorの観測を保持し、引数を検証し、path、Object validator、Interface参照とvalidator、および観測に存在する`scope_ref`をProtocol requestへ設定する。AIにvalidatorやscope Objectの`ref`の取得・コピー・更新をさせない。同一性管理やvalidatorはsignatureに含めない。`ref`のないscopeではidentityによる差し替え検出ができないことを許容し、AIへ代替identityの指定を要求しない。
|
||||
Interfaceが表示されていても、それだけで実行権限があるとは限らない。Pathを知っていることも同様である。Hostは呼出時の公開状態、権限、capability、実行条件を検証し、Clientは返されたresultをDescriptorに従って検証してから提示する。
|
||||
|
||||
Pathを知っていることやInterfaceが表示されたことは、実行権限を意味しない。Hostは呼出時の公開状態、権限、capability、実行条件を引き続き検証する。ClientはresultをDescriptorに従って検証してから提示する。
|
||||
|
||||
再観測とOperationの再実行を同一視しない。Stale、取消、拒否、結果不明などの扱いはProtocolと[ClientのFailure handling](4-client.md#failure-handling)に従う。結果不明のOperationを自動再実行せず、Operation名からread / writeやretry可否を推測しない。
|
||||
再観測とOperationの再実行は、別の処理である。Stale、取消、拒否、結果不明などは、Protocolと[ClientのFailure handling](4-client.md#failure-handling)に従って扱う。結果不明のOperationを自動再実行せず、Operation名からread / writeやretry可否を推測しない。
|
||||
|
||||
## Pathの発見と移動
|
||||
|
||||
AIはtree、会話で示されたpath、Operation結果の`entry`などから次の対象を知ることができる。Pathを知っていること、ClientがObjectを観測・保持していること、indexable edgeも観測していることは別の状態である。[Clientの取得・保持・提示の分離](4-client.md#取得保持提示の分離)に従い、未観測のpathは必要になった時点でinspectできる。
|
||||
AIはtree、会話で示されたpath、Operation結果の`entry`などから次の対象を知ることができる。しかし、pathを知ること、ClientがObjectを観測・保持すること、indexable edgeも観測することは別である。[Clientの取得・保持・提示の分離](4-client.md#取得保持提示の分離)に従い、未観測のpathは必要になった時点でinspectできる。
|
||||
|
||||
Clientが内部で保持する情報とAIに表示済みの情報は一致する必要がない。Treeの取得でObject情報を保持していても、AIには名前と構造だけを見せ、詳しい説明と操作はinspectで提示できる。
|
||||
Clientが保持する情報と、AIに表示済みの情報も一致する必要はない。Treeの取得でObject情報を保持していても、AIには名前と構造だけを見せ、詳しい説明と操作はinspectで提示できる。
|
||||
|
||||
Operation結果の`entry`型pathは、そのまま次の対象への参照として提示できる。すべての結果を即時に観測したり、再帰展開したりすることを必須にしない。AIが必要なpathを選んでinspectするときに、Clientが観測できる。
|
||||
Operation結果の`entry`型pathは、次の対象への参照としてそのまま提示できる。全件を即時に観測したり再帰展開したりする必要はなく、AIが必要なpathを選んでinspectするときに観測できる。Entryの抽出はDescriptorの型に従い、普通のstringを内容だけからpathと推測しない。結果のentryを観測・保持しても、それだけではindexable edgeを追加しない。
|
||||
|
||||
Entryの抽出はDescriptorの型に従う。普通のstringを内容だけからpathと推測しない。結果のentryを観測・保持しても、それだけでindexable edgeを追加しない。
|
||||
|
||||
Clientが付随的なentry取得を行う場合、その取得失敗と元のOperationの成否を混同しない。成功済みOperationを、結果の参照先が取得できなかったために失敗扱いして再実行させてはならない。
|
||||
Clientが付随的にentryを取得する場合、参照先の取得失敗を元のOperationの失敗と混同しない。すでに成功したOperationを、参照先が取得できなかったために失敗扱いして再実行させてはならない。
|
||||
|
||||
## Tool bindingと表示
|
||||
|
||||
本章の小文字表記は操作の呼称であり、providerごとのTool登録名の大文字・小文字やProtocol名の接頭辞は規定しない。AI providerがTool schemaを要求する場合は、Client adapterがこの3操作のschemaを提供する。Hostにprovider固有のTool schemaを提供させたり、取得したDescriptorごとにToolを追加登録したりしない。DescriptorはClient内部のOperation validationとsignature生成に用いる。
|
||||
`tree`、`inspect`、`invoke`という小文字表記は操作の呼称であり、providerごとのTool登録名の大文字・小文字やProtocol名の接頭辞を規定しない。AI providerがTool schemaを要求する場合は、Client adapterが3操作のschemaを提供する。Hostへprovider固有のschemaを要求したり、取得したDescriptorごとにToolを追加登録したりしない。DescriptorはClient内部のOperation validationとsignature生成に使う。
|
||||
|
||||
Treeの具体的なtext layout、`depth`等の引数のdefault、inspectの表示選択引数、Tool response envelopeについて、本章は共通の具体schemaを定義しない。各bindingは、それらを提供するときにAIへ明示する契約として定め、本章の操作の意味を変えない。Text Viewもsignatureを包むresponse構造や配置を定義するものではない。
|
||||
Treeのtext layout、`depth`等のdefault、inspectの表示選択引数、Tool response envelopeには、共通の具体schemaを定めない。各bindingは、それらを提供するときにAIへ明示する契約として定め、3操作の意味は変えない。Text Viewも、signatureを包むresponse構造や配置までは定義しない。
|
||||
|
||||
取得・表示・cache保持を分離するが、特定の内部class構成、eviction algorithm、prefetch schedulerは要求しない。
|
||||
取得・表示・cache保持は分離する。ただし、特定の内部class構成、eviction algorithm、prefetch schedulerは要求しない。
|
||||
|
||||
## 検証する利用シナリオ
|
||||
|
||||
|
||||
+31
-21
@@ -2,13 +2,13 @@
|
||||
|
||||
## 位置付け
|
||||
|
||||
WIP Text Viewは、ObjectとInterfaceをAIおよび人間へ提示するためのプレーンテキスト表示形式である。ClientのTool結果、CLI、GUIのテキスト表示などで共通に利用できる。
|
||||
Objectの公開情報と、Interfaceの型・Operation・documentationは、Clientが取得・検証したデータである。それをAIや人間へプレーンテキストで示す形式が、WIP Text Viewである。Tool結果、CLI、GUIのテキスト表示などで共通に使える。
|
||||
|
||||
この章では、一つのObjectの公開情報を表す **Object signature** と、一つのInterfaceの型・Operation・documentationを表す **Interface signature** の表記を定義する。ここでのsignatureは表示上のまとまりを指し、暗号学的な署名やidentityではない。
|
||||
一つのObjectの公開情報を表すまとまりをObject signature、一つのInterfaceの型・Operation・documentationを表すまとまりをInterface signatureと呼ぶ。Signatureは表示上のまとまりであり、暗号学的な署名やidentityではない。
|
||||
|
||||
どのObjectやInterfaceを表示するか、各signatureをどこにどの順序で並べるか、同時に提示するかは表示側が決める。本章はsignatureを包む共通document構造、ObjectとInterfaceの組み合わせ、Tool response envelopeを定義しない。
|
||||
表示対象、配置、順序、両signatureを同時に提示するかは、表示側が決める。Signatureを包む共通document構造、ObjectとInterfaceの組み合わせ、Tool response envelopeは定義しない。
|
||||
|
||||
データの正本は[3. Protocol](3-protocol.md)のObjectおよびInterface Descriptorであり、Text Viewは検証済みの情報から生成する表示である。
|
||||
データの正本は[3. Protocol](3-protocol.md)のObjectとInterface Descriptorである。Text Viewは、検証済みの情報から生成する表示である。
|
||||
|
||||
- Interfaceの交換形式、手書きのschema言語、Objectのserializationではない。
|
||||
- Parser、round-trip、表示textからのDescriptor復元は要求しない。
|
||||
@@ -18,11 +18,11 @@ WIP Text Viewは、ObjectとInterfaceをAIおよび人間へ提示するため
|
||||
|
||||
## 表示の入力と責務
|
||||
|
||||
Object signatureは検証済みObjectと、分かる場合はそのObjectを観測したpathから生成する。PathはObjectのfieldではなく配置contextである。Object signatureの生成にInterface Descriptorは必要ない。
|
||||
Object signatureの入力は、検証済みObjectと、分かる場合はそのObjectを観測したpathである。Interface Descriptorは必要ない。PathはObjectのfieldではなく、配置contextとして使う。
|
||||
|
||||
Interface signatureは、Interface参照と、それに対応する検証済みかつ対応formatのInterface Descriptorから生成する。Interface参照はDescriptor自身のfieldではない。Interface signatureの生成にObjectは必要ない。
|
||||
一方、Interface signatureの入力は、Interface参照と、それに対応する検証済みかつ対応formatのDescriptorである。Objectは必要ない。Interface参照も、Descriptor自身のfieldではない。
|
||||
|
||||
観測、Descriptor取得、cache、表示対象の選択、rendererの呼び出しAPIや内部structは本章で規定しない。表示は観測時点の公開情報であり、最新状態、Operationの実行権限、実行成功を保証しない。
|
||||
どちらも観測時点の公開情報を表示するものであり、最新状態、Operationの実行権限、実行成功は保証しない。観測、Descriptor取得、cache、表示対象の選択、rendererの呼び出しAPIや内部structは規定しない。
|
||||
|
||||
Object tree全体の描画、Operation arguments / resultのvalue表示、Protocol errorや診断metadataの表示は本章の対象に含めない。
|
||||
|
||||
@@ -54,19 +54,25 @@ Interface参照のscopeとlocal nameの境界は`::`、InterfaceとOperationの
|
||||
"/github"::"issue.read"#"read"
|
||||
```
|
||||
|
||||
最後の例は`/github::issue.read#read`と同じ成分を表す。引用やescapeは元の値を変えず、ProtocolへASCII制限や禁止文字を追加しない。Unicode normalization、case変換、URL encodingをしない。制御文字等には[名前と文字列](#名前と文字列)の安全なescape規則も適用する。境界を読む場合は引用とescapeを考慮し、単純な文字列splitを使用しない。
|
||||
最後の例と`/github::issue.read#read`は、同じ成分を表す。引用やescapeを変えても、元の値は変わらない。ProtocolへASCII制限や禁止文字を追加せず、Unicode normalization、case変換、URL encodingも行わない。
|
||||
|
||||
制御文字等には[名前と文字列](#名前と文字列)の安全なescape規則も適用する。境界を読むときは引用とescapeを考慮し、単純な文字列splitを使わない。
|
||||
|
||||
### Scope省略とcontext
|
||||
|
||||
Scopeの相対pathは、Protocol dataにも短縮表記にも認めない。`github::issue.read`や`../github::issue.read`を相対scopeとして解決しない。Scopeを明示するなら、引用の有無にかかわらずcanonical absolute pathを使う。
|
||||
|
||||
`issue.read#read`やInterfaceだけの`issue.read`は、scopeが別途明示されたcontextでのみ使える省略形である。Scopeの省略はroot scope `/`の指定とは異なる。Contextがなければ解決できず、rootへのdefault、対象Objectのpathからの推測、祖先の同名Interfaceの探索は行わない。Scopeが確定してもmembership、祖先制約、authorizationは検証する。
|
||||
`issue.read#read`や、Interfaceだけの`issue.read`は、scopeが別途明示されたcontextでのみ使える省略形である。省略はroot scope `/`の指定ではない。Contextがなければ解決できず、rootへのdefault、対象Objectのpathからの推測、祖先の同名Interfaceの探索も行わない。
|
||||
|
||||
Scopeが確定した後も、membership、祖先制約、authorizationは検証する。
|
||||
|
||||
短縮表記を入力として受けるかはToolやUIが決める。受ける場合は専用の入力境界で成分を検証し、構造化参照とOperation名へ変換する。Signature全体を再parseして呼出先を決めることとは区別し、`invoke`を単一string引数の操作へ変更しない。
|
||||
|
||||
### Signature内の参照
|
||||
|
||||
Object signatureとInterface signatureでは、公開先の絶対scopeを含む完全な短縮表記を生成し、その全体をJSON stringとして引用表示する。外側の引用はsignatureの文字列表記であり、成分の引用とは別の層である。例えばscope `/github`、name `issue#read`の参照は`"/github::\"issue#read\""`と表示する。Scopeを省略したり、Upstreamの参照へ戻したりしない。
|
||||
両signature内のInterface参照は、公開先の絶対scopeを含む完全な短縮表記を生成し、その全体をJSON stringとして引用する。例えば、scope `/github`、name `issue#read`の参照は`"/github::\"issue#read\""`となる。
|
||||
|
||||
成分ごとの引用と、参照全体をsignature内の文字列として示す外側の引用は、別の層である。Scopeを省略したり、Upstreamの参照へ戻したりしない。
|
||||
|
||||
## Object signature
|
||||
|
||||
@@ -88,11 +94,11 @@ Block内は次の順に表示する。
|
||||
|
||||
Descriptionがない場合はそのpropertyを省略し、存在する空文字列は`description: "";`と表示する。説明の欠落からObjectの用途を補完しない。
|
||||
|
||||
Rootは`object at "/"`かつ`name: "";`となり、scope `/`のInterfaceを公開できる。Interface参照は[参照表記](#参照表記)に従い絶対scopeを含め、別の参照やlocal nameだけへ置き換えない。Scope Objectの`ref`やvalidatorを`@...`として参照へ付け足さず、`scope_ref`はClient runtimeの観測・precondition metadataとして扱う。
|
||||
Rootの表示は`object at "/"`かつ`name: "";`となり、scope `/`のInterfaceを公開できる。Interface参照は[参照表記](#参照表記)に従って絶対scopeを含め、別の参照やlocal nameだけへ置き換えない。
|
||||
|
||||
`ref`とvalidatorはObject signatureへ含めない。同一性管理と条件付き実行はClient runtimeの責務のままである。Objectの本文やdomain metadataを推測してpropertyとして追加しない。
|
||||
Scope Objectの`ref`やvalidatorを`@...`として参照に付け足さない。`scope_ref`はClient runtimeが扱う観測・precondition metadataである。Object自身の`ref`とvalidatorもsignatureへ含めず、同一性管理と条件付き実行はClient runtimeが引き受ける。
|
||||
|
||||
ChildrenもObject signatureに含めない。表示にchildrenがないことは、未観測または観測済みの空集合を意味しない。
|
||||
Objectの本文やdomain metadataを推測してpropertyとして追加しない。Childrenもsignatureに含めないため、表示にchildrenがないことから、未観測か観測済みの空集合かを判断することはできない。
|
||||
|
||||
## Interface signature
|
||||
|
||||
@@ -120,11 +126,11 @@ interface "/::issue.read" {
|
||||
|
||||
Interfaceは`interface "<reference>" { ... }`と表示する。`<reference>`は構造化参照から生成した完全な短縮表記であり、前述の規則で全体を引用表示する。
|
||||
|
||||
Block内では、Descriptorの全`types`を宣言順に、続いて全`operations`を宣言順に表示する。Record field、parameter、enum / union caseもすべて元の順序で表示する。この順序はsignature内部の表示規則であり、Protocol上の名前解決やvalidationを変更しない。
|
||||
Block内には、Descriptorの全`types`、全`operations`の順で、それぞれを宣言順に表示する。Record field、parameter、enum / union caseも元の順序を保つ。これは表示の順序であり、Protocol上の名前解決やvalidationには影響しない。
|
||||
|
||||
Interface signatureでは宣言の折りたたみや抜粋、budgetによる省略を行わない。型を`...`へ置き換えたり、field、parameter、caseを削ったりしない。DescriptorにtypeもOperationもない場合は空のblockとなる。
|
||||
宣言を折りたたんだり抜粋したりせず、budgetによる省略も行わない。型を`...`へ置き換えず、field、parameter、caseを削らない。DescriptorにtypeもOperationもなければ、空のblockを表示する。
|
||||
|
||||
異なるInterfaceの宣言を一つのnamespaceへ平坦化しない。同名Operationも各Interfaceのblock内に表示する。Blockの参照が出所を示すため、Operation名を改名したり、参照をOperation名へ連結したりしない。
|
||||
同名Operationが異なるInterfaceにあっても、それぞれのblock内に表示する。Blockの参照によって出所を示し、Operation名を改名したり、参照をOperation名へ連結したりしない。異なるInterfaceの宣言を、一つのnamespaceへ平坦化しない。
|
||||
|
||||
### 型の表記
|
||||
|
||||
@@ -140,9 +146,9 @@ Interface signatureでは宣言の折りたたみや抜粋、budgetによる省
|
||||
|
||||
Named type declarationは`type Name = Type;`とする。Enum、unionを含むすべてのTypeExprを、Descriptorで現れる位置にそのまま表示する。Named typeは同じInterface Descriptor内の宣言を参照し、別Interfaceの同名typeとは混同しない。
|
||||
|
||||
`?`は`required = false`のfieldまたはparameterにだけ付ける。Nullable、default value、一般化したoptional型は意味しない。`unit`とoptionalの違いを維持する。
|
||||
`?`を付けるのは、`required = false`のfieldまたはparameterだけである。Nullable、default value、一般化したoptional型を示す記号ではなく、`unit`とも区別する。
|
||||
|
||||
Payloadのないunion caseは`empty`、payloadがあるcaseは`found(Type)`とする。`empty`と`empty(unit)`を区別する。
|
||||
Unionでは、payloadがなければ`empty`、あれば`found(Type)`と表示する。そのため、`empty`と`empty(unit)`は異なる表示になる。
|
||||
|
||||
```text
|
||||
type CloseResult = union {
|
||||
@@ -202,9 +208,11 @@ Escapeは表示上のものであり、元の値は変更しない。
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentationがある場合は対象の直前に`/// "<summary>"`を表示する。対象はInterface、type、Operation、parameter、record field、enum / union caseである。Objectの説明はこの表記ではなく、Object signatureの`description` propertyで表示する。
|
||||
Documentationは、対象の直前に`/// "<summary>"`として表示する。対象はInterface、type、Operation、parameter、record field、enum / union caseである。Objectの説明だけは、Object signatureの`description` propertyを使う。
|
||||
|
||||
通常表示では`summary`を使う。表示側がdetailsを含める場合は、同じ対象のsummary行に続けて`/// details "<details>"`を追加する。Summaryと選択されたdetailsは短縮せず表示し、documentationがない対象へ説明を生成しない。存在する空文字列は`""`として表示する。
|
||||
通常表示には`summary`を使い、detailsを含める場合は、同じ対象のsummary行に続けて`/// details "<details>"`を追加する。表示するsummaryと、選択されたdetailsは短縮しない。
|
||||
|
||||
Documentationがなければ、説明を生成して補わない。空文字列が存在する場合は、`""`として表示する。
|
||||
|
||||
ReturnDeclarationのdocumentationは、parameter listの閉じ括弧の後、return arrowの直前に置く。
|
||||
|
||||
@@ -225,4 +233,6 @@ operation search(
|
||||
|
||||
Text ViewはUTF-8 plain textとし、ANSI color、Markdown、syntax highlightingがなくても読めるようにする。本章のcode fenceは説明用であり、signatureそのものの一部ではない。Markdown内へ埋め込む場合は、表示側が内容に衝突しないcode fence等を選ぶ。
|
||||
|
||||
Signature内部の標準的なlayoutはLF、2 space indentとする。短いrecord、parameter list、enum / unionは1行に置ける。Documentationを持つfieldやcaseは別行に置く。表示用の改行幅やindentの違いは意味を持たず、byte単位のcanonical renderingは要求しない。文字列・名前の内部にある空白は元の値として保持する。
|
||||
標準的なsignature内部のlayoutは、LFと2 space indentを使う。短いrecord、parameter list、enum / unionは1行に置けるが、documentationを持つfieldやcaseは別行に置く。
|
||||
|
||||
表示用の改行幅やindentの違いに意味はなく、byte単位のcanonical renderingは要求しない。一方、文字列・名前の内部にある空白は表示上の余白ではないため、元の値として保持する。
|
||||
|
||||
+22
-22
@@ -1,12 +1,12 @@
|
||||
# 5. Transport Bindings
|
||||
|
||||
WIP Transport Bindingは、[3. Protocol](3-protocol.md)のlogical data modelとCore interactionを、具体的な通信方式とwire representationへ対応付ける。
|
||||
WIP Transport Bindingは、[3. Protocol](3-protocol.md)で定義するlogical data modelとCore interactionを、具体的な通信方式とwire representationへ対応付ける。
|
||||
|
||||
標準bindingは[5.1. WIP over HTTP](5.1-wip-over-http.md)で定義する。
|
||||
標準bindingの定義は[5.1. WIP over HTTP](5.1-wip-over-http.md)にある。
|
||||
|
||||
## 責務
|
||||
|
||||
Transport Bindingは、少なくとも次を定義する。
|
||||
Transport Bindingには、少なくとも次の定義が必要になる。
|
||||
|
||||
- Endpointまたはpeerの指定方法。
|
||||
- Requestとresponseのframing、および一つのlogical interactionとの対応付け。
|
||||
@@ -16,27 +16,27 @@ Transport Bindingは、少なくとも次を定義する。
|
||||
- Transport failureとProtocol failureの区別。
|
||||
- ValidatorおよびOperation preconditionのmapping。
|
||||
|
||||
BindingはProtocolのlogical type、validation、error、visibility、Operation executionの意味を変更してはならない。Binding固有のstatus、header、stream event等を根拠として、新しいProtocol failureまたは回復actionを暗黙に追加してはならない。
|
||||
これらは通信上の表現を定めるものであり、BindingはProtocolのlogical type、validation、error、visibility、Operation executionの意味を変更してはならない。Binding固有のstatus、header、stream event等を根拠に、新しいProtocol failureや回復actionを暗黙に追加してはならない。
|
||||
|
||||
## Logical interactionとphysical exchange
|
||||
|
||||
一つのCore interactionは、一つ以上のphysical messageを使用して実装できる。ただし、ClientとHostは各successまたはProtocol failureを、元のlogical requestおよびそのeffective Worldspace viewへ一意に対応付けられなければならない。
|
||||
一つのCore interactionを、一つ以上のphysical messageで実装してよい。その場合も、ClientとHostは各successまたはProtocol failureを、元のlogical requestとそのeffective Worldspace viewへ一意に対応付けられなければならない。
|
||||
|
||||
複数のCore interactionは、それぞれ独立したrequestとresponseを持つ。Clientはそれらを並行実行でき、Transport実装は複数のexchangeを一つのconnection上でmultiplexできる。WIPは複数のinteractionを包む共通request / response envelope、transaction、共通snapshot、request間のdata dependencyを定義しない。
|
||||
複数のCore interactionは、それぞれ独立したrequestとresponseを持つ。Clientは並行実行でき、Transport実装は複数のexchangeを一つのconnection上でmultiplexできる。ただし、WIPは複数のinteractionを包む共通request / response envelope、transaction、共通snapshot、request間のdata dependencyを定義しない。
|
||||
|
||||
## Effective Worldspace view
|
||||
|
||||
Endpoint、認証主体、tenant、capability、その他のdeployment contextによって、同じHostが異なるClient-facing Worldspaceを公開できる。
|
||||
同じHostでも、endpoint、認証主体、tenant、capability、その他のdeployment contextに応じて、異なるClient-facing Worldspaceを公開できる。
|
||||
|
||||
Clientは異なるeffective Worldspace viewのObject observation、Interface observation、validator、Known Spaceを混在させない。Transport connectionの再利用やmultiplexingによって、この分離を失ってはならない。
|
||||
Clientは、異なるeffective Worldspace viewのObject observation、Interface observation、validator、Known Spaceを混在させない。Connectionを再利用したりexchangeをmultiplexしたりする場合も、この分離を失ってはならない。
|
||||
|
||||
どのauthentication方式または通信securityを使ってviewを確立するかは、Transport実装とdeploymentの責務であり、WIPは標準化しない。
|
||||
Viewを確立するためのauthentication方式と通信securityは、Transport実装とdeploymentが選択する。WIPはこれらを標準化しない。
|
||||
|
||||
## Protocol failureとTransport failure
|
||||
|
||||
Hostがlogical interactionを受理して返す`ProtocolError`はProtocol failureである。Transport Bindingは、これを機械可読なwire representationへ対応付ける。
|
||||
Hostがlogical interactionを受理したうえで返す`ProtocolError`がProtocol failureである。Transport Bindingは、そのerrorを機械可読なwire representationへ対応付ける。
|
||||
|
||||
次はProtocol failureではない。
|
||||
一方、次のfailureはProtocol failureではない。
|
||||
|
||||
- Endpointまたはrouteへ到達できない。
|
||||
- Transport implementationまたはdeploymentがrequestを拒否する。
|
||||
@@ -47,32 +47,32 @@ Hostがlogical interactionを受理して返す`ProtocolError`はProtocol failur
|
||||
|
||||
ClientはTransport failureから`ProtocolErrorCode`を推測しない。Transport authentication failureを`PermissionDenied`へ、未知routeを`NotFound`へ、timeoutを`Internal`または`OperationOutcomeUnknown`へ変換しない。
|
||||
|
||||
Protocol上の`OperationOutcomeUnknown`はHostが返すProtocol failureである。Response不達によってClient側でOperation outcomeを確定できない状態はTransport failureであり、Hostから同codeを受信したことにはしない。
|
||||
`OperationOutcomeUnknown`も、Hostが返すProtocol failureの一つである。Response不達によってClientがOperation outcomeを確定できない場合はTransport failureとして扱い、Hostから同codeを受信したことにはしない。
|
||||
|
||||
## Dispatchとreplay
|
||||
|
||||
Transport Bindingは、physical exchangeの再送によって一つのlogical `call_operation`を複数回dispatchしてはならない。
|
||||
Transport Bindingは、physical exchangeを再送することで、一つのlogical `call_operation`を複数回dispatchしてはならない。
|
||||
|
||||
RequestがHostへdispatchされた可能性のある状態でresponseを取得できなかった場合、Clientは`call_operation`を自動再実行しない。Connection close、timeout、local cancellationは、Host上の実行またはcommitを取り消した証拠ではない。
|
||||
RequestがHostへdispatchされた可能性があり、responseを取得できなかった場合、Clientは`call_operation`を自動再実行しない。Connection close、timeout、local cancellationが起きても、Host上の実行やcommitが取り消された証拠にはならない。
|
||||
|
||||
取得interactionを再送するか、どのtimeoutやretry policyを使用するかはClientとTransport実装の責務である。取得用policyを`call_operation`へ適用しない。
|
||||
取得interactionの再送とtimeout・retry policyは、ClientとTransport実装が決定する。取得用のpolicyを`call_operation`へ適用しない。
|
||||
|
||||
## Validator
|
||||
|
||||
Object validatorとInterface validatorはProtocol structのlogical fieldであり、Bindingはそれらをlosslessにencodeする。
|
||||
Object validatorとInterface validatorは、Protocol structのlogical fieldである。Bindingはこれらをlosslessにencodeする。
|
||||
|
||||
Transport固有のcache validator、conditional request metadata、message identifier等は、WIP validatorと独立している。それらを理由にProtocol bodyのvalidatorを省略せず、ClientもTransport metadataをWIP validatorとして解釈しない。
|
||||
Transport固有のcache validator、conditional request metadata、message identifier等は、WIP validatorとは独立している。これらのmetadataがあってもProtocol bodyのvalidatorを省略せず、ClientもTransport metadataをWIP validatorとして解釈しない。
|
||||
|
||||
## Transport運用との境界
|
||||
|
||||
通信scheme、TLS、peer authentication、proxy、gateway、HTTP cache、compression、connection管理、message size、timeout、concurrency、rate limit等はTransport実装またはdeploymentの関心であり、WIPは値、negotiation、運用profileを定義しない。
|
||||
通信scheme、TLS、peer authentication、proxy、gateway、HTTP cache、compression、connection管理、message size、timeout、concurrency、rate limit等は、Transport実装またはdeploymentが設定・運用する。WIPはそれらの値、negotiation、運用profileを定義しない。
|
||||
|
||||
それらの設定によってrequestまたはresponseを交換できない場合はTransport failureである。HTTP status等がProtocol errorのcanonical mappingと偶然一致しても、validなProtocol error representationを受信しない限りProtocol failureへ変換しない。
|
||||
これらの設定によってrequestやresponseを交換できない場合は、Transport failureとなる。HTTP status等がProtocol errorのcanonical mappingと偶然一致しても、validなProtocol error representationを受信しない限り、Protocol failureへ変換しない。
|
||||
|
||||
WIPのlogical struct、observation completeness、TypeExpr / Value、Host execution等に対する`ResourceLimitExceeded`は[3. Protocol](3-protocol.md)で定義する。Transport implementation自身のmessage-size、timeout、queue、connection等の上限を同codeで包まない。
|
||||
これに対し、WIPのlogical struct、observation completeness、TypeExpr / Value、Host execution等に対する`ResourceLimitExceeded`は[3. Protocol](3-protocol.md)に従う。Transport implementation自身のmessage-size、timeout、queue、connection等の上限を、同codeで包まない。
|
||||
|
||||
## Versioning
|
||||
|
||||
各Bindingは、wire互換性へ影響するmajor versionの選択方法を定義する。異なるmajor version間でroute、message、fieldを推測変換しない。
|
||||
各Bindingは、wire互換性に影響するmajor versionの選択方法を定義する。異なるmajor version間で、route、message、fieldを推測して変換しない。
|
||||
|
||||
ProtocolまたはInterface Descriptorのversionと、Transport Bindingのversionは別である。Transport major versionが同じでも、Interface Descriptorの`format`は独立して検証する。
|
||||
ProtocolまたはInterface Descriptorのversionは、Transport Bindingのversionとは別に扱う。Transport major versionが同じ場合も、Interface Descriptorの`format`を独立して検証する。
|
||||
|
||||
+107
-93
@@ -1,33 +1,33 @@
|
||||
# 5.1. WIP over HTTP
|
||||
|
||||
WIP over HTTPは、[3. Protocol](3-protocol.md)のlogical data modelをHTTP request / responseとJSONへ対応付ける標準Transport Bindingである。[5. Transport Bindings](5-transports.md)の共通要件に従う。
|
||||
WIP over HTTPは、[3. Protocol](3-protocol.md)のlogical data modelをHTTP request / responseとJSONへ対応付ける標準Transport Bindingである。HTTP上の表現は、[5. Transport Bindings](5-transports.md)の共通要件に従って定める。
|
||||
|
||||
## 目的と範囲
|
||||
|
||||
このbindingは、次のCore interactionをHTTPへ対応付ける。
|
||||
HTTPへ対応付けるCore interactionは、次の三つである。
|
||||
|
||||
- `observe`
|
||||
- `fetch_interface`
|
||||
- `call_operation`
|
||||
|
||||
HTTP version、communication scheme、TLS、authentication方式、connection reuse、multiplexing、proxy、gateway、cache、Host内部のframework、backend接続方式は規定しない。HTTP実装とdeploymentは、それらをWIPとは独立して選択する。複数のCore interactionが必要な場合も、それぞれを独立したHTTP request / responseとして交換する。
|
||||
HTTP version、communication scheme、TLS、authentication方式、connection reuse、multiplexing、proxy、gateway、cache、Host内部のframework、backend接続方式は規定しない。これらはHTTP実装とdeploymentが、WIPとは独立して選択する。複数のCore interactionが必要な場合も、それぞれを独立したHTTP request / responseとして交換する。
|
||||
|
||||
OperationがWebSocket等のendpointをApplication dataとして返すことは妨げない。そのendpoint上のprotocol、状態、lifecycleはWIPの規定対象外である。
|
||||
Operationは、WebSocket等のendpointをApplication dataとして返してよい。ただし、そのendpoint上のprotocol、状態、lifecycleは、WIPの規定対象外である。
|
||||
|
||||
## Endpoint
|
||||
|
||||
WIP endpointは、一つのWorldspace access surfaceを指すabsolute hierarchical base URLである。Endpoint URLのpath prefixはdeploymentが任意に選択できる。Clientはorigin直下、`/wip`等の既定prefix、またはWorldspace pathとの対応を仮定しない。
|
||||
WIP endpointは、一つのWorldspace access surfaceを指すabsolute hierarchical base URLである。URLのpath prefixはdeploymentが任意に選択できる。Clientはorigin直下や`/wip`等の既定prefixを仮定せず、Worldspace pathとの対応も仮定しない。
|
||||
|
||||
Endpoint URLは次を満たす。
|
||||
Endpoint URLには、次の条件を適用する。
|
||||
|
||||
- HTTP実装がrequest先として使用できるabsolute URLである。
|
||||
- Authorityとhostを持つ。
|
||||
- Queryとfragmentを持たない。
|
||||
- Pathが`/`で終わるcanonical formである。
|
||||
|
||||
利用者から末尾`/`のないURLを受け付ける実装は、routeを解決する前に`/`を追加してcanonical formへ変換する。URL schemeと通信securityの選択はHTTP実装とdeploymentの責務であり、このbindingは制約しない。
|
||||
利用者から末尾`/`のないURLを受け付ける実装は、routeを解決する前に`/`を追加し、canonical formへ変換する。URL schemeと通信securityはHTTP実装とdeploymentが選択し、このbindingは制約しない。
|
||||
|
||||
各routeはendpoint URLをbase URLとして、次のrelative referenceをRFC 3986に従って解決する。すべてのinteractionは`POST`を使用する。
|
||||
各routeは、endpoint URLをbase URLとして、次のrelative referenceをRFC 3986に従って解決する。すべてのinteractionで`POST`を使用する。
|
||||
|
||||
| Interaction | Method | Relative reference |
|
||||
| --- | --- | --- |
|
||||
@@ -35,27 +35,27 @@ Endpoint URLは次を満たす。
|
||||
| `fetch_interface` | `POST` | `v1/fetch_interface` |
|
||||
| `call_operation` | `POST` | `v1/call_operation` |
|
||||
|
||||
`v1`はWIP over HTTP Bindingのmajor versionである。Interactionごとに独立したrouteを設定したり、固定suffixを別名へ置き換えたりしない。
|
||||
`v1`は、WIP over HTTP Bindingのmajor versionを表す。Interactionごとに独立したrouteを設定したり、固定suffixを別名へ置き換えたりしない。
|
||||
|
||||
例えば、次のendpointを指定した場合、
|
||||
例えば、endpointが次のURLである場合、
|
||||
|
||||
```text
|
||||
http://gateway.example/tenants/acme/filesystem/wip/
|
||||
```
|
||||
|
||||
`observe` routeは次になる。
|
||||
`observe` routeは次のURLになる。
|
||||
|
||||
```text
|
||||
http://gateway.example/tenants/acme/filesystem/wip/v1/observe
|
||||
```
|
||||
|
||||
Endpointのpath prefixはHTTP deployment上のroutingであり、Worldspace内のpathではない。Worldspace pathはrequest bodyのlogical fieldとして渡す。標準route URLはqueryを持たず、query付きURLまたは追加path segmentを同じrouteとして扱わない。
|
||||
このpath prefixはHTTP deployment上のroutingであり、Worldspace内のpathではない。Worldspace pathは、request bodyのlogical fieldとして渡す。標準route URLはqueryを持たず、query付きURLや追加path segmentを同じrouteとして扱わない。
|
||||
|
||||
Clientは異なるcanonical endpoint間でKnown Space、Object observation、Interface observation、validatorを混在させない。同じendpointでも認証主体、tenant、capability、その他のsecurity contextによって異なるWorldspaceが見える場合、それらを異なるeffective Worldspace viewとして分離する。
|
||||
Clientは、異なるcanonical endpoint間でKnown Space、Object observation、Interface observation、validatorを混在させない。同じendpointでも、認証主体、tenant、capability、その他のsecurity contextによって異なるWorldspaceが見える場合は、異なるeffective Worldspace viewとして分離する。
|
||||
|
||||
## HTTP request metadata
|
||||
|
||||
標準routeへのrequestは次を満たす。
|
||||
標準routeへのrequestには、次の条件を適用する。
|
||||
|
||||
- Methodは`POST`。
|
||||
- `Content-Type`は`application/json`または`application/json; charset=utf-8`。
|
||||
@@ -63,15 +63,15 @@ Clientは異なるcanonical endpoint間でKnown Space、Object observation、Int
|
||||
- 標準Clientは`Accept: application/json`を送る。
|
||||
- Request URLは標準routeと完全に一致し、queryを持たない。
|
||||
|
||||
Media typeとparameter名の比較はHTTPの規則に従ってcase-insensitiveに行う。`charset`を指定する場合は`utf-8`だけを許可する。同じparameterの重複、UTF-8以外のcharset、その他のmedia type parameterを標準bindingでは使用しない。
|
||||
Media typeとparameter名は、HTTPの規則に従ってcase-insensitiveに比較する。`charset`を指定する場合に許可するのは`utf-8`だけである。同じparameterの重複、UTF-8以外のcharset、その他のmedia type parameterは、標準bindingでは使用しない。
|
||||
|
||||
`Accept`がないrequestをHostは受理してよい。`Accept`がある場合はHTTP content negotiationの規則を適用し、`application/json`に対するeffective quality valueが正である場合だけJSON responseを返す。
|
||||
Hostは、`Accept`がないrequestを受理してよい。`Accept`がある場合はHTTP content negotiationの規則を適用し、`application/json`に対するeffective quality valueが正である場合だけ、JSON responseを返す。
|
||||
|
||||
すべての実装はHTTP identity content codingを扱える。Requestまたはresponseのcontent codingはoptionalなHTTP機能であり、本bindingは特定の圧縮方式を必須にしない。Hostが対応しないrequest content codingはTransport-levelの`415 Unsupported Media Type`として拒否する。Clientが対応しないresponse content codingはTransport failureとして扱う。
|
||||
すべての実装はHTTP identity content codingを扱える。Requestとresponseのcontent codingはoptionalなHTTP機能であり、このbindingは特定の圧縮方式を必須にしない。Hostが対応しないrequest content codingは、Transport-levelの`415 Unsupported Media Type`として拒否する。Clientが対応しないresponse content codingは、Transport failureとして扱う。
|
||||
|
||||
## Routeとmetadataのfailure
|
||||
|
||||
Route、method、content negotiation、Transport authenticationはlogical WIP requestの外側にある。次のresponseはProtocol error envelopeを要求しない。
|
||||
Route、method、content negotiation、Transport authenticationは、logical WIP requestの外側で扱う。次のresponseには、Protocol error envelopeを要求しない。
|
||||
|
||||
| 条件 | HTTP response |
|
||||
| --- | --- |
|
||||
@@ -82,25 +82,25 @@ Route、method、content negotiation、Transport authenticationはlogical WIP re
|
||||
| Transport authenticationが必要 | `401 Unauthorized`、必要なら`WWW-Authenticate` |
|
||||
| Endpoint access surface全体へのaccess拒否 | `403 Forbidden` |
|
||||
|
||||
Clientはこれらを`InvalidRequest`、`NotFound`、`PermissionDenied`等へ変換しない。
|
||||
Clientは、これらを`InvalidRequest`、`NotFound`、`PermissionDenied`等へ変換しない。
|
||||
|
||||
標準route、method、media type、authenticationが受理された後、bodyがmalformed JSONである、logical requestへdecodeできない、required fieldがない、未知fieldがある、またはfield semanticsに違反する場合、Hostは`400 Bad Request`と`invalid_request`のProtocol error envelopeを返す。
|
||||
一方、標準route、method、media type、authenticationを受理した後に、bodyがmalformed JSONである、logical requestへdecodeできない、required fieldがない、未知fieldがある、またはfield semanticsに違反する場合は、Protocol failureとなる。Hostは`400 Bad Request`と、`invalid_request`のProtocol error envelopeを返す。
|
||||
|
||||
HTTP server、proxy、gateway、またはClientの運用設定によってmessage size、timeout、rate等の上限に達した場合はTransport failureである。HTTP statusが後述するProtocol error mappingと同じでも、validなProtocol error envelopeを持たないresponseをProtocol failureへ変換しない。
|
||||
HTTP server、proxy、gateway、Clientの運用設定によってmessage size、timeout、rate等の上限に達した場合は、Transport failureである。HTTP statusがProtocol error mappingと同じでも、validなProtocol error envelopeを持たないresponseをProtocol failureへ変換しない。
|
||||
|
||||
## HTTP response
|
||||
|
||||
各interactionが成功した場合、Hostは`200 OK`を返し、response bodyへ対応するProtocol responseを直接encodeする。Success bodyを`result`等の共通envelopeで包まない。
|
||||
各interactionが成功した場合、Hostは`200 OK`を返す。Response bodyには、対応するProtocol responseを直接encodeし、`result`等の共通envelopeで包まない。
|
||||
|
||||
Protocol failureの場合、Hostは非2xx statusを返し、bodyへ本章のProtocol error envelopeをencodeする。
|
||||
Protocol failureの場合は非2xx statusを返し、bodyにProtocol error envelopeをencodeする。
|
||||
|
||||
WIP Hostが生成するbody付きresponseは`Content-Type: application/json`または`application/json; charset=utf-8`を持つ。Protocol successまたはProtocol failureのbodyはUTF-8 JSON textである。
|
||||
WIP Hostが生成するbody付きresponseは、`Content-Type: application/json`または`application/json; charset=utf-8`を持つ。Protocol successとProtocol failureのbodyは、いずれもUTF-8 JSON textである。
|
||||
|
||||
HTTP statusは大分類とHTTP infrastructureのための補助情報であり、Protocol failureの意味を決定しない。Clientはstatus、reason phrase、人間向けmessageから`ProtocolErrorCode`を推測しない。
|
||||
HTTP statusは、大分類とHTTP infrastructureのための補助情報である。Protocol failureの意味を決定するものではなく、Clientはstatus、reason phrase、人間向けmessageから`ProtocolErrorCode`を推測しない。
|
||||
|
||||
非2xx responseが、正しいContent-TypeとvalidなProtocol error envelopeを持たない場合、ClientはTransport failureとして扱う。これにより、proxyやgatewayが生成したresponseをHost由来のProtocol failureと誤認しない。
|
||||
非2xx responseが、正しいContent-TypeとvalidなProtocol error envelopeを持たない場合、ClientはTransport failureとして扱う。Proxyやgatewayが生成したresponseを、Host由来のProtocol failureと誤認しないためである。
|
||||
|
||||
次は`InvalidResponse`である。
|
||||
これに対し、次のresponseは`InvalidResponse`となる。
|
||||
|
||||
- `200 OK`以外の2xx statusにvalidなsuccess bodyがある。
|
||||
- 2xx statusにProtocol error envelopeがある。
|
||||
@@ -109,7 +109,7 @@ HTTP statusは大分類とHTTP infrastructureのための補助情報であり
|
||||
|
||||
## JSON共通規則
|
||||
|
||||
JSON encodingには次の規則を適用する。
|
||||
JSON encodingには、次の規則を適用する。
|
||||
|
||||
- JSON textはUTF-8でencodeする。
|
||||
- RecordはJSON object、listはJSON arrayで表す。
|
||||
@@ -123,11 +123,11 @@ JSON encodingには次の規則を適用する。
|
||||
- StringへUnicode normalizationを適用しない。
|
||||
- JSON syntaxが表現できないNaNと正負Infinityを使用しない。
|
||||
|
||||
本章のschema表で`optional`と記していないfieldはrequiredである。
|
||||
Schema表で`optional`と記していないfieldは、requiredとして扱う。
|
||||
|
||||
`Vec<u8>`はRFC 4648 Section 4のstandard base64 alphabetを使用し、必要な`=` paddingを含むcanonical base64 stringとしてencodeする。Whitespace、URL-safe alphabet、padding省略、同じoctet列に対する別表現を受理しない。
|
||||
`Vec<u8>`はcanonical base64 stringとしてencodeする。RFC 4648 Section 4のstandard base64 alphabetを使用し、必要な`=` paddingを含める。Whitespace、URL-safe alphabet、padding省略、同じoctet列に対する別表現は受理しない。
|
||||
|
||||
Path、Interface参照、Object identity、名前、validatorの意味はfieldの位置とProtocolの型によって決まる。
|
||||
Path、Interface参照、Object identity、名前、validatorの意味は、fieldの位置とProtocolの型によって決まる。JSON上の表現は次のとおりである。
|
||||
|
||||
| Logical field | JSON encoding |
|
||||
| --- | --- |
|
||||
@@ -138,11 +138,11 @@ Path、Interface参照、Object identity、名前、validatorの意味はfield
|
||||
| `format` | string |
|
||||
| Object / Interface validator | canonical base64 string |
|
||||
|
||||
JSON serializationのwhitespace、escapeの選択、object member順をcanonical化しない。同じlogical valueがbyte-for-byte同一のJSONになることを要求しない。
|
||||
ただし、JSON serialization全体をcanonical化するわけではない。Whitespace、escapeの選択、object member順はcanonical化せず、同じlogical valueがbyte-for-byte同一のJSONになることも要求しない。
|
||||
|
||||
## Interface Reference
|
||||
|
||||
`InterfaceReference`は次のexactなJSON objectとしてencodeする。
|
||||
`InterfaceReference`は、次のfieldだけを持つexactなJSON objectとしてencodeする。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
@@ -156,13 +156,15 @@ JSON serializationのwhitespace、escapeの選択、object member順をcanonical
|
||||
}
|
||||
```
|
||||
|
||||
`Object.interfaces`の各要素、`FetchInterfaceRequest.interface`、`FetchInterfaceResponse.interface`、`InterfaceTarget.reference`のすべてでこの形を使用する。Scopeの省略、relative scope、`null`、空name、追加member、旧形式のopaque string、`"/github::issue.read"`等の短縮表記stringは認めない。Requestでの違反は`400 Bad Request`と`invalid_request`、responseでの違反はClient-localな`InvalidResponse`とする。
|
||||
`Object.interfaces`の各要素、`FetchInterfaceRequest.interface`、`FetchInterfaceResponse.interface`、`InterfaceTarget.reference`で、この形を共通して使用する。Scopeの省略、relative scope、`null`、空name、追加member、旧形式のopaque string、`"/github::issue.read"`等の短縮表記stringは認めない。Requestで違反した場合は`400 Bad Request`と`invalid_request`を返し、responseで違反した場合はClient-localな`InvalidResponse`とする。
|
||||
|
||||
参照の比較はJSON decode後の`scope`と`name`の完全一致であり、object member順やJSON escapeの違いは同一性に影響しない。ScopeへURL encoding / decodingを追加せず、name内の`::`や`#`も通常の文字として保持する。ScopeはWorldspace上のpathであり、HTTP endpointのprefixを加えたりscope用のHTTP routeを生成したりしない。合成によるscope変換は[ProtocolのWorldspace composition](3-protocol.md#worldspace-composition)に従う。
|
||||
参照は、JSON decode後の`scope`と`name`の完全一致で比較する。Object member順やJSON escapeの違いは、同一性に影響しない。ScopeへURL encoding / decodingを追加せず、name内の`::`や`#`も通常の文字として保持する。
|
||||
|
||||
ScopeはWorldspace上のpathである。HTTP endpointのprefixを加えたり、scope用のHTTP routeを生成したりしない。合成によるscope変換は、[ProtocolのWorldspace composition](3-protocol.md#worldspace-composition)に従う。
|
||||
|
||||
## ObjectとObjectObservation
|
||||
|
||||
`Object`は次のJSON objectとしてencodeする。
|
||||
`Object`は、次のfieldを持つJSON objectとしてencodeする。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
@@ -172,7 +174,7 @@ JSON serializationのwhitespace、escapeの選択、object member順をcanonical
|
||||
| `ref` | string | optional |
|
||||
| `validator` | canonical base64 string | optional |
|
||||
|
||||
例:
|
||||
ObjectのJSON表現の例:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -187,28 +189,32 @@ JSON serializationのwhitespace、escapeの選択、object member順をcanonical
|
||||
}
|
||||
```
|
||||
|
||||
`interfaces`のarray順はHostの立項順を保持し、`(scope, name)`の完全一致による重複を含まない。各参照のscopeは観測pathに対してProtocolの祖先制約を満たす。
|
||||
`interfaces`のarray順にはHostの立項順を保持し、`(scope, name)`の完全一致による重複を含めない。各参照のscopeは、観測pathに対してProtocolの祖先制約を満たす。
|
||||
|
||||
`ObjectObservation`は次のJSON objectとしてencodeする。
|
||||
`ObjectObservation`は、Objectとchildrenの観測を次のJSON objectで表す。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
| `object` | `Object` | required |
|
||||
| `children` | `ObjectObservation`のarray | optional |
|
||||
|
||||
`children`の省略は未観測を、空arrayは観測済みで直接のindexable childが存在しないことを表す。Requested depthより浅いnodeでは`children`を必ず含め、depth境界のnodeでは省略する。Success responseはrequested depthまで完全であり、truncated markerやpartial successを返さない。
|
||||
`children`の省略は未観測を表す。空arrayは、観測済みで直接のindexable childが存在しないことを表す。Requested depthより浅いnodeでは`children`を必ず含め、depth境界のnodeでは省略する。
|
||||
|
||||
Success responseはrequested depthまで完全であり、truncated markerやpartial successを返さない。
|
||||
|
||||
## Interface Descriptor
|
||||
|
||||
### Format discriminator
|
||||
|
||||
標準v1のInterface Descriptor formatは`wip-interface/1`である。このformatでは以下のJSON grammarを使用する。
|
||||
標準v1のInterface Descriptor formatは、`wip-interface/1`である。このformatのJSON grammarは、以下の定義に従う。
|
||||
|
||||
ClientはDescriptor objectのstring `format`を先に読み、対応しないformatを`UnsupportedDescriptorFormat`として扱える。未知formatでもDescriptorはJSON objectであり、string `format`を必ず持つ。それ以外のmemberはそのformat固有であるため、Clientは`wip-interface/1`のschemaで存在、欠落、型を判定せず、内容をOperationへ使用しない。標準v1 Hostは`wip-interface/1`を返す。
|
||||
Clientは、Descriptor objectのstring `format`を先に読み、対応しないformatを`UnsupportedDescriptorFormat`として扱える。未知formatでも、DescriptorはJSON objectであり、string `format`を必ず持つ。
|
||||
|
||||
それ以外のmemberはformat固有であるため、未知formatを`wip-interface/1`のschemaで検証しない。Memberの存在、欠落、型をこのschemaで判定せず、内容をOperationへ使用しない。標準v1 Hostは`wip-interface/1`を返す。
|
||||
|
||||
### Documentation
|
||||
|
||||
`Documentation`は次のJSON objectである。
|
||||
`Documentation`は、説明を次のJSON objectで表す。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
@@ -217,7 +223,7 @@ ClientはDescriptor objectのstring `format`を先に読み、対応しないfor
|
||||
|
||||
### Descriptor root
|
||||
|
||||
`InterfaceDescriptor`は次のJSON objectである。
|
||||
`InterfaceDescriptor`のrootは、次のJSON objectである。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
@@ -226,7 +232,7 @@ ClientはDescriptor objectのstring `format`を先に読み、対応しないfor
|
||||
| `types` | `TypeDeclaration`のarray | required |
|
||||
| `operations` | `OperationDeclaration`のarray | required |
|
||||
|
||||
`types`と`operations`のarray順はdeclaration順を保持する。
|
||||
`types`と`operations`のarrayには、declaration順を保持する。
|
||||
|
||||
### TypeDeclaration
|
||||
|
||||
@@ -238,7 +244,7 @@ ClientはDescriptor objectのstring `format`を先に読み、対応しないfor
|
||||
|
||||
### TypeExpr
|
||||
|
||||
すべての`TypeExpr`はstring `kind`を持つJSON objectとしてencodeする。`kind`ごとの正確なmemberは次のとおりである。
|
||||
すべての`TypeExpr`を、string `kind`を持つJSON objectとしてencodeする。`kind`ごとに持つmemberは、次のものに限定する。
|
||||
|
||||
| TypeExpr | `kind` | その他のmember |
|
||||
| --- | --- | --- |
|
||||
@@ -256,7 +262,7 @@ ClientはDescriptor objectのstring `format`を先に読み、対応しないfor
|
||||
| Enum | `enum` | required `EnumCase` array `cases` |
|
||||
| Union | `union` | required `UnionCase` array `cases` |
|
||||
|
||||
例:
|
||||
ListのitemsがNamedであるTypeExprの例:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -268,7 +274,7 @@ ClientはDescriptor objectのstring `format`を先に読み、対応しないfor
|
||||
}
|
||||
```
|
||||
|
||||
Primitive kindのobjectへ別memberを追加してはならない。例えばUnitは`{"kind":"unit"}`だけで表す。
|
||||
Primitive kindのobjectに、別memberを追加してはならない。例えばUnitの表現は、`{"kind":"unit"}`だけである。
|
||||
|
||||
### FieldDeclaration
|
||||
|
||||
@@ -294,7 +300,7 @@ Primitive kindのobjectへ別memberを追加してはならない。例えばUni
|
||||
| `documentation` | `Documentation` | optional |
|
||||
| `payload` | `TypeExpr` | optional |
|
||||
|
||||
`payload`の省略はpayloadを持たないcaseを表す。
|
||||
`payload`を省略したcaseは、payloadを持たない。
|
||||
|
||||
### OperationDeclaration
|
||||
|
||||
@@ -321,9 +327,9 @@ Primitive kindのobjectへ別memberを追加してはならない。例えばUni
|
||||
| `documentation` | `Documentation` | optional |
|
||||
| `type` | `TypeExpr` | required |
|
||||
|
||||
Descriptorの名前一意性、Named参照、cycle、型整合性等は[3. Protocol](3-protocol.md)に従って検証する。JSON shapeが正しいことだけではvalidなDescriptorにならない。
|
||||
JSON shapeの検証だけでは、Descriptorのvalidityは確認できない。名前一意性、Named参照、cycle、型整合性等も、[3. Protocol](3-protocol.md)に従って検証する。
|
||||
|
||||
完全なDescriptorの例:
|
||||
Typesとoperationsを含む、完全なDescriptorの例:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -400,15 +406,17 @@ Operationのargumentsとresultは、選択したInterface Descriptorの`TypeExpr
|
||||
| Enum | 宣言されたcase名のstring |
|
||||
| Union | `$case`と、必要なら`value`を持つobject |
|
||||
|
||||
`Json`内部でもnumberは相互運用可能なsafe integerまたはfinite binary64として扱い、Bytesの特別なencodingは適用しない。小数部と指数部を持たずsafe integer範囲にあるJSON numberはlogical `Integer`、それ以外のfiniteなJSON numberはlogical `Number`としてdecodeする。`Json`内のbase64らしいstringをBytesと推測しない。
|
||||
`Json`内部のnumberにも、相互運用可能なsafe integerまたはfinite binary64という制約を適用する。小数部と指数部を持たずsafe integer範囲にあるJSON numberはlogical `Integer`として、それ以外のfiniteなJSON numberはlogical `Number`としてdecodeする。
|
||||
|
||||
RecordとOperation argumentsでは、`required = false`のfieldまたはparameterを省略できる。省略と`null`は異なる。TypeExprがUnit、Json、またはそれらへ解決されるNamedである場合、存在するmemberの`null`はその型のvalueであり、省略を表さない。それ以外の型で`null`を使用してはならない。
|
||||
`Json`内部にはBytesの特別なencodingを適用しない。Base64らしいstringがあっても、Bytesと推測しない。
|
||||
|
||||
Record valueはdeclarationされていないmemberを持たない。Operation argumentsはparameter名をmember名とするJSON objectであり、位置引数を使用しない。引数のないOperationは空objectを使用する。
|
||||
RecordとOperation argumentsでは、`required = false`のfieldやparameterを省略できる。ただし、省略と`null`は異なる。TypeExprがUnit、Json、またはそれらへ解決されるNamedである場合、存在するmemberの`null`はその型のvalueであり、省略を表さない。それ以外の型では`null`を使用してはならない。
|
||||
|
||||
Enumはcase名だけをstringで表し、object envelopeを使用しない。
|
||||
Record valueには、declarationされていないmemberを含めない。Operation argumentsは、parameter名をmember名とするJSON objectであり、位置引数を使用しない。引数のないOperationには空objectを使用する。
|
||||
|
||||
Payloadを持つUnionは次の形である。
|
||||
Enumはcase名だけをstringで表し、object envelopeは使用しない。
|
||||
|
||||
Payloadを持つUnionは、`$case`と`value`を持つ次の形で表す。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -417,7 +425,7 @@ Payloadを持つUnionは次の形である。
|
||||
}
|
||||
```
|
||||
|
||||
Payloadを持たないUnionは`value`を省略する。
|
||||
Payloadを持たないUnionでは、`value`を省略する。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -425,15 +433,15 @@ Payloadを持たないUnionは`value`を省略する。
|
||||
}
|
||||
```
|
||||
|
||||
Union objectは`$case`と必要な`value`以外のmemberを持たない。Payloadありcaseでは`value`がrequired、payloadなしcaseでは`value`は禁止される。
|
||||
Union objectに含めるmemberは、`$case`と必要な`value`だけである。Payloadありcaseでは`value`がrequiredとなり、payloadなしcaseでは`value`を禁止する。
|
||||
|
||||
ClientはDescriptorに従ってvalueをdecodeし、`entry`と宣言された位置のstringだけをWorldspace entryとして認識する。通常のStringやopaqueなJsonに含まれるpathらしいstringをentryとして推測しない。
|
||||
Clientは、Descriptorに従ってvalueをdecodeする。Worldspace entryとして認識するのは、`entry`と宣言された位置のstringだけである。通常のStringやopaqueなJsonに含まれるpathらしいstringから、entryを推測しない。
|
||||
|
||||
## `observe`
|
||||
|
||||
### Request
|
||||
|
||||
`POST v1/observe`のbodyは次のJSON objectである。
|
||||
`POST v1/observe`のbodyには、次のJSON objectを使用する。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
@@ -449,7 +457,7 @@ ClientはDescriptorに従ってvalueをdecodeし、`entry`と宣言された位
|
||||
|
||||
### Success response
|
||||
|
||||
Success bodyはrootの`ObjectObservation`を直接encodeする。
|
||||
Success bodyには、rootの`ObjectObservation`を直接encodeする。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -473,13 +481,13 @@ Success bodyはrootの`ObjectObservation`を直接encodeする。
|
||||
}
|
||||
```
|
||||
|
||||
Responseのpath、name、depth、children completenessはrequestと[3. Protocol](3-protocol.md)に従って検証する。完全なresponseをresource budget内で構築できない場合、Hostはpartial successではなく`resource_limit_exceeded`を返す。
|
||||
Responseのpath、name、depth、children completenessは、requestと[3. Protocol](3-protocol.md)に従って検証する。Hostがresource budget内で完全なresponseを構築できない場合は、partial successにせず、`resource_limit_exceeded`を返す。
|
||||
|
||||
## `fetch_interface`
|
||||
|
||||
### Request
|
||||
|
||||
`POST v1/fetch_interface`のbodyは次のJSON objectである。
|
||||
`POST v1/fetch_interface`のbodyには、次のJSON objectを使用する。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
@@ -513,9 +521,11 @@ Responseのpath、name、depth、children completenessはrequestと[3. Protocol]
|
||||
}
|
||||
```
|
||||
|
||||
Responseの`interface`はrequestと完全一致しなければならない。`scope_ref`、Descriptor、validatorは一つのInterface observationとして扱う。`scope_ref`はscope Objectの`ref`と同じopaque stringであり、base64化しない。Scope Objectが`ref`を持つ場合は含め、持たない場合は省略する。`null`は使用しない。
|
||||
Responseの`interface`は、requestと完全一致しなければならない。`scope_ref`、Descriptor、validatorは、一つのInterface observationとして扱う。
|
||||
|
||||
Scope Objectに`ref`がない場合のvalidなresponse例:
|
||||
`scope_ref`はscope Objectの`ref`と同じopaque stringであり、base64化しない。Scope Objectが`ref`を持つ場合は含め、持たない場合は省略する。`null`は使用しない。
|
||||
|
||||
Scope Objectに`ref`がない場合も、次のresponseはvalidである。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -528,13 +538,13 @@ Scope Objectに`ref`がない場合のvalidなresponse例:
|
||||
}
|
||||
```
|
||||
|
||||
`scope_ref`もvalidatorもない観測は許可される。Scopeのidentity追跡はできず、Descriptorはその参照の有効期間中immutableとして扱う。
|
||||
`scope_ref`もvalidatorもない観測は許可される。この場合、scopeのidentityは追跡できず、Descriptorは参照の有効期間中immutableとして扱う。
|
||||
|
||||
## `call_operation`
|
||||
|
||||
### Request
|
||||
|
||||
`POST v1/call_operation`のbodyは次のJSON objectである。
|
||||
`POST v1/call_operation`のbodyには、次のJSON objectを使用する。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
@@ -543,14 +553,14 @@ Scope Objectに`ref`がない場合のvalidなresponse例:
|
||||
| `operation` | Operation名のstring | required |
|
||||
| `arguments` | 選択したOperation declarationに従うobject | required |
|
||||
|
||||
`Target`は次のfieldを持つ。
|
||||
`Target`は、pathとoptionalなvalidatorを次のfieldで表す。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
| `path` | canonical absolute Worldspace pathのstring | required |
|
||||
| `validator` | canonical base64 string | optional |
|
||||
|
||||
`InterfaceTarget`は次のfieldを持つ。
|
||||
`InterfaceTarget`は、参照とその観測metadataを次のfieldで表す。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
@@ -576,11 +586,13 @@ Scope Objectに`ref`がない場合のvalidなresponse例:
|
||||
}
|
||||
```
|
||||
|
||||
`interface.reference`と`operation`は別fieldである。表示用の`/::item-interface-v1#get_related_item`を一つのfieldへencodeしない。`scope_ref`も`InterfaceReference`のmemberや短縮表記の一部にはせず、`InterfaceTarget`の独立したoptional fieldとして送る。
|
||||
`interface.reference`と`operation`は、別fieldである。表示用の`/::item-interface-v1#get_related_item`を、一つのfieldへencodeしない。`scope_ref`も`InterfaceReference`のmemberや短縮表記の一部にはせず、`InterfaceTarget`の独立したoptional fieldとして送る。
|
||||
|
||||
Clientは`fetch_interface`の観測に`scope_ref`があればコピーし、なければ省略する。`scope_ref`に`null`や非stringを指定したrequestは`invalid_request`である。Validなstringを指定したが現在のscope Objectの`ref`と一致しない場合(現在の`ref`の欠落を含む)は、Protocolの検証順に従って`interface_mismatch`とする。省略時はscope identity preconditionを適用せず、それだけを理由に拒否しない。
|
||||
Clientは、`fetch_interface`の観測に`scope_ref`があればコピーし、なければ省略する。`null`や非stringを`scope_ref`に指定したrequestは、`invalid_request`となる。
|
||||
|
||||
HostはDescriptorに依存しない`target`、`interface`、`operation`のshapeを検証し、Protocolのerror precedenceに従って現在のObjectとInterface observationを固定した後、そのDescriptorに従って`arguments`をdecodeする。
|
||||
Validなstringを指定していても、現在のscope Objectの`ref`と一致しない場合は、Protocolの検証順に従って`interface_mismatch`とする。現在の`ref`が欠落している場合も含む。`scope_ref`を省略した場合はscope identity preconditionを適用せず、省略だけを理由に拒否しない。
|
||||
|
||||
Hostはまず、Descriptorに依存しない`target`、`interface`、`operation`のshapeを検証する。次に、Protocolのerror precedenceに従って現在のObjectとInterface observationを固定し、そのDescriptorに従って`arguments`をdecodeする。
|
||||
|
||||
### Success response
|
||||
|
||||
@@ -599,11 +611,11 @@ HostはDescriptorに依存しない`target`、`interface`、`operation`のshape
|
||||
}
|
||||
```
|
||||
|
||||
`result`はrequestで指定し、Hostがprecondition確認時に固定したOperation declarationのreturn TypeExprに従ってencodeする。
|
||||
`result`のencodingには、requestで指定し、Hostがprecondition確認時に固定したOperation declarationのreturn TypeExprを使用する。
|
||||
|
||||
## Validator
|
||||
|
||||
Object validatorとInterface validatorは対応するJSON body fieldへcanonical base64としてencodeする。
|
||||
Object validatorとInterface validatorは、対応する次のJSON body fieldにcanonical base64としてencodeする。
|
||||
|
||||
- `Object.validator`
|
||||
- `Target.validator`
|
||||
@@ -611,7 +623,9 @@ Object validatorとInterface validatorは対応するJSON body fieldへcanonical
|
||||
- `InterfaceTarget.validator`
|
||||
- `CallOperationResponse.validator`
|
||||
|
||||
WIP validatorを`ETag`、`If-Match`、その他のHTTP headerだけへ移してbody fieldを省略してはならない。Host、gateway、HTTP cacheは通常のHTTP representation metadataとして`ETag`等を追加してよいが、その値はWIP validatorと独立している。ClientはHTTP metadataをObject identity、Interface observation、Operation preconditionとして解釈しない。
|
||||
WIP validatorを`ETag`、`If-Match`、その他のHTTP headerだけへ移し、body fieldを省略してはならない。
|
||||
|
||||
Host、gateway、HTTP cacheは、通常のHTTP representation metadataとして`ETag`等を追加してよい。ただし、その値はWIP validatorとは独立している。ClientはHTTP metadataを、Object identity、Interface observation、Operation preconditionとして解釈しない。
|
||||
|
||||
## Protocol error envelope
|
||||
|
||||
@@ -626,16 +640,16 @@ WIP validatorを`ETag`、`If-Match`、その他のHTTP headerだけへ移してb
|
||||
}
|
||||
```
|
||||
|
||||
Top-level objectは`error`だけを持つ。`error` objectは次のfieldだけを持つ。
|
||||
Top-level objectに含めるのは`error`だけである。`error` objectも、次のfieldだけを持つ。
|
||||
|
||||
| Field | JSON type | Presence |
|
||||
| --- | --- | --- |
|
||||
| `code` | 下表のstableなsnake_case string | required |
|
||||
| `message` | boundedな人間向け診断string | required |
|
||||
|
||||
Clientは`message`で分岐しない。Hostは秘密情報、credential、非公開Object、backend path、内部stack traceをmessageへ含めない。
|
||||
Clientは`message`を分岐に使用しない。Hostは秘密情報、credential、非公開Object、backend path、内部stack traceをmessageへ含めない。
|
||||
|
||||
`ProtocolErrorCode`のwire spellingとcanonical HTTP statusは次のとおりである。
|
||||
`ProtocolErrorCode`には、次のwire spellingとcanonical HTTP statusを対応付ける。
|
||||
|
||||
| Protocol error code | Wire spelling | HTTP status |
|
||||
| --- | --- | --- |
|
||||
@@ -654,35 +668,35 @@ Clientは`message`で分岐しない。Hostは秘密情報、credential、非公
|
||||
| `Internal` | `internal` | `500 Internal Server Error` |
|
||||
| `OperationOutcomeUnknown` | `operation_outcome_unknown` | `500 Internal Server Error` |
|
||||
|
||||
`resource_limit_exceeded`は、observation completeness、logical nesting、collection件数等、WIP Protocolが扱うlogical requestまたはresponseの上限超過を表し、`422 Unprocessable Content`を使用する。HTTP implementationやdeploymentのmessage-size上限超過をこのcodeへ変換しない。
|
||||
`resource_limit_exceeded`は、WIP Protocolが扱うlogical requestまたはresponseの上限超過を表す。Observation completeness、logical nesting、collection件数等が対象であり、HTTP statusには`422 Unprocessable Content`を使用する。HTTP implementationやdeploymentのmessage-size上限超過を、このcodeへ変換しない。
|
||||
|
||||
Rate limitまたはdeployment concurrency limitによる`429 Too Many Requests`はTransport / deployment concernである。ValidなProtocol error envelopeがない限り`ResourceLimitExceeded`へ変換しない。
|
||||
Rate limitやdeployment concurrency limitによる`429 Too Many Requests`は、Transport / deployment concernである。ValidなProtocol error envelopeがない限り、`ResourceLimitExceeded`へ変換しない。
|
||||
|
||||
Hostはinteractionごとに[3. Protocol](3-protocol.md)で許可されたcodeだけを返す。ClientがvalidなProtocol error envelopeを取得した場合、HTTP statusがcanonical mappingと一致しなくても回復actionは`code`に従い、statusから別codeを推測しない。Status mismatchはBinding違反として診断してよい。
|
||||
Hostは、interactionごとに[3. Protocol](3-protocol.md)で許可されたcodeだけを返す。ClientがvalidなProtocol error envelopeを取得した場合、回復actionは`code`に従う。HTTP statusがcanonical mappingと一致しなくても、statusから別codeを推測しない。Status mismatchは、Binding違反として診断してよい。
|
||||
|
||||
`InvalidResponse`と`UnsupportedDescriptorFormat`はClient-local classificationであり、Hostがerror envelopeで返してはならない。Domain failureもProtocol errorへ変換せず、Operation return typeに従うApplication dataとしてencodeする。
|
||||
|
||||
## HTTP authentication boundary
|
||||
|
||||
Authentication方式、credential、mTLS、proxy、gateway等はHTTP implementationとdeploymentの関心であり、本bindingは規定しない。
|
||||
Authentication方式、credential、mTLS、proxy、gateway等は、HTTP implementationとdeploymentが設定・運用する。このbindingは規定しない。
|
||||
|
||||
HTTP layerがWIP interactionを開始せずに返す`401 Unauthorized`または`403 Forbidden`は、validなProtocol error envelopeを持たないTransport failureである。Clientはそれらを`PermissionDenied`へ変換しない。
|
||||
HTTP layerがWIP interactionを開始せずに返す`401 Unauthorized`や`403 Forbidden`は、validなProtocol error envelopeを持たないTransport failureである。Clientはこれらを`PermissionDenied`へ変換しない。
|
||||
|
||||
WIP interactionを開始した後のObject、Interface、Operation visibilityとauthorizationはProtocol semanticsに従う。Protocolの`PermissionDenied`はvalidなWIP error envelopeを持つ`403 Forbidden`であり、HTTP layerのaccess rejectionと区別する。
|
||||
WIP interactionを開始した後のObject、Interface、Operation visibilityとauthorizationは、Protocol semanticsに従う。Protocolの`PermissionDenied`は、validなWIP error envelopeを持つ`403 Forbidden`であり、HTTP layerのaccess rejectionとは区別する。
|
||||
|
||||
## HTTP replay
|
||||
|
||||
`3xx` response自体はWIP successまたはProtocol failureではなく、Transport failureである。Endpoint discovery、redirect、proxy routingをどう扱うかはHTTP implementationとdeploymentの責務である。
|
||||
`3xx` response自体は、WIP successやProtocol failureではなく、Transport failureである。Endpoint discovery、redirect、proxy routingの扱いは、HTTP implementationとdeploymentが決定する。
|
||||
|
||||
ただし、それらの機構によって一つのlogical `call_operation` requestを複数回dispatchしてはならない。Dispatch後にredirect responseを受け取った場合も、methodとbodyを維持して別endpointへ自動再送しない。
|
||||
ただし、これらの機構によって、一つのlogical `call_operation` requestを複数回dispatchしてはならない。Dispatch後にredirect responseを受け取った場合も、methodとbodyを維持して別endpointへ自動再送しない。
|
||||
|
||||
## Dispatchとretry
|
||||
|
||||
RequestがHostへ送信されていないとTransport実装が証明できるfailureはnot dispatchedである。送信開始後にresponseを取得できない場合、ClientはHostがrequestを受理した可能性を否定しない。
|
||||
Failureをnot dispatchedとして扱えるのは、requestがHostへ送信されていないとTransport実装が証明できる場合である。送信開始後にresponseを取得できない場合、ClientはHostがrequestを受理した可能性を否定しない。
|
||||
|
||||
`observe`と`fetch_interface`を再送するか、どのtimeout、retry、cancellation policyを使用するかはClientとHTTP実装の責務である。Response ownershipとProtocol上のcache semanticsは[4. Client](4-client.md)に従う。
|
||||
`observe`と`fetch_interface`の再送とtimeout・retry・cancellation policyは、ClientとHTTP実装が決定する。Response ownershipとProtocol上のcache semanticsは、[4. Client](4-client.md)に従う。
|
||||
|
||||
`call_operation`には次を適用する。
|
||||
`call_operation`には、次の規則を適用する。
|
||||
|
||||
- Redirect、connection retry、HTTP stackのautomatic replay、proxy retryによって再送しない。
|
||||
- `Retry-After`またはHTTP statusだけを理由に再送しない。
|
||||
@@ -694,15 +708,15 @@ HTTP implementationとdeploymentは、失敗した`POST v1/call_operation`を透
|
||||
|
||||
## HTTP facilitiesとの境界
|
||||
|
||||
HTTP cache、proxy、gateway、compression、connection管理、TLS、message size、timeout、concurrency、rate limit等の設定と運用は、本bindingの規定対象外である。
|
||||
HTTP cache、proxy、gateway、compression、connection管理、TLS、message size、timeout、concurrency、rate limit等の設定と運用は、このbindingの規定対象外である。
|
||||
|
||||
HTTP cache metadataはWIP ClientのKnown Space、Interface cache、Object validator、Interface validatorとは別の機構である。`ETag`、`If-Match`、Age、Last-Modified等をWIP observationまたはvalidatorとして解釈しない。それ以外のHTTP cache policyはHTTP implementationとdeploymentが決定する。
|
||||
HTTP cache metadataは、WIP ClientのKnown Space、Interface cache、Object validator、Interface validatorとは別の機構である。`ETag`、`If-Match`、Age、Last-Modified等を、WIP observationやvalidatorとして解釈しない。それ以外のHTTP cache policyは、HTTP implementationとdeploymentが決定する。
|
||||
|
||||
HTTP implementationまたはdeploymentの都合でrequestまたはresponseを交換できない場合、ClientはTransport failureとして扱う。WIPはそれらの運用設定を規定せず、そのfailureを`resource_limit_exceeded`その他のProtocol errorへ包み直さない。
|
||||
HTTP implementationやdeploymentの都合でrequestやresponseを交換できない場合、ClientはTransport failureとして扱う。WIPはこれらの運用設定を規定せず、そのfailureを`resource_limit_exceeded`その他のProtocol errorへ包み直さない。
|
||||
|
||||
## 非目標
|
||||
|
||||
本bindingは次を定義しない。
|
||||
このbindingの定義には、次のものを含めない。
|
||||
|
||||
- Host内部のAPI、database、filesystem、queueとの接続方法。
|
||||
- Endpoint discovery service。
|
||||
|
||||
@@ -2,35 +2,35 @@
|
||||
|
||||
## Status
|
||||
|
||||
この文書は、Rust / TypeScript Host APIを設計する前に、動的なWorldspaceを運用するための責務、処理モデル、要件を整理するdraftである。
|
||||
動的なWorldspaceを運用するには、Hostが何を解決し、どの範囲のObjectを投影し、どうOperationを実行するかを整理する必要がある。このdraftは、Rust / TypeScript Host APIの設計に先立って、その責務、処理モデル、要件をまとめたものである。
|
||||
|
||||
具体的なtrait、class、function、decorator、macro、builderのsyntaxは定義しない。この文書で使用する`Projection Provider`、`Projection Resolution`、`Projected Object`等の名称もworking terminologyであり、canonicalなProtocol typeではない。
|
||||
具体的なtrait、class、function、decorator、macro、builderのsyntaxは、ここでは定義しない。`Projection Provider`、`Projection Resolution`、`Projected Object`等もHost内部の設計を検討するためのworking terminologyであり、canonicalなProtocol typeではない。
|
||||
|
||||
Protocol上の確定事項は[Core Model](../2-core-model.md)、[Object tree / Schema](../2.1-object-tree-schema.md)、[Operation and Interface](../2.2-operation-and-interface.md)、[Protocol](../3-protocol.md)を参照する。言語別のconceptual API modelは[Rust API Model](rust-api-model.md)と[TypeScript API Model](typescript-api-model.md)で扱う。
|
||||
Protocol上の確定事項は[Core Model](../2-core-model.md)、[Object tree / Schema](../2.1-object-tree-schema.md)、[Operation and Interface](../2.2-operation-and-interface.md)、[Protocol](../3-protocol.md)に従う。言語別のconceptual API modelは[Rust API Model](rust-api-model.md)と[TypeScript API Model](typescript-api-model.md)で扱う。
|
||||
|
||||
## 目的
|
||||
|
||||
WIP Hostの中心的な実装対象は、事前に列挙された静的endpointだけではない。Filesystem、database row、repository内容、検索結果、外部service上のentityなど、実行時に存在と状態が決まる空間をWorldspaceへ投影できなければならない。
|
||||
WIP Hostは、事前に列挙した静的endpointだけでなく、実行時に存在と状態が決まる空間をWorldspaceへ投影できなければならない。Filesystem、database row、repository内容、検索結果、外部service上のentityなどが、その実装対象になる。
|
||||
|
||||
HostがWorldspace内のすべてのObjectを常駐instanceとして保持することは要求しない。Backend dataをrequest時に解決し、必要な範囲だけObjectとして投影できることを基本とする。
|
||||
そのために、すべてのObjectを常駐instanceとして保持する必要はない。Backend dataをrequest時に解決し、必要な範囲だけObjectとして投影できることを基本とする。
|
||||
|
||||
固定pathへ登録されたObjectやservice endpointは、同じ動的projection modelにおいてrouting / resolution structureが固定された特殊ケースとして扱えるべきである。現在の主体やbackend stateに応じて、そこで返すObject representationやvalidatorが変化することは妨げない。静的登録だけを基本形とし、動的空間を大量のObject登録によって模倣する設計にはしない。
|
||||
固定pathに登録するObjectやservice endpointも、同じ動的projection modelで表現できるべきである。この場合はrouting / resolution structureが固定されているが、返すObject representationやvalidatorは、現在の主体やbackend stateに応じて変化してよい。静的登録を基本形にして大量のObject登録で動的空間を模倣するのではなく、動的な解決の特殊ケースとして静的登録を扱う。
|
||||
|
||||
## 基本的な分離
|
||||
|
||||
Host実装APIでは、少なくとも次の責務を区別する必要がある。この区別を一対一のpublic type、trait、objectへ分割することまでは決定しない。
|
||||
Host実装APIでは、少なくとも次の責務を区別する必要がある。ただし、責務ごとに一つのpublic type、trait、objectを設けるかどうかは未決定である。
|
||||
|
||||
### Interface Declaration
|
||||
|
||||
Operation名、parameter、return type、documentation等を宣言し、Interface Descriptor、Value codec、boundary validationを生成する。
|
||||
Interface Declarationは、Operation名、parameter、return type、documentation等の宣言である。その宣言からInterface Descriptor、Value codec、boundary validationを生成する。
|
||||
|
||||
Interface Declarationは、多数のObjectが共有できる比較的静的なcapability schemaである。Objectの列挙やbackend dataのlifecycleは担当しない。
|
||||
多数のObjectが共有できる、比較的静的なcapability schemaとして扱う。Objectの列挙とbackend dataのlifecycleは、Interface Declarationの責務には含めない。
|
||||
|
||||
### Worldspace Projection
|
||||
|
||||
Clientから受け取ったpathを、現在の主体とbackend stateに基づいて解決し、Protocol上のObject projectionを生成する。一つのProjected Objectは、Host固有のentityを持たないvirtual nodeでも、一つのbackend entityに対応するものでも、複数のbackend entityを集約したものでもよい。
|
||||
Worldspace Projectionは、Clientから受け取ったpathを現在の主体とbackend stateに基づいて解決し、Protocol上のObject projectionを生成する処理である。投影するObjectは、Host固有のentityを持たないvirtual nodeでも、一つのbackend entityに対応するものでも、複数のbackend entityを集約したものでもよい。
|
||||
|
||||
Projectionは、少なくとも次を決定する。
|
||||
Projectionで決定する内容には、少なくとも次が含まれる。
|
||||
|
||||
- そのpathのObjectを現在の主体へ公開するか。
|
||||
- 公開する`name`、optionalな`description`、`ref`、`validator`。
|
||||
@@ -40,26 +40,28 @@ Projectionは、少なくとも次を決定する。
|
||||
|
||||
### Host Runtime
|
||||
|
||||
Protocol requestを受け取り、ProjectionとInterface registryを協調させる。Operation callでは、現在のProjected Objectが公開するInterface集合からClientが指定した一つのDescriptorを固定し、そこに宣言されたOperationをObjectのHost内implementationへ適用する。
|
||||
Host RuntimeはProtocol requestを受け取り、ProjectionとInterface registryを協調させる。Operation callでは、現在のProjected Objectが公開するInterface集合からClient指定のDescriptorを一つ固定し、そのDescriptorに宣言されたOperationをObjectのHost内implementationへ適用する。
|
||||
|
||||
Host RuntimeはProtocol validation、Interface Descriptorの公開、Value validation、error mapping、resource limit等を担う。Backend固有のprojection resolutionやdomain operation implementationそのものはProvider等のHost componentへ委譲できる。Object implementationとOperation実装を接続する具体的なAPIは各言語のSDK側で決定し、Protocol conceptとして追加しない。
|
||||
また、Protocol validation、Interface Descriptorの公開、Value validation、error mapping、resource limit等も担う。一方、backend固有のprojection resolutionやdomain operation implementationそのものは、Provider等のHost componentへ委譲できる。
|
||||
|
||||
Object implementationとOperation実装を接続する具体的なAPIは、各言語のSDK側で決める。これはHost内の実装を接続するためのAPIであり、新たなProtocol conceptとしては追加しない。
|
||||
|
||||
## Working terminology
|
||||
|
||||
このdraftでは、Host内部の議論のために次の語を使う。
|
||||
Host内部の投影処理を検討するため、次の語を使う。
|
||||
|
||||
- **Backend Entity** — File、database row、issue、repository、remote entity等、projectionの入力になり得るHost固有の実体。一つのProjected Objectと一対一に対応するとは限らない。
|
||||
- **Projection Resolution** — 現在の主体とpathについて、公開するObject representationと、後続処理に必要なHost-private情報を得るrequest-timeの解決結果。
|
||||
- **Projection Provider** — Pathと主体からProjection Resolutionを生成し、indexable childrenの発見を支援するHost component。
|
||||
- **Projected Object** — その時点でClientへ返すProtocol `Object`。Backend Entityを持たないvirtual Object、一つのentityのprojection、複数entityのaggregateのいずれでもよく、Host内の常駐instanceを意味しない。
|
||||
|
||||
HostがOperationを実装するために使用するRust struct、TypeScript object、backend handle、transaction context等はHost-privateであり、共通するHost abstractionを必須にしない。これらをProtocolの`Target { path, validator }`、canonical path、Objectの`ref`として公開または流用しない。
|
||||
Operationの実装に使うRust struct、TypeScript object、backend handle、transaction context等はHost-privateである。これらに共通するHost abstractionは必須としない。また、Protocolの`Target { path, validator }`、canonical path、Objectの`ref`として公開したり、それらへ流用したりしない。
|
||||
|
||||
## 運用モデル
|
||||
|
||||
### `observe`
|
||||
|
||||
`observe(path, depth)`では、Hostは概念的に次を行う。
|
||||
`observe(path, depth)`では、要求された範囲のObjectを現在の状態から投影する。Hostが概念的に行う処理は次のとおりである。
|
||||
|
||||
1. Requestの主体とpathをProjection Providerへ渡す。
|
||||
2. Providerが現在のbackend stateから、そのpathで公開するProjected ObjectのProjection Resolutionを生成する。
|
||||
@@ -69,23 +71,25 @@ HostがOperationを実装するために使用するRust struct、TypeScript obj
|
||||
6. Nodeがrequested depthに達した場合はchildrenを観測せず、responseの`children` fieldを省略する。
|
||||
7. Requested depthまで完全な`ObjectObservation`を返す。
|
||||
|
||||
ProviderはWorldspace全体をmaterializeせず、現在処理しているparentのdirect childrenを遅延して発見できる必要がある。Childrenをstream、iterator、cursor等のHost内部表現で生成してよいが、一つのparentについて成功するには公開されたindexable childrenをすべて調べ終えなければならない。
|
||||
Providerには、Worldspace全体をmaterializeせず、処理中のparentのdirect childrenを遅延して発見できることが必要になる。Host内部ではstream、iterator、cursor等でchildrenを生成してよい。ただし、一つのparentの観測を成功とするには、その主体へ公開するindexable childrenをすべて調べ終える必要がある。
|
||||
|
||||
Backend failure、cancellation、またはresource budget超過をclean exhaustionとして扱わない。Requested depthまでの完全なresponseを構築できない場合、Host Runtimeは部分的なsuccess responseを返さず、対応するfailureを返す。
|
||||
Backend failure、cancellation、resource budget超過は、列挙が正常に完了したclean exhaustionとは区別する。Requested depthまで完全なresponseを構築できなければ、Host Runtimeは部分的なsuccess responseではなく、対応するfailureを返す。
|
||||
|
||||
各childについて現在のObject projectionを構築し、そのcanonical pathを後の`observe`または`call_operation`で現在のProjected Objectとして再解決できなければならない。以前観測したbackend entityやHost内部instanceを再現することは要求しない。
|
||||
列挙した各childについては、現在のObject projectionを構築し、そのcanonical pathを後の`observe`や`call_operation`で再解決できなければならない。再解決の対象は、その時点のProjected Objectである。以前観測したbackend entityやHost内部instanceの再現は要求しない。
|
||||
|
||||
この処理のために、Hostが以前の`observe`で生成したObject instanceを保持している必要はない。内部cacheを利用してもよいが、cacheの有無によってProtocol semanticsを変えてはならない。
|
||||
したがって、Hostが以前の`observe`で作ったObject instanceを保持する必要もない。内部cacheは利用してよいが、その有無でProtocol semanticsを変えてはならない。
|
||||
|
||||
### `fetch_interface`
|
||||
|
||||
ProjectionがObjectへ割り当てた各Interface参照は、同じ主体から取得可能なDescriptorへ解決されなければならない。
|
||||
ProjectionがObjectへ割り当てた各Interface参照は、その主体から取得可能なDescriptorへ解決されなければならない。複数のObjectで同じ参照を共有でき、一つのObjectに複数のInterfaceを組み合わせることもできる。
|
||||
|
||||
複数のObjectは同じInterface参照を共有でき、一つのObjectも複数のInterfaceを組み合わせられる。Validatorを返すInterface参照ではDescriptorを変更でき、同じ参照とvalidatorの組が一つのDescriptor observationを表す。Validatorを返さない参照のDescriptorは、その参照が有効な間immutableである。Interface参照のscopeは[2.2. Operation and Interface](../2.2-operation-and-interface.md#interfaceの識別とscope)に従う。登録API、生成時期、deduplication、cacheの具体実装は別途決定する。
|
||||
Descriptorの変更可否は、Interface参照がvalidatorを返すかどうかで分かれる。Validatorを返す参照ではDescriptorを変更でき、同じ参照とvalidatorの組が一つのDescriptor observationを表す。Validatorを返さない参照では、その参照が有効な間、Descriptorはimmutableである。
|
||||
|
||||
Interface参照のscopeは[2.2. Operation and Interface](../2.2-operation-and-interface.md#interfaceの識別とscope)に従う。登録API、生成時期、deduplication、cacheの具体実装は別途決める。
|
||||
|
||||
### `call_operation`
|
||||
|
||||
`call_operation`では、HostはProviderまたはbackend固有のexecution boundaryへ入り、その同じboundary内で概念的に次を行う。
|
||||
`call_operation`では、現在のObjectを解決する処理と、そのObjectへの実行を同じexecution boundary内で行う。HostはProviderまたはbackend固有のboundaryに入り、概念的に次を行う。
|
||||
|
||||
1. 現在の主体と`target.path`からProjection Resolutionを生成し、同じbackend対象を保持するstable handleまたはtransaction contextを取得する。
|
||||
2. そのpathのProjected Objectを現在も公開するか確認する。
|
||||
@@ -97,15 +101,15 @@ ProjectionがObjectへ割り当てた各Interface参照は、同じ主体から
|
||||
8. 固定したObject implementation、Interface Descriptor、Operation implementationを、step 1で固定した同じbackend対象へ適用する。
|
||||
9. Resultを同じOperation Declarationで検証し、nestedしたすべての`Entry`へ現在の主体のvisibility checkを適用してから、実行後Object validatorとともに返す。
|
||||
|
||||
Step 3から8までの間にObject、Interface、Descriptor、Operation implementation、backend対象の対応が変化し得る実装は、この手順を満たさない。Projectionは、Host RuntimeとProviderがこのatomicityを実現するために必要なstable handle、transaction、conditional operation、lock等を使用できるようにしなければならない。
|
||||
Step 3から8までの間に、Object、Interface、Descriptor、Operation implementation、backend対象の対応が変わり得る実装では、この手順を満たせない。Host RuntimeとProviderがatomicityを実現できるよう、Projectionは必要なstable handle、transaction、conditional operation、lock等を使用できる必要がある。
|
||||
|
||||
過去の`observe`で作成したHost内部Object instanceの存在を前提にしてはならない。Clientから届くprotocol-level Targetはpathとoptionalなvalidatorであり、Hostはcall時の現在状態からObject implementationを解決する。Validatorを省略したcallを現在のOperationが許可する場合は、過去に観測したObjectではなく、実行時にpathが指す現在のObjectへ無条件に実行する。
|
||||
解決には、過去の`observe`で作ったHost内部Object instanceを前提としない。Clientから届くprotocol-level Targetはpathとoptionalなvalidatorであり、Hostはcall時の現在状態からObject implementationを解決する。現在のOperationがvalidatorの省略を許可する場合、callは過去に観測したObjectではなく、実行時にpathが指す現在のObjectへ無条件に実行する。
|
||||
|
||||
Validatorを指定した場合は、Clientが観測したObject identityおよび保護対象状態と実行対象を照合する。Path、`ref`、以前のProjection Resolution、cacheされたHost内部instanceのいずれも、単独ではProtocol上の永続的なObject identityとして扱わない。
|
||||
一方、validatorが指定されたcallでは、Clientが観測したObject identityと保護対象状態を実行対象へ照合する。Path、`ref`、以前のProjection Resolution、cacheされたHost内部instanceは、いずれも単独ではProtocol上の永続的なObject identityとして扱わない。
|
||||
|
||||
## Filesystem projectionの評価例
|
||||
|
||||
Filesystemを`/files`以下へ投影する場合、Hostが全file / directoryに対応するObject instanceを作成する必要はない。
|
||||
Filesystemを`/files`以下へ投影する場合も、全file / directory分のObject instanceを作成する必要はない。Request時の解決と投影を、次のように行える。
|
||||
|
||||
- `observe("/files/a.txt", depth = 0)`では、pathをfilesystem entryへ解決し、stat等からObject projectionを構築する。
|
||||
- `observe("/files", depth = 1)`では、directory listingから直接のindexable childrenをすべて列挙する。
|
||||
@@ -113,7 +117,7 @@ Filesystemを`/files`以下へ投影する場合、Hostが全file / directoryに
|
||||
- 主体の権限に応じてObjectを非公開にしたり、read-only、writable等のcapability Interfaceを集合へ追加または除外できる。
|
||||
- File Operationでは、call時にpathを現在のfilesystem entryへ解決し、Rust struct、TypeScript object、file descriptor等を使ってOperationを実装できる。
|
||||
|
||||
この例で、Objectはfileそのものでも、file内容を複製したrecordでもない。FileをWorldspace上で発見および操作するためのprotocol-level projectionである。
|
||||
この場合のObjectは、fileをWorldspace上で発見・操作するためのprotocol-level projectionである。Fileそのものを渡すわけでも、file内容を複製したrecordを渡すわけでもない。
|
||||
|
||||
## 要件
|
||||
|
||||
@@ -167,13 +171,13 @@ Filesystemを`/files`以下へ投影する場合、Hostが全file / directoryに
|
||||
- Operation call時には、その時点でpathが指す現在のProjected ObjectとInterface集合を再解決し、Clientが指定した参照のmembershipとvalidatorを確認する。
|
||||
- 非公開Object、非公開Operation、公開済みだが実行拒否されたOperationをProtocolで定めたerrorへ一貫して対応付ける。
|
||||
|
||||
Backendが並行して変化する場合、複数request間で同じsnapshotを保証することまでは要求しない。観測したObject identityおよび状態と条件付き実行の対応はvalidatorで保護する。Validatorを省略したcallをOperationが許可する場合は実行時の現在Objectへ無条件に実行し、Operationがvalidatorを要求する場合は`ValidatorRequired`として実行しない。
|
||||
Backendが並行して変化する場合、複数request間で同じsnapshotを保証することまでは要求しない。観測したObject identityと状態を条件付き実行の対象へ対応付けるため、validatorを使う。Operationがvalidatorの省略を許可すれば、callは実行時の現在Objectへ無条件に実行する。Validatorを要求するOperationでは、省略されたcallを`ValidatorRequired`として拒否し、実行しない。
|
||||
|
||||
### Operation解決と実行対象のatomicity
|
||||
|
||||
Operation callでは、visibility、Object identityとvalidator、Interface membershipとvalidator、Interface Descriptor、Operation implementation、backend対象を一つのlinearization pointで固定しなければならない。ValidatorなしのObject実行をOperationが許可する場合でも、解決後にpathの指すProjected Objectが変化し、以前のDescriptorに基づくOperation implementationが現在の別Objectへ適用されることを許してはならない。
|
||||
Operation callでは、visibility、Object identityとvalidator、Interface membershipとvalidator、Interface Descriptor、Operation implementation、backend対象を、一つのlinearization pointで固定しなければならない。Object validatorなしの実行をOperationが許可する場合も、この固定は必要である。解決後にpathの指すProjected Objectが変わり、以前のDescriptorに基づくOperation implementationが現在の別Objectへ適用されることは許さない。
|
||||
|
||||
少なくとも次を一つのatomicなexecution boundaryとして扱えなければならない。
|
||||
少なくとも次の処理を、一つのatomicなexecution boundaryとして扱える必要がある。
|
||||
|
||||
- Pathが現在指すObject identityと保護対象状態の確認。
|
||||
- Object validatorの必須判定と比較。
|
||||
@@ -183,9 +187,9 @@ Operation callでは、visibility、Object identityとvalidator、Interface memb
|
||||
- Authorizationの確認。
|
||||
- 同じbackend対象へのOperation実行。
|
||||
|
||||
単にpathを一度解決し、validatorを比較した後に、同じpathを別途開き直してOperationを実行する実装では、比較と実行の間に対象が差し替わる可能性がある。
|
||||
Pathを一度解決してvalidatorを比較しても、その後に同じpathを別途開き直して実行すると、比較と実行の間に対象が差し替わる可能性がある。
|
||||
|
||||
Host APIは、backend transaction、lock、revision付きconditional update、file descriptor、identity再確認等、Providerがbackendに適した方法でatomicityを実現できる境界を提供する必要がある。すべてのbackendに共通する特定のlocking方式は要求しない。
|
||||
そのためHost APIには、Providerがbackendに適した方法でatomicityを実現できる境界が必要になる。Backend transaction、lock、revision付きconditional update、file descriptor、identity再確認等を利用できるようにするが、すべてのbackendに共通する特定のlocking方式は要求しない。
|
||||
|
||||
### Host内部Object stateのlifecycleを限定できること
|
||||
|
||||
@@ -229,7 +233,7 @@ Host APIは、backend transaction、lock、revision付きconditional update、fi
|
||||
|
||||
### RustとTypeScriptで同じsemanticsを表現できること
|
||||
|
||||
言語ごとのAPI syntaxは異なってよいが、少なくとも次を両方で実現できなければならない。
|
||||
RustとTypeScriptでAPI syntaxが異なっても、次の処理や制約は両方で実現できなければならない。
|
||||
|
||||
- Asyncなpath resolutionとchild enumeration。
|
||||
- Data-drivenなObject projection。
|
||||
@@ -240,11 +244,11 @@ Host APIは、backend transaction、lock、revision付きconditional update、fi
|
||||
- Request context、主体、cancellation、resource limitの伝播。
|
||||
- Protocol errorとbackend / implementation errorの分離。
|
||||
|
||||
Rust固有のtrait、generic、associated type、type erasureや、TypeScript固有のobject definition、generic inference、runtime schema libraryの選択は、この要件を満たすためのAPI設計段階で決める。
|
||||
Rustのtrait、generic、associated type、type erasureや、TypeScriptのobject definition、generic inference、runtime schema libraryの選択は、これらの要件を満たすAPIを設計する段階で決める。
|
||||
|
||||
## Host内部cache
|
||||
|
||||
Hostは性能最適化として、path resolution、backend metadata、Object projection、Interface Descriptor、Host内部Object state等をcacheしてよい。ただし、cacheはProtocol contractではない。
|
||||
Host内部のcacheは性能最適化であり、Protocol contractではない。Path resolution、backend metadata、Object projection、Interface Descriptor、Host内部Object state等をcacheしてよいが、次の条件を守る必要がある。
|
||||
|
||||
- Cacheがなくても正しく動作しなければならない。
|
||||
- Visibilityとauthorizationを異なる主体間で誤って共有してはならない。
|
||||
@@ -254,21 +258,23 @@ Hostは性能最適化として、path resolution、backend metadata、Object pr
|
||||
|
||||
## Composition
|
||||
|
||||
一つのHostは、Filesystem、database、remote API等の複数Providerを一つのWorldspaceへ合成できることが望ましい。
|
||||
一つのHostで、Filesystem、database、remote API等の複数Providerを一つのWorldspaceへ合成できることが望ましい。
|
||||
|
||||
Composition後も、同じ主体へ公開されたWorldspace全体で`ref`とInterface参照のProtocol semanticsを保たなければならない。Provider-local identityやInterface keyをそのまま公開すると衝突し得るため、Hostはnamespace、変換、中央発行等の方法で次を保証する。
|
||||
合成する場合も、同じ主体へ公開するWorldspace全体で`ref`とInterface参照のProtocol semanticsを保たなければならない。Provider-local identityやInterface keyをそのまま公開すると衝突し得るため、Hostはnamespace、変換、中央発行等によって次を保証する。
|
||||
|
||||
- 異なるObjectへ同じ有効な`ref`を割り当てない。
|
||||
- 同じ有効なInterface参照とvalidatorの組を異なるDescriptorへ対応付けない。
|
||||
- Validator比較がProvider routingの変更によって別Objectへ適用されない。
|
||||
|
||||
Interface参照は[2.2. Operation and Interface](../2.2-operation-and-interface.md#interfaceの識別とscope)の構造化参照を使用する。Objectの祖先制約と、合成後のscope・path・typed `entry`の変換は[ProtocolのWorldspace composition](../3-protocol.md#worldspace-composition)に従う。Objectの`ref`の具体的な発行方式は引き続き未決定とする。ScopeはそのpathのObjectに紐づき、self-scopeを許可する。RuntimeはDescriptorとscope Objectのoptionalな`ref`を一つのInterface observationとして公開し、callで指定された`scope_ref`をrouting・implementationの固定と一貫して確認する。`ref`のないscopeも許可し、そのidentityの追跡を要求しない。
|
||||
Interface参照には、[2.2. Operation and Interface](../2.2-operation-and-interface.md#interfaceの識別とscope)の構造化参照を使う。Objectの祖先制約と、合成後のscope・path・typed `entry`の変換は、[ProtocolのWorldspace composition](../3-protocol.md#worldspace-composition)に従う。Objectの`ref`を具体的にどう発行するかは、引き続き未決定である。
|
||||
|
||||
ただし、mount API、path routing、Provider間のparent / child関係、同名entry conflict、cross-provider Operation、transaction boundaryは未決定である。ProtocolはHost内部のcomposition方式を公開しないため、Host APIでこれらをどこまで標準化するかを別途検討する。
|
||||
ScopeはそのpathのObjectに紐づき、self-scopeも許可する。RuntimeはDescriptorとscope Objectのoptionalな`ref`を一つのInterface observationとして公開する。Callで指定された`scope_ref`は、routing・implementationの固定と一貫して確認する。`ref`のないscopeも許可し、そのidentityの追跡は要求しない。
|
||||
|
||||
Host内部の合成方法については、mount API、path routing、Provider間のparent / child関係、同名entry conflict、cross-provider Operation、transaction boundaryが未決定である。ProtocolではHost内部のcomposition方式を公開しないため、これらをHost APIでどこまで標準化するかは別途検討する。
|
||||
|
||||
## 非目標
|
||||
|
||||
このdraftは次を要求しない。
|
||||
動的projectionの要件には、次を含めない。
|
||||
|
||||
- Worldspace全体の事前materialization。
|
||||
- Objectごとの常駐class instanceまたはactor。
|
||||
@@ -281,7 +287,7 @@ Interface参照は[2.2. Operation and Interface](../2.2-operation-and-interface.
|
||||
|
||||
## API設計前に評価するシナリオ
|
||||
|
||||
具体API案は、少なくとも次のシナリオで不自然な大量登録や永続Object instanceを要求しないことを確認する。
|
||||
具体API案は、少なくとも次のシナリオで評価する。不自然な大量登録や、永続Object instanceの保持を要求しないことを確認する必要がある。
|
||||
|
||||
1. 数百万entryを持つFilesystemを、directory listingに応じて遅延投影する。
|
||||
2. Database tableのrowをObjectとして投影し、row keyからcall時に再解決する。
|
||||
@@ -318,4 +324,4 @@ Interface参照は[2.2. Operation and Interface](../2.2-operation-and-interface.
|
||||
- Provider composition時のconflict、ownership、lifecycle、shutdown順序。
|
||||
- Observability、tracing、auditで非公開Objectやsecretを漏らさないための標準hook。
|
||||
|
||||
これらを決定する前に、Rust / TypeScript双方で複数のAPI sketchを作り、上記の評価シナリオと要件に照らして比較する。
|
||||
これらの決定に先立ち、Rust / TypeScript双方で複数のAPI sketchを作る。各案をここに挙げた評価シナリオと要件に照らして比較し、具体的なAPIを検討する。
|
||||
|
||||
@@ -2,30 +2,32 @@
|
||||
|
||||
## 位置付け
|
||||
|
||||
この文書は、YoiのFS投影に対して決めた探索仕様を、WIP Hostの投影方針として記録するdraftである。**Treeで発見するのはfilesystemの入口までとし、その内容はdirectoryのlist / glob / grep Operationで発見する。**
|
||||
YoiのFS投影では、Treeで発見する範囲をfilesystemの入口までとし、その内容をdirectoryのlist / glob / grep Operationで発見する方針を決めた。このdraftは、その探索仕様をWIP Hostの投影方針として記録するものである。
|
||||
|
||||
Indexableな範囲の全量取得をClientで縮めるのではなく、Hostが何をindexableとして公開するかを修正する。個々のファイル・フォルダは非indexableなObjectとして要求時に直接解決できる。
|
||||
変更するのは、Hostがindexableとして公開する範囲である。Indexableな範囲の全量取得をClient側で縮めるのではなく、Hostで入口と内容を分ける。個々のファイル・フォルダは非indexableなObjectとして、要求時に直接解決できる。
|
||||
|
||||
[Object tree / SchemaのIndexable children](../2.1-object-tree-schema.md#indexable-children)、[Client](../4-client.md)、[4.1. AI Tools](../4.1-ai-tools.md)を前提とする。Yoi側の実装追跡はT-714、既存投影はT-699である。本稿は仕様決定の記録であり、実装済みであることを示さない。
|
||||
前提となる仕様は、[Object tree / SchemaのIndexable children](../2.1-object-tree-schema.md#indexable-children)、[Client](../4-client.md)、[4.1. AI Tools](../4.1-ai-tools.md)に従う。Yoi側の実装追跡はT-714、既存投影はT-699である。このdraftは仕様決定の記録であり、実装済みであることを示すものではない。
|
||||
|
||||
## なぜFS全体をindexableにしないか
|
||||
|
||||
Indexable childrenには、件数がboundedまたは現実的な範囲で列挙でき、比較的安価で、自然な所有・内包関係であることが求められる。全件列挙が高コストまたは不自然な集合は、対象の集合を扱うObjectのOperationで検索・filter・paginationし、`entry` を返すのが既存の方針である。
|
||||
任意のfilesystem directoryには、浅い位置でも大量のfileやdirectoryが存在し得る。Depthを小さくしてもAIが把握できない件数になるため、depth制限だけで適切な探索空間を作ることはできない。
|
||||
|
||||
任意のfilesystem directoryは、浅い位置に大量のfileやdirectoryを持ち得る。Depthが小さくてもAIが把握できない件数になるため、depth制限だけでは適切な探索空間を作れない。
|
||||
Indexable childrenには、件数がboundedまたは現実的な範囲で列挙でき、比較的安価で、自然な所有・内包関係であることが求められる。全件列挙が高コストまたは不自然な集合については、その集合を扱うObjectのOperationで検索・filter・paginationし、`entry` を返すのが既存の方針である。
|
||||
|
||||
次の対策だけでFS内容をindexableにしてよいとはしない。
|
||||
FS内容の公開でも、このindexabilityの判断は必要になる。次の対策だけを理由に、内容をindexableにしてよいとはしない。
|
||||
|
||||
- Clientが小さいdepthを選ぶこと。
|
||||
- Hostが件数上限で `ResourceLimitExceeded` を返すこと。
|
||||
- Directoryをlazyに列挙し、常駐Objectや事前indexを作らないこと。
|
||||
- Clientが応答を切り捨てて表示すること。
|
||||
|
||||
前二者は必要な安全弁、lazy解決は実装上の性質であり、indexabilityの判断とは別である。部分的な応答を完全なtreeとして見せることもできない。本投影では、内容の件数が少ない場合だけchildrenへ展開する方式にはせず、入口と内容の境界を一貫させる。
|
||||
Depth制限と件数上限は必要な安全弁であり、lazy解決は実装上の性質である。いずれも、内容をindexableにするかどうかとは別の判断になる。また、部分的な応答を、完全なtreeとして見せることもできない。
|
||||
|
||||
この投影では、内容の件数が少ない場合だけchildrenへ展開する方式も採らない。件数によって探索方式が変わらないよう、入口と内容の境界を一貫させる。
|
||||
|
||||
## Indexableな公開境界
|
||||
|
||||
Yoiでは、rootからtreeで発見できる範囲を次とする。
|
||||
Yoiでrootからtreeを通じて発見できるのは、現在利用できるfilesystemの入口までである。配置は次の範囲とする。
|
||||
|
||||
```text
|
||||
/
|
||||
@@ -34,9 +36,9 @@ Yoiでは、rootからtreeで発見できる範囲を次とする。
|
||||
└── docs
|
||||
```
|
||||
|
||||
`/checkouts/<checkout-name>` は、現在利用できるfilesystemの入口であり、directoryとして操作できるObjectでもある。その直下から先のfile / directoryは、入口や内容directoryのindexable childrenには含めない。
|
||||
`/checkouts/<checkout-name>` はfilesystemの入口であり、directoryとして操作できるObjectでもある。ただし、その直下から先のfile / directoryは、入口にも内容directoryにもindexable childrenとして含めない。
|
||||
|
||||
例えば次のpathは有効な操作対象として存在し得るが、上のtreeには展開しない。
|
||||
例えば、次のpathは有効な操作対象として存在し得るが、上のtreeには展開しない。
|
||||
|
||||
```text
|
||||
/checkouts/main/README.md
|
||||
@@ -44,24 +46,24 @@ Yoiでは、rootからtreeで発見できる範囲を次とする。
|
||||
/checkouts/main/src/lib.rs
|
||||
```
|
||||
|
||||
`/checkouts` はYoi側の配置例であり、WIP Protocol全体へ固定namespaceを追加するものではない。単一FSをWorldspace `/` に直接投影する場合は `/` 自体が入口に相当し、その内容をindexable childrenへ展開しない。
|
||||
`/checkouts` はYoi側の配置例であり、WIP Protocol全体の固定namespaceにはしない。単一FSをWorldspace `/` に直接投影する場合は、`/` 自体を入口として扱い、その内容をindexable childrenへ展開しない。
|
||||
|
||||
入口の一覧自体についてもindexableの一般条件は維持する。この境界は、入口の数まで無制限に増やしてよいという意味ではない。
|
||||
入口の一覧にも、indexableの一般条件を適用する。内容をtreeに含めないからといって、入口の数まで無制限に増やしてよいわけではない。
|
||||
|
||||
## 非indexableと非公開は別
|
||||
|
||||
File / directoryをindexableにしないことは、Objectとして非公開にすることでも、pathで解決不能にすることでもない。
|
||||
File / directoryを非indexableにしても、Objectとしての公開やpathによる直接解決は可能である。Treeから発見できる範囲と、既知のpathから解決できる範囲は分けて扱う。
|
||||
|
||||
- `observe("/checkouts/main/src/lib.rs", depth = 0)` は、現在の主体に公開できるfileなら、そのObjectを返せる。
|
||||
- AI向け `inspect(path)` は、ClientがまだObjectを観測していないpathにも使える。
|
||||
- Directoryの探索やfileの読み書きは、そのObjectが公開するInterfaceのOperationとして実行する。
|
||||
- 直接解決にも現在の可視性・権限を適用する。Pathを知っていることは権限ではない。
|
||||
|
||||
非公開・存在しないpathの扱いは従来のProtocolに従う。非indexableという理由だけで `NotFound` を返してはならない。
|
||||
非公開・存在しないpathの扱いは、従来のProtocolに従う。非indexableという理由だけで `NotFound` を返してはならない。
|
||||
|
||||
## observeのchildren契約
|
||||
|
||||
FS入口およびその内容のdirectory/fileは、この投影では内容のindexable childrenを持たない。
|
||||
この投影では、FS入口にも、その配下のdirectory/fileにも、内容を表すindexable childrenはない。そのため、depthを深くしても入口から先の内容は列挙しない。
|
||||
|
||||
| 観測 | 応答の意味 |
|
||||
| --- | --- |
|
||||
@@ -71,13 +73,13 @@ FS入口およびその内容のdirectory/fileは、この投影では内容のi
|
||||
| `observe("/checkouts/main", 1)` | 入口Objectと `children: []` |
|
||||
| `observe("/checkouts/main/src", 1)` | 直接解決したdirectory Objectと `children: []` |
|
||||
|
||||
`children: []` は「indexable childがない」という意味であり、filesystem directoryが空であるとは限らない。内容が空かどうかを知るにはdirectoryの `list` を使う。
|
||||
`children: []` が示すのは、indexable childがないことである。Filesystem directoryが空であることまでは示さない。内容が空かどうかは、directoryの `list` で確認する。
|
||||
|
||||
File数が増えても、入口の正のdepth観測でbackendの全内容を列挙する必要はない。Client側だけで非表示にするのではなく、Hostのchildren列挙境界で内容への走査を行わないようにする。Depth境界でのfield省略と、観測済みの空のchildrenは従来どおり区別する。
|
||||
File数が増えても、入口を正のdepthで観測するためにbackendの全内容を列挙する必要はない。Client側で内容を非表示にするだけではなく、Hostのchildren列挙境界で内容への走査を行わないようにする。Depth境界でfieldを省略する場合と、観測した結果が空のchildrenである場合は、従来どおり区別する。
|
||||
|
||||
## 内容を発見するOperation
|
||||
|
||||
FS入口と配下directoryには、次の探索Operationを提供する。これらはInterface上のOperationであり、新しいProtocol interactionや `list` という子Objectではない。
|
||||
FS入口と配下directoryの内容は、次の探索Operationで発見する。これらはInterface上のOperationであり、新しいProtocol interactionを追加したり、`list` という子Objectを設けたりするものではない。
|
||||
|
||||
| Operation | 役割 |
|
||||
| --- | --- |
|
||||
@@ -93,32 +95,32 @@ FS入口と配下directoryには、次の探索Operationを提供する。これ
|
||||
- 大量件数はOperation結果として上限と継続を明示して扱う。部分的な一覧を全件であるかのように返さない。
|
||||
- Cursor等の継続方式、並び順、parameter名、result recordの具体形は、このdraftでは未決とする。
|
||||
|
||||
Yoiの `/checkouts` 自体にある `list` は入口一覧であり、`/checkouts/<checkout-name>` や配下directoryの `list` は内容一覧である。Operation名が同じでも対象とInterfaceが異なる。
|
||||
Yoiの `/checkouts` にある `list` は入口一覧を返す。一方、`/checkouts/<checkout-name>` や配下directoryの `list` は内容一覧を返す。同じOperation名でも、対象とInterfaceは異なる。
|
||||
|
||||
### glob / grep
|
||||
|
||||
対象directoryを基準にpatternや検索範囲を解釈する。Resultsからfile / directoryへ移動する参照は `entry` 型として宣言し、表示済みのfilenameや行番号付きtextを再parseして作らない。
|
||||
Patternや検索範囲は、対象directoryを基準に解釈する。Resultsからfile / directoryへ移動する参照は、`entry` 型として宣言する。表示済みのfilenameや行番号付きtextを再parseして参照を作る方式は採らない。
|
||||
|
||||
Globは条件に一致する対象を探す操作であり、directory直下の全内容を表すとは限らない。特にYoiの通常ToolsのGlobはfileのみを返し、directoryは返さず、配下を再帰的に走査し得る。`glob("*")` を `list` と同じ契約として扱わない。
|
||||
Globで得るのは条件に一致する対象であり、directory直下の全内容とは限らない。特にYoiの通常ToolsのGlobはfileのみを返し、directoryは返さず、配下を再帰的に走査し得る。このため、`glob("*")` を `list` と同じ契約として扱わない。
|
||||
|
||||
Grepは内容検索であり、treeの名前・構造の探索ではない。Ignore、検索範囲、結果上限、context、継続等は各Operationの明示された契約に従う。
|
||||
Grepはfileの内容検索であり、treeの名前や構造を探索する操作ではない。Ignore、検索範囲、結果上限、context、継続等は、各Operationに明示された契約に従う。
|
||||
|
||||
## Clientとの接続
|
||||
|
||||
典型的な利用は次の流れになる。これは操作の順序の例であり、Tool responseやInterface参照の具体構文を定義するものではない。
|
||||
Clientでは、treeで入口を知り、directoryのOperationで内容のpathを得て、必要なObjectだけをinspectする流れになる。典型的な操作順序は次のとおりである。これは利用例であり、Tool responseやInterface参照の具体構文を定義するものではない。
|
||||
|
||||
1. `tree("/", depth)` で `/checkouts/main` の入口を知る。
|
||||
2. `inspect("/checkouts/main")` でdirectoryの `list` / `glob` / `grep` を知る。
|
||||
3. 選んだOperationを `invoke` し、結果から `/checkouts/main/src` やfileのpathを得る。
|
||||
4. 必要なpathだけを `inspect` し、さらに探索するかfileのOperationを実行する。
|
||||
|
||||
結果のすべてのentryを即時にObjectへ展開する必要はない。AIはまずpathだけを受け取り、説明や操作が必要になったときにClientへinspectを依頼できる。
|
||||
Operation結果のすべてのentryを、直ちにObjectへ展開する必要はない。AIはまずpathだけを受け取り、説明や操作が必要になった時点でClientへinspectを依頼できる。
|
||||
|
||||
Clientがそれらを観測してKnown Spaceへ保持しても、rootからのtreeには混ぜない。Pathの階層関係だけからindexable edgeを生成しない。既知の内容directoryをtreeの起点として直接指定した場合も、そのObjectは表示できるが、内容をindexableな子として復活させない。
|
||||
観測した内容をClientがKnown Spaceへ保持しても、rootからのtreeには混ぜない。Pathの階層関係だけではindexable edgeを生成しない。既知の内容directoryをtreeの起点として直接指定した場合も、そのObjectは表示できるが、内容をindexableな子として再び表示することはしない。
|
||||
|
||||
## 解決・権限・整合性
|
||||
|
||||
探索方式の変更で、既存の権限やファイル操作の意味を変えない。
|
||||
内容の探索をOperationへ分けても、既存の権限やファイル操作の意味は変えない。一覧・検索・直接解決・操作には、次の境界を適用する。
|
||||
|
||||
- 現在の主体、root/attachment、scope、provider capabilityに基づき、一覧・検索・直接path解決・操作を実行する。
|
||||
- Hostの一覧結果には、主体へ公開できる対象だけを含める。直接path指定でも同じ境界を守る。
|
||||
@@ -126,15 +128,15 @@ Clientがそれらを観測してKnown Spaceへ保持しても、rootからのtr
|
||||
- Object identityとvalidatorは既存Protocolとproviderの実行境界で管理する。
|
||||
- Fileのread/edit/write等の具体的契約は、各投影のInterfaceに従う。本稿はbyte操作やtext操作の統一を要求しない。
|
||||
|
||||
Yoiの実装では、既存Workdir providerのList/Glob/Grep処理と権限・transport境界を共有する。Worker側でremote filesystemのホストパスを直接開く方法や、Bashの `ls` を必須にする方法へ置き換えない。
|
||||
Yoiの実装では、既存Workdir providerのList/Glob/Grep処理と権限・transport境界を共有する。Workerがremote filesystemのホストパスを直接開く方法や、Bashの `ls` を必須にする方法には置き換えない。
|
||||
|
||||
## 現行文書・実装との差
|
||||
|
||||
[Dynamic Worldspace Projection](dynamic-worldspace-projection.md)のFilesystem評価例と、現行 `wip-fs-server` は、directoryの内容をchildrenとして列挙する経路を持つ。Yoiの既存 `/checkouts` 投影も、内部List処理をchildren列挙に使い、directoryの独立した `list` Operationは提供していなかった。
|
||||
[Dynamic Worldspace Projection](dynamic-worldspace-projection.md)のFilesystem評価例と現行 `wip-fs-server` には、directoryの内容をchildrenとして列挙する経路がある。Yoiの既存 `/checkouts` 投影でも、内部List処理をchildren列挙に使い、directoryの独立した `list` Operationは提供していなかった。
|
||||
|
||||
本draftは、それらの現状を承認する文書ではなく、FS内容の探索をOperationへ分離する修正方針である。Lazyなrequest-time解決と、既知pathからの直接解決は維持するが、directory listingをindexable childrenの列挙として使う部分は見直す。
|
||||
それに対し、このdraftはFS内容の探索をOperationへ分離する修正方針である。Lazyなrequest-time解決と、既知pathからの直接解決は維持する。ただし、directory listingをindexable childrenの列挙に使う部分は見直すため、現状の実装を承認するものではない。
|
||||
|
||||
既存ProtocolがあらゆるFS Objectのindexabilityを一律に禁止する明文を持つという意味ではない。既存のindexable条件を、任意件数の内容を持つFSへ適用する具体的方針として、このdraftでは入口で境界を固定する。
|
||||
この方針は、既存Protocolに「あらゆるFS Objectを一律に非indexableとする」明文があることを意味しない。任意件数の内容を持つFSに既存のindexable条件を適用する具体的方針として、入口で境界を固定する。
|
||||
|
||||
## 検証するシナリオ
|
||||
|
||||
@@ -152,4 +154,4 @@ Yoiの実装では、既存Workdir providerのList/Glob/Grep処理と権限・tr
|
||||
- Symlinkや特殊entryのlist上の種別表現、各providerのignore方針との整合。
|
||||
- 本稿を既存の動的投影例、FS実装、AI用Toolsの正本文書へどう反映するか。
|
||||
|
||||
通常Tools modeへ独立したList Toolを追加するかどうかは、directory Operationの公開とは別の適用事項であり、本稿で追加済みとは扱わない。
|
||||
通常Tools modeへ独立したList Toolを追加するかどうかは、directory Operationの公開とは別の適用事項である。このdraftで追加済みとは扱わない。
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
## Status
|
||||
|
||||
この文書は、WIP HostをRustで実装するための概念的なAPI modelを整理するdraftである。
|
||||
WIP HostをRustで実装する際、Interfaceの宣言とObjectの実装をどう組み合わせて公開するか。このdraftは、そのための概念的なAPI modelを提案する。
|
||||
|
||||
このdraftで示すtrait名、attribute、derive、generated type名、method signatureは説明用であり、具体的なRust API syntaxとして確定しない。Ownership、lifetime、async runtime、error type、macro crate構成も未決定である。
|
||||
Trait名、attribute、derive、generated type名、method signatureは説明用であり、具体的なRust API syntaxは確定していない。Ownership、lifetime、async runtime、error type、macro crate構成も未決定である。
|
||||
|
||||
Protocol上の確定事項は[Core Model](../2-core-model.md)、[Object tree / Schema](../2.1-object-tree-schema.md)、[Operation and Interface](../2.2-operation-and-interface.md)、[Protocol](../3-protocol.md)を参照する。動的なObject projectionの運用要件は[Dynamic Worldspace Projection](dynamic-worldspace-projection.md)、対応するTypeScript modelは[TypeScript API Model](typescript-api-model.md)を参照する。
|
||||
Protocol上の確定事項は[Core Model](../2-core-model.md)、[Object tree / Schema](../2.1-object-tree-schema.md)、[Operation and Interface](../2.2-operation-and-interface.md)、[Protocol](../3-protocol.md)に従う。動的なObject projectionの運用要件は[Dynamic Worldspace Projection](dynamic-worldspace-projection.md)、対応するTypeScript modelは[TypeScript API Model](typescript-api-model.md)を参照する。
|
||||
|
||||
## 目的
|
||||
|
||||
Rust APIでは、次のmodelを自然に表現できるようにする。
|
||||
Rust APIでは、Interfaceをtraitで宣言し、structで実装するmodelを自然に表現できるようにする。ただし、structが実装しているInterfaceと、ある時点で公開するInterfaceは分けて選べるようにする。
|
||||
|
||||
1. Rust traitでWIP Interfaceを宣言する。
|
||||
2. Rust structが、Protocol Objectとして公開するmetadataを提供する。
|
||||
@@ -19,13 +19,13 @@ Rust APIでは、次のmodelを自然に表現できるようにする。
|
||||
5. Trait declarationからInterface Descriptor、Value codec、validation、Operation dispatchに必要なcodeを生成する。
|
||||
6. Struct instanceをWorldspace全体について常駐させず、`observe`と`call_operation`の解決時にdata-drivenに生成できる。
|
||||
|
||||
このmodelでは、Protocol上のObjectとInterfaceの関連付けは`Object.interfaces`だけで表す。Operation implementationへ到達するためのRust内部codeはSDK / Host Runtimeの実装詳細であり、新しいProtocol conceptを導入しない。
|
||||
Protocol上でObjectとInterfaceを関連付けるのは`Object.interfaces`だけである。Operation implementationを呼び出すためのRust内部codeはSDK / Host Runtimeの実装詳細として扱い、新しいProtocol conceptは導入しない。
|
||||
|
||||
## Conceptual model
|
||||
|
||||
### Interface trait
|
||||
|
||||
WIP InterfaceはRust traitとして宣言する。
|
||||
WIP Interfaceは、Rust traitとして宣言する。
|
||||
|
||||
```rust
|
||||
#[wip::interface]
|
||||
@@ -47,9 +47,9 @@ trait FileInterface {
|
||||
}
|
||||
```
|
||||
|
||||
このcodeはconceptual syntaxである。`Option<i64>`の対応範囲、`Bytes` wrapper、documentationの取得方法、async methodとerrorの具体形は別途決定する。
|
||||
このcodeはconceptual syntaxであり、具体的なAPIとして確定したものではない。`Option<i64>`の対応範囲、`Bytes` wrapper、documentationの取得方法、async methodとerrorの具体形は別途決定する。
|
||||
|
||||
Trait declarationは、次のlogical declarationに対応する。
|
||||
Trait declarationとProtocol上の宣言は、次のように対応付ける。
|
||||
|
||||
- 一つのtrait declarationを、一つのInterface Descriptorを生成するRust側definitionとして扱う。
|
||||
- Trait名はgenerated Rust itemやlocal registrationを識別するために使用できるが、Protocol `InterfaceDescriptor`のfieldや構造化されたInterface参照としてそのまま公開しない。
|
||||
@@ -60,11 +60,11 @@ Trait declarationは、次のlogical declarationに対応する。
|
||||
- Rust typeをDescriptorの`TypeExpr`とProtocol `Value`へ対応付ける。
|
||||
- Documentationをtrait、method、parameter、return、関連typeから取得できるようにする。
|
||||
|
||||
Descriptorのshapeはtrait declarationによって決まり、各structのtrait implementationによって変化しない。
|
||||
したがって、Descriptorのshapeを決めるのはtrait declarationである。各structがそのtraitをどう実装するかによって、Descriptorは変化しない。
|
||||
|
||||
### Object implementation
|
||||
|
||||
Rust structは、その時点でWorldspaceへ投影するObjectをHost内部で表現できる。
|
||||
その時点でWorldspaceへ投影するObjectは、Host内部ではRust structとして表現できる。
|
||||
|
||||
```rust
|
||||
struct File {
|
||||
@@ -75,7 +75,7 @@ struct File {
|
||||
}
|
||||
```
|
||||
|
||||
Structは概念上の`WipObject` traitを実装する。
|
||||
公開metadataを提供するために、structが概念上の`WipObject` traitを実装する形を想定する。
|
||||
|
||||
```rust
|
||||
impl WipObject for File {
|
||||
@@ -97,20 +97,20 @@ impl WipObject for File {
|
||||
}
|
||||
```
|
||||
|
||||
`WipObject`が返す値のownershipやborrow形式は未決定である。概念的にはProtocol Objectのうち、publication時に選択するInterface集合以外の公開metadataを提供する。
|
||||
`WipObject`は、Protocol Objectの公開metadataのうち、publication時に選ぶInterface集合以外を提供する。返す値のownershipやborrow形式は未決定である。
|
||||
|
||||
`WipObject`がProtocol metadataとして返す値へ次を含めない。これらをimplementing structのprivate fieldとして保持することは禁止しない。
|
||||
Structが保持する情報のすべてを公開metadataにするわけではない。次の情報は`WipObject`がProtocol metadataとして返す値に含めないが、implementing structのprivate fieldとして保持してよい。
|
||||
|
||||
- Absolute path。PathはObjectのfieldではなく、Worldspace resolutionのcontextである。
|
||||
- Children。ChildrenはTree上の配置contextである。
|
||||
- 固定されたInterface集合。InterfaceはObjectを公開する時点で追加する。
|
||||
- Backend connection、file descriptor、credential等のHost-private state。
|
||||
|
||||
Structが保持する`path`やbackend handle等はHost内部のOperation implementationに使用できるが、`WipObject`のmetadataまたはProtocol Objectへ自動的にserializeしない。
|
||||
たとえば、structの`path`やbackend handleはHost内部のOperation implementationで使用できる。ただし、`WipObject`のmetadataやProtocol Objectへ自動的にserializeする対象にはしない。
|
||||
|
||||
### Interface implementation
|
||||
|
||||
Object implementationとして使用するstructは、公開したいInterface traitを実装する。
|
||||
Object implementationに使うstructは、公開したいInterface traitを実装する。
|
||||
|
||||
```rust
|
||||
impl FileInterface for File {
|
||||
@@ -131,15 +131,15 @@ impl FileInterface for File {
|
||||
}
|
||||
```
|
||||
|
||||
Trait implementationはOperationのbehaviorを提供する。Interface Descriptorはtrait implementationから生成せず、Interface trait declarationから生成する。
|
||||
Trait implementationが提供するのはOperationのbehaviorである。Interface Descriptorの生成元は、このimplementationではなくInterface trait declarationとする。
|
||||
|
||||
Rustには、あるtypeが実装しているtraitを列挙する一般的なreflectionがない。また、一つのstructが複数のInterface traitを実装し得る。そのため、trait implementationが存在するだけでInterfaceを暗黙に公開しない。
|
||||
また、traitを実装しただけでInterfaceを公開することにはしない。Rustには、あるtypeが実装しているtraitを列挙する一般的なreflectionがなく、一つのstructが複数のInterface traitを実装することもあるためである。
|
||||
|
||||
### Object publication
|
||||
|
||||
ObjectをWorldspaceへ返す時点で、struct instanceと公開する0個以上のInterface Declarationを明示的に組み合わせる。Interfaceは追加順を保持するordered setであり、同じDefinitionを重複して追加しない。
|
||||
ObjectをWorldspaceへ返す時点で、struct instanceと、公開する0個以上のInterface Declarationを明示的に組み合わせる。Interfaceは追加順を保持するordered setとし、同じDefinitionの重複追加は認めない。
|
||||
|
||||
Conceptual syntaxは次のようになる。
|
||||
次のconceptual syntaxでは、解決した`File`に公開するInterfaceを追加している。
|
||||
|
||||
```rust
|
||||
let file = File::resolve(path).await?;
|
||||
@@ -147,9 +147,9 @@ let object = PublishedObject::new(file)
|
||||
.expose(FileInterfaceDefinition);
|
||||
```
|
||||
|
||||
ここで各`InterfaceDefinition`は、`#[wip::interface]`が生成するconceptualなcompanion definitionである。実際の名前や、associated item、module、valueのどの形で生成するかは未決定である。
|
||||
各`InterfaceDefinition`は、`#[wip::interface]`が生成するconceptualなcompanion definitionを表す。実際の名前も、associated item、module、valueのどの形で生成するかも未決定である。
|
||||
|
||||
Publication APIは、追加する各Definitionについて概念的に次のboundを要求する。
|
||||
Publication APIが各Definitionの追加に要求するboundは、概念的には次の形になる。
|
||||
|
||||
```rust
|
||||
impl<T: WipObject> PublishedObject<T> {
|
||||
@@ -165,9 +165,9 @@ impl<T: FileInterface> InterfaceDefinition<T>
|
||||
}
|
||||
```
|
||||
|
||||
このboundにより、Objectへ追加する各Interfaceについて、公開する値がObject metadataとOperation implementationの両方を提供することをcompile timeに確認できる。どのInterfaceも追加しないObjectも表現できる。
|
||||
このboundで、公開する値がObject metadataを提供し、追加する各InterfaceのOperation implementationも提供することをcompile timeに確認できる。Interfaceを一つも追加しないObjectも表現できる。
|
||||
|
||||
`PublishedObject`はHost SDK内のconceptual typeであり、Protocol typeとして追加しない。Host Runtimeはこれを使用して次を構築する。
|
||||
`PublishedObject`はHost SDK内のconceptual typeであり、Protocol typeには追加しない。Host Runtimeはこの値からProtocol Objectを構築する。
|
||||
|
||||
```rust
|
||||
Object {
|
||||
@@ -179,13 +179,15 @@ Object {
|
||||
}
|
||||
```
|
||||
|
||||
`selected_interface_references`は、Hostが各generated Interface Definitionを登録または公開するときに割り当てる構造化参照`{ scope, name }`を、publication APIへの追加順で並べたものである。同じ`(scope, name)`の重複は拒否し、Objectの配置pathに対する祖先制約も検証する。Macroが参照をcompile timeに固定することは要求しない。
|
||||
`selected_interface_references`には、Hostが各generated Interface Definitionの登録または公開時に割り当てる構造化参照`{ scope, name }`を、publication APIへの追加順で並べる。同じ`(scope, name)`の重複は拒否し、Objectの配置pathに対する祖先制約も検証する。Macroが参照をcompile timeに固定することは要求しない。
|
||||
|
||||
`observe`はDescriptor自体をObjectへ埋め込まず、このInterface参照のordered setだけを返す。Clientが各参照を`fetch_interface`したとき、Host Runtimeはtrait declarationから生成されたDescriptorと、存在する場合はInterface validatorを返す。同じ参照とvalidatorの組のDescriptorはHost上で共有およびcacheできるため、Object instanceごとにDescriptorを再生成または再送する必要はない。
|
||||
`observe`でObjectに含めるのは、Descriptor自体ではなく、このInterface参照のordered setだけである。Clientが各参照を`fetch_interface`すると、Host Runtimeはtrait declarationから生成したDescriptorと、存在する場合はInterface validatorを返す。
|
||||
|
||||
同じ参照とvalidatorの組に対応するDescriptorは、Host上で共有およびcacheできる。このため、Object instanceごとにDescriptorを再生成したり再送したりする必要はない。
|
||||
|
||||
## Macroが生成する責務
|
||||
|
||||
`#[wip::interface]`は、conceptualには次を生成または提供する。
|
||||
`#[wip::interface]`には、宣言からDescriptorを生成するだけでなく、Valueの変換・検証とdispatchに必要なcodeを提供する役割を想定する。
|
||||
|
||||
- Interface Descriptorを構築するdefinition。
|
||||
- Trait methodとOperation Declarationの対応情報。
|
||||
@@ -196,15 +198,13 @@ Object {
|
||||
- 任意の`T: WipObject + InterfaceTrait`について、そのInterfaceを`PublishedObject<T>`へ追加するための型付きentry point。
|
||||
- HostがInterface参照、optional validator、Descriptorを登録、取得、cacheするためのidentity。
|
||||
|
||||
MacroがRust codebase内のtrait implementationを探索または列挙することは要求しない。Publication時のgeneric boundによって、対象structがInterface traitを実装していることを確認する。
|
||||
対象structがInterface traitを実装しているかは、publication時のgeneric boundで確認する。MacroがRust codebase内のtrait implementationを探索したり列挙したりすることは要求しない。
|
||||
|
||||
Generated dispatch codeはRust SDK内部の実装詳細である。Host内部のdispatch mechanismやRust type名等をProtocolへ公開しない。
|
||||
Generated dispatch codeはRust SDK内部の実装詳細である。Host内部のdispatch mechanismやRust type名等はProtocolへ公開しない。
|
||||
|
||||
## Rust typeとProtocol type
|
||||
|
||||
Interface traitで使用できるRust typeは、Descriptorの`TypeExpr`とProtocol `Value`へ対応付けられなければならない。
|
||||
|
||||
Conceptualにはderiveまたは明示的なcodec implementationを使用する。
|
||||
Interface traitで使うRust typeには、Descriptorの`TypeExpr`とProtocol `Value`への対応付けが必要となる。概念的には、deriveまたは明示的なcodec implementationを使うことを想定する。
|
||||
|
||||
```rust
|
||||
#[derive(WipType)]
|
||||
@@ -221,7 +221,7 @@ enum ReadResult {
|
||||
}
|
||||
```
|
||||
|
||||
このsyntaxも未決定である。少なくとも次を表現できる必要がある。
|
||||
このsyntaxも未決定である。Typeの対応付けでは、少なくとも次を表現できる必要がある。
|
||||
|
||||
- Unit、Boolean、Integer、Number、String、Bytes、Json、Entry。
|
||||
- Record、List、Enum、Union。
|
||||
@@ -229,15 +229,15 @@ enum ReadResult {
|
||||
- Requiredとoptionalなfieldおよびparameter。
|
||||
- Documentation。
|
||||
|
||||
Rustの`Option<T>`をoptional field / parameter以外で許可するか、enumをProtocol EnumまたはUnionへどう対応付けるか、newtypeをどのように扱うかは未決定である。
|
||||
Rustの`Option<T>`をoptional field / parameter以外でも許可するかは、まだ決めていない。EnumをProtocol EnumまたはUnionへどう対応付けるか、newtypeをどう扱うかも未決定である。
|
||||
|
||||
Rust SDKは、integerのProtocol相互運用範囲、finite number、unknown field、Union shape、Entry path等をProtocol boundaryで検証しなければならない。
|
||||
型の対応付けとは別に、Protocol boundaryでの検証も必要となる。Rust SDKは、integerのProtocol相互運用範囲、finite number、unknown field、Union shape、Entry path等をそこで検証しなければならない。
|
||||
|
||||
## Dynamic Worldspaceでのlifecycle
|
||||
|
||||
### `observe`
|
||||
|
||||
Projection implementationはrequest時にbackend dataを解決し、struct instanceを生成する。
|
||||
Projection implementationはrequest時にbackend dataを解決し、その時点のstruct instanceを生成する。
|
||||
|
||||
```rust
|
||||
async fn resolve_file(path: &WipPath) -> Result<PublishedObject> {
|
||||
@@ -255,9 +255,9 @@ async fn resolve_file(path: &WipPath) -> Result<PublishedObject> {
|
||||
}
|
||||
```
|
||||
|
||||
Host Runtimeは`PublishedObject`からProtocol Objectを返す。Response生成後にこの`File` instanceを破棄してよく、Worldspace内の全fileに対応するinstanceを保持しない。
|
||||
Host Runtimeは`PublishedObject`からProtocol Objectを返す。Responseを生成した後は、この`File` instanceを破棄してよい。Worldspace内の全fileに対応するinstanceを保持する必要はない。
|
||||
|
||||
Requested depthが正の場合、Directory listingで発見した各entryについて、その時点のstruct instanceを生成して公開できる。Providerがdirectory全体を先に収集してから`Vec`として返すことは要求しないが、一つのparentのobservationを成功させるには公開されたindexable childrenをclean exhaustionまで列挙しなければならない。
|
||||
Requested depthが正の場合は、Directory listingで発見した各entryについて、その時点のstruct instanceを生成して公開できる。Providerがdirectory全体を先に収集し、`Vec`として返すことは要求しない。ただし、一つのparentのobservationを成功させるには、公開されたindexable childrenをclean exhaustionまで列挙する必要がある。
|
||||
|
||||
```rust
|
||||
let mut entries = filesystem.discover_children(path, budget).await?;
|
||||
@@ -274,7 +274,7 @@ while let Some(entry) = entries.next().await? {
|
||||
}
|
||||
```
|
||||
|
||||
`discover_children`、`budget`、`observation`はconceptualであり、具体APIではない。必要なsemanticsは次である。
|
||||
`discover_children`、`budget`、`observation`は具体APIではなく、conceptualな表記である。Childの発見と公開には、次のsemanticsを要求する。
|
||||
|
||||
- 各childを現在の主体についてprojectし、非公開Objectとそのsubtreeを返さない。
|
||||
- 公開されたindexable childrenをすべて列挙し、clean exhaustionで終了する。
|
||||
@@ -286,7 +286,7 @@ while let Some(entry) = entries.next().await? {
|
||||
|
||||
### `call_operation`
|
||||
|
||||
Hostはcall時に、requestのTargetと`InterfaceTarget`をHost Runtimeへ渡す。Runtimeは一つのatomic execution boundaryへ入ってから`target.path`を現在のObject implementationとstableなbackend対象へ解決する。
|
||||
Call時には現在のObjectを解決し直す。HostはrequestのTargetと`InterfaceTarget`をHost Runtimeへ渡し、Runtimeは一つのatomic execution boundaryに入ってから、`target.path`を現在のObject implementationとstableなbackend対象へ解決する。
|
||||
|
||||
```rust
|
||||
runtime.call_current(
|
||||
@@ -299,7 +299,7 @@ runtime.call_current(
|
||||
).await
|
||||
```
|
||||
|
||||
`resolve_current_projection`と`runtime.call_current`というAPIを採用することは決定しない。ResolverはRuntimeが開始したexecution boundary内で呼ばれ、返す値はそのboundary中にObject identityとbackend対象を安定して保持するhandleまたはtransaction contextを含む必要がある。このexampleが示す要件は次である。
|
||||
`resolve_current_projection`と`runtime.call_current`を実際のAPIとして採用するかは決めていない。ResolverはRuntimeが開始したexecution boundary内で呼ばれ、そのboundary中にObject identityとbackend対象を安定して保持するhandleまたはtransaction contextを含む値を返す必要がある。上のexampleは、次の要件を表す。
|
||||
|
||||
- 過去の`observe`で作成したstruct instanceを保持していなくてもOperationを実行できる。
|
||||
- Call時の現在の主体、Object visibility、Object metadata、Interface集合を同じProjection pathで再解決し、Objectが非公開なら`NotFound`を返す。
|
||||
@@ -315,15 +315,15 @@ runtime.call_current(
|
||||
- Macro-generated dispatchが、選択されたInterface traitのmethodを現在のstruct instanceに対して呼び出す。異なるInterfaceの同名Operationをmergeまたは曖昧に解決しない。
|
||||
- Operation実行後の現在状態に対応するvalidatorを、必要に応じて`CallOperationResponse`へ返せる。
|
||||
|
||||
Object validatorをObject identityへ結び付けるためにClient-facingな`ref`を要求しない。ProviderまたはHostはHost-privateなidentityを使用できるが、そのidentity confirmation、Object validator比較、Interface membershipとvalidator比較、DescriptorとOperation implementationの固定、authorization、Operation実行を一つのatomic boundaryで行う。
|
||||
Object validatorとObject identityの関連付けに、Client-facingな`ref`は必須ではない。ProviderまたはHostはHost-privateなidentityを使用できる。ただし、identity confirmation、Object validator比較、Interface membershipとvalidator比較、DescriptorとOperation implementationの固定、authorization、Operation実行は、一つのatomic boundaryで行う。
|
||||
|
||||
Operation実行後validatorを、同じstruct instanceから再計算するか、Operation resultとともに返すか、backendを再観測するかは未決定である。どの方式でも、resultを生成した実行後状態とvalidatorを対応させなければならない。
|
||||
実行後のvalidatorをどこから取得するかも未決定である。同じstruct instanceから再計算する方式、Operation resultとともに返す方式、backendを再観測する方式が考えられるが、いずれもresultを生成した実行後状態とvalidatorを対応させる必要がある。
|
||||
|
||||
Structが`PathBuf`だけを保持し、validator比較後にpathを無条件で開き直す実装は、対象差し替えに対するatomicityを保証できない場合がある。Rust APIは、file descriptor、transaction、conditional update、lock等を利用してbackend固有のatomicityを実現できる必要があるが、その抽象化は未決定である。
|
||||
Structが`PathBuf`だけを保持している場合、validator比較後にpathを無条件で開き直すと、対象差し替えに対するatomicityを保証できないことがある。Rust APIには、file descriptor、transaction、conditional update、lock等でbackend固有のatomicityを実現できることを求める。そのための抽象化は未決定である。
|
||||
|
||||
## 複数Interfaceの実装
|
||||
|
||||
一つのstructは複数のInterface traitを実装できる。
|
||||
一つのstructは、複数のInterface traitを実装できる。
|
||||
|
||||
```rust
|
||||
impl ReadOnlyFileInterface for File {
|
||||
@@ -335,7 +335,7 @@ impl WritableFileInterface for File {
|
||||
}
|
||||
```
|
||||
|
||||
Publication時に、現在の主体へ公開する0個以上のInterfaceを選択し、表示したい順で追加する。
|
||||
そのうち、現在の主体へ公開する0個以上のInterfaceをpublication時に選び、表示したい順に追加する。
|
||||
|
||||
```rust
|
||||
let mut object = PublishedObject::new(file)
|
||||
@@ -348,15 +348,15 @@ if authorization.can_write() {
|
||||
object
|
||||
```
|
||||
|
||||
Protocol Objectの`interfaces`は追加順を保持するordered setである。同じInterface参照の重複は拒否する。各Interface Descriptorは独立したOperation集合を宣言し、複数trait implementationを一つのDescriptorへ暗黙にmergeしない。
|
||||
Protocol Objectの`interfaces`は追加順を保持するordered setであり、同じInterface参照の重複は拒否する。各Interface Descriptorは独立したOperation集合を宣言するため、複数のtrait implementationを一つのDescriptorへ暗黙にmergeしない。
|
||||
|
||||
異なるInterfaceは同じOperation名を宣言できる。Dispatchはrequestの`InterfaceTarget.reference`とOperation名の組で一意に決まり、表示上のqualified表記は[Text Viewの参照表記](../4.2-text-view.md#参照表記)に従う。Signature全体をProtocol request fieldへparseし直さない。
|
||||
異なるInterfaceが同じOperation名を宣言しても、dispatchはrequestの`InterfaceTarget.reference`とOperation名の組で一意に決まる。表示上のqualified表記は[Text Viewの参照表記](../4.2-text-view.md#参照表記)に従い、signature全体をProtocol request fieldへparseし直さない。
|
||||
|
||||
Rust traitのsupertrait、trait alias、複数traitの組み合わせをDescriptor生成へどう対応付けるかは未決定であり、Protocol上のInterface inheritanceを暗黙に導入しない。
|
||||
Rust traitのsupertrait、trait alias、複数traitの組み合わせをDescriptor生成にどう対応付けるかは未決定である。これらによって、Protocol上のInterface inheritanceを暗黙に導入することはしない。
|
||||
|
||||
## 固定Interface向けのsugar
|
||||
|
||||
一つのRust typeを常に同じInterface集合で公開する場合、次のようなsugarを追加してよい。
|
||||
一つのRust typeを常に同じInterface集合で公開する場合は、次のようなsugarを追加してよい。
|
||||
|
||||
```rust
|
||||
#[wip::object(interfaces = [ReadOnlyFileInterface, WritableFileInterface])]
|
||||
@@ -365,19 +365,19 @@ struct File {
|
||||
}
|
||||
```
|
||||
|
||||
または、conceptualには`WipObject`のassociated itemとしてdefault Interface集合を指定できる。
|
||||
別のconceptualな形として、`WipObject`のassociated itemにdefault Interface集合を指定することも考えられる。
|
||||
|
||||
ただし、固定Interface集合をcore modelにはしない。主体、policy、resource kind等に基づいて同じstructへ異なるInterface subsetを追加できるexplicit publication modelを基礎とする。
|
||||
ただし、core modelの基礎はexplicit publicationとする。主体、policy、resource kind等によって、同じstructへ異なるInterface subsetを追加できる必要があるため、固定Interface集合をcore modelにはしない。
|
||||
|
||||
Sugarを使用する場合も、対象typeが指定されたすべてのInterface traitを実装していることと、同じInterfaceを重複していないことを確認しなければならない。
|
||||
Sugarを使う場合も、対象typeが指定したすべてのInterface traitを実装していることと、同じInterfaceを重複していないことを確認する必要がある。
|
||||
|
||||
## HostへのInterface登録
|
||||
|
||||
Generated Interface DefinitionをHostへ登録し、構造化されたInterface参照を割り当てる必要がある。
|
||||
Generated Interface Definitionには、Hostへの登録を通じて構造化されたInterface参照を割り当てる必要がある。参照の絶対scopeとlocal name、および配置pathへの祖先制約は[2.2. Operation and Interface](../2.2-operation-and-interface.md#interfaceの識別とscope)に従う。
|
||||
|
||||
参照の絶対scopeとlocal name、および配置pathへの祖先制約は[2.2. Operation and Interface](../2.2-operation-and-interface.md#interfaceの識別とscope)に従う。以下のconceptual API例はscope / nameを渡す具体構文を省略しており、暗黙のroot scopeを定めるものではない。登録時のcontextと引数の具体APIは引き続き未決定である。Self-scopeは許可され、scopeはそのpathのObjectに紐づく。Scope Objectのoptionalな`ref`はProtocolの`scope_ref`として観測・検証し、`ref`がない場合のidentity追跡不能も許容する。Scopeの移動追跡や独立したscope validatorを導入しない。
|
||||
Self-scopeは許可され、scopeはそのpathのObjectに紐づく。Scope Objectのoptionalな`ref`はProtocolの`scope_ref`として観測・検証する。`ref`がなければidentityを追跡できないことも許容し、scopeの移動追跡や独立したscope validatorは導入しない。
|
||||
|
||||
Conceptualには、eager registrationとlazy registrationの両方が考えられる。
|
||||
登録方法としては、conceptualにはeager registrationとlazy registrationの両方が考えられる。次の例はscope / nameを渡す具体構文を省略しており、暗黙のroot scopeを定めてはいない。登録時のcontextと引数の具体APIも引き続き未決定である。
|
||||
|
||||
```rust
|
||||
let file_read_interface = host.register_interface(ReadOnlyFileInterfaceDefinition)?;
|
||||
@@ -387,9 +387,7 @@ let object = PublishedObject::new(file)
|
||||
.expose(file_write_interface);
|
||||
```
|
||||
|
||||
または、Hostが最初のpublication時に各definitionをinternしてよい。
|
||||
|
||||
どちらの場合も次を守る。
|
||||
また、Hostが最初のpublication時に各definitionをinternする方式でもよい。どちらの登録方法でも、次の条件を満たす必要がある。
|
||||
|
||||
- MutableなInterface参照では`fetch_interface`がvalidatorを返し、同じ参照とvalidatorの組から異なるDescriptorを返さない。
|
||||
- Validatorを返さないInterface参照は、その参照の有効期間中Descriptorをimmutableにする。
|
||||
@@ -401,7 +399,7 @@ let object = PublishedObject::new(file)
|
||||
|
||||
## Request contextとauthorization
|
||||
|
||||
Operation implementationは、Object instance以外にrequest contextを必要とし得る。
|
||||
Operation implementationが必要とする情報は、Object instanceだけとは限らない。Request contextとして、次の情報も必要になり得る。
|
||||
|
||||
- 現在の主体。
|
||||
- Cancellationとdeadline。
|
||||
@@ -409,30 +407,30 @@ Operation implementationは、Object instance以外にrequest contextを必要
|
||||
- Host serviceやbackend connectionへのaccess。
|
||||
- Resource limit。
|
||||
|
||||
これらをmethod parameter、receiver wrapper、generated context、task-local stateのどれで渡すかは未決定である。Protocol parameterとして宣言された値と、Hostが注入するcontextを明確に区別しなければならない。
|
||||
このcontextをmethod parameter、receiver wrapper、generated context、task-local stateのどれで渡すかは未決定である。どの形でも、Protocol parameterとして宣言された値とHostが注入するcontextは明確に区別する必要がある。
|
||||
|
||||
Object publication時のInterface集合の選択と、Operation call時のmembership、Interface validator、authorizationの再評価を実装できる必要がある。以前の`observe`で公開されたInterface集合だけを根拠に、現在のOperation実行を許可してはならない。
|
||||
Interface集合はObject publication時に選択するだけでなく、Operation call時にもmembership、Interface validator、authorizationを再評価できなければならない。以前の`observe`で公開したInterface集合だけを根拠に、現在のOperation実行を許可することはできない。
|
||||
|
||||
Operation resultのRecord、List、Union等へnestedしたすべての`Entry`位置について、Hostは現在の主体にそのpathを認識させてよいことを再帰的に確認できなければならない。通常のstructural Value validationだけではこのvisibility checkを満たさない。Macro-generated code、Projection、Host Runtimeのどこで確認するかは未決定である。
|
||||
Resultのvisibility checkも、通常のstructural Value validationとは別に必要となる。Hostは、Operation resultのRecord、List、Union等にnestedしたすべての`Entry`について、現在の主体にそのpathを認識させてよいかを再帰的に確認できなければならない。この確認をMacro-generated code、Projection、Host Runtimeのどこで行うかは未決定である。
|
||||
|
||||
Operation argumentとして`Entry` pathを受け取った場合も、pathの所持をauthorizationの証明として扱わず、参照先へ作用する時点で現在の主体について認可する。
|
||||
Argumentとして受け取った`Entry` pathについても、pathの所持をauthorizationの証明にはしない。参照先へ作用する時点で、現在の主体について認可する。
|
||||
|
||||
## Error model
|
||||
|
||||
Rust methodのerror表現は、次の四種類を区別できなければならない。
|
||||
Rust methodのerror表現には、次の四種類を区別できることを求める。
|
||||
|
||||
- Protocol validationまたはdispatch failure。
|
||||
- Effectがcommitされていないと保証できるHost / backend implementation failure。
|
||||
- Operationの未実行またはrollbackを保証できない`OperationOutcomeUnknown`。
|
||||
- Interfaceがreturn typeとして宣言したdomain failure。
|
||||
|
||||
現在のProtocolでは、domain failureはOperation resultのUnion caseとして表す。Rustの`Result<T, E>`をdomain Union、Host error、または両方のどれへ対応付けるかは未決定である。
|
||||
現在のProtocolはdomain failureをOperation resultのUnion caseとして表す。Rustの`Result<T, E>`をdomain Union、Host error、またはその両方のどれに対応付けるかは未決定である。
|
||||
|
||||
MacroがRust panic、result encode failure、宣言型に違反するimplementation resultをdomain failureへ自動変換してはならない。Host Runtimeは、effectがcommitされていないと保証できる場合だけ`Internal`へmappingし、保証できない場合は`OperationOutcomeUnknown`へmappingする。Clientへ送信済みか不明なtransport切断も自動retryしない。
|
||||
Rust panic、result encode failure、宣言型に違反するimplementation resultを、Macroがdomain failureへ自動変換することは認めない。Host Runtimeは、effectがcommitされていないと保証できる場合だけ`Internal`へmappingし、保証できなければ`OperationOutcomeUnknown`へmappingする。Clientへ送信済みかどうか不明なtransport切断についても、自動retryはしない。
|
||||
|
||||
## 要件
|
||||
|
||||
Rust API modelは少なくとも次を満たす。
|
||||
このdraftのRust API modelに求める要件は、少なくとも次のとおりである。
|
||||
|
||||
- Interface Descriptorをtrait declarationから生成し、trait implementationごとに変化させない。
|
||||
- Structが`WipObject`と、追加する各Interface traitを実装していることをcompile timeに確認する。
|
||||
@@ -459,7 +457,7 @@ Rust API modelは少なくとも次を満たす。
|
||||
|
||||
## 非目標
|
||||
|
||||
このdraftは次を決定しない。
|
||||
次の事項は、このdraftでは決定しない。
|
||||
|
||||
- 最終的なcrate、module、trait、attribute、deriveの名前。
|
||||
- Proc macroの内部実装。
|
||||
@@ -495,4 +493,4 @@ Rust API modelは少なくとも次を満たす。
|
||||
- 主体ごとに異なるInterface combinationを追加したPublished Objectのconcrete return typeをどう統一するか。
|
||||
- Rust client APIを同じtrait declarationから生成するか。
|
||||
|
||||
これらを決定する際は、静的service objectだけでなく、Filesystem、database row、virtual aggregate、主体別Interface compositionを同じAPIで実装できるか評価する。
|
||||
これらの選択は、静的service objectに使えるだけでは足りない。Filesystem、database row、virtual aggregate、主体別Interface compositionも、同じAPIで実装できるかを評価して決定する。
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
## Status
|
||||
|
||||
この文書は、WIP HostをTypeScriptで実装するための概念的なAPI modelを整理するdraftである。
|
||||
TypeScriptの型は、通常のJavaScript outputには残らない。WIP Hostがruntimeに必要とするInterface Descriptorやvalidationをどう定義するか。このdraftは、TypeScriptでHostを実装するための概念的なAPI modelを提案する。
|
||||
|
||||
このdraftで示すfunction、object shape、generic type、class、method signatureは説明用であり、具体的なTypeScript API syntaxとして確定しない。Package構成、schema builderの最終名称、ownership、async context、error type、bundler integrationも未決定である。
|
||||
Function、object shape、generic type、class、method signatureは説明用であり、具体的なTypeScript API syntaxは確定していない。Package構成、schema builderの最終名称、ownership、async context、error type、bundler integrationも未決定である。
|
||||
|
||||
Protocol上の確定事項は[Core Model](../2-core-model.md)、[Object tree / Schema](../2.1-object-tree-schema.md)、[Operation and Interface](../2.2-operation-and-interface.md)、[Protocol](../3-protocol.md)を参照する。動的なObject projectionの運用要件は[Dynamic Worldspace Projection](dynamic-worldspace-projection.md)、対応するRust modelは[Rust API Model](rust-api-model.md)を参照する。
|
||||
Protocol上の確定事項は[Core Model](../2-core-model.md)、[Object tree / Schema](../2.1-object-tree-schema.md)、[Operation and Interface](../2.2-operation-and-interface.md)、[Protocol](../3-protocol.md)に従う。動的なObject projectionの運用要件は[Dynamic Worldspace Projection](dynamic-worldspace-projection.md)、対応するRust modelは[Rust API Model](rust-api-model.md)を参照する。
|
||||
|
||||
## 目的
|
||||
|
||||
TypeScript APIでは、次のmodelを自然に表現できるようにする。
|
||||
TypeScript APIでは、runtimeに残るdefinition objectを使い、Protocol上の宣言とTypeScript型の推論を対応付けるmodelを自然に表現できるようにする。Objectの実装と、ある時点で公開するInterfaceの選択は分けて扱う。
|
||||
|
||||
1. Runtimeに残るdefinition objectでWIP Interfaceを宣言する。
|
||||
2. 同じdefinition objectからInterface Descriptor、Value codec、validationを構築する。
|
||||
@@ -20,11 +20,11 @@ TypeScript APIでは、次のmodelを自然に表現できるようにする。
|
||||
6. Object instanceをWorldspace全体について常駐させず、`observe`と`call_operation`の解決時にdata-drivenに生成できる。
|
||||
7. Custom transpiler、TypeScript compiler plugin、decorator metadataをcore APIの前提にしない。
|
||||
|
||||
このmodelでは、Protocol上のObjectとInterfaceの関連付けは`Object.interfaces`だけで表す。Operation implementationへ到達するためのTypeScript / JavaScript内部codeはSDK / Host Runtimeの実装詳細であり、新しいProtocol conceptを導入しない。
|
||||
Protocol上でObjectとInterfaceを関連付けるのは`Object.interfaces`だけである。Operation implementationを呼び出すためのTypeScript / JavaScript内部codeはSDK / Host Runtimeの実装詳細として扱い、新しいProtocol conceptは導入しない。
|
||||
|
||||
## TypeScriptの型消去
|
||||
|
||||
TypeScriptの`interface`、`type`、generic type argumentは、通常のJavaScript outputには残らない。
|
||||
TypeScriptの`interface`、`type`、generic type argumentは、通常のJavaScript outputには残らない。たとえば、次のdeclarationだけからruntimeにInterface Descriptorを取得することはできない。
|
||||
|
||||
```ts
|
||||
interface FileOperations {
|
||||
@@ -32,13 +32,11 @@ interface FileOperations {
|
||||
}
|
||||
```
|
||||
|
||||
このdeclarationだけから、runtimeにInterface Descriptorを取得することはできない。
|
||||
|
||||
```ts
|
||||
host.registerInterface(FileOperations); // runtime valueではない
|
||||
```
|
||||
|
||||
そのため、TypeScript APIではruntime valueをsource of truthにする。
|
||||
そこで、TypeScript APIではruntime valueをsource of truthにする。
|
||||
|
||||
```ts
|
||||
const FileInterface = wip.interface({
|
||||
@@ -46,13 +44,13 @@ const FileInterface = wip.interface({
|
||||
});
|
||||
```
|
||||
|
||||
このobjectは通常のJavaScript valueとしてtranspile後も残る。TypeScript型は、このruntime definitionから推論する。
|
||||
このobjectは、通常のJavaScript valueとしてtranspile後も残る。TypeScript型は、このruntime definitionから推論する。
|
||||
|
||||
## Conceptual model
|
||||
|
||||
### Type definition
|
||||
|
||||
Record、Enum、Union等もruntime definition objectとして宣言する。
|
||||
Record、Enum、Union等の型も、runtime definition objectとして宣言する。
|
||||
|
||||
```ts
|
||||
const WriteResult = wip.union({
|
||||
@@ -70,17 +68,17 @@ const WriteResult = wip.union({
|
||||
});
|
||||
```
|
||||
|
||||
TypeScript型はdefinitionから推論する。
|
||||
このdefinitionから、TypeScript型を推論する。
|
||||
|
||||
```ts
|
||||
type WriteResult = wip.Infer<typeof WriteResult>;
|
||||
```
|
||||
|
||||
`wip.Infer`の具体的な型実装と、Union DTOのTypeScript representationは未決定である。Runtime definitionと推論された型が、同じProtocol `TypeExpr`および`Value` semanticsを表すことが要件である。
|
||||
`wip.Infer`の具体的な型実装とUnion DTOのTypeScript representationは、まだ決めていない。Runtime definitionと推論した型が、同じProtocol `TypeExpr`および`Value` semanticsを表すことを要件とする。
|
||||
|
||||
### Interface definition
|
||||
|
||||
WIP Interfaceはruntime definition objectとして宣言する。
|
||||
WIP Interfaceも、runtime definition objectとして宣言する。
|
||||
|
||||
```ts
|
||||
const FileInterface = wip.interface({
|
||||
@@ -125,9 +123,9 @@ const FileInterface = wip.interface({
|
||||
});
|
||||
```
|
||||
|
||||
このsyntaxはconceptualである。Definition objectのproperty名、documentationの指定方法、Named typeの参照方法、`optional`の表現は未決定である。
|
||||
このsyntaxはconceptualであり、具体的なAPIとして確定したものではない。Definition objectのproperty名、documentationの指定方法、Named typeの参照方法、`optional`の表現は未決定である。
|
||||
|
||||
Interface definitionは、次のlogical declarationに対応する。
|
||||
Interface definitionとProtocol上の宣言は、次のように対応付ける。
|
||||
|
||||
- 一つのruntime definition objectを、一つのInterface Descriptorを構築するTypeScript側definitionとして扱う。
|
||||
- JavaScript variable名`FileInterface`はlocal code上の識別子であり、Protocol `InterfaceDescriptor`のfieldや構造化されたInterface参照としてそのまま公開しない。
|
||||
@@ -137,22 +135,24 @@ Interface definitionは、次のlogical declarationに対応する。
|
||||
- Runtime type definitionをDescriptorの`TypeExpr`とProtocol `Value`へ対応付ける。
|
||||
- DocumentationをInterface、Operation、parameter、return、type、field、caseへ指定できるようにする。
|
||||
|
||||
Descriptorのshapeはruntime definition objectによって決まり、各classまたはplain objectのimplementationによって変化しない。
|
||||
Descriptorのshapeを決めるのはruntime definition objectである。各classまたはplain objectがどう実装するかによって、Descriptorは変化しない。
|
||||
|
||||
Operation、parameter、type、field、caseのProtocol名はfinalized definition内のstableなstring dataとして保持する。JavaScript variable名、constructor名、functionの`.name`、prototype上のmethod列挙から推測しない。Hostはincoming Operation名をfinalized dispatch table上のinternal tokenまたはaccessorへ解決し、Clientから受け取った名前をそのままobject property lookupへ使用しない。Object-literal keyをauthoring sugarとして使う場合にproperty manglingからどう保護するかは未決定である。
|
||||
Operation、parameter、type、field、caseのProtocol名は、finalized definition内にstableなstring dataとして保持する。JavaScript variable名、constructor名、functionの`.name`、prototype上のmethod列挙から推測することはしない。
|
||||
|
||||
Definition objectはHostへの登録時に一つのimmutableなartifactとしてfinalizeする。MutableなProtocol Interface参照を提供する場合は、同じ参照へ新しいvalidatorと新しいfinalized artifactを対応付け、同じ参照とvalidatorの組から異なるDescriptorを返してはならない。
|
||||
Hostはincoming Operation名を、finalized dispatch table上のinternal tokenまたはaccessorへ解決する。Clientから受け取った名前をそのままobject property lookupに使うことはしない。Object-literal keyをauthoring sugarとして使う場合、property manglingからどう保護するかは未決定である。
|
||||
|
||||
Definition objectは、Hostへの登録時に一つのimmutableなartifactとしてfinalizeする。MutableなProtocol Interface参照を提供する場合は、同じ参照へ新しいvalidatorと新しいfinalized artifactを対応付ける。同じ参照とvalidatorの組から異なるDescriptorを返すことは認めない。
|
||||
|
||||
### Operation implementation type
|
||||
|
||||
Interface definitionから、必要なOperation implementationのTypeScript型を推論する。
|
||||
必要なOperation implementationのTypeScript型は、Interface definitionから推論する。
|
||||
|
||||
```ts
|
||||
type FileOperations =
|
||||
wip.Implementation<typeof FileInterface>;
|
||||
```
|
||||
|
||||
Conceptualには、次のようなobject typeへ展開される。
|
||||
概念的には、次のようなobject typeへ展開される。
|
||||
|
||||
```ts
|
||||
type FileOperations = {
|
||||
@@ -167,9 +167,9 @@ type FileOperations = {
|
||||
};
|
||||
```
|
||||
|
||||
Function-valued propertyを基本形とし、`strictFunctionTypes`を含むstrictなcompiler settingsの下でparameterをcontravariantに検査できるようにする。通常のclass method parameterはTypeScriptのassignabilityでbivariantに扱われる場合があるため、method-shapedな`implements`だけを「すべてのschema-valid argumentを安全に受け取れる」ことの完全な証明としては扱わない。
|
||||
Function-valued propertyを基本形とし、`strictFunctionTypes`を含むstrictなcompiler settingsの下でparameterをcontravariantに検査できるようにする。通常のclass method parameterは、TypeScriptのassignabilityでbivariantに扱われる場合がある。そのため、method-shapedな`implements`だけを「すべてのschema-valid argumentを安全に受け取れる」ことの完全な証明にはしない。
|
||||
|
||||
Definition-awareなbuilderでimplementationをcontextual typingする形も考えられる。
|
||||
Definition-awareなbuilderによって、implementationをcontextual typingする形も考えられる。
|
||||
|
||||
```ts
|
||||
const operations = FileInterface.implement({
|
||||
@@ -183,13 +183,13 @@ const operations = FileInterface.implement({
|
||||
});
|
||||
```
|
||||
|
||||
`implement`の採用は未決定である。どの形でもruntime validationとcallable checkをauthorityとし、TypeScript型はdeveloper feedbackとして扱う。
|
||||
`implement`を採用するかは未決定である。どの形でも、実行時の判定はruntime validationとcallable checkをauthorityとし、TypeScript型はdeveloper feedbackとして扱う。
|
||||
|
||||
このfunction property shapeは説明用である。Operation parameterを一つのarguments objectとして渡すか、request contextを別parameterにするか、sync returnを許可するかは未決定である。
|
||||
上のfunction property shapeも説明用である。Operation parameterを一つのarguments objectとして渡すか、request contextを別parameterにするか、sync returnを許可するかは、まだ決めていない。
|
||||
|
||||
### Object implementation
|
||||
|
||||
Classは、その時点でWorldspaceへ投影するObjectをHost内部で表現できる。
|
||||
その時点でWorldspaceへ投影するObjectは、Host内部ではclassとして表現できる。
|
||||
|
||||
```ts
|
||||
class File implements WipObject, FileOperations {
|
||||
@@ -218,7 +218,7 @@ class File implements WipObject, FileOperations {
|
||||
}
|
||||
```
|
||||
|
||||
`WipObject`と`WipObjectMetadata`の具体的なshapeは未決定である。概念的にはProtocol Objectのうち、publication時に選択するInterface集合以外の公開metadataを提供する。
|
||||
`WipObject`と`WipObjectMetadata`は、Protocol Objectの公開metadataのうち、publication時に選ぶInterface集合以外を提供するための概念的な型である。具体的なshapeは未決定である。
|
||||
|
||||
```ts
|
||||
interface WipObject {
|
||||
@@ -233,16 +233,16 @@ interface WipObjectMetadata {
|
||||
}
|
||||
```
|
||||
|
||||
`WipObjectMetadata`へ次を含めない。これらをimplementing classのprivate fieldとして保持することは禁止しない。
|
||||
Classが保持する情報のすべてを公開metadataにするわけではない。次の情報は`WipObjectMetadata`に含めないが、implementing classのprivate fieldとして保持してよい。
|
||||
|
||||
- Absolute path。PathはObjectのfieldではなく、Worldspace resolutionのcontextである。
|
||||
- Children。ChildrenはTree上の配置contextである。
|
||||
- 固定されたInterface集合。InterfaceはObjectを公開する時点で追加する。
|
||||
- Backend connection、file descriptor、credential等のHost-private state。
|
||||
|
||||
Classが保持するpathやbackend handle等をOperation implementationに使用してよいが、Object metadataまたはProtocol Objectへ自動的にserializeしない。
|
||||
Classのpathやbackend handleはOperation implementationで使用できる。ただし、Object metadataやProtocol Objectへ自動的にserializeする対象にはしない。
|
||||
|
||||
Classの使用は必須ではない。Structural typingを利用してplain objectも使用できる。
|
||||
また、classの使用は必須ではない。Structural typingを利用し、plain objectを使うこともできる。
|
||||
|
||||
```ts
|
||||
const file = {
|
||||
@@ -262,7 +262,7 @@ const file = {
|
||||
|
||||
### Object publication
|
||||
|
||||
ObjectをWorldspaceへ返す時点で、object instanceと公開する0個以上のInterface definitionを明示的に組み合わせる。Interfaceは追加順を保持するordered setであり、同じdefinitionまたは同じ登録済み参照を重複して追加しない。
|
||||
ObjectをWorldspaceへ返す時点で、object instanceと、公開する0個以上のInterface definitionを明示的に組み合わせる。Interfaceは追加順を保持するordered setとし、同じdefinitionまたは同じ登録済み参照の重複追加は認めない。
|
||||
|
||||
```ts
|
||||
const file = new File(path, objectMetadata);
|
||||
@@ -270,7 +270,7 @@ const object = wip.publish(file)
|
||||
.expose(FileInterface, file);
|
||||
```
|
||||
|
||||
Publication APIはconceptualに、追加する各definitionと、そのImplementation型を満たすbindingを受け取る。
|
||||
Publication APIは、概念的には各definitionと、そのImplementation型を満たすbindingを受け取る。
|
||||
|
||||
```ts
|
||||
interface WipInterfaceDefinition<Implementation extends object> {
|
||||
@@ -285,11 +285,13 @@ interface PublishedObject<T extends WipObject> {
|
||||
}
|
||||
```
|
||||
|
||||
実際のTypeScript generic表現は未決定である。このconstraintは、strictなcompiler settingsとfunction-valued property shapeを使用する場合に、追加する各Interfaceへbindした値が必要なOperation functionを提供するか検査する。Object instance自身をimplementationとして渡しても、Interface専用のadapter objectを渡してもよく、どのInterfaceも追加しないObjectも表現できる。通常のclass methodやtype assertion等で検査を弱められるため、各Interfaceを追加する時点とdispatch時のruntime callable check、およびValue validationは常に必要である。
|
||||
実際のTypeScript generic表現は未決定である。このconstraintは、strictなcompiler settingsとfunction-valued property shapeを使用した場合に、各Interfaceへbindする値が必要なOperation functionを提供するかを検査するためのものである。
|
||||
|
||||
Host Runtimeは各bindingを登録済みInterface参照と対応付ける。異なるInterfaceが同名Operationを宣言しても、選択されたInterface参照に対応するbindingだけをdispatch対象とし、単一object上の同名propertyへmergeしない。
|
||||
ImplementationにはObject instance自身を渡しても、Interface専用のadapter objectを渡してもよい。Interfaceを一つも追加しないObjectも表現できる。ただし、通常のclass methodやtype assertion等で型の検査を弱められるため、各Interfaceの追加時とdispatch時にはruntime callable checkを行い、Value validationも常に行う必要がある。
|
||||
|
||||
`PublishedObject`はHost SDK内のconceptual typeであり、Protocol typeとして追加しない。Host Runtimeはこれを使用して次を構築する。
|
||||
Host Runtimeは、各bindingを登録済みInterface参照と対応付ける。異なるInterfaceが同名Operationを宣言していても、dispatch対象は選択されたInterface参照に対応するbindingだけであり、単一object上の同名propertyへmergeしない。
|
||||
|
||||
`PublishedObject`はHost SDK内のconceptual typeであり、Protocol typeには追加しない。Host Runtimeはこの値からProtocol Objectを構築する。
|
||||
|
||||
```ts
|
||||
const protocolObject = {
|
||||
@@ -301,15 +303,17 @@ const protocolObject = {
|
||||
};
|
||||
```
|
||||
|
||||
Optional propertyの`undefined`はwireへ出力せず、field省略として扱う。`null`へ自動変換しない。
|
||||
Optional propertyの`undefined`はwireへ出力せず、fieldの省略として扱う。`null`へ自動変換することはしない。
|
||||
|
||||
`selectedInterfaceReferences`は、Hostが各runtime Interface definitionを登録または公開するときに割り当てる構造化参照`{ scope, name }`を、publication APIへの追加順で並べたものである。同じ`(scope, name)`の重複は拒否し、Objectの配置pathに対する祖先制約も検証する。Variable名やJavaScript object identityをportableなProtocol参照としてそのまま使用することは要求しない。
|
||||
`selectedInterfaceReferences`には、Hostが各runtime Interface definitionの登録または公開時に割り当てる構造化参照`{ scope, name }`を、publication APIへの追加順で並べる。同じ`(scope, name)`の重複は拒否し、Objectの配置pathに対する祖先制約も検証する。Variable名やJavaScript object identityを、そのままportableなProtocol参照として使用することは要求しない。
|
||||
|
||||
`observe`はDescriptor自体をObjectへ埋め込まず、このInterface参照のordered setだけを返す。Clientが各参照を`fetch_interface`したとき、Host Runtimeはruntime definition objectから構築したDescriptorと、存在する場合はInterface validatorを返す。同じ参照とvalidatorの組のDescriptorはHost上で共有およびcacheできるため、Object instanceごとにDescriptorを再生成または再送する必要はない。
|
||||
`observe`でObjectに含めるのは、Descriptor自体ではなく、このInterface参照のordered setだけである。Clientが各参照を`fetch_interface`すると、Host Runtimeはruntime definition objectから構築したDescriptorと、存在する場合はInterface validatorを返す。
|
||||
|
||||
同じ参照とvalidatorの組に対応するDescriptorは、Host上で共有およびcacheできる。このため、Object instanceごとにDescriptorを再生成したり再送したりする必要はない。
|
||||
|
||||
## Runtime definitionの責務
|
||||
|
||||
`wip.interface(...)`が返すdefinition objectは、conceptualには次を生成または提供する。
|
||||
`wip.interface(...)`が返すdefinition objectには、Descriptorの構築に加えて、Valueの変換・検証とInterfaceごとのdispatchに必要な情報を提供する役割を想定する。
|
||||
|
||||
- Interface Descriptorを構築するruntime definition。
|
||||
- Operation propertyとOperation Declarationの対応情報。
|
||||
@@ -320,7 +324,7 @@ Optional propertyの`undefined`はwireへ出力せず、field省略として扱
|
||||
- 各Interfaceの推論されたImplementation型を満たす値を、そのInterface参照へbindするtyped entry point。
|
||||
- HostがInterface参照、optional validator、Descriptorを登録、取得、cacheするためのlocal identity。
|
||||
|
||||
TypeScriptのstructural type checkはcompile-timeのdeveloper feedbackである。Runtimeでは少なくとも次を検証する。
|
||||
TypeScriptのstructural type checkはcompile-timeのdeveloper feedbackである。Runtimeでは、少なくとも次を検証する必要がある。
|
||||
|
||||
- Object metadataがProtocol上有効である。
|
||||
- 追加する各InterfaceへbindしたOperation implementationが呼び出し可能である。
|
||||
@@ -329,13 +333,11 @@ TypeScriptのstructural type checkはcompile-timeのdeveloper feedbackである
|
||||
- ResultがDescriptorに従っている。
|
||||
- Integer、Number、Entry、Record、Union等の追加制約を満たす。
|
||||
|
||||
Generatedまたはgenericなdispatch codeはTypeScript SDK内部の実装詳細である。Host内部のdispatch mechanism、class名、prototype、constructor等をProtocolへ公開しない。
|
||||
Generatedまたはgenericなdispatch codeは、TypeScript SDK内部の実装詳細である。Host内部のdispatch mechanism、class名、prototype、constructor等はProtocolへ公開しない。
|
||||
|
||||
## TypeScript typeとProtocol type
|
||||
|
||||
Runtime definitionは、Descriptorの`TypeExpr`、Protocol `Value`、TypeScript static typeの三つを対応付ける。
|
||||
|
||||
Conceptualな対応例は次のとおりである。
|
||||
Runtime definitionは、Descriptorの`TypeExpr`、Protocol `Value`、TypeScript static typeの三つを対応付ける。次の表はconceptualな対応例であり、具体的なpublic typeは未決定である。
|
||||
|
||||
| WIP definition | TypeScript representation | Runtime validation |
|
||||
| --- | --- | --- |
|
||||
@@ -352,21 +354,19 @@ Conceptualな対応例は次のとおりである。
|
||||
| `wip.enum(...)` | String literal union | 宣言caseのみ |
|
||||
| `wip.union(...)` | Discriminated union | Case、payload、shape |
|
||||
|
||||
この表の具体的なpublic typeは未決定である。
|
||||
`wip.json()`が受け付けるのは、null、boolean、finite number、string、Array、およびstring-keyedなplain objectから再帰的に構成されるJSON-compatible valueだけである。`undefined`、`bigint`、`symbol`、function、`Uint8Array`、`Date`、`Map`、`Set`、class instance、cyclic object graph、NaN、正負Infinity、sparse array等は、暗黙にJsonへ変換せずvalidation failureとする。Integerとして表現する値にも、Protocolの相互運用範囲を適用する。
|
||||
|
||||
`wip.json()`が受け付けるのは、null、boolean、finite number、string、Array、およびstring-keyedなplain objectから再帰的に構成されるJSON-compatible valueだけである。`undefined`、`bigint`、`symbol`、function、`Uint8Array`、`Date`、`Map`、`Set`、class instance、cyclic object graph、NaN、正負Infinity、sparse array等を暗黙にJsonへ変換せず、validation failureとする。Integerとして表現する値にもProtocolの相互運用範囲を適用する。
|
||||
TypeScriptの`number`はIntegerにもNumberにも使えるため、runtime schemaの位置に基づいて検証する。`wip.integer()`ではProtocolの相互運用範囲を超える値を拒否し、`bigint`を暗黙にIntegerへ変換しない。
|
||||
|
||||
TypeScriptの`number`はIntegerとNumberの両方に使用できるため、runtime schemaの位置に基づいて検証する。`wip.integer()`はProtocolの相互運用範囲を超える値を拒否する。`bigint`を暗黙にIntegerへ変換しない。
|
||||
Protocol `Unit`には、たとえば`wip.unitValue`のような専用singleton / sentinelを使い、`undefined`は使わない。RequiredなUnit fieldではsentinelを明示する。OptionalなUnit fieldでは、sentinelがpresent Unitを表し、property欠落またはoptional positionの`undefined`が省略を表す。
|
||||
|
||||
Protocol `Unit`は、例えば`wip.unitValue`のような専用singleton / sentinelで表す。`undefined`をUnitとして使用しない。RequiredなUnit fieldはsentinelを明示し、optionalなUnit fieldではsentinelがpresent Unit、property欠落またはoptional positionの`undefined`が省略を表す。
|
||||
|
||||
`wip.optional(T)`はfieldまたはparameterの省略を表し、`null`を許可する意味ではない。Implementationへ渡すarguments objectではproperty欠落を省略とし、optional positionへ明示された`undefined`をSDK convenienceとして省略へnormalizeしてよい。`undefined`自体をProtocol Valueとしてencodeせず、required positionの`undefined`はvalidation failureとする。
|
||||
同様に、`wip.optional(T)`はfieldまたはparameterの省略を表すものであり、`null`を許可する意味ではない。Implementationへ渡すarguments objectではproperty欠落を省略として扱う。Optional positionに明示した`undefined`は、SDK convenienceとして省略へnormalizeしてよいが、`undefined`自体をProtocol Valueとしてencodeすることはしない。Required positionの`undefined`はvalidation failureとする。
|
||||
|
||||
## Dynamic Worldspaceでのlifecycle
|
||||
|
||||
### `observe`
|
||||
|
||||
Projection implementationはrequest時にbackend dataを解決し、classまたはplain object instanceを生成する。
|
||||
Projection implementationはrequest時にbackend dataを解決し、その時点のclassまたはplain object instanceを生成する。
|
||||
|
||||
```ts
|
||||
async function resolveFile(
|
||||
@@ -399,9 +399,9 @@ async function resolveFile(
|
||||
}
|
||||
```
|
||||
|
||||
Host Runtimeは`PublishedObject`からProtocol Objectを返す。Response生成後にこの`File` instanceを破棄してよく、Worldspace内の全fileに対応するinstanceを保持しない。
|
||||
Host Runtimeは`PublishedObject`からProtocol Objectを返す。Responseを生成した後は、この`File` instanceを破棄してよい。Worldspace内の全fileに対応するinstanceを保持する必要はない。
|
||||
|
||||
Requested depthが正の場合、Providerはdirect childrenをlazyに発見できる。Directory全体を先に収集してArrayとして返すことは要求しないが、一つのparentのobservationを成功させるには公開されたindexable childrenをclean exhaustionまで列挙しなければならない。ConceptualにはAsync Iterable等を利用できる。
|
||||
Requested depthが正の場合、Providerはdirect childrenをlazyに発見できる。Directory全体を先に収集し、Arrayとして返すことは要求しない。ただし、一つのparentのobservationを成功させるには、公開されたindexable childrenをclean exhaustionまで列挙する必要がある。概念的には、Async Iterable等を利用できる。
|
||||
|
||||
```ts
|
||||
const entries = filesystem.discoverChildren(path, budget);
|
||||
@@ -420,7 +420,7 @@ for await (const entry of entries) {
|
||||
}
|
||||
```
|
||||
|
||||
`discoverChildren`、`budget`、`observation`はconceptualであり、具体APIではない。必要なsemanticsは次である。
|
||||
`discoverChildren`、`budget`、`observation`は具体APIではなく、conceptualな表記である。Childの発見と公開には、次のsemanticsを要求する。
|
||||
|
||||
- 各childを現在の主体についてprojectし、非公開Objectとそのsubtreeを返さない。
|
||||
- 公開されたindexable childrenをすべて列挙し、clean exhaustionで終了する。
|
||||
@@ -432,7 +432,7 @@ for await (const entry of entries) {
|
||||
|
||||
### `call_operation`
|
||||
|
||||
Hostはcall時に、requestのTargetと`InterfaceTarget`をHost Runtimeへ渡す。Runtimeは一つのatomic execution boundaryへ入ってから`target.path`を現在のObject implementationとstableなbackend対象へ解決する。
|
||||
Call時には現在のObjectを解決し直す。HostはrequestのTargetと`InterfaceTarget`をHost Runtimeへ渡し、Runtimeは一つのatomic execution boundaryに入ってから、`target.path`を現在のObject implementationとstableなbackend対象へ解決する。
|
||||
|
||||
```ts
|
||||
await runtime.callCurrent(
|
||||
@@ -445,7 +445,7 @@ await runtime.callCurrent(
|
||||
);
|
||||
```
|
||||
|
||||
`resolveCurrentProjection`と`runtime.callCurrent`というAPIを採用することは決定しない。ResolverはRuntimeが開始したexecution boundary内で呼ばれ、返す値はそのboundary中にObject identityとbackend対象を安定して保持するhandleまたはtransaction contextを含む必要がある。このexampleが示す要件は次である。
|
||||
`resolveCurrentProjection`と`runtime.callCurrent`を実際のAPIとして採用するかは決めていない。ResolverはRuntimeが開始したexecution boundary内で呼ばれ、そのboundary中にObject identityとbackend対象を安定して保持するhandleまたはtransaction contextを含む値を返す必要がある。上のexampleは、次の要件を表す。
|
||||
|
||||
- 過去の`observe`で作成したobject instanceを保持していなくてもOperationを実行できる。
|
||||
- Call時の現在の主体、Object visibility、Object metadata、Interface集合を同じProjection pathで再解決し、Objectが非公開なら`NotFound`を返す。
|
||||
@@ -461,15 +461,15 @@ await runtime.callCurrent(
|
||||
- Runtime dispatchが、選択されたInterface definitionへbindしたOperation implementationを現在のObject contextへ適用する。異なるInterfaceの同名Operationをmergeまたは曖昧に解決しない。
|
||||
- Operation実行後の現在状態に対応するvalidatorを、必要に応じて`CallOperationResponse`へ返せる。
|
||||
|
||||
Object validatorをObject identityへ結び付けるためにClient-facingな`ref`を要求しない。ProviderまたはHostはHost-privateなidentityを使用できるが、そのidentity confirmation、Object validator比較、Interface membershipとvalidator比較、DescriptorとInterface-specific implementation bindingの固定、authorization、Operation実行を一つのatomic boundaryで行う。
|
||||
Object validatorとObject identityの関連付けに、Client-facingな`ref`は必須ではない。ProviderまたはHostはHost-privateなidentityを使用できる。ただし、identity confirmation、Object validator比較、Interface membershipとvalidator比較、DescriptorとInterface-specific implementation bindingの固定、authorization、Operation実行は、一つのatomic boundaryで行う。
|
||||
|
||||
Operation実行後validatorを、同じobject instanceから再計算するか、Operation resultとともに返すか、backendを再観測するかは未決定である。どの方式でも、resultを生成した実行後状態とvalidatorを対応させなければならない。
|
||||
実行後のvalidatorをどこから取得するかも未決定である。同じobject instanceから再計算する方式、Operation resultとともに返す方式、backendを再観測する方式が考えられるが、いずれもresultを生成した実行後状態とvalidatorを対応させる必要がある。
|
||||
|
||||
Object instanceがpath文字列だけを保持し、validator比較後にpathを無条件で開き直す実装は、対象差し替えに対するatomicityを保証できない場合がある。TypeScript APIは、transaction、conditional update、backend handle、lock等を利用してbackend固有のatomicityを実現できる必要があるが、その抽象化は未決定である。
|
||||
Object instanceがpath文字列だけを保持している場合、validator比較後にpathを無条件で開き直すと、対象差し替えに対するatomicityを保証できないことがある。TypeScript APIには、transaction、conditional update、backend handle、lock等でbackend固有のatomicityを実現できることを求める。そのための抽象化は未決定である。
|
||||
|
||||
## 複数Interfaceの実装
|
||||
|
||||
一つのclassまたはplain objectは複数のInterface implementation typeを満たせる。Property名が衝突する場合やInterfaceごとに異なるbehaviorを提供する場合は、Interfaceごとのadapter objectをbindできる。
|
||||
一つのclassまたはplain objectは、複数のInterface implementation typeを満たせる。Property名が衝突する場合や、Interfaceごとに異なるbehaviorを提供する場合は、Interfaceごとのadapter objectをbindできる。
|
||||
|
||||
```ts
|
||||
type ReadOnlyFileOperations =
|
||||
@@ -488,7 +488,7 @@ class File
|
||||
}
|
||||
```
|
||||
|
||||
Publication時に、現在の主体へ公開する0個以上のInterfaceを選択し、表示したい順で追加する。
|
||||
現在の主体へ公開する0個以上のInterfaceはpublication時に選び、表示したい順に追加する。
|
||||
|
||||
```ts
|
||||
let object = wip.publish(file)
|
||||
@@ -505,9 +505,9 @@ if (authorization.canWrite) {
|
||||
return object;
|
||||
```
|
||||
|
||||
Protocol Objectの`interfaces`は追加順を保持するordered setである。同じInterface参照の重複は拒否する。複数のImplementation bindingを一つのDescriptorやdispatch tableへ暗黙にmergeせず、各Interface Descriptorとbindingは独立したOperation集合およびbehaviorを提供する。
|
||||
Protocol Objectの`interfaces`は追加順を保持するordered setであり、同じInterface参照の重複は拒否する。各Interface Descriptorとbindingは、それぞれ独立したOperation集合およびbehaviorを提供する。複数のImplementation bindingを一つのDescriptorやdispatch tableへ暗黙にmergeすることはしない。
|
||||
|
||||
異なるInterfaceは同じOperation名を宣言できる。Dispatchはrequestの`InterfaceTarget.reference`とOperation名の組で一意に決まり、そのInterfaceへbindしたimplementationを使用するため、同じObjectに意味の異なる同名Operationを実装できる。
|
||||
異なるInterfaceが同じOperation名を宣言しても、dispatchはrequestの`InterfaceTarget.reference`とOperation名の組で一意に決まる。そのInterfaceへbindしたimplementationを使うため、同じObjectに意味の異なる同名Operationを実装できる。
|
||||
|
||||
```ts
|
||||
const object = wip.publish(file)
|
||||
@@ -519,17 +519,17 @@ const object = wip.publish(file)
|
||||
});
|
||||
```
|
||||
|
||||
表示上のqualified表記は[Text Viewの参照表記](../4.2-text-view.md#参照表記)に従う。Signature全体をProtocol request fieldへparseし直さない。
|
||||
表示上のqualified表記は[Text Viewの参照表記](../4.2-text-view.md#参照表記)に従い、signature全体をProtocol request fieldへparseし直さない。
|
||||
|
||||
Intersection type、interface extension、mixin等をDescriptorのcompositionへ自動変換しない。Protocol上のInterface inheritanceを暗黙に導入しない。
|
||||
Intersection type、interface extension、mixin等をDescriptorのcompositionへ自動変換することはしない。これらによって、Protocol上のInterface inheritanceを暗黙に導入することもない。
|
||||
|
||||
## HostへのInterface登録
|
||||
|
||||
Runtime Interface definitionをHostへ登録し、構造化されたInterface参照を割り当てる必要がある。
|
||||
Runtime Interface definitionには、Hostへの登録を通じて構造化されたInterface参照を割り当てる必要がある。参照の絶対scopeとlocal name、および配置pathへの祖先制約は[2.2. Operation and Interface](../2.2-operation-and-interface.md#interfaceの識別とscope)に従う。
|
||||
|
||||
参照の絶対scopeとlocal name、および配置pathへの祖先制約は[2.2. Operation and Interface](../2.2-operation-and-interface.md#interfaceの識別とscope)に従う。以下のconceptual API例はscope / nameを渡す具体構文を省略しており、暗黙のroot scopeを定めるものではない。登録時のcontextと引数の具体APIは引き続き未決定である。Self-scopeは許可され、scopeはそのpathのObjectに紐づく。Scope Objectのoptionalな`ref`はProtocolの`scope_ref`として観測・検証し、`ref`がない場合のidentity追跡不能も許容する。Scopeの移動追跡や独立したscope validatorを導入しない。
|
||||
Self-scopeは許可され、scopeはそのpathのObjectに紐づく。Scope Objectのoptionalな`ref`はProtocolの`scope_ref`として観測・検証する。`ref`がなければidentityを追跡できないことも許容し、scopeの移動追跡や独立したscope validatorは導入しない。
|
||||
|
||||
Conceptualには、eager registrationとlazy registrationの両方が考えられる。
|
||||
登録方法としては、conceptualにはeager registrationとlazy registrationの両方が考えられる。次の例はscope / nameを渡す具体構文を省略しており、暗黙のroot scopeを定めてはいない。登録時のcontextと引数の具体APIも引き続き未決定である。
|
||||
|
||||
```ts
|
||||
const readInterface = host.registerInterface(ReadOnlyFileInterface);
|
||||
@@ -543,9 +543,7 @@ const object = wip.publish(file)
|
||||
});
|
||||
```
|
||||
|
||||
または、Hostが最初のpublication時に各definitionをinternしてよい。
|
||||
|
||||
どちらの場合も次を守る。
|
||||
また、Hostが最初のpublication時に各definitionをinternする方式でもよい。どちらの登録方法でも、次の条件を満たす必要がある。
|
||||
|
||||
- MutableなInterface参照では`fetch_interface`がvalidatorを返し、同じ参照とvalidatorの組から異なるDescriptorを返さない。
|
||||
- Validatorを返さないInterface参照は、その参照の有効期間中Descriptorをimmutableにする。
|
||||
@@ -556,11 +554,13 @@ const object = wip.publish(file)
|
||||
- Variable名、module path、JavaScript object address等を、そのままportableなProtocol semanticsとして扱わない。
|
||||
- 一つのfinalized artifactを後からmutationしてDescriptorを変化させない。Hostはdefinitionをfreezeするか、検証済みのimmutable snapshotを保持できなければならない。
|
||||
|
||||
Registrationまたは最初のpublicationで、Descriptor、type codec、validator、Operation dispatch tableを一つのfinalized artifactとして再帰的に検証および固定する。`fetch_interface`、arguments / result validation、Operation dispatchはすべて同じartifactを使用する。Descriptorだけをsnapshotし、dispatch時にはmutation可能な元definitionを読み直す実装にはしない。Finalization後のmutationは拒否するか、既存artifactへ影響しないものとして扱う。
|
||||
Registrationまたは最初のpublicationでは、Descriptor、type codec、validator、Operation dispatch tableを一つのfinalized artifactとして再帰的に検証し、固定する。`fetch_interface`、arguments / result validation、Operation dispatchは、すべて同じartifactを使う。
|
||||
|
||||
Descriptorだけをsnapshotし、dispatch時にmutation可能な元definitionを読み直す方式では、この固定を満たせない。Finalization後のmutationは拒否するか、既存artifactに影響しないものとして扱う。
|
||||
|
||||
## Request contextとauthorization
|
||||
|
||||
Operation implementationは、Object instance以外にrequest contextを必要とし得る。
|
||||
Operation implementationが必要とする情報は、Object instanceだけとは限らない。Request contextとして、次の情報も必要になり得る。
|
||||
|
||||
- 現在の主体。
|
||||
- `AbortSignal`等のcancellation。
|
||||
@@ -569,34 +569,34 @@ Operation implementationは、Object instance以外にrequest contextを必要
|
||||
- Host serviceやbackend connectionへのaccess。
|
||||
- Resource limit。
|
||||
|
||||
これらをmethod parameter、wrapper object、AsyncLocalStorage、closure等のどれで渡すかは未決定である。Protocol parameterとして宣言された値と、Hostが注入するcontextを明確に区別しなければならない。
|
||||
このcontextをmethod parameter、wrapper object、AsyncLocalStorage、closure等のどれで渡すかは未決定である。どの形でも、Protocol parameterとして宣言された値とHostが注入するcontextは明確に区別する必要がある。
|
||||
|
||||
Object publication時のInterface集合の選択と、Operation call時のmembership、Interface validator、authorizationの再評価を実装できる必要がある。以前の`observe`で公開されたInterface集合だけを根拠に、現在のOperation実行を許可してはならない。
|
||||
Interface集合はObject publication時に選択するだけでなく、Operation call時にもmembership、Interface validator、authorizationを再評価できなければならない。以前の`observe`で公開したInterface集合だけを根拠に、現在のOperation実行を許可することはできない。
|
||||
|
||||
Operation resultのRecord、List、Union等へnestedしたすべての`Entry`位置について、Hostは現在の主体にそのpathを認識させてよいことを再帰的に確認できなければならない。通常のstructural Value validationだけではこのvisibility checkを満たさない。Runtime definition、Projection、Host Runtimeのどこで確認するかは未決定である。
|
||||
Resultのvisibility checkも、通常のstructural Value validationとは別に必要となる。Hostは、Operation resultのRecord、List、Union等にnestedしたすべての`Entry`について、現在の主体にそのpathを認識させてよいかを再帰的に確認できなければならない。この確認をRuntime definition、Projection、Host Runtimeのどこで行うかは未決定である。
|
||||
|
||||
Operation argumentとして`Entry` pathを受け取った場合も、pathの所持をauthorizationの証明として扱わず、参照先へ作用する時点で現在の主体について認可する。
|
||||
Argumentとして受け取った`Entry` pathについても、pathの所持をauthorizationの証明にはしない。参照先へ作用する時点で、現在の主体について認可する。
|
||||
|
||||
## Error model
|
||||
|
||||
TypeScript methodのerror表現は、次の四種類を区別できなければならない。
|
||||
TypeScript methodのerror表現には、次の四種類を区別できることを求める。
|
||||
|
||||
- Protocol validationまたはdispatch failure。
|
||||
- Effectがcommitされていないと保証できるHost / backend implementation failure。
|
||||
- Operationの未実行またはrollbackを保証できない`OperationOutcomeUnknown`。
|
||||
- Interfaceがreturn typeとして宣言したdomain failure。
|
||||
|
||||
現在のProtocolでは、domain failureはOperation resultのUnion caseとして表す。Thrown value、rejected Promise、explicit result objectをdomain resultまたはHost errorへどう対応付けるかは、Interface authoring APIで明示しなければならない。
|
||||
現在のProtocolはdomain failureをOperation resultのUnion caseとして表す。Thrown value、rejected Promise、explicit result objectをdomain resultまたはHost errorへどう対応付けるかは、Interface authoring APIで明示する必要がある。
|
||||
|
||||
Arbitrary thrown value、Promise rejection、result encode failure、宣言型に違反するimplementation resultをdomain failureへ自動変換してはならない。Host Runtimeは、effectがcommitされていないと保証できる場合だけ`Internal`へmappingし、保証できない場合は`OperationOutcomeUnknown`へmappingする。Abortやtransport切断を未実行の証明として扱わず、自動retryしない。
|
||||
Arbitrary thrown value、Promise rejection、result encode failure、宣言型に違反するimplementation resultを、domain failureへ自動変換することは認めない。Host Runtimeは、effectがcommitされていないと保証できる場合だけ`Internal`へmappingし、保証できなければ`OperationOutcomeUnknown`へmappingする。Abortやtransport切断は未実行の証明とはならず、自動retryはしない。
|
||||
|
||||
## Custom transpilerを必須にしない
|
||||
|
||||
通常のTypeScript `interface`または`type`をsource of truthにしてInterface Descriptorを生成するには、TypeScript Compiler API、custom transformer、code generator等が必要になる。
|
||||
通常のTypeScript `interface`または`type`をsource of truthとしてInterface Descriptorを生成するには、TypeScript Compiler API、custom transformer、code generator等が必要になる。
|
||||
|
||||
Core APIはこれを要求しない。Runtime definition objectをsource of truthにすることで、通常の`tsc`、bundler、JavaScript runtimeで動作できるようにする。
|
||||
Core APIでは、これらを必須にしない。Runtime definition objectをsource of truthとすることで、通常の`tsc`、bundler、JavaScript runtimeで動作できるようにする。
|
||||
|
||||
Build-time toolingはoptionalなauthoring layerとして追加してよい。
|
||||
Build-time toolingは、optionalなauthoring layerとして追加してよい。
|
||||
|
||||
```ts
|
||||
/** @wip-interface */
|
||||
@@ -605,9 +605,9 @@ interface FileOperations {
|
||||
}
|
||||
```
|
||||
|
||||
Toolingがこのdeclarationからruntime definition moduleを生成することはできるが、生成物はcore APIと同じruntime definition semanticsを満たさなければならない。
|
||||
Toolingによってこのdeclarationからruntime definition moduleを生成することはできる。ただし、その生成物もcore APIと同じruntime definition semanticsを満たす必要がある。
|
||||
|
||||
Build-time toolingには少なくとも次の課題がある。
|
||||
Build-time toolingには、少なくとも次の課題がある。
|
||||
|
||||
- TypeScript compiler versionとCompiler APIへの依存。
|
||||
- Bundler、incremental build、package boundaryとの統合。
|
||||
@@ -618,15 +618,15 @@ Build-time toolingには少なくとも次の課題がある。
|
||||
|
||||
## Decoratorをcoreにしない
|
||||
|
||||
Decoratorだけでは、通常はUnion case、Record field、optional、Entry、Integer、nested type、generic、documentation等を完全なruntime schemaとして取得できない。
|
||||
Decoratorだけでは通常、Union case、Record field、optional、Entry、Integer、nested type、generic、documentation等を、完全なruntime schemaとして取得できない。
|
||||
|
||||
`emitDecoratorMetadata`等が提供するconstructor-level metadataだけでは、WIP Interface DescriptorとValue validationを再構築するには不足する。Decoratorをoptional sugarとして提供する場合も、明示的なruntime definitionまたはbuild-time generationへ接続する。
|
||||
`emitDecoratorMetadata`等が提供するconstructor-level metadataも、WIP Interface DescriptorとValue validationを再構築するには不足する。Decoratorをoptional sugarとして提供する場合には、明示的なruntime definitionまたはbuild-time generationへ接続する。
|
||||
|
||||
Class inheritanceをInterface declarationの基本形にすると、複数Interface implementation、plain object、data-drivenなpublicationが不必要に制限されるため、runtime definition objectをcore modelとする。
|
||||
また、class inheritanceをInterface declarationの基本形にすると、複数Interface implementation、plain object、data-drivenなpublicationを不必要に制限することになる。このため、runtime definition objectをcore modelとする。
|
||||
|
||||
## 要件
|
||||
|
||||
TypeScript API modelは少なくとも次を満たす。
|
||||
このdraftのTypeScript API modelに求める要件は、少なくとも次のとおりである。
|
||||
|
||||
- Runtime definition objectをInterface Descriptorとruntime validationのsource of truthにする。
|
||||
- 同じdefinitionからTypeScript implementation typeを推論できる。
|
||||
@@ -656,7 +656,7 @@ TypeScript API modelは少なくとも次を満たす。
|
||||
|
||||
## 非目標
|
||||
|
||||
このdraftは次を決定しない。
|
||||
次の事項は、このdraftでは決定しない。
|
||||
|
||||
- 最終的なpackage、module、function、generic type、class、methodの名前。
|
||||
- 特定のschema libraryまたはvalidation libraryの採用。
|
||||
@@ -698,4 +698,4 @@ TypeScript API modelは少なくとも次を満たす。
|
||||
- TypeScript client APIを同じruntime definitionから生成するか。
|
||||
- Build-time code generationをoptional packageとして提供するか。
|
||||
|
||||
これらを決定する際は、静的service objectだけでなく、Filesystem、database row、virtual aggregate、主体別Interface compositionを同じAPIで実装できるか評価する。
|
||||
これらの選択は、静的service objectに使えるだけでは足りない。Filesystem、database row、virtual aggregate、主体別Interface compositionも、同じAPIで実装できるかを評価して決定する。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 未解決事項
|
||||
|
||||
主要な未解決事項を、canonicalなCore文書へのindexとしてまとめる。
|
||||
主要な未解決事項と、対応するcanonicalなCore文書への参照をまとめる。
|
||||
|
||||
## 2.2. Operation and Interface
|
||||
|
||||
@@ -19,16 +19,18 @@
|
||||
|
||||
### Interface scopeとcomposition
|
||||
|
||||
構造化参照と絶対scopeは[2.2. Operation and Interface](../2.2-operation-and-interface.md#interfaceの識別とscope)、fieldとHost変換は[3. Protocol](../3-protocol.md#worldspace-composition)、短縮表記は[4.2. Text View](../4.2-text-view.md#参照表記)、JSON encodingは[5.1. WIP over HTTP](../5.1-wip-over-http.md#interface-reference)へ統合済み。
|
||||
Interface scopeとcompositionの基本仕様は、すでに各文書へ統合されている。構造化参照と絶対scopeは[2.2. Operation and Interface](../2.2-operation-and-interface.md#interfaceの識別とscope)、fieldとHost変換は[3. Protocol](../3-protocol.md#worldspace-composition)、短縮表記は[4.2. Text View](../4.2-text-view.md#参照表記)、JSON encodingは[5.1. WIP over HTTP](../5.1-wip-over-http.md#interface-reference)に従う。
|
||||
|
||||
Self-scopeとroot scope `/`の利用は許可する。ScopeはそのpathのObjectに紐づき、optionalな`ref`で紐付きを確認する。`ref`なしの差し替えをidentityで追跡できないことは仕様上許容する。Scopeが消えれば旧参照の有効期間は終了し、Clientは不要なcacheを破棄できる。移動先は別参照であり、Clientの移動追跡は要求しない。詳細は[Scope Objectと有効期間](../2.2-operation-and-interface.md#scope-objectと有効期間)および[Protocolの紐付き確認](../3-protocol.md#scope-objectの紐付き)に従う。
|
||||
Self-scopeとroot scope `/` の利用は許可する。ScopeはそのpathのObjectに紐づき、optionalな`ref`で紐付きを確認する。`ref`がない場合、Objectの差し替えをidentityで追跡できないことは仕様上許容する。
|
||||
|
||||
残る論点:
|
||||
Scopeが消えると旧参照の有効期間は終了し、Clientは不要なcacheを破棄できる。移動先は別参照となるため、Clientに移動追跡は要求しない。詳細は[Scope Objectと有効期間](../2.2-operation-and-interface.md#scope-objectと有効期間)および[Protocolの紐付き確認](../3-protocol.md#scope-objectの紐付き)に従う。
|
||||
|
||||
これらの仕様を前提として、次の論点が残る。
|
||||
|
||||
- **複数配置とidentity**:同じUpstream Objectを複数scopeへ投影する場合の共通祖先scopeでの共有やprojectionごとの`ref`・validator。同じ`ref`とvalidatorで異なるrepresentationを返せるようにはしない。
|
||||
- **部分合成と拒否条件**:公開subtree外のscope・typed `entry`への対応付け、対応不能時の公開・実行拒否とerror mapping。非公開情報の秘匿と既存のerror precedenceを維持し、effectがcommit済みか不明なcallを未実行保証のerrorへ置き換えない。
|
||||
|
||||
Host内部のmount API、Provider登録API、合成設定の共通data structureは引き続きHost API draftで検討する。
|
||||
Host内部のmount API、Provider登録API、合成設定の共通data structureも引き続き検討対象であり、Host API draftで扱う。
|
||||
|
||||
### Type / Value
|
||||
|
||||
|
||||
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,8 +17,10 @@ export default defineConfig({
|
||||
site,
|
||||
base,
|
||||
trailingSlash: 'always',
|
||||
// Browser tests exercise the site, not the development toolbar overlay.
|
||||
devToolbar: { enabled: process.env.SITE_BROWSER_TEST !== '1' },
|
||||
markdown: {
|
||||
processor: unified({ remarkPlugins: [[documentLinks, { base }]], smartypants: false }),
|
||||
shikiConfig: { theme: 'github-light', 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",
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "7.3.2",
|
||||
"@vortexlabs/kirari": "1.1.0",
|
||||
"animejs": "4.5.0",
|
||||
"astro": "7.3.8"
|
||||
},
|
||||
@@ -1932,6 +1933,12 @@
|
||||
"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": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
|
||||
|
||||
@@ -7,17 +7,24 @@
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build:wasm": "node scripts/build-wasm.mjs",
|
||||
"predev": "npm run build:wasm",
|
||||
"prebuild": "npm run build:wasm",
|
||||
"test:rust": "cargo test --workspace --locked",
|
||||
"dev": "astro dev",
|
||||
"check": "astro check",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"build": "astro build",
|
||||
"check:links": "node scripts/check-links.mjs",
|
||||
"verify": "npm run test && npm run check && npm run build && npm run check:links",
|
||||
"pretest:browser": "node scripts/build-wasm.mjs --test-probes",
|
||||
"test:browser": "playwright test",
|
||||
"test:dev": "playwright test --config playwright.dev.config.ts",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "7.3.2",
|
||||
"@vortexlabs/kirari": "1.1.0",
|
||||
"animejs": "4.5.0",
|
||||
"astro": "7.3.8"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}${base}/`,
|
||||
javaScriptEnabled: false,
|
||||
colorScheme: 'light',
|
||||
launchOptions: { executablePath: process.env.CHROME_PATH },
|
||||
},
|
||||
projects: [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export function normalizeBase(value = '/') {
|
||||
return value === '/' ? '/' : `/${value.split('/').filter(Boolean).join('/')}/`;
|
||||
}
|
||||
|
||||
/** Rewrite Markdown document links, not code, fragments, or external URLs. */
|
||||
/** Rewrite published Markdown links; null means a source-only draft target. */
|
||||
export function documentHref(href, sourceFile, base = '/') {
|
||||
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(href)) return href;
|
||||
const match = href.match(/^([^?#]+\.md)([?#].*)?$/i);
|
||||
@@ -20,18 +20,30 @@ export function documentHref(href, sourceFile, base = '/') {
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw new Error(`Markdown link outside contents/: ${href} in ${sourceFile}`);
|
||||
}
|
||||
if (relative.split(path.sep)[0] === 'draft') return null;
|
||||
const slug = relative.replace(/\.md$/i, '').split(path.sep).map(encodeURIComponent).join('/');
|
||||
return `${normalizeBase(base)}book/${slug}/${match[2] ?? ''}`;
|
||||
}
|
||||
|
||||
export default function documentLinks({ base = '/' } = {}) {
|
||||
return (tree, file) => {
|
||||
function walk(node) {
|
||||
if ((node.type === 'link' || node.type === 'definition') && typeof node.url === 'string') {
|
||||
node.url = documentHref(node.url, file.path, base);
|
||||
}
|
||||
for (const child of node.children ?? []) walk(child);
|
||||
const draftReferences = new Set();
|
||||
function collect(node) {
|
||||
if (node.type === 'definition' && documentHref(node.url, file.path, base) === null) draftReferences.add(node.identifier);
|
||||
for (const child of node.children ?? []) collect(child);
|
||||
}
|
||||
walk(tree);
|
||||
collect(tree);
|
||||
function rewrite(node) {
|
||||
if (node.children) node.children = node.children.flatMap(rewrite);
|
||||
if (node.type === 'linkReference' && draftReferences.has(node.identifier)) return node.children;
|
||||
if ((node.type === 'link' || node.type === 'definition') && typeof node.url === 'string') {
|
||||
const url = documentHref(node.url, file.path, base);
|
||||
// Retain authored link text/formatting, but expose no unpublished URL.
|
||||
if (url === null) return node.type === 'link' ? node.children : [];
|
||||
node.url = url;
|
||||
}
|
||||
return [node];
|
||||
}
|
||||
rewrite(tree);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
/* Self-hosted, unmodified upstream fonts; revisions and licenses: /fonts/README.md.
|
||||
Vite resolves these public asset URLs against the configured site base. */
|
||||
/* Self-hosted, unmodified upstream fonts; revisions and licenses: README.md.
|
||||
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-family: 'Lyth Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/LythMono-Regular.woff2') format('woff2');
|
||||
src: url('LythMono-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Lyth Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/LythMono-Bold.woff2') format('woff2');
|
||||
src: url('LythMono-Bold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/EBGaramond-Regular.woff2') format('woff2');
|
||||
src: url('EBGaramond-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/EBGaramond-Bold.woff2') format('woff2');
|
||||
src: url('EBGaramond-Bold.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/EBGaramond-Italic.woff2') format('woff2');
|
||||
src: url('EBGaramond-Italic.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
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]);
|
||||
@@ -18,7 +18,12 @@ async function document(filename) {
|
||||
if (!cache.has(filename)) cache.set(filename, load(await readFile(filename, 'utf8')));
|
||||
return cache.get(filename);
|
||||
}
|
||||
const htmlFiles = (await files(root)).filter((file) => file.endsWith('.html'));
|
||||
const outputFiles = await files(root);
|
||||
for (const filename of outputFiles) {
|
||||
const relative = path.relative(root, filename).split(path.sep).join('/');
|
||||
if (/^(?:book\/)?draft(?:\/|$)/.test(relative)) failures.push(`Unpublished draft in output: ${relative}`);
|
||||
}
|
||||
const htmlFiles = outputFiles.filter((file) => file.endsWith('.html'));
|
||||
for (const filename of htmlFiles) {
|
||||
const relative = path.relative(root, filename).split(path.sep).join('/');
|
||||
const pagePath = base + relative.replace(/index\.html$/, '');
|
||||
@@ -57,9 +62,9 @@ for (const filename of htmlFiles) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every source document must have its own generated page (including drafts).
|
||||
// Every published source has a page; draft sources must never be emitted.
|
||||
const contents = fileURLToPath(new URL('../../contents/', import.meta.url));
|
||||
const markdown = (await files(contents)).filter((file) => file.endsWith('.md'));
|
||||
const markdown = (await files(contents)).filter((file) => file.endsWith('.md') && path.relative(contents, file).split(path.sep)[0] !== 'draft');
|
||||
for (const file of markdown) {
|
||||
const slug = path.relative(contents, file).replace(/\.md$/, '');
|
||||
const output = path.join(root, 'book', slug, 'index.html');
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import { documents, bookHref, title, href } from '../lib/book';
|
||||
import { documentTree, childrenOf } from '../lib/landing-tree';
|
||||
interface Props { path?: string; }
|
||||
const { path = '/' } = Astro.props;
|
||||
const nodes = documentTree((await documents()).map((entry) => ({ id: entry.id, title: title(entry), href: bookHref(entry.id) })));
|
||||
const children = childrenOf(nodes, path);
|
||||
---
|
||||
<h1>{path === '/' ? 'Book' : `${path.slice(1)}/`}</h1>
|
||||
<ul class="directory-index">
|
||||
{children.map((node) => <li><a href={node.href ?? href(`book${node.path}/`)}>{node.title ?? `${node.name}/`}</a></li>)}
|
||||
</ul>
|
||||
@@ -1,19 +1,22 @@
|
||||
---
|
||||
import { documents, bookHref, title } from '../lib/book';
|
||||
import { documentTree, childrenOf, treePrefix, serializeTree } from '../lib/landing-tree';
|
||||
import { documents, bookHref, title, href } from '../lib/book';
|
||||
import { publicTree, childrenOf, treePrefix, serializeTree } from '../lib/landing-tree';
|
||||
import '../styles/landing.css';
|
||||
import TreePrefix from './TreePrefix.astro';
|
||||
import TreeGlyphs from './TreeGlyphs.astro';
|
||||
import ProtocolPanel from './ProtocolPanel.astro';
|
||||
import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs';
|
||||
const objects = documentTree((await documents()).map((entry) => {
|
||||
if (!entry.rendered) throw new Error(`Missing rendered content: ${entry.id}`);
|
||||
return { id: entry.id, title: title(entry), href: bookHref(entry.id), html: entry.rendered.html };
|
||||
}));
|
||||
const initialObject = objects.find((object) => object.href)!;
|
||||
interface Props { currentPath?: string; label?: string; draft?: boolean; }
|
||||
const entries = await documents();
|
||||
const { currentPath = '/', label, draft = false } = Astro.props;
|
||||
const folderHref = (path: string) => path === '/' ? href() : path === '/book' ? href('book/') : href(`book${path}/`);
|
||||
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) }));
|
||||
const current = objects.find((object) => object.path === currentPath);
|
||||
const initialContent = entries.find((entry) => `/${entry.id}.md` === currentPath)?.rendered?.html;
|
||||
const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1) * 24 + branchY + branchThickness / 2;
|
||||
---
|
||||
<section class="landing" aria-label="WIP" data-landing-stage>
|
||||
<TreeGlyphs />
|
||||
<section class="landing" aria-label="WIP" data-landing-stage data-current-path={currentPath} data-phase="complete">
|
||||
<div class="intro-art" aria-hidden="true">
|
||||
<svg class="orbit" viewBox="-300 -300 600 600" focusable="false">
|
||||
<defs><path id="welcome-orbit" d="M0 -180 C99 -180 180 -99 180 0 C180 99 99 180 0 180 C-99 180 -180 99 -180 0 C-180 -99 -99 -180 0 -180" /></defs>
|
||||
@@ -22,25 +25,26 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1)
|
||||
</div>
|
||||
<button class="intro-skip" type="button" aria-label="アニメーションをスキップ">Skip ↗</button>
|
||||
<div class="landing-content explorer">
|
||||
<div class="tree-pane">
|
||||
<div class="tree-pane" transition:persist="document-tree">
|
||||
<TreeGlyphs />
|
||||
<div class="tree-viewport">
|
||||
<div class="demo-tree" role="tree" aria-label="contents">
|
||||
<div class="tree-scene">
|
||||
{objects.map((object, index) => {
|
||||
const root = object.path === '/';
|
||||
const children = childrenOf(objects, object.path);
|
||||
const Tag = object.href ? 'a' : 'button';
|
||||
return <div class:list={['node-row', { 'root-slot': root }]}>
|
||||
{!root && <TreePrefix prefix={treePrefix(object, objects)} id={index} />}
|
||||
<Tag
|
||||
href={object.href} type={object.href ? undefined : 'button'}
|
||||
<a
|
||||
href={object.href ?? folderHref(object.path)}
|
||||
role="treeitem" class:list={['demo-node', { 'root-mark': root }]}
|
||||
data-path={object.path} aria-label={root ? 'ルート /' : object.name}
|
||||
aria-level={object.depth} aria-expanded={children.length ? true : undefined}
|
||||
aria-setsize={root ? 1 : childrenOf(objects, object.parent!).length}
|
||||
aria-posinset={root ? 1 : childrenOf(objects, object.parent!).findIndex((item) => item.path === object.path) + 1}
|
||||
aria-selected={object.path === initialObject.path}
|
||||
tabindex={object.href ? 0 : -1}
|
||||
aria-selected={object.path === currentPath}
|
||||
aria-current={object.path === currentPath ? 'page' : undefined}
|
||||
tabindex="0"
|
||||
>
|
||||
{root ? <svg class="root-vector" viewBox="0 0 48 64" aria-hidden="true" focusable="false">
|
||||
<g class="slash-art">
|
||||
@@ -59,19 +63,26 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1)
|
||||
<defs><mask id="trunk-growth-mask" maskUnits="userSpaceOnUse" x={-stemWidth} y="0" width={stemWidth * 2} height="10000" mask-type="alpha"><rect class="trunk-reveal" x={-stemWidth} y="0" width={stemWidth * 2} height={trunkLength} fill="white" /></mask></defs>
|
||||
<g mask="url(#trunk-growth-mask)"><path class="trunk-line" d={trunkPath} transform={`scale(1 ${trunkLength})`} /></g>
|
||||
</g>
|
||||
</svg> : <><span class="node-label">{object.name}</span>{children.length > 0 && <span class="node-indicator" aria-hidden="true">−</span>}</>}
|
||||
</Tag>
|
||||
</svg> : <span class="node-label">{object.name}</span>}
|
||||
</a>
|
||||
{!root && children.length > 0 && <button class="node-toggle node-indicator" type="button" data-toggle-path={object.path} aria-label={`${object.name}を開閉`} aria-expanded="true" tabindex="-1">−</button>}
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<article class="selected-content prose" data-selected-content data-reveal aria-label={initialObject.title} tabindex="0" set:html={initialObject.html} />
|
||||
<div class="document-pane" data-reveal>
|
||||
<ProtocolPanel />
|
||||
{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">
|
||||
<slot><Fragment set:html={initialContent} /></slot>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="landing-documents" set:html={serializeTree(objects)} />
|
||||
</section>
|
||||
<script>
|
||||
import { initLanding } from '../scripts/landing';
|
||||
initLanding();
|
||||
import { setupExplorerNavigation } from '../scripts/landing';
|
||||
setupExplorerNavigation();
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
import { href } from '../lib/book';
|
||||
---
|
||||
<div class="root-page" data-root-page>
|
||||
<header class="root-heading">
|
||||
<p class="root-name">Worldspace Interface Protocol</p>
|
||||
<h1 class="root-wordmark" aria-label="WIP">WIP<span aria-hidden="true">/</span></h1>
|
||||
<p class="root-statement">操作に、<br />共通の言葉を。</p>
|
||||
</header>
|
||||
<p class="root-description">人間も、AIも、システムも。<br />アプリケーションの発見・理解・操作を、<br class="root-break" />ひとつのインタフェースで。</p>
|
||||
<nav class="root-nav" aria-label="ルートページのナビゲーション">
|
||||
<a href={href('book/')}>
|
||||
<span class="root-number" aria-hidden="true">01</span>
|
||||
<span class="root-destination">Book<span class="root-detail">コンセプトと仕様</span></span>
|
||||
<span class="root-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
<a href={href('sources/')}>
|
||||
<span class="root-number" aria-hidden="true">02</span>
|
||||
<span class="root-destination">Sources<span class="root-detail">仕様と実装のソース</span></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>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root-page {
|
||||
container-type: inline-size;
|
||||
color: var(--ink);
|
||||
font: 14px/var(--row) var(--mono);
|
||||
}
|
||||
.root-name {
|
||||
margin: 0 0 24px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: .025em;
|
||||
}
|
||||
.root-wordmark {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
border-bottom: 8px solid currentColor;
|
||||
padding-bottom: 20px;
|
||||
font: 700 clamp(80px, 38cqw, 224px)/.85 var(--mono);
|
||||
letter-spacing: -.09em;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
.root-wordmark span {
|
||||
color: var(--green);
|
||||
font-weight: 400;
|
||||
letter-spacing: -.12em;
|
||||
padding-right: .08em;
|
||||
}
|
||||
.root-statement {
|
||||
margin: 24px 0 0;
|
||||
font: 500 clamp(28px, 6.5cqw, 44px)/1.45 var(--serif);
|
||||
letter-spacing: -.045em;
|
||||
}
|
||||
.root-description {
|
||||
margin: 24px 0 36px;
|
||||
font: 16px/1.9 var(--serif);
|
||||
}
|
||||
.root-nav {
|
||||
border-top: 2px solid var(--ink);
|
||||
}
|
||||
.root-nav a {
|
||||
display: grid;
|
||||
grid-template-columns: 3ch minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 1ch;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
padding: 16px 12px 16px 0;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
transition: background-color .18s, color .18s;
|
||||
}
|
||||
.root-nav a:hover, .root-nav a:focus-visible {
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
}
|
||||
.root-nav a:focus-visible {
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: -3px;
|
||||
border-radius: 0;
|
||||
}
|
||||
.root-number {
|
||||
align-self: start;
|
||||
padding-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.root-destination {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
column-gap: 24px;
|
||||
row-gap: 2px;
|
||||
font-size: 24px;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -.04em;
|
||||
}
|
||||
.root-detail {
|
||||
font: 12px/1.6 var(--serif);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.root-arrow {
|
||||
align-self: center;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
@container (max-width: 380px) {
|
||||
.root-name { font-size: 11px; }
|
||||
.root-description { font-size: 14px; }
|
||||
.root-destination { display: block; }
|
||||
.root-detail { display: block; margin-top: 4px; }
|
||||
.root-break { display: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root-nav a { transition: none; }
|
||||
}
|
||||
@media print {
|
||||
.root-wordmark { font-size: 80px; }
|
||||
}
|
||||
</style>
|
||||
@@ -4,8 +4,8 @@ import { glob } from 'astro/loaders';
|
||||
const book = defineCollection({
|
||||
loader: glob({
|
||||
base: '../contents',
|
||||
pattern: '**/*.md',
|
||||
// Keep source filenames stable, including chapter numbers and draft paths.
|
||||
pattern: ['**/*.md', '!draft/**'],
|
||||
// Keep published source filenames stable; drafts remain source-only.
|
||||
generateId: ({ entry }) => entry.replace(/\.md$/, ''),
|
||||
}),
|
||||
});
|
||||
|
||||
+17
-10
@@ -1,23 +1,27 @@
|
||||
---
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import '../styles/global.css';
|
||||
import { href, repository } from '../lib/book';
|
||||
interface Props { title?: string; description?: string; book?: boolean; landing?: boolean; }
|
||||
const { title = 'Web Interface Protocol', description = 'デジタル環境を、発見・観測・操作できるWorldspaceへ。WIPのコンセプトと仕様を読む。', book = false, landing = false } = Astro.props;
|
||||
interface Props { title?: string; description?: string; book?: boolean; landing?: boolean; intro?: boolean; }
|
||||
const { title = 'Worldspace Interface Protocol', description = 'デジタル環境を、発見・観測・操作できるWorldspaceへ。WIPのコンセプトと仕様を読む。', book = false, landing = true, intro = false } = Astro.props;
|
||||
const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ja">
|
||||
<html lang="ja" transition:animate="none">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
{landing && <script is:inline>
|
||||
// Progressive enhancement: a missing/blocked module must never strand the page.
|
||||
if (!matchMedia('(prefers-reduced-motion: reduce)').matches && !location.hash) {
|
||||
<ClientRouter fallback="swap" />
|
||||
<script is:inline define:vars={{ intro }}>
|
||||
// Only a fresh visit to the landing route plays the introduction.
|
||||
// The window flag survives client navigation, but not a full reload.
|
||||
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';
|
||||
setTimeout(() => {
|
||||
if (document.documentElement.dataset.landing === 'boot') delete document.documentElement.dataset.landing;
|
||||
}, 3500);
|
||||
}
|
||||
</script>}
|
||||
window.__wipVisited = true;
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} · WIP</title>
|
||||
<meta name="description" content={description} />
|
||||
@@ -25,14 +29,17 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
|
||||
<meta property="og:title" content={`${title} · WIP`} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#123c39" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fafaf8" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#171a17" />
|
||||
<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="stylesheet" href={href('fonts/fonts.css')} />
|
||||
</head>
|
||||
<body class:list={{ 'landing-page': landing }}>
|
||||
<a class="skip-link" href="#main">本文へスキップ</a>
|
||||
<header class="site-header">
|
||||
<a class="brand" href={href()} aria-label="WIP トップページ"><span class="brand-mark">W</span><span>WIP{!landing && <span class="brand-caption">Web Interface Protocol</span>}</span></a>
|
||||
<a class="brand" href={href()} aria-label="WIP トップページ"><span class="brand-mark">W</span><span>WIP{!landing && <span class="brand-caption">Worldspace Interface Protocol</span>}</span></a>
|
||||
<nav aria-label="メインナビゲーション">
|
||||
{!landing && <a href={href()} aria-current={!book ? 'page' : undefined}>Home</a>}
|
||||
<a href={href('book/')} aria-current={book ? 'true' : undefined}>Book</a>
|
||||
@@ -40,6 +47,6 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
|
||||
</nav>
|
||||
</header>
|
||||
<slot />
|
||||
{!landing && <footer class="site-footer"><span>WIP / Web Interface Protocol</span><span>文書を正本に、ひとつのWorldspaceを。</span><a href={repository}>ソースと変更履歴 ↗</a></footer>}
|
||||
{!landing && <footer class="site-footer"><span>WIP / Worldspace Interface Protocol</span><span>文書を正本に、ひとつのWorldspaceを。</span><a href={repository}>ソースと変更履歴 ↗</a></footer>}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,5 +19,5 @@ export function sourceHref(document: Document): string {
|
||||
return `${repository}/src/branch/main/contents/${document.id.split('/').map(encodeURIComponent).join('/')}.md`;
|
||||
}
|
||||
export async function documents(): Promise<Document[]> {
|
||||
return (await getCollection('book')).sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
|
||||
return (await getCollection('book', (entry) => !isDraft(entry))).sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }));
|
||||
}
|
||||
|
||||
@@ -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,8 +1,10 @@
|
||||
import { catalogPages } from './catalog.ts';
|
||||
import { playgroundPage } from './playground.ts';
|
||||
|
||||
export interface DocumentSource {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
@@ -12,7 +14,6 @@ export interface TreeNode {
|
||||
depth: number;
|
||||
title?: string;
|
||||
href?: string;
|
||||
html?: string;
|
||||
}
|
||||
|
||||
// Mirror the content directory. No authored taxonomy, summaries or demo data.
|
||||
@@ -25,7 +26,7 @@ export function documentTree(documents: readonly DocumentSource[]): TreeNode[] {
|
||||
const path = `${parent === '/' ? '' : parent}/${name}`;
|
||||
const file = index === segments.length - 1;
|
||||
if (!nodes.some((node) => node.path === path)) {
|
||||
nodes.push({ name, path, parent, depth: index + 2, ...(file ? { title: document.title, href: document.href, html: document.html } : {}) });
|
||||
nodes.push({ name, path, parent, depth: index + 2, ...(file ? { title: document.title, href: document.href } : {}) });
|
||||
}
|
||||
parent = path;
|
||||
});
|
||||
@@ -34,6 +35,43 @@ export function documentTree(documents: readonly DocumentSource[]): TreeNode[] {
|
||||
return [nodes[0], ...visit('/')];
|
||||
}
|
||||
|
||||
// Display numbered sections beneath their chapter without changing file paths,
|
||||
// links, or the physical directory tree used by directory indexes and routes.
|
||||
export function chapterTree(documents: readonly DocumentSource[]): TreeNode[] {
|
||||
const nodes = documentTree(documents);
|
||||
const numbered = nodes.filter((node) => node.href).flatMap((node) => {
|
||||
const number = node.name.match(/^(\d+(?:\.\d+)*)-/)?.[1];
|
||||
return number ? [{ node, number, directory: node.parent! }] : [];
|
||||
});
|
||||
for (const { node, number, directory } of numbered) {
|
||||
const separator = number.lastIndexOf('.');
|
||||
if (separator < 0) continue;
|
||||
const parent = numbered.find((candidate) => candidate.directory === directory && candidate.number === number.slice(0, separator));
|
||||
if (parent) node.parent = parent.node.path;
|
||||
}
|
||||
const visit = (parent: TreeNode): TreeNode[] => childrenOf(nodes, parent.path).flatMap((node) => {
|
||||
node.depth = parent.depth + 1;
|
||||
return [node, ...visit(node)];
|
||||
});
|
||||
return [nodes[0], ...visit(nodes[0])];
|
||||
}
|
||||
|
||||
// The public explorer has a book Object below the site root.
|
||||
export function bookTree(documents: readonly DocumentSource[]): TreeNode[] {
|
||||
const [root, ...chapters] = chapterTree(documents);
|
||||
return [root, { name: 'book', path: '/book', parent: '/', depth: 2 }, ...chapters.map((node) => ({
|
||||
...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 function visibleNodes(nodes: readonly TreeNode[], collapsed: ReadonlySet<string>): TreeNode[] {
|
||||
@@ -50,10 +88,10 @@ export function visibleNodes(nodes: readonly TreeNode[], collapsed: ReadonlySet<
|
||||
export function treePrefix(node: TreeNode, nodes: readonly TreeNode[], visible: readonly TreeNode[] = nodes): string {
|
||||
if (!node.parent) return '';
|
||||
const isLast = (item: TreeNode) => childrenOf(visible, item.parent!).at(-1)?.path === item.path;
|
||||
let prefix = isLast(node) ? '└── ' : '├── ';
|
||||
let prefix = isLast(node) ? '└─ ' : '├─ ';
|
||||
let parent = nodes.find((item) => item.path === node.parent);
|
||||
while (parent?.parent) {
|
||||
prefix = (isLast(parent) ? ' ' : '│ ') + prefix;
|
||||
prefix = (isLast(parent) ? ' ' : '│ ') + prefix;
|
||||
parent = nodes.find((item) => item.path === parent!.parent);
|
||||
}
|
||||
return prefix;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
---
|
||||
import Site from '../layouts/Site.astro';
|
||||
import Landing from '../components/Landing.astro';
|
||||
import { href } from '../lib/book';
|
||||
---
|
||||
<Site title="ページが見つかりません">
|
||||
<main id="main" class="not-found"><p class="eyebrow">404 / NOT FOUND</p><h1>このページは見つかりません。</h1><p>Bookの目次から、お探しの章へ移動できます。</p><a class="button primary" href={href('book/')}>Bookの目次へ →</a></main>
|
||||
<main id="main"><Landing currentPath="" label="ページが見つかりません">
|
||||
<p>404 / NOT FOUND</p><h1>このページは見つかりません。</h1><p>Bookの目次から、お探しの章へ移動できます。</p><a href={href('book/')}>Bookの目次へ →</a>
|
||||
</Landing></main>
|
||||
</Site>
|
||||
|
||||
@@ -1,36 +1,27 @@
|
||||
---
|
||||
import { render } from 'astro:content';
|
||||
import Site from '../../layouts/Site.astro';
|
||||
import ChapterNav from '../../components/ChapterNav.astro';
|
||||
import { documents, bookHref, title, isDraft, sourceHref, href, type Document } from '../../lib/book';
|
||||
import Landing from '../../components/Landing.astro';
|
||||
import DirectoryIndex from '../../components/DirectoryIndex.astro';
|
||||
import { documents, bookHref, title, isDraft, type Document } from '../../lib/book';
|
||||
import { documentTree } from '../../lib/landing-tree';
|
||||
export async function getStaticPaths() {
|
||||
return (await documents()).map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
|
||||
const entries = await documents();
|
||||
const folders = documentTree(entries.map((entry) => ({ id: entry.id, title: title(entry), href: bookHref(entry.id) })))
|
||||
.filter((node) => !node.href && node.path !== '/');
|
||||
return [
|
||||
...entries.map((entry) => ({ params: { slug: entry.id }, props: { entry, path: `/${entry.id}.md` } })),
|
||||
...folders.map((node) => ({ params: { slug: node.path.slice(1) }, props: { path: node.path } })),
|
||||
];
|
||||
}
|
||||
interface Props { entry: Document; }
|
||||
const { entry } = Astro.props;
|
||||
const entries = await documents();
|
||||
const { Content, headings } = await render(entry);
|
||||
const draft = isDraft(entry);
|
||||
const sequence = entries.filter((item) => isDraft(item) === draft);
|
||||
const position = sequence.findIndex((item) => item.id === entry.id);
|
||||
const previous = sequence[position - 1];
|
||||
const next = sequence[position + 1];
|
||||
const toc = headings.filter((heading) => heading.depth === 2 || heading.depth === 3);
|
||||
interface Props { entry?: Document; path: string; }
|
||||
const { entry, path } = Astro.props;
|
||||
const rendered = entry ? await render(entry) : undefined;
|
||||
const Content = rendered?.Content;
|
||||
const label = entry ? title(entry) : `${path.slice(1)}/`;
|
||||
---
|
||||
<Site title={title(entry)} description={`${title(entry)} — WIP ${draft ? '設計草案' : 'リファレンス'}`} book>
|
||||
<div class="book-shell">
|
||||
<aside class="sidebar"><ChapterNav documents={entries} current={entry.id} /></aside>
|
||||
<main id="main" class="book-content document">
|
||||
<div class="document-meta"><a href={href('book/')}>Book</a><span>/ {draft ? 'Drafts' : 'Reference'}</span><a class="source-link" href={sourceHref(entry)}>Markdown ↗</a></div>
|
||||
<details class="mobile-chapters"><summary>章を選ぶ</summary><ChapterNav documents={entries} current={entry.id} /></details>
|
||||
{draft && <aside class="draft-notice"><strong>Draft / 検討中の文書</strong><p>このページは草案です。確定したProtocolの契約は正本を参照してください。</p></aside>}
|
||||
{toc.length > 0 && <details class="mobile-toc"><summary>このページの目次</summary><ul>{toc.map((heading) => <li><a href={`#${heading.slug}`}>{heading.text}</a></li>)}</ul></details>}
|
||||
<article class="prose"><Content /></article>
|
||||
<nav class="pagination" aria-label="前後の章">
|
||||
{previous ? <a href={bookHref(previous.id)}><small>← 前の{draft ? '草案' : '章'}</small><span>{title(previous)}</span></a> : <span />}
|
||||
{next ? <a href={bookHref(next.id)}><small>次の{draft ? '草案' : '章'} →</small><span>{title(next)}</span></a> : <a href={href('book/')}><small>目次に戻る →</small><span>Book</span></a>}
|
||||
</nav>
|
||||
</main>
|
||||
<aside class="page-toc"><nav aria-label="このページの目次"><p class="nav-label">ON THIS PAGE</p><ul>{toc.map((heading) => <li class:list={{ nested: heading.depth === 3 }}><a href={`#${heading.slug}`}>{heading.text}</a></li>)}</ul></nav></aside>
|
||||
</div>
|
||||
<Site title={label} book>
|
||||
<main id="main"><Landing currentPath={path} label={label} draft={entry && isDraft(entry)}>
|
||||
{Content ? <Content /> : <DirectoryIndex path={path} />}
|
||||
</Landing></main>
|
||||
</Site>
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
---
|
||||
import Site from '../../layouts/Site.astro';
|
||||
import ChapterNav from '../../components/ChapterNav.astro';
|
||||
import { documents, bookHref, title, isDraft, href } from '../../lib/book';
|
||||
const entries = await documents();
|
||||
const chapters = entries.filter((entry) => !isDraft(entry));
|
||||
const drafts = entries.filter(isDraft);
|
||||
import Landing from '../../components/Landing.astro';
|
||||
import DirectoryIndex from '../../components/DirectoryIndex.astro';
|
||||
---
|
||||
<Site title="Book" book>
|
||||
<div class="book-shell book-index">
|
||||
<aside class="sidebar"><ChapterNav documents={entries} /></aside>
|
||||
<main id="main" class="book-content">
|
||||
<p class="eyebrow">THE WIP BOOK</p>
|
||||
<h1>世界の見方から、<br />通信の約束まで。</h1>
|
||||
<p class="lead">WIPのコンセプト、共通モデル、ClientとHostの契約を、ひとつの流れで読むためのリファレンスです。</p>
|
||||
<a class="button primary" href={bookHref(chapters[0].id)}>第1章から読む <span aria-hidden="true">→</span></a>
|
||||
<section class="index-section" aria-labelledby="reference">
|
||||
<h2 id="reference">仕様を読む <span class="count">{chapters.length} chapters</span></h2>
|
||||
<ol class="chapter-list">{chapters.map((entry) => <li><a href={bookHref(entry.id)}><span>{title(entry)}</span><span aria-hidden="true">↗</span></a></li>)}</ol>
|
||||
</section>
|
||||
<section class="index-section" aria-labelledby="drafts">
|
||||
<p class="eyebrow">WORK IN PROGRESS</p>
|
||||
<h2 id="drafts">草案と未解決事項</h2>
|
||||
<p>API設計や投影方針を検討する文書です。正本とは分けて掲載し、確定したProtocolの契約と混同しません。</p>
|
||||
<ul class="chapter-list">{drafts.map((entry) => <li><a href={bookHref(entry.id)}><span>{title(entry)}</span><span class="badge">Draft</span></a></li>)}</ul>
|
||||
</section>
|
||||
<p class="muted"><a href={href()}>← WIPについて</a></p>
|
||||
</main>
|
||||
</div>
|
||||
<main id="main"><Landing currentPath="/book" label="Book"><DirectoryIndex /></Landing></main>
|
||||
</Site>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
import Site from '../layouts/Site.astro';
|
||||
import Landing from '../components/Landing.astro';
|
||||
import RootPage from '../components/RootPage.astro';
|
||||
---
|
||||
<Site landing>
|
||||
<Site landing intro description="人間も、AIも、システムも。アプリケーションの発見・理解・操作を、ひとつのインタフェースで。">
|
||||
<main id="main">
|
||||
<Landing />
|
||||
<Landing currentPath="/" label="Worldspace Interface Protocol"><RootPage /></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.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;
|
||||
}
|
||||
}
|
||||
+286
-46
@@ -1,6 +1,9 @@
|
||||
import { animate, createTimeline, cubicBezier } from 'animejs';
|
||||
import { childrenOf, visibleNodes, treePrefix, type TreeNode } from '../lib/landing-tree';
|
||||
import { glitchText } from '../lib/landing-ascii.mjs';
|
||||
import { applyWipDocument, readWipPage } from './browser-wip';
|
||||
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 progress = (time: number, start: number, duration: number) => clamp((time - start) / duration);
|
||||
@@ -24,22 +27,55 @@ const circumference = 2 * Math.PI * orbitRadius;
|
||||
// Scene 4 feeds the text down this rail; the circle's geometry never changes.
|
||||
const circularTrack = 'M0 -180 A180 180 0 1 1 0 180 A180 180 0 1 1 0 -180';
|
||||
|
||||
export async function initLanding() {
|
||||
export async function initLanding(lifetime: AbortSignal) {
|
||||
const element = document.querySelector<HTMLElement>('[data-landing-stage]');
|
||||
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.
|
||||
const boot = new AbortController();
|
||||
let bootStart = false;
|
||||
let bootKeyboard = false;
|
||||
const skipBoot = () => { delete document.documentElement.dataset.landing; };
|
||||
const onBootClick = (event: MouseEvent) => {
|
||||
if (document.documentElement.dataset.landing !== 'boot') return;
|
||||
if ((event.target as Element).closest('.intro-skip, .skip-link')) { skipBoot(); return; }
|
||||
if ((event.target as Element).closest('.root-mark')) event.preventDefault();
|
||||
bootStart = true;
|
||||
};
|
||||
document.addEventListener('click', onBootClick, { signal: boot.signal, capture: true });
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (document.documentElement.dataset.landing !== 'boot' || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (['Meta', 'Control', 'Alt', 'Shift'].includes(event.key)) return;
|
||||
if (event.key === 'Escape') { skipBoot(); return; }
|
||||
if ((event.target as Element).closest('.intro-skip, .skip-link') && event.key === 'Enter') return;
|
||||
if (event.key !== 'Tab') event.preventDefault();
|
||||
bootStart = true;
|
||||
bootKeyboard = true;
|
||||
}, { signal: boot.signal });
|
||||
const abortBoot = () => boot.abort();
|
||||
lifetime.addEventListener('abort', abortBoot, { once: true });
|
||||
// Wait for fixed mono metrics before measuring the landing trajectory.
|
||||
// A rejected font fetch still leaves usable fallback text and SVG outlines.
|
||||
await Promise.race([
|
||||
document.fonts.load('14px "Lyth Mono"').catch(() => []),
|
||||
new Promise((resolve) => setTimeout(resolve, 2500)),
|
||||
]);
|
||||
const element = document.querySelector<HTMLElement>('[data-landing-stage]');
|
||||
if (!element) return;
|
||||
boot.abort();
|
||||
lifetime.removeEventListener('abort', abortBoot);
|
||||
if (lifetime.aborted || !element.isConnected) return;
|
||||
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 initialObject = objects.find((object) => object.href)!;
|
||||
const currentPath = stage.dataset.currentPath;
|
||||
const html = document.documentElement;
|
||||
const motion = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const get = <T extends Element = HTMLElement>(selector: string) => stage.querySelector<T>(selector)!;
|
||||
const root = get<HTMLButtonElement>('.root-mark');
|
||||
const root = get<HTMLAnchorElement>('.root-mark');
|
||||
const slashArt = get<SVGGElement>('.slash-art');
|
||||
const restingSlashScale = Number(getComputedStyle(slashArt).getPropertyValue('--slash-rest-scale'));
|
||||
const trunk = get<SVGPathElement>('.trunk-line');
|
||||
@@ -63,13 +99,25 @@ export async function initLanding() {
|
||||
const orbitPath = get<SVGPathElement>('#welcome-orbit');
|
||||
const textPath = get<SVGTextPathElement>('.orbit textPath');
|
||||
const skip = get<HTMLButtonElement>('.intro-skip');
|
||||
const result = get('[data-selected-content]');
|
||||
const blocked = [...document.querySelectorAll<HTMLElement>('.site-header, .site-footer, main > :not(.landing)')];
|
||||
const abort = new AbortController();
|
||||
let state: 'armed' | 'playing' | 'complete' = 'complete';
|
||||
let selected = initialObject;
|
||||
let focused = Math.max(0, objects.findIndex((object) => object.path === currentPath));
|
||||
let keyboardStarted = false;
|
||||
const collapsed = new Set<string>();
|
||||
// The persisted tree is the source of its expansion state across URL changes.
|
||||
const collapsed = new Set(nodes.filter((node) => node.getAttribute('aria-expanded') === 'false').map((node) => node.dataset.path!));
|
||||
let ancestor = objects.find((object) => object.path === currentPath)?.parent;
|
||||
while (ancestor) {
|
||||
collapsed.delete(ancestor);
|
||||
ancestor = objects.find((object) => object.path === ancestor)?.parent;
|
||||
}
|
||||
nodes.forEach((node) => {
|
||||
const current = node.dataset.path === currentPath;
|
||||
node.setAttribute('aria-selected', String(current));
|
||||
if (current) node.setAttribute('aria-current', 'page');
|
||||
else node.removeAttribute('aria-current');
|
||||
});
|
||||
tree.dataset.enhanced = 'true';
|
||||
const glitchTargets = [...stage.querySelectorAll<HTMLElement>('.node-label')].map((element, seed) => ({
|
||||
element, text: element.textContent ?? '', start: rowStart(element.closest('.node-row') as HTMLElement), duration: 580, seed,
|
||||
}));
|
||||
@@ -148,17 +196,9 @@ export async function initLanding() {
|
||||
trunk.dataset.length = String(Math.max(0, length));
|
||||
}
|
||||
|
||||
function select(index: number, focus = false) {
|
||||
selected = objects[index];
|
||||
nodes.forEach((node, i) => {
|
||||
node.setAttribute('aria-selected', String(i === index));
|
||||
node.tabIndex = i === index ? 0 : -1;
|
||||
});
|
||||
// HTML is the site's existing, build-rendered Markdown, not generated copy.
|
||||
result.innerHTML = selected.html ?? '';
|
||||
result.hidden = selected.html === undefined;
|
||||
result.setAttribute('aria-label', selected.title ?? selected.path);
|
||||
result.scrollTop = 0;
|
||||
function focusNode(index: number, focus = false) {
|
||||
focused = index;
|
||||
nodes.forEach((node, i) => { node.tabIndex = i === index ? 0 : -1; });
|
||||
if (focus) {
|
||||
nodes[index].focus({ preventScroll: true });
|
||||
const box = nodes[index].getBoundingClientRect(), bounds = viewport.getBoundingClientRect();
|
||||
@@ -177,8 +217,18 @@ export async function initLanding() {
|
||||
if (childrenOf(objects, object.path).length) {
|
||||
const expanded = !collapsed.has(object.path);
|
||||
nodes[index].setAttribute('aria-expanded', String(expanded));
|
||||
const indicator = nodes[index].querySelector('.node-indicator');
|
||||
if (indicator) indicator.textContent = expanded ? '−' : '+';
|
||||
const indicator = rows[index].querySelector('.node-indicator');
|
||||
if (indicator) {
|
||||
indicator.textContent = 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');
|
||||
if (prefixElement && paths.has(object.path)) {
|
||||
@@ -192,26 +242,32 @@ export async function initLanding() {
|
||||
}
|
||||
});
|
||||
drawTrunk(restingLength());
|
||||
if (!paths.has(selected.path)) {
|
||||
const next = shown.find((object) => object.href) ?? shown[0];
|
||||
select(objects.indexOf(next));
|
||||
if (!paths.has(objects[focused].path)) {
|
||||
let parent = objects[focused].parent;
|
||||
while (parent && !paths.has(parent)) parent = objects.find((object) => object.path === parent)?.parent;
|
||||
focusNode(Math.max(0, objects.findIndex((object) => object.path === parent)));
|
||||
}
|
||||
}
|
||||
|
||||
nodes.forEach((node, index) => node.addEventListener('click', (event) => {
|
||||
if (state !== 'complete') return;
|
||||
if (node instanceof HTMLAnchorElement && (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)) return;
|
||||
event.preventDefault();
|
||||
select(index);
|
||||
if (childrenOf(objects, objects[index].path).length) {
|
||||
const path = objects[index].path;
|
||||
// Delegate tree interaction so observed Playground children share keyboard/focus behavior.
|
||||
tree.addEventListener('click', (event) => {
|
||||
const node = (event.target as Element).closest<HTMLElement>('.demo-node');
|
||||
if (node) {
|
||||
if (state !== 'complete') { event.preventDefault(); return; }
|
||||
const index = nodes.indexOf(node);
|
||||
if (index >= 0) focusNode(index);
|
||||
}
|
||||
const toggle = (event.target as Element).closest<HTMLElement>('[data-toggle-path]');
|
||||
if (toggle && state === 'complete') {
|
||||
const path = toggle.dataset.togglePath!;
|
||||
collapsed.has(path) ? collapsed.delete(path) : collapsed.add(path);
|
||||
updateExpanded();
|
||||
focusNode(objects.findIndex((object) => object.path === path), true);
|
||||
}
|
||||
}));
|
||||
}, { signal: lifetime });
|
||||
|
||||
tree.addEventListener('keydown', (event) => {
|
||||
if (state !== 'complete') return;
|
||||
if (state !== 'complete' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||||
const index = nodes.indexOf(document.activeElement as HTMLElement);
|
||||
if (index < 0) return;
|
||||
const visible = nodes.map((_, i) => i).filter((i) => !rows[i].hidden);
|
||||
@@ -238,8 +294,8 @@ export async function initLanding() {
|
||||
default: return;
|
||||
}
|
||||
event.preventDefault();
|
||||
select(target, true);
|
||||
});
|
||||
focusNode(target, true);
|
||||
}, { signal: lifetime });
|
||||
|
||||
function finish() {
|
||||
timeline.pause();
|
||||
@@ -277,7 +333,7 @@ export async function initLanding() {
|
||||
wire.style.removeProperty('clip-path');
|
||||
});
|
||||
updateExpanded();
|
||||
select(objects.indexOf(selected), keyboardStarted || document.activeElement === skip || document.activeElement === root);
|
||||
focusNode(focused, !lifetime.aborted && (keyboardStarted || document.activeElement === skip));
|
||||
}
|
||||
|
||||
// Anime.js owns the clock and easing; this renderer resolves only the
|
||||
@@ -303,8 +359,8 @@ export async function initLanding() {
|
||||
|
||||
const longLength = Math.max(0, innerHeight * .4 - root.offsetHeight * 7.2 / 2 - 8);
|
||||
drawTrunk(mix(longLength * extension / scale, restingLength(), converge));
|
||||
const ink = [[32, 142], [34, 152], [31, 134]].map(([from, to]) => Math.round(mix(from, to, converge)));
|
||||
trunk.style.fill = 'rgb(' + ink.join(',') + ')';
|
||||
// Resolve theme tokens in CSS so even a paused intro follows OS changes.
|
||||
trunk.style.fill = `color-mix(in srgb, var(--ink), var(--tree-ink) ${converge * 100}%)`;
|
||||
trunk.style.opacity = String(cues.trunkOpacity);
|
||||
|
||||
// Keep the circle centred on the moving root until the narrow viewport
|
||||
@@ -378,6 +434,7 @@ export async function initLanding() {
|
||||
document.addEventListener('wheel', scrollTrigger, { signal, passive: false });
|
||||
document.addEventListener('touchmove', scrollTrigger, { signal, passive: false });
|
||||
document.addEventListener('scroll', () => start(), { signal, passive: true });
|
||||
if (bootStart) start(bootKeyboard);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
@@ -391,17 +448,200 @@ export async function initLanding() {
|
||||
const onVisibility = () => { if (document.hidden && state === 'playing') finish(); };
|
||||
const onMotion = () => { if (motion.matches && state !== 'complete') finish(); };
|
||||
const onPageShow = (event: PageTransitionEvent) => { if (event.persisted && state !== 'complete') finish(); };
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
motion.addEventListener('change', onMotion);
|
||||
window.addEventListener('pageshow', onPageShow);
|
||||
window.addEventListener('pagehide', (event) => {
|
||||
document.addEventListener('visibilitychange', onVisibility, { signal: lifetime });
|
||||
motion.addEventListener('change', onMotion, { signal: lifetime });
|
||||
window.addEventListener('pageshow', onPageShow, { signal: lifetime });
|
||||
const dispose = () => {
|
||||
if (state !== 'complete') finish();
|
||||
if (event.persisted) return;
|
||||
timeline.cancel();
|
||||
tapLoops.forEach((loop) => loop.cancel());
|
||||
resize.disconnect();
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
motion.removeEventListener('change', onMotion);
|
||||
window.removeEventListener('pageshow', onPageShow);
|
||||
};
|
||||
lifetime.addEventListener('abort', dispose, { once: true });
|
||||
window.addEventListener('pagehide', (event) => {
|
||||
if (state !== 'complete') finish();
|
||||
if (!event.persisted) dispose();
|
||||
}, { signal: lifetime });
|
||||
|
||||
// Enhancement never blocks the intro, static article, or native link routing.
|
||||
// Each route owns this continuation, while browser-wip owns the shared WASM.
|
||||
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() {
|
||||
if (navigationInstalled) return;
|
||||
navigationInstalled = true;
|
||||
let controller = new AbortController();
|
||||
let activeStage = document.querySelector('[data-landing-stage]');
|
||||
// Initial page-load waits for font downloads. Install boot controls at module
|
||||
// execution instead; later ClientRouter loads still initialize after each swap.
|
||||
void initLanding(controller.signal);
|
||||
let position = { top: 0, left: 0 };
|
||||
// Each history entry owns its reading position, even when a URL is revisited.
|
||||
// Keep this separate from Astro's window scroll and the persistent tree scroll.
|
||||
const readingPositions = new Map<string, { top: number; left: number }>();
|
||||
let readingEntry: string | undefined;
|
||||
let readingURL = new URL(location.href);
|
||||
let entrySequence = 0;
|
||||
const newReadingEntry = () => `${Date.now()}-${entrySequence++}`;
|
||||
const sameDocument = (url: URL) => url.origin === readingURL.origin
|
||||
&& url.pathname === readingURL.pathname && url.search === readingURL.search;
|
||||
const saveReadingPosition = () => {
|
||||
const article = document.querySelector('[data-selected-content]');
|
||||
if (article && readingEntry) readingPositions.set(readingEntry, { top: article.scrollTop, left: article.scrollLeft });
|
||||
};
|
||||
const restoreReadingPosition = () => {
|
||||
const article = document.querySelector('[data-selected-content]');
|
||||
const saved = readingEntry && readingPositions.get(readingEntry);
|
||||
if (article && saved) { article.scrollTop = saved.top; article.scrollLeft = saved.left; }
|
||||
};
|
||||
// Astro's same-document fragment shortcut calls pushState and scrolls to the
|
||||
// hash without any swap/page-load events. Capture the outgoing pane BEFORE
|
||||
// that scroll, and give every push its own key (never inherit the prior key).
|
||||
// Full-route pushes happen AFTER the swap; their outgoing pane was saved by
|
||||
// before-swap, so do not record the new DOM under the previous entry there.
|
||||
const pushState = history.pushState;
|
||||
history.pushState = function (data, unused, url) {
|
||||
const nextURL = new URL(url == null ? location.href : url, location.href);
|
||||
const fragment = !controller.signal.aborted && sameDocument(nextURL);
|
||||
if (fragment) saveReadingPosition();
|
||||
const entry = newReadingEntry();
|
||||
pushState.call(this, { ...data, wipReadingEntry: entry }, unused, url);
|
||||
if (fragment) { readingEntry = entry; readingURL = nextURL; }
|
||||
};
|
||||
window.addEventListener('popstate', (event) => {
|
||||
if (!event.state || controller.signal.aborted) return;
|
||||
// Capture before Astro's bubble listener changes window scroll. For a full
|
||||
// route traversal leave adoption/restoration to page-load after the swap.
|
||||
saveReadingPosition();
|
||||
const nextURL = new URL(location.href);
|
||||
if (!sameDocument(nextURL)) return;
|
||||
const entry = event.state.wipReadingEntry;
|
||||
if (!entry) return;
|
||||
const previousEntry = readingEntry;
|
||||
// Native history restoration follows popstate dispatch. Restore only the
|
||||
// nested pane afterwards; Astro/browser still own window scroll and routing.
|
||||
// A newer navigation or a swap invalidates this pending restoration. Keep
|
||||
// the outgoing key until then, in case Astro takes its full-swap path.
|
||||
const currentStage = activeStage;
|
||||
requestAnimationFrame(() => {
|
||||
if (controller.signal.aborted || activeStage !== currentStage
|
||||
|| readingEntry !== previousEntry || history.state?.wipReadingEntry !== entry) return;
|
||||
readingEntry = entry;
|
||||
readingURL = nextURL;
|
||||
restoreReadingPosition();
|
||||
});
|
||||
}, { capture: true });
|
||||
document.addEventListener('astro:before-swap', () => {
|
||||
saveReadingPosition();
|
||||
const viewport = document.querySelector('.tree-viewport');
|
||||
if (viewport) position = { top: viewport.scrollTop, left: viewport.scrollLeft };
|
||||
controller.abort();
|
||||
});
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const stage = document.querySelector('[data-landing-stage]');
|
||||
const initialize = stage !== activeStage || controller.signal.aborted;
|
||||
if (initialize) {
|
||||
controller.abort();
|
||||
controller = new AbortController();
|
||||
activeStage = stage;
|
||||
}
|
||||
readingEntry = history.state?.wipReadingEntry ?? newReadingEntry();
|
||||
readingURL = new URL(location.href);
|
||||
history.replaceState({ ...history.state, wipReadingEntry: readingEntry }, '');
|
||||
restoreReadingPosition();
|
||||
if (initialize) {
|
||||
const viewport = document.querySelector('.tree-viewport');
|
||||
if (viewport) { viewport.scrollTop = position.top; viewport.scrollLeft = position.left; }
|
||||
void initLanding(controller.signal);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+10
-19
@@ -1,15 +1,6 @@
|
||||
@import './fonts.css';
|
||||
@import './theme.css';
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #f7f7f2;
|
||||
--surface: #fff;
|
||||
--ink: #202f2c;
|
||||
--muted: #596762;
|
||||
--line: #dce1d8;
|
||||
--green: #123c39;
|
||||
--accent: #d8ecc9;
|
||||
--soft: #edf1e8;
|
||||
--mono: 'Lyth Mono', ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', 'Noto Sans Mono CJK JP', monospace;
|
||||
--serif: 'EB Garamond', 'Noto Serif JP', 'Hiragino Mincho ProN', 'Yu Mincho', 'YuMincho', serif;
|
||||
font-family: var(--serif);
|
||||
@@ -23,16 +14,16 @@
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
a { color: inherit; text-underline-offset: .22em; }
|
||||
a:hover { color: #226c51; }
|
||||
:focus-visible { outline: 3px solid #33846c; outline-offset: 4px; border-radius: 3px; }
|
||||
a:hover { color: var(--link-hover); }
|
||||
:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; border-radius: 3px; }
|
||||
::selection { background: var(--accent); color: var(--green); }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1, h2, h3 { line-height: 1.5; letter-spacing: -.035em; }
|
||||
h1 { font-size: clamp(2rem, 3.6vw, 3.8rem); font-weight: 800; }
|
||||
h2 { font-size: clamp(1.65rem, 2.5vw, 2.5rem); }
|
||||
code, pre { font-family: var(--mono); }
|
||||
.skip-link { position: fixed; top: -6rem; left: 1rem; z-index: 100; padding: .7rem 1rem; background: var(--green); color: white; }
|
||||
.skip-link:focus { top: 1rem; color: white; }
|
||||
.skip-link { position: fixed; top: -6rem; left: 1rem; z-index: 100; padding: .7rem 1rem; background: var(--green); color: var(--on-green); }
|
||||
.skip-link:focus { top: 1rem; color: var(--on-green); }
|
||||
.site-header { min-height: 90px; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem max(5vw, calc((100vw - 1400px)/2)); border-bottom: 1px solid var(--line); background: var(--paper); }
|
||||
.brand { display: flex; align-items: center; gap: .8rem; text-decoration: none; font-size: 1.45rem; font-weight: 800; line-height: 1.3; letter-spacing: -.04em; }
|
||||
.brand-mark { display: grid; place-items: center; background: var(--green); color: var(--accent); width: 42px; height: 42px; border-radius: 10px; font: 800 1.4rem var(--mono); }
|
||||
@@ -44,7 +35,7 @@ code, pre { font-family: var(--mono); }
|
||||
.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #73ab72; margin-right: .6rem; }
|
||||
.lead { color: var(--muted); line-height: 2; font-size: 1.05rem; }
|
||||
.button { display: inline-flex; align-items: center; gap: 2rem; padding: .9rem 1.4rem; border-radius: 5px; text-decoration: none; font-size: .88rem; font-weight: 600; }
|
||||
.primary { background: var(--green); color: white; border: 1px solid var(--green); }
|
||||
.primary { background: var(--green); color: var(--on-green); border: 1px solid var(--green); }
|
||||
.primary:hover { background: #23534a; color: white; }
|
||||
.text-link { font-size: .85rem; font-weight: 600; }
|
||||
.hero { display: grid; grid-template-columns: 1.25fr 1fr; gap: 5vw; max-width: 1280px; margin: auto; padding: 6rem 3rem 5rem; align-items: center; }
|
||||
@@ -97,7 +88,7 @@ code, pre { font-family: var(--mono); }
|
||||
.chapter-nav ul, .chapter-nav ol, .page-toc ul { list-style: none; padding: 0; margin: 0; }
|
||||
.chapter-nav a { display: block; padding: .6rem .7rem; text-decoration: none; font-size: .77rem; line-height: 1.6; border-radius: 4px; color: var(--muted); }
|
||||
.chapter-nav a:hover { background: var(--soft); color: var(--green); }
|
||||
.chapter-nav a[aria-current] { background: var(--green); color: white; }
|
||||
.chapter-nav a[aria-current] { background: var(--green); color: var(--on-green); }
|
||||
.chapter-nav .subchapter { padding-left: .7rem; }
|
||||
.draft-label { margin-top: 2.5rem; }
|
||||
.book-content { min-width: 0; padding: 3rem 0 4rem; }
|
||||
@@ -122,17 +113,17 @@ code, pre { font-family: var(--mono); }
|
||||
.prose h3 { font-size: 1.13rem; margin: 2rem 0 .8rem; }
|
||||
.prose h4 { font-size: 1rem; }
|
||||
.prose :is(h1,h2,h3,h4) { scroll-margin-top: 1.5rem; }
|
||||
.prose a { color: #246449; }
|
||||
.prose a { color: var(--link); }
|
||||
.prose p, .prose ul, .prose ol { margin-bottom: 1.2rem; }
|
||||
.prose ul, .prose ol { padding-left: 1.5rem; }
|
||||
.prose li + li { margin-top: .35rem; }
|
||||
.prose :not(pre) > code { background: #e9eee6; padding: .15em .35em; border-radius: 3px; font-size: .86em; color: #284c3a; }
|
||||
.prose :not(pre) > code { background: var(--code-bg); padding: .15em .35em; border-radius: 3px; font-size: .86em; color: var(--code-ink); }
|
||||
.prose pre { max-width: 100%; overflow-x: auto; padding: 1.25rem; border: 1px solid var(--line); border-radius: 6px; font-size: .78rem; line-height: 1.7; }
|
||||
.prose pre code { background: none; }
|
||||
.prose table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; font-size: .82rem; margin: 1.5rem 0; }
|
||||
.prose th, .prose td { border: 1px solid var(--line); padding: .65rem .8rem; text-align: left; vertical-align: top; min-width: 100px; }
|
||||
.prose th { background: var(--soft); font-weight: 600; }
|
||||
.prose blockquote { margin: 1rem 0; padding: .5rem 1.2rem; border-left: 3px solid #8ca58a; color: var(--muted); }
|
||||
.prose blockquote { margin: 1rem 0; padding: .5rem 1.2rem; border-left: 3px solid var(--quote-line); color: var(--muted); }
|
||||
.prose blockquote p:last-child { margin-bottom: 0; }
|
||||
.prose img { max-width: 100%; height: auto; }
|
||||
.page-toc { padding-top: 3rem; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+18
-10
@@ -1,6 +1,5 @@
|
||||
.landing-page {
|
||||
--paper: #fafaf8; --ink: #20221f; --muted: #686d64; --green: #283e25;
|
||||
--accent: #dcf9b0; --line: #d9ddd3; --row: 24px;
|
||||
--row: 24px;
|
||||
font: 14px/var(--row) var(--mono); background: var(--paper);
|
||||
}
|
||||
.landing-page button { font: inherit; }
|
||||
@@ -29,19 +28,28 @@ html[data-landing] .tree-viewport { overflow: visible; }
|
||||
.node-row:not(.root-slot) { transform-origin: 0 0; }
|
||||
/* The slash foot and the persistent trunk share the same mono column. */
|
||||
.node-row.root-slot { padding: 0 0 0 calc(var(--row) * .1875); height: calc(3 * var(--row)); align-items: end; }
|
||||
.branch-prefix { display: block; flex: 0 0 auto; height: var(--row); color: #8e9886; pointer-events: none; }
|
||||
.branch-prefix { display: block; flex: 0 0 auto; height: var(--row); color: var(--tree-ink); pointer-events: none; }
|
||||
.branch-vector { display: block; width: 100%; height: var(--row); fill: currentColor; overflow: visible; }
|
||||
.demo-node { position: relative; flex: 0 0 auto; border: 0; border-radius: 0; padding: 0 1ch; margin: 0; height: var(--row); display: flex; align-items: center; gap: 1ch; cursor: pointer; background: transparent; color: inherit; text-align: left; text-decoration: none; transition: background .18s; }
|
||||
.demo-node[aria-selected="true"] { background: var(--accent); color: #273621; }
|
||||
.demo-node:hover { background: #eaf0e2; }
|
||||
.demo-node[aria-selected="true"]:hover { background: #d1f39b; }
|
||||
.node-indicator { width: 1ch; color: var(--muted); }
|
||||
.demo-node { position: relative; flex: 0 0 auto; border: 0; border-radius: 0; padding: 0 1ch 0 0; margin: 0; height: var(--row); display: flex; align-items: center; gap: 1ch; cursor: pointer; background: transparent; color: inherit; text-align: left; text-decoration: none; transition: background .18s; }
|
||||
.demo-node[aria-selected="true"] { background: var(--accent); color: var(--node-selected-ink); }
|
||||
.demo-node:hover { background: var(--node-hover); }
|
||||
.demo-node[aria-selected="true"]:hover { background: var(--node-selected-hover); }
|
||||
.node-indicator { color: var(--muted); }
|
||||
.node-toggle { display: none; border: 0; padding: 0 1ch; height: var(--row); background: transparent; cursor: pointer; }
|
||||
.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); }
|
||||
html[data-landing] .node-toggle { visibility: hidden; }
|
||||
.document-pane { min-width: 0; }
|
||||
.root-mark { width: 3ch; height: var(--row); display: block; padding: 0; background: transparent !important; transform-origin: center; z-index: 25; transition: none; }
|
||||
.root-vector { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||
.slash-art { --slash-rest-scale: 0.5833333333333333; transform-origin: 12px 64px; transform: scale(var(--slash-scale, var(--slash-rest-scale))); }
|
||||
html[data-landing="boot"] .slash-art { --slash-scale: 1; }
|
||||
.root-shape { fill: currentColor; }
|
||||
.root-mark[aria-selected="true"] { color: #4e752d; }
|
||||
.root-mark[aria-selected="true"] { color: var(--root-selected); }
|
||||
.selected-content { min-width: 0; max-height: calc(100svh - 8 * var(--row)); overflow: auto; margin-top: calc(2 * var(--row)); padding-inline: 2ch; font: 18px/1.8 var(--serif); }
|
||||
.selected-content[hidden] { display: none; }
|
||||
.selected-content h1 { font-size: 24px; font-weight: 500; margin-bottom: var(--row); }
|
||||
@@ -58,7 +66,7 @@ html[data-landing="boot"] .root-mark { position: fixed; left: calc(50vw - 1.5ch)
|
||||
html[data-landing] .intro-art { display: block; position: fixed; z-index: 10; width: 100%; height: 100svh; inset: 0; pointer-events: none; }
|
||||
.orbit { position: absolute; left: 50%; top: 50%; width: min(640px, 112vw); height: min(640px, 112vw); overflow: visible; transform: translate(-50%, -50%); opacity: 0; }
|
||||
.trunk-anchor { pointer-events: none; }
|
||||
.trunk-line { fill: #8e9886; stroke: none; }
|
||||
.trunk-line { fill: var(--tree-ink); stroke: none; }
|
||||
html[data-landing="boot"] .trunk-line { opacity: 0; }
|
||||
.orbit text { fill: var(--ink); font: 500 17px var(--mono); letter-spacing: 1.5px; }
|
||||
.tap-mask { pointer-events: none; }
|
||||
|
||||
@@ -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,65 @@
|
||||
/* Theme colors are CSS-only: first paint, no-JS, and live OS changes agree. */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #f7f7f2;
|
||||
--surface: #fff;
|
||||
--ink: #202f2c;
|
||||
--muted: #596762;
|
||||
--line: #dce1d8;
|
||||
--green: #123c39;
|
||||
--accent: #d8ecc9;
|
||||
--soft: #edf1e8;
|
||||
--link: #246449;
|
||||
--link-hover: #226c51;
|
||||
--focus: #33846c;
|
||||
--on-green: #fff;
|
||||
--code-bg: #e9eee6;
|
||||
--code-ink: #284c3a;
|
||||
--quote-line: #8ca58a;
|
||||
--tree-ink: #8e9886;
|
||||
--node-selected-ink: #273621;
|
||||
--node-hover: #eaf0e2;
|
||||
--node-selected-hover: #d1f39b;
|
||||
--root-selected: #4e752d;
|
||||
}
|
||||
.landing-page {
|
||||
--paper: #fafaf8;
|
||||
--ink: #20221f;
|
||||
--muted: #686d64;
|
||||
--green: #283e25;
|
||||
--accent: #dcf9b0;
|
||||
--line: #d9ddd3;
|
||||
}
|
||||
/* Print deliberately keeps the light palette and Shiki's light defaults. */
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
:root { color-scheme: dark; }
|
||||
:root, .landing-page {
|
||||
--paper: #171a17;
|
||||
--surface: #20251e;
|
||||
--ink: #e1e4dc;
|
||||
--muted: #a7b09e;
|
||||
--line: #454e40;
|
||||
--green: #b4c995;
|
||||
--accent: #374a2f;
|
||||
--soft: #22291f;
|
||||
--link: #b4d69c;
|
||||
--link-hover: #cce4af;
|
||||
--focus: #b7d68f;
|
||||
--on-green: #171a17;
|
||||
--code-bg: #283023;
|
||||
--code-ink: #cedfbb;
|
||||
--quote-line: #899580;
|
||||
--tree-ink: #899580;
|
||||
--node-selected-ink: #e4edda;
|
||||
--node-hover: #283023;
|
||||
--node-selected-hover: #425b36;
|
||||
--root-selected: #b7d68f;
|
||||
}
|
||||
.astro-code, .astro-code span {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// Existing Astro book collection filenames, not a second authored guide dataset.
|
||||
export const documentPaths = [
|
||||
'/1-concept.md',
|
||||
'/1.1-interaction-abstraction.md',
|
||||
'/1.2-existing-abstractions.md',
|
||||
'/1.3-design-principles.md',
|
||||
'/1.4-concepts-and-scope.md',
|
||||
'/2-core-model.md',
|
||||
'/2.1-object-tree-schema.md',
|
||||
'/2.2-operation-and-interface.md',
|
||||
@@ -10,13 +14,30 @@ export const documentPaths = [
|
||||
'/4.2-text-view.md',
|
||||
'/5-transports.md',
|
||||
'/5.1-wip-over-http.md',
|
||||
];
|
||||
// Formerly published draft URLs remain regression inputs, never public documents.
|
||||
export const draftPaths = [
|
||||
'/draft/dynamic-worldspace-projection.md',
|
||||
'/draft/filesystem-discovery.md',
|
||||
'/draft/rust-api-model.md',
|
||||
'/draft/typescript-api-model.md',
|
||||
'/draft/unresolved-issues.md',
|
||||
];
|
||||
export const treePaths = ['/', ...documentPaths.filter((path) => !path.startsWith('/draft/')), '/draft', ...documentPaths.filter((path) => path.startsWith('/draft/'))];
|
||||
export const catalogPaths = ['/sources', '/packages'];
|
||||
export const treePaths = ['/', '/book', ...documentPaths, ...catalogPaths, '/playground'];
|
||||
export const treeNodeCount = treePaths.length;
|
||||
export const rootChildCount = treePaths.filter((path) => path !== '/' && path.lastIndexOf('/') === 0).length;
|
||||
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'],
|
||||
'/2-core-model.md': ['/2.1-object-tree-schema.md', '/2.2-operation-and-interface.md'],
|
||||
'/4-client.md': ['/4.1-ai-tools.md', '/4.2-text-view.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' || catalogPaths.includes(path) || path === '/playground' ? '/' : '/book']));
|
||||
for (const [chapter, sections] of Object.entries(chapterSections)) {
|
||||
for (const section of sections) treeParents[section] = chapter;
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { rootChildCount } from './documents';
|
||||
import { rootChildCount, treeParents } from './documents';
|
||||
|
||||
test.use({ javaScriptEnabled: true });
|
||||
|
||||
test('self-hosted Lyth Mono and EB Garamond load with the configured base', async ({ page }, testInfo) => {
|
||||
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) => {
|
||||
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 () => {
|
||||
await document.fonts.ready;
|
||||
const mono = await document.fonts.load('14px "Lyth Mono"', '0W│├└─');
|
||||
const bold = await document.fonts.load('700 14px "Lyth Mono"', 'WIP');
|
||||
const serif = await document.fonts.load('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 {
|
||||
mono: mono.map((font) => font.status), bold: bold.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,
|
||||
prefix: document.querySelector('.branch-prefix')!.textContent!.trim(),
|
||||
};
|
||||
@@ -26,35 +35,68 @@ test('self-hosted Lyth Mono and EB Garamond load with the configured base', asyn
|
||||
expect(fonts.bold).toEqual(['loaded']);
|
||||
expect(fonts.serif).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.prefix).toBe(''); // SSR uses the same glyph paths, not HTML wires.
|
||||
expect(external).toEqual([]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete');
|
||||
await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator('[data-selected-content]').getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
expect(await page.locator('[data-selected-content]').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
|
||||
await page.locator('[data-selected-content]').screenshot({ path: testInfo.outputPath('eb-garamond-selected-content.png') });
|
||||
await page.goto('./book/1-concept/');
|
||||
expect(await page.locator('.prose').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
|
||||
const tree = await page.locator('.tree-pane').elementHandle();
|
||||
await page.locator('.demo-node[data-path="/1-concept.md"]').click();
|
||||
await expect(page).toHaveURL(/\/book\/1-concept\/$/);
|
||||
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete');
|
||||
expect(await tree!.evaluate((element) => element === document.querySelector('.tree-pane'))).toBe(true);
|
||||
await expect(page.locator('.tree-pane .root-vector .trunk-line')).toHaveCount(1);
|
||||
await expect(page.locator('.tree-pane .trunk-reveal')).toHaveCount(1);
|
||||
// Measure the actual root trunk, not branch-spine glyphs (nested prefixes have their own spines).
|
||||
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');
|
||||
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.use({ javaScriptEnabled: false, deviceScaleFactor: 4 });
|
||||
test('glyph joins paint continuously across SVG cell boundaries', async ({ page }, testInfo) => {
|
||||
test('single horizontal branches paint continuously from their stems', async ({ page }, testInfo) => {
|
||||
await page.goto('./');
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
// Sample every root-child join without clipping the final branch in the scrollable tree.
|
||||
// Sample root and book-child joins without clipping the final branch in the scrollable tree.
|
||||
await page.setViewportSize({ width: page.viewportSize()!.width, height: 2000 });
|
||||
await page.locator('.tree-viewport').evaluate((el) => { (el as HTMLElement).style.height = 'auto'; });
|
||||
const joins = await page.locator('.demo-node[aria-level="2"]').evaluateAll((nodes) => nodes.map((node) => {
|
||||
const joins = await page.locator('.demo-node[aria-level="2"], .demo-node[aria-level="3"]').evaluateAll((nodes) => nodes.map((node) => {
|
||||
const svg = node.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
||||
const p = new DOMPoint(Number(svg.dataset.cellWidth) / 2, 12).matrixTransform(svg.getScreenCTM()!);
|
||||
return { x: p.x, y: p.y };
|
||||
const level = Number(node.getAttribute('aria-level'));
|
||||
const p = new DOMPoint((3 * (level - 2) + .5) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!);
|
||||
const end = new DOMPoint((3 * (level - 2) + 2) * 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 };
|
||||
}));
|
||||
expect(joins).toHaveLength(rootChildCount);
|
||||
expect(rootChildCount).toBe(4);
|
||||
expect(joins.filter(({ level }) => level === 2)).toHaveLength(rootChildCount);
|
||||
expect(joins.filter(({ level }) => level === 3)).toHaveLength(Object.values(treeParents).filter((parent) => parent === '/book').length);
|
||||
for (const [index, join] of joins.entries()) {
|
||||
const clip = { x: Math.floor(join.x), y: Math.floor(join.y) - 1, width: 14, height: 3 };
|
||||
const clip = { x: Math.floor(join.x), y: Math.floor(join.y) - 1, width: Math.ceil(join.gapEnd) - Math.floor(join.x), height: 3 };
|
||||
const png = (await page.screenshot({ clip, path: testInfo.outputPath(`glyph-join-${index}.png`) })).toString('base64');
|
||||
const columns = await page.evaluate(async (png) => {
|
||||
const image = new Image(); image.src = 'data:image/png;base64,' + png;
|
||||
@@ -72,9 +114,34 @@ test.describe('static font and tree rendering', () => {
|
||||
return darkest;
|
||||
});
|
||||
}, png);
|
||||
// Ignore only the outer antialiasing fringe at the trunk's left edge.
|
||||
// The hook and one horizontal glyph are followed by a blank cell.
|
||||
const start = Math.ceil((join.x - clip.x + .25) * 4);
|
||||
expect(columns.slice(start).every((ink) => ink < 710), JSON.stringify(columns)).toBe(true);
|
||||
const end = Math.floor((join.end - clip.x - .25) * 4);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
expect(columns.slice(start, end).every((ink) => ink < 710), JSON.stringify(columns)).toBe(true);
|
||||
const gapStart = Math.ceil((join.end - clip.x + 1) * 4);
|
||||
const gapEnd = Math.floor((join.gapEnd - clip.x - .25) * 4);
|
||||
expect(gapEnd).toBeGreaterThan(gapStart);
|
||||
expect(columns.slice(gapStart, gapEnd).every((ink) => ink > 710), JSON.stringify(columns)).toBe(true);
|
||||
}
|
||||
});
|
||||
test('node names start exactly one space after the branch', async ({ page }) => {
|
||||
await page.goto('./');
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const spacing = await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.map((node) => {
|
||||
const svg = node.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
||||
const width = svg.viewBox.baseVal.width;
|
||||
const cell = Number(svg.dataset.cellWidth);
|
||||
const matrix = svg.getScreenCTM()!;
|
||||
const branchEnd = new DOMPoint(width - cell, 12).matrixTransform(matrix).x;
|
||||
const prefixEnd = new DOMPoint(width, 12).matrixTransform(matrix).x;
|
||||
const label = node.querySelector('.node-label')!.getBoundingClientRect();
|
||||
return { padding: getComputedStyle(node).paddingLeft, gap: label.left - branchEnd, space: prefixEnd - branchEnd };
|
||||
}));
|
||||
expect(spacing.length).toBeGreaterThan(0);
|
||||
for (const { padding, gap, space } of spacing) {
|
||||
expect(padding).toBe('0px');
|
||||
expect(Math.abs(gap - space)).toBeLessThan(.1);
|
||||
}
|
||||
});
|
||||
test('the same SVG trunk and Lyth Mono joins are visible without JavaScript', async ({ page }, testInfo) => {
|
||||
@@ -82,12 +149,12 @@ test.describe('static font and tree rendering', () => {
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
await expect(page.locator('.trunk-line')).toBeVisible();
|
||||
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('use')).toHaveCount(4);
|
||||
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-9500');
|
||||
await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5);
|
||||
await expect(page.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);
|
||||
await page.screenshot({ path: testInfo.outputPath('lyth-mono-static.png') });
|
||||
});
|
||||
|
||||
@@ -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 { documentPaths, treePaths, treeNodeCount, bookURL } from './documents';
|
||||
import { documentPaths, treePaths, treeNodeCount, bookURL, routeURL, chapterSections, treeParents } from './documents';
|
||||
|
||||
test.use({ javaScriptEnabled: true });
|
||||
|
||||
@@ -11,7 +11,7 @@ const visiblePrefixes = '.node-row:not([hidden]) .branch-prefix';
|
||||
|
||||
async function expectNodeGlitch(page: import('@playwright/test').Page) {
|
||||
expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.some((node) =>
|
||||
node.querySelector('.node-label')!.textContent !== (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);
|
||||
}
|
||||
|
||||
@@ -27,16 +27,16 @@ async function exposeTree(page: import('@playwright/test').Page) {
|
||||
}
|
||||
|
||||
async function checkGlyphRefs(page: import('@playwright/test').Page) {
|
||||
const rendered = await page.locator(visiblePrefixes).evaluateAll((prefixes) => {
|
||||
const rendered = await page.locator(visiblePrefixes).evaluateAll((prefixes, parents) => {
|
||||
const paths = [...document.querySelectorAll<HTMLElement>('.node-row:not([hidden]) .demo-node')].map((node) => node.dataset.path!);
|
||||
const parent = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
|
||||
const parent = (path: string) => parents[path];
|
||||
const last = (path: string) => paths.filter((other) => other !== '/' && parent(other) === parent(path)).at(-1) === path;
|
||||
return prefixes.map((prefix) => {
|
||||
const path = prefix.parentElement!.querySelector<HTMLElement>('.demo-node')!.dataset.path!;
|
||||
let expected = last(path) ? '└── ' : '├── ';
|
||||
let expected = last(path) ? '└─ ' : '├─ ';
|
||||
let ancestor = parent(path);
|
||||
while (ancestor !== '/') {
|
||||
expected = (last(ancestor) ? ' ' : '│ ') + expected;
|
||||
expected = (last(ancestor) ? ' ' : '│ ') + expected;
|
||||
ancestor = parent(ancestor);
|
||||
}
|
||||
const refs = [...prefix.querySelectorAll<SVGUseElement>('use[data-cell]')].map((use) => ({
|
||||
@@ -45,7 +45,7 @@ async function checkGlyphRefs(page: import('@playwright/test').Page) {
|
||||
}));
|
||||
return { actual: prefix.getAttribute('data-prefix'), expected, refs };
|
||||
});
|
||||
});
|
||||
}, treeParents);
|
||||
expect(rendered.length).toBeGreaterThan(0);
|
||||
for (const { actual, expected, refs } of rendered) {
|
||||
expect(actual).toBe(expected);
|
||||
@@ -108,7 +108,7 @@ async function complete(page: import('@playwright/test').Page) {
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
expect((await page.locator('.root-shape').boundingBox())!.height).toBeCloseTo(12.25, 4);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test('only the resting slash shrinks; intro ink and tree dimensions stay unchanged', async ({ page }, testInfo) => {
|
||||
@@ -345,7 +345,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
||||
const root = page.locator('.root-mark');
|
||||
await expect(root.locator('svg .root-shape')).toHaveCount(1);
|
||||
await expect(root.locator('.root-shape')).toHaveAttribute('d', 'M31 4H41L17 60H7Z'); // The slash is geometry, not a font glyph.
|
||||
await expect(page.locator(selectedContent)).toHaveAttribute('inert', '');
|
||||
await expect(page.locator('.document-pane[data-reveal]')).toHaveAttribute('inert', '');
|
||||
const initial = await root.boundingBox();
|
||||
expect(initial).not.toBeNull();
|
||||
expect(Math.abs(initial!.x + initial!.width / 2 - page.viewportSize()!.width / 2)).toBeLessThan(2);
|
||||
@@ -429,7 +429,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
||||
spineOpacity: Number(getComputedStyle(first.querySelector('.branch-spine')!).opacity),
|
||||
trunkOpacity: Number(getComputedStyle(document.querySelector('.trunk-line')!).opacity),
|
||||
labelOpacity: Number(getComputedStyle(document.querySelector('.demo-node:not(.root-mark)')!).opacity),
|
||||
articleOpacity: Number(getComputedStyle(document.querySelector('[data-selected-content]')!).opacity),
|
||||
articleOpacity: Number(getComputedStyle(document.querySelector('.document-pane[data-reveal]')!).opacity),
|
||||
sceneTransform: getComputedStyle(document.querySelector('.tree-scene')!).transform,
|
||||
rowTransforms: [...document.querySelectorAll('.node-row, .demo-node')].map((row) => getComputedStyle(row).transform),
|
||||
};
|
||||
@@ -458,9 +458,9 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
||||
await page.clock.runFor(800);
|
||||
await complete(page);
|
||||
await expect(root).toHaveCount(1);
|
||||
await expect(page.locator(selectedContent)).not.toHaveAttribute('inert');
|
||||
await expect(page.locator('.document-pane[data-reveal]')).not.toHaveAttribute('inert');
|
||||
await expect(page.locator('[data-glitching]')).toHaveCount(0);
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.branch-lines')).toHaveCount(0);
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||||
@@ -472,7 +472,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
||||
const grid = await page.evaluate(() => {
|
||||
const root = document.querySelector('.root-mark')!.getBoundingClientRect();
|
||||
const prefix = document.querySelector<HTMLElement>('.branch-prefix')!;
|
||||
const cell = prefix.getBoundingClientRect().width / 4;
|
||||
const cell = prefix.getBoundingClientRect().width / prefix.dataset.prefix!.length;
|
||||
const frame = document.querySelector('.landing-content')!.getBoundingClientRect();
|
||||
const rows = [...document.querySelectorAll<HTMLElement>('.node-row:not(.root-slot):not([hidden])')].map((row) => row.getBoundingClientRect());
|
||||
const nodes = [...document.querySelectorAll<HTMLElement>('.node-row:not([hidden]) .demo-node:not(.root-mark)')].map((node) => node.getBoundingClientRect());
|
||||
@@ -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');
|
||||
await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none');
|
||||
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 page.clock.runFor(4000);
|
||||
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 complete(page);
|
||||
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.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);
|
||||
expect(await page.locator(selectedContent).innerHTML()).toBe(html);
|
||||
await page.locator('.root-mark').hover();
|
||||
@@ -557,29 +557,39 @@ test('landing contains only the real filename tree and selected canonical conten
|
||||
await expect(page.getByRole('button', { name: '本文を読む' })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: 'Home', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('.site-header')).toContainText('WIP');
|
||||
await expect(page.getByRole('link', { name: 'Source', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.site-header').getByRole('link', { name: 'Source', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||||
expect(await page.locator('.demo-node').evaluateAll((nodes) => nodes.map((node) => (node as HTMLElement).dataset.path))).toEqual(treePaths);
|
||||
for (const path of documentPaths) {
|
||||
const node = nodeAt(page, path);
|
||||
await expect(node).toHaveAttribute('role', 'treeitem');
|
||||
await expect(node).toHaveAttribute('aria-level', path.startsWith('/draft/') ? '3' : '2');
|
||||
await expect(node).toHaveAttribute('aria-level', treeParents[path] === '/book' ? '3' : '4');
|
||||
await expect(node.locator('.node-label')).toHaveText(path.split('/').at(-1)!);
|
||||
expect(await node.evaluate((node) => node.tagName)).toBe('A');
|
||||
expect(await node.evaluate((node) => (node as HTMLAnchorElement).href)).toBe(bookURL(path, testInfo.project.use.baseURL!));
|
||||
await expect(node).not.toHaveAttribute('aria-expanded');
|
||||
if (chapterSections[path]) await expect(node).toHaveAttribute('aria-expanded', 'true');
|
||||
else await expect(node).not.toHaveAttribute('aria-expanded');
|
||||
}
|
||||
for (const path of ['/', '/draft']) {
|
||||
for (const path of ['/', '/book']) {
|
||||
const folder = nodeAt(page, path);
|
||||
expect(await folder.evaluate((node) => node.tagName)).toBe('BUTTON');
|
||||
expect(await folder.evaluate((node) => node.tagName)).toBe('A');
|
||||
await expect(folder).toHaveAttribute('href', new URL(path === '/' ? './' : 'book/', testInfo.project.use.baseURL!).pathname);
|
||||
await expect(folder).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(folder).toHaveAttribute('aria-level', path === '/' ? '1' : '2');
|
||||
if (path === '/book') {
|
||||
await expect(folder.locator('.node-label')).toHaveText('book');
|
||||
const toggle = page.locator('.node-toggle[data-toggle-path="/book"]');
|
||||
await expect(toggle).toHaveText('−');
|
||||
expect(await toggle.evaluate((button) => button.tagName)).toBe('BUTTON');
|
||||
expect(await toggle.evaluate((button) => button.parentElement === document.querySelector('.demo-node[data-path="/book"]')!.parentElement)).toBe(true);
|
||||
await expect(folder.locator('button')).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
await expect(page.locator('.demo-node[data-path^="/draft"], .landing a[href*="/draft/"]')).toHaveCount(0);
|
||||
expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.every((node) =>
|
||||
node.textContent === node.querySelector('.node-label')!.textContent + (node.querySelector('.node-indicator')?.textContent ?? '')
|
||||
&& [...node.children].every((child) => child.matches('.node-label, .node-indicator'))
|
||||
&& [...node.querySelectorAll('.node-indicator')].every((indicator) => /^[+−-]$/.test(indicator.textContent!))
|
||||
node.textContent === node.querySelector('.node-label')!.textContent
|
||||
&& [...node.children].every((child) => child.matches('.node-label'))
|
||||
))).toBe(true);
|
||||
const payload = page.locator('script#landing-documents');
|
||||
await expect(payload).toHaveCount(1);
|
||||
@@ -592,94 +602,153 @@ test('landing contains only the real filename tree and selected canonical conten
|
||||
const document = stored.find((document) => document.path === path)!;
|
||||
expect(document.href).toBe(new URL(bookURL(path, testInfo.project.use.baseURL!)).pathname);
|
||||
expect(document.title).toBeTruthy();
|
||||
expect(document.html).toContain('<h1');
|
||||
expect(document).not.toHaveProperty('html');
|
||||
}
|
||||
for (const path of ['/', '/draft']) {
|
||||
for (const path of ['/', '/book']) {
|
||||
const folder = stored.find((document) => document.path === path)!;
|
||||
expect(folder.html).toBeUndefined();
|
||||
expect(folder.title).toBeUndefined();
|
||||
expect(folder.href).toBeUndefined();
|
||||
expect(folder).not.toHaveProperty('html');
|
||||
expect(folder.href).toBe(new URL(path === '/' ? './' : 'book/', testInfo.project.use.baseURL!).pathname);
|
||||
}
|
||||
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'false');
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('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(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)).not.toContainText('1. コンセプト');
|
||||
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);
|
||||
});
|
||||
|
||||
test('each file immediately displays exactly its Book HTML, including links, without fetching or navigating', async ({ page }, testInfo) => {
|
||||
test('numbered chapters nest visually, fold independently, and retain section URLs', async ({ page }, testInfo) => {
|
||||
const section = '/1.1-interaction-abstraction.md';
|
||||
await page.goto(bookURL(section, testInfo.project.use.baseURL!));
|
||||
await complete(page);
|
||||
const chapter = nodeAt(page, initialPath);
|
||||
const child = nodeAt(page, section);
|
||||
await expect(chapter).toHaveAttribute('aria-level', '3');
|
||||
await expect(child).toHaveAttribute('aria-level', '4');
|
||||
const prefix = child.locator('..').locator('.branch-prefix');
|
||||
await expect(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();
|
||||
await nodeAt(page, '/1-concept.md').locator('..').hover();
|
||||
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
|
||||
for (const path of chapterSections[initialPath]) await expect(nodeAt(page, path)).toBeHidden();
|
||||
await expect(nodeAt(page, '/2-core-model.md')).toBeVisible();
|
||||
await expect(page).toHaveURL(bookURL(section, testInfo.project.use.baseURL!));
|
||||
expect(await page.locator(selectedContent).innerHTML()).toBe(before);
|
||||
await expect(child).toHaveAttribute('aria-current', 'page');
|
||||
await checkGlyphRefs(page);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(child).toBeVisible();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(child).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(chapter).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(child).toBeHidden();
|
||||
// Other navigation preserves the collapsed chapter; returning to a child reveals it.
|
||||
await nodeAt(page, '/2-core-model.md').click();
|
||||
await expect(nodeAt(page, '/2-core-model.md')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(chapter).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.goBack();
|
||||
await expect(child).toHaveAttribute('aria-current', 'page');
|
||||
await expect(chapter).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(child).toBeVisible();
|
||||
await expect(child).toHaveAttribute('href', new URL(bookURL(section, testInfo.project.use.baseURL!)).pathname);
|
||||
await checkGlyphRefs(page);
|
||||
});
|
||||
|
||||
test('each file navigates to exactly its Book HTML and title, including links', async ({ page }, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('./');
|
||||
await complete(page);
|
||||
const book = await page.context().newPage();
|
||||
const bodies = new Map<string, string>();
|
||||
const documents = new Map<string, { html: string; title: string }>();
|
||||
for (const path of documentPaths) {
|
||||
await book.goto(bookURL(path, testInfo.project.use.baseURL!));
|
||||
bodies.set(path, await book.locator('article.prose').innerHTML());
|
||||
documents.set(path, { html: await book.locator('article.prose').innerHTML(), title: await book.title() });
|
||||
}
|
||||
await book.close();
|
||||
const landingURL = page.url();
|
||||
expect(await page.locator(selectedContent).innerHTML()).toBe(bodies.get(initialPath));
|
||||
const requests: string[] = [];
|
||||
page.on('request', (request) => requests.push(request.url()));
|
||||
expect(await page.locator(selectedContent).innerHTML()).not.toBe(documents.get(initialPath)!.html);
|
||||
await nodeAt(page, initialPath).click();
|
||||
await expect(page).toHaveURL(bookURL(initialPath, testInfo.project.use.baseURL!));
|
||||
await expect.poll(() => page.locator(selectedContent).innerHTML()).toBe(documents.get(initialPath)!.html);
|
||||
for (const path of documentPaths) {
|
||||
// Observe the body in the same task as the click, not after waiting for a read/reveal.
|
||||
const displayed = await nodeAt(page, path).evaluate((node) => {
|
||||
(node as HTMLElement).click();
|
||||
const article = document.querySelector<HTMLElement>('[data-selected-content]')!;
|
||||
return { html: article.innerHTML, hidden: article.hidden };
|
||||
});
|
||||
expect(displayed).toEqual({ html: bodies.get(path), hidden: false });
|
||||
await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true');
|
||||
expect(page.url()).toBe(landingURL);
|
||||
}
|
||||
expect(requests).toEqual([]);
|
||||
for (const path of ['/draft', '/']) {
|
||||
await nodeAt(page, path).click();
|
||||
await expect(page.locator(selectedContent)).toBeHidden();
|
||||
expect(await page.locator(selectedContent).innerHTML()).toBe('');
|
||||
await expect(page).toHaveURL(bookURL(path, testInfo.project.use.baseURL!));
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
|
||||
await expect(page).toHaveTitle(documents.get(path)!.title);
|
||||
await expect.poll(() => page.locator(selectedContent).innerHTML()).toBe(documents.get(path)!.html);
|
||||
await expect(page.locator('article.prose')).toHaveCount(1);
|
||||
await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('.demo-node[aria-current="page"]')).toHaveCount(1);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
}
|
||||
});
|
||||
|
||||
test('file selection and folder collapse work with keyboard navigation in the scrollable viewport', async ({ page }) => {
|
||||
test('keyboard arrows only move focus or fold; Enter and Space activate route links', async ({ page }, testInfo) => {
|
||||
await armed(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await complete(page);
|
||||
await expect(nodeAt(page, initialPath)).toBeFocused();
|
||||
const originalURL = page.url();
|
||||
const originalHTML = await page.locator(selectedContent).innerHTML();
|
||||
const unchanged = async () => {
|
||||
expect(page.url()).toBe(originalURL);
|
||||
expect(await page.locator(selectedContent).innerHTML()).toBe(originalHTML);
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
|
||||
};
|
||||
await expect(nodeAt(page, '/')).toBeFocused();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(nodeAt(page, '/2-core-model.md')).toBeFocused();
|
||||
await expect(nodeAt(page, '/2-core-model.md')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('2. コアモデル');
|
||||
const draft = nodeAt(page, '/draft');
|
||||
await draft.focus();
|
||||
await expect(nodeAt(page, '/book')).toBeFocused();
|
||||
await unchanged();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(nodeAt(page, initialPath)).toBeFocused();
|
||||
await unchanged();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused();
|
||||
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toHaveAttribute('aria-selected', 'false');
|
||||
await unchanged();
|
||||
const book = nodeAt(page, '/book');
|
||||
await book.focus();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(draft).toHaveAttribute('aria-expanded', 'false');
|
||||
for (const path of documentPaths.filter((path) => path.startsWith('/draft/'))) await expect(nodeAt(page, path)).toBeHidden();
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount - 5);
|
||||
await expect(page.locator(selectedContent)).toBeHidden();
|
||||
expect(await page.locator(selectedContent).innerHTML()).toBe('');
|
||||
await expect(book).toHaveAttribute('aria-expanded', 'false');
|
||||
for (const path of documentPaths) await expect(nodeAt(page, path)).toBeHidden();
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(5);
|
||||
await unchanged();
|
||||
await checkGlyphRefs(page);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(draft).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(book).toHaveAttribute('aria-expanded', 'true');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(nodeAt(page, documentPaths[10])).toBeFocused();
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await expect(nodeAt(page, initialPath)).toBeFocused();
|
||||
await unchanged();
|
||||
await page.keyboard.press('ArrowLeft'); // Fold the chapter before moving to its parent.
|
||||
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(draft).toBeFocused();
|
||||
await expect(page.locator(selectedContent)).toBeHidden();
|
||||
await expect(book).toBeFocused();
|
||||
await unchanged();
|
||||
await nodeAt(page, initialPath).focus();
|
||||
await page.keyboard.press('ArrowRight'); // Restore all descendants for root collapse/reopen.
|
||||
await page.keyboard.press('Home');
|
||||
const root = nodeAt(page, '/');
|
||||
await expect(root).toBeFocused();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(1);
|
||||
expect((await trunkGeometry(page)).length).toBe(0);
|
||||
await unchanged();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||||
await checkGlyphRefs(page);
|
||||
await page.keyboard.press('End');
|
||||
const finalLeaf = nodeAt(page, documentPaths.at(-1)!);
|
||||
const finalPath = '/playground';
|
||||
const finalLeaf = nodeAt(page, finalPath);
|
||||
await expect(finalLeaf).toBeFocused();
|
||||
await expect(finalLeaf).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(finalLeaf).toHaveAttribute('aria-selected', 'false');
|
||||
const scroll = await page.locator('.tree-viewport').evaluate((viewport) => {
|
||||
const node = document.activeElement!.getBoundingClientRect(), box = viewport.getBoundingClientRect();
|
||||
return { top: viewport.scrollTop, needsScroll: viewport.scrollHeight > viewport.clientHeight, inside: node.top >= box.top && node.bottom <= box.bottom && node.left >= box.left && node.right <= box.right };
|
||||
@@ -687,18 +756,21 @@ test('file selection and folder collapse work with keyboard navigation in the sc
|
||||
if (scroll.needsScroll) expect(scroll.top).toBeGreaterThan(0);
|
||||
expect(scroll.inside).toBe(true);
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect(nodeAt(page, '/draft/typescript-api-model.md')).toBeFocused();
|
||||
const previousPath = '/packages';
|
||||
await expect(nodeAt(page, previousPath)).toBeFocused();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(finalLeaf).toBeFocused();
|
||||
await unchanged();
|
||||
await expect(page.locator('.demo-node[tabindex="0"]')).toHaveCount(1);
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(finalLeaf).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page).toHaveURL(routeURL(finalPath, testInfo.project.use.baseURL!));
|
||||
await expect(finalLeaf).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await nodeAt(page, previousPath).focus();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(finalLeaf).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page).toHaveURL(routeURL(previousPath, testInfo.project.use.baseURL!));
|
||||
await expect(nodeAt(page, previousPath)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(selectedContent)).toBeFocused();
|
||||
});
|
||||
|
||||
test.describe('glyph trunk regressions', () => {
|
||||
@@ -789,19 +861,20 @@ test.describe('glyph trunk regressions', () => {
|
||||
return trunk.length;
|
||||
};
|
||||
const expanded = await checkEnd('expanded');
|
||||
const draft = nodeAt(page, '/draft');
|
||||
await draft.focus();
|
||||
const book = nodeAt(page, '/book');
|
||||
await book.focus();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(draft).toHaveAttribute('aria-expanded', 'false');
|
||||
// Draft is the last root child: its descendants never extend the first-column spine.
|
||||
expect(await checkEnd('draft')).toBe(expanded);
|
||||
await expect(book).toHaveAttribute('aria-expanded', 'false');
|
||||
// Collapsing Book moves the following catalog leaves up and shortens the root spine.
|
||||
const collapsed = await checkEnd('book');
|
||||
expect(collapsed).toBeLessThan(expanded);
|
||||
await page.keyboard.press('Home');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(1);
|
||||
await checkEnd('root');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await checkEnd('root-reopened')).toBe(expanded);
|
||||
await draft.focus();
|
||||
expect(await checkEnd('root-reopened')).toBe(collapsed);
|
||||
await book.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await checkEnd('all-reopened')).toBe(expanded);
|
||||
});
|
||||
@@ -810,10 +883,20 @@ test.describe('glyph trunk regressions', () => {
|
||||
test.describe('JavaScript-free glyph tree', () => {
|
||||
test.use({ javaScriptEnabled: false });
|
||||
|
||||
test('initial canonical article and every file link remain usable without JavaScript', async ({ page }, testInfo) => {
|
||||
test('independent home article and every file link remain usable without JavaScript', async ({ page }, testInfo) => {
|
||||
await page.goto('./');
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト');
|
||||
const home = page.locator(selectedContent + ' .root-page[data-root-page]');
|
||||
await expect(home).toContainText('Worldspace Interface Protocol');
|
||||
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: /^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();
|
||||
await expect(page.locator('.static-guides, .guide-body, .ascii-panel')).toHaveCount(0);
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||||
@@ -824,7 +907,13 @@ test.describe('JavaScript-free glyph tree', () => {
|
||||
await node.click();
|
||||
await expect(page).toHaveURL(bookURL(path, testInfo.project.use.baseURL!));
|
||||
await expect(page.locator('article.prose h1')).toBeVisible();
|
||||
if (path === initialPath) expect(await page.locator('article.prose').innerHTML()).toBe(initialHTML);
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
|
||||
if (path === initialPath) {
|
||||
await expect(page.locator('article.prose h1')).toHaveText('1. コンセプト');
|
||||
expect(await page.locator('article.prose').innerHTML()).not.toBe(initialHTML);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -844,8 +933,10 @@ test.describe('JavaScript-free glyph tree', () => {
|
||||
expect(Math.abs(trunk.axis)).toBeLessThan(.1);
|
||||
expect(trunk.gapRatio).toBeCloseTo(1 / 14, 5);
|
||||
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('no-js-glyph-tree.png') });
|
||||
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');
|
||||
await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click();
|
||||
await expect(page).toHaveURL(/\/book\/$/);
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('.landing a[href$="/book/1-concept/"]').last()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -866,6 +957,12 @@ test('changing motion preference and resizing cannot strand the intro', async ({
|
||||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await complete(page);
|
||||
// The dedicated home is deliberately short; retain the mobile reading-scroll
|
||||
// regression on the long canonical chapter rather than requiring home filler.
|
||||
await nodeAt(page, initialPath).click();
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||||
// Keep the wheel outside the tree's overscroll-contained scroll viewport.
|
||||
await page.mouse.move(5, page.viewportSize()!.height / 2);
|
||||
await page.mouse.wheel(0, 650);
|
||||
await expect.poll(() => page.evaluate(() => scrollY)).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -874,15 +971,17 @@ test('blocked animation module fails open to readable content', async ({ page })
|
||||
await page.route('**/*.js', (route) => route.abort());
|
||||
await page.goto('./');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing', { timeout: 6000 });
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await nodeAt(page, '/draft/rust-api-model.md').click();
|
||||
await expect(page).toHaveURL(/\/book\/draft\/rust-api-model\/$/);
|
||||
await nodeAt(page, '/2-core-model.md').click();
|
||||
await expect(page).toHaveURL(/\/book\/2-core-model\/$/);
|
||||
await expect(page.locator('article.prose h1')).toBeVisible();
|
||||
await page.goto('./');
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');
|
||||
await page.locator('.site-header').getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/book\/$/);
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('.landing a[href$="/book/1-concept/"]').last()).toBeVisible();
|
||||
});
|
||||
|
||||
test('touch swipe starts the uninterrupted timeline', async ({ page }, testInfo) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,793 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { load } from 'cheerio';
|
||||
import { bookURL, documentPaths, draftPaths, treeNodeCount, treePaths, routeURL } from './documents';
|
||||
|
||||
test.use({ javaScriptEnabled: true });
|
||||
|
||||
const stage = '[data-landing-stage]';
|
||||
const article = 'article.selected-content.prose[data-selected-content]';
|
||||
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 urlFor = routeURL;
|
||||
|
||||
async function settled(page: Page, path: string) {
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator('.demo-node[aria-current="page"]')).toHaveCount(1);
|
||||
}
|
||||
|
||||
// Catch even a transient intro state, rather than merely waiting for it to finish.
|
||||
function recordIntroStates() {
|
||||
const states: string[] = [];
|
||||
(window as Window & { introStates?: string[] }).introStates = states;
|
||||
new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
const target = mutation.target as Element;
|
||||
const values = [mutation.oldValue, target.getAttribute(mutation.attributeName!)];
|
||||
for (const value of values) {
|
||||
if (value && (mutation.attributeName === 'data-landing' || ['armed', 'flow'].includes(value))) states.push(value);
|
||||
}
|
||||
}
|
||||
}).observe(document, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['data-landing', 'data-phase'] });
|
||||
}
|
||||
|
||||
async function expectNoIntroStates(page: Page) {
|
||||
expect(await page.evaluate(() => (window as Window & { introStates?: string[] }).introStates)).toEqual([]);
|
||||
}
|
||||
|
||||
async function expectMetadata(page: Page, expectedURL: string) {
|
||||
const title = await page.title();
|
||||
expect(title).toBeTruthy();
|
||||
await expect(page.locator('meta[property="og:title"]')).toHaveAttribute('content', title);
|
||||
const description = await page.locator('meta[name="description"]').getAttribute('content');
|
||||
expect(description).toBeTruthy();
|
||||
await expect(page.locator('meta[property="og:description"]')).toHaveAttribute('content', description!);
|
||||
const canonical = page.locator('link[rel="canonical"]');
|
||||
if (process.env.SITE_URL) {
|
||||
await expect(canonical).toHaveCount(1);
|
||||
await expect(canonical).toHaveAttribute('href', new URL(new URL(expectedURL).pathname, process.env.SITE_URL).href);
|
||||
} else {
|
||||
await expect(canonical).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
|
||||
test('home response contains independent root content and the unchanged public tree', async ({ request }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
const response = await request.get(base);
|
||||
expect(response.ok()).toBe(true);
|
||||
const $ = load(await response.text());
|
||||
const chapterResponse = await request.get(bookURL('/1-concept.md', base));
|
||||
expect(chapterResponse.ok()).toBe(true);
|
||||
const chapter = load(await chapterResponse.text());
|
||||
expect($(stage).attr('data-current-path')).toBe('/');
|
||||
expect($(article).length).toBe(1);
|
||||
expect($('article.prose').length).toBe(1);
|
||||
const home = $(article).find('.root-page[data-root-page]');
|
||||
expect(home.length).toBe(1);
|
||||
expect($(article).find('h1').length).toBe(1);
|
||||
expect(home.find('h1').attr('aria-label')).toBe('WIP');
|
||||
const heading = home.find('h1').clone();
|
||||
heading.find('[aria-hidden="true"]').remove();
|
||||
expect(heading.text().trim()).toBe('WIP');
|
||||
expect(home.find('p').text()).toContain('Worldspace Interface Protocol');
|
||||
const copy = home.find('p').text();
|
||||
expect(copy).toMatch(/人(?:間)?/);
|
||||
expect(copy).toMatch(/AI/);
|
||||
expect(copy).toMatch(/システム/);
|
||||
expect(copy).toMatch(/共通/);
|
||||
expect(copy).toMatch(/インタフェース/);
|
||||
const nav = home.find('nav[aria-label="ルートページのナビゲーション"]');
|
||||
expect(nav.length).toBe(1);
|
||||
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('Sources')).attr('href')).toBe(new URL('sources/', base).pathname);
|
||||
expect(nav.find('a').filter((_, el) => $(el).text().includes('Packages')).attr('href')).toBe(new URL('packages/', base).pathname);
|
||||
expect($(article).html()).not.toBe(chapter(article).html());
|
||||
expect($(article).text()).not.toContain('1. コンセプト');
|
||||
const chapterCopy = chapter(article).find('p').filter((_, el) => Boolean(chapter(el).text().trim())).first().text().trim();
|
||||
expect(chapterCopy).toBeTruthy();
|
||||
expect($(article).text()).not.toContain(chapterCopy);
|
||||
expect($('.root-mark').attr('aria-current')).toBe('page');
|
||||
expect($('.root-mark').attr('aria-selected')).toBe('true');
|
||||
expect($('.demo-node[aria-current="page"]').length).toBe(1);
|
||||
const treeShape = (document: ReturnType<typeof load>) => document('.demo-node').map((_, el) => {
|
||||
const node = document(el);
|
||||
return { tag: node.prop('tagName'), path: node.attr('data-path'), href: node.attr('href'), role: node.attr('role'), level: node.attr('aria-level'), parent: node.parent().prop('tagName') };
|
||||
}).get();
|
||||
expect(treeShape($)).toEqual(treeShape(chapter));
|
||||
expect($('.demo-node').map((_, el) => $(el).attr('data-path')).get()).toEqual(treePaths);
|
||||
const serialized = $('#landing-documents').text();
|
||||
expect(serialized).not.toContain('<');
|
||||
const nodes = JSON.parse(serialized) as Record<string, unknown>[];
|
||||
expect(nodes.map((node) => node.path)).toEqual(treePaths);
|
||||
for (const node of nodes) {
|
||||
expect(node).not.toHaveProperty('html');
|
||||
expect(node).not.toHaveProperty('body');
|
||||
}
|
||||
expect($('meta[property="og:title"]').attr('content')).toBe($('title').text());
|
||||
expect($('meta[name="description"]').attr('content')).toBeTruthy();
|
||||
expect($('meta[property="og:description"]').attr('content')).toBe($('meta[name="description"]').attr('content'));
|
||||
if (process.env.SITE_URL) {
|
||||
expect($('link[rel="canonical"]').attr('href')).toBe(new URL(new URL(base).pathname, process.env.SITE_URL).href);
|
||||
} else {
|
||||
expect($('link[rel="canonical"]').length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('root content navigation works without changing the tree or overflowing narrow mobile viewports', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.goto('./');
|
||||
await settled(page, '/');
|
||||
const home = page.locator(article + ' .root-page[data-root-page]');
|
||||
await expect(home).toBeVisible();
|
||||
await expect(home.getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator(article + ' h1')).toHaveCount(1);
|
||||
await expect(home.locator('p').filter({ hasText: 'Worldspace Interface Protocol' })).toBeVisible();
|
||||
await expect(page.locator(article)).not.toContainText('1. コンセプト');
|
||||
const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
|
||||
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: /^Sources(?:\s|$)/ })).toHaveAttribute('href', new URL('sources/', base).pathname);
|
||||
await expect(nav.getByRole('link', { name: /^Packages(?:\s|$)/ })).toHaveAttribute('href', new URL('packages/', base).pathname);
|
||||
if (testInfo.project.name === 'mobile') {
|
||||
for (const width of [320, 360, 390]) {
|
||||
await page.setViewportSize({ width, height: 740 });
|
||||
await expect(home).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${width}px home has no horizontal overflow`).toBe(true);
|
||||
const bounds = await home.boundingBox();
|
||||
expect(bounds!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width);
|
||||
}
|
||||
}
|
||||
await nav.getByRole('link', { name: /^Book(?:\s|$)/ }).click();
|
||||
await expect(page).toHaveURL(new URL('book/', base).href);
|
||||
await settled(page, '/book');
|
||||
await expect(page.locator('[data-root-page]')).toHaveCount(0);
|
||||
await expect(page.locator(article).locator('a[href$="/book/1-concept/"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('all 14 document responses contain the current article and metadata-only public tree', async ({ request }, testInfo) => {
|
||||
expect(documentPaths).toHaveLength(14);
|
||||
expect(treeNodeCount).toBe(19);
|
||||
for (const path of documentPaths) {
|
||||
const url = bookURL(path, testInfo.project.use.baseURL!);
|
||||
const response = await request.get(url);
|
||||
expect(response.ok(), path).toBe(true);
|
||||
const $ = load(await response.text());
|
||||
expect($('.demo-node').length, path).toBe(treeNodeCount);
|
||||
expect($(stage).attr('data-current-path')).toBe(path);
|
||||
expect($(article).length).toBe(1);
|
||||
expect($('article.prose').length).toBe(1);
|
||||
expect($(article).find('h1').text()).toBeTruthy();
|
||||
expect($(`.demo-node[data-path="${path}"]`).attr('aria-current')).toBe('page');
|
||||
expect($(`.demo-node[data-path="${path}"]`).attr('href')).toBe(new URL(url).pathname);
|
||||
expect($('.sidebar, .mobile-chapters, .mobile-toc, .page-toc, .pagination').length).toBe(0);
|
||||
const payload = $('#landing-documents');
|
||||
expect(payload.length).toBe(1);
|
||||
const serialized = payload.text();
|
||||
expect(serialized).not.toContain('<');
|
||||
const nodes = JSON.parse(serialized) as Record<string, unknown>[];
|
||||
expect(nodes.map((node) => node.path)).toEqual(treePaths);
|
||||
for (const node of nodes) {
|
||||
expect(node).not.toHaveProperty('html');
|
||||
expect(node).not.toHaveProperty('body');
|
||||
expect(node.href).toBe(new URL(urlFor(node.path as string, testInfo.project.use.baseURL!)).pathname);
|
||||
}
|
||||
expect($('title').text()).toContain($(article).find('h1').first().text());
|
||||
expect($('meta[property="og:title"]').attr('content')).toBe($('title').text());
|
||||
expect($('meta[name="description"]').attr('content')).toBeTruthy();
|
||||
if (process.env.SITE_URL) {
|
||||
expect($('link[rel="canonical"]').attr('href')).toBe(new URL(new URL(url).pathname, process.env.SITE_URL).href);
|
||||
} else {
|
||||
expect($('link[rel="canonical"]').length).toBe(0);
|
||||
}
|
||||
expect($('.demo-node[data-path^="/draft"], a[href*="/draft/"], .draft-notice').length).toBe(0);
|
||||
expect(serialized).not.toContain('/draft');
|
||||
}
|
||||
});
|
||||
|
||||
test('draft directory and all five formerly public draft documents return 404 without publishing content', async ({ request }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
expect(draftPaths).toHaveLength(5);
|
||||
const missing = await request.get(new URL('missing-public-document/', base).href);
|
||||
const notFound = load(await missing.text());
|
||||
expect(missing.status()).toBe(404);
|
||||
for (const url of [new URL('book/draft/', base).href, ...draftPaths.map((path) => bookURL(path, base))]) {
|
||||
const response = await request.get(url);
|
||||
expect(response.status(), url).toBe(404);
|
||||
const $ = load(await response.text());
|
||||
expect($('.demo-node').length).toBe(treeNodeCount);
|
||||
expect($('.demo-node[data-path^="/draft"], a[href*="/draft/"], .draft-notice').length).toBe(0);
|
||||
expect($(article).length).toBe(1);
|
||||
expect($(article).html()).toBe(notFound(article).html());
|
||||
expect($(article).find('h1').text()).toBe('このページは見つかりません。');
|
||||
expect($(stage).attr('data-current-path') ?? '').not.toMatch(/^\/draft/);
|
||||
const nodes = JSON.parse($('#landing-documents').text()) as { path: string; html?: string; body?: string }[];
|
||||
expect(nodes.map((node) => node.path)).toEqual(treePaths);
|
||||
for (const node of nodes) {
|
||||
expect(node).not.toHaveProperty('html');
|
||||
expect(node).not.toHaveProperty('body');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('direct documents never arm the intro; history and reload restore route body and metadata', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
const first = '/1-concept.md', second = '/2-core-model.md';
|
||||
await page.addInitScript(recordIntroStates);
|
||||
await page.goto(bookURL(first, base));
|
||||
await settled(page, first);
|
||||
const firstHTML = await page.locator(article).innerHTML();
|
||||
const firstTitle = await page.title();
|
||||
await expectMetadata(page, bookURL(first, base));
|
||||
const pane = await page.locator('.tree-pane').elementHandle();
|
||||
const oldStage = await page.locator(stage).elementHandle();
|
||||
const oldArticle = await page.locator(article).elementHandle();
|
||||
await nodeAt(page, second).click();
|
||||
await expect(page).toHaveURL(bookURL(second, base));
|
||||
await settled(page, second);
|
||||
const secondHTML = await page.locator(article).innerHTML();
|
||||
const secondTitle = await page.title();
|
||||
expect(secondHTML).not.toBe(firstHTML);
|
||||
expect(secondTitle).not.toBe(firstTitle);
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
expect(await oldStage!.evaluate((el) => el === document.querySelector('[data-landing-stage]'))).toBe(false);
|
||||
expect(await oldArticle!.evaluate((el) => el === document.querySelector('article.prose'))).toBe(false);
|
||||
await expectMetadata(page, bookURL(second, base));
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(bookURL(first, base));
|
||||
await settled(page, first);
|
||||
expect(await page.locator(article).innerHTML()).toBe(firstHTML);
|
||||
await expect(page).toHaveTitle(firstTitle);
|
||||
await expectMetadata(page, bookURL(first, base));
|
||||
await page.goForward();
|
||||
await settled(page, second);
|
||||
expect(await page.locator(article).innerHTML()).toBe(secondHTML);
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
await expectMetadata(page, bookURL(second, base));
|
||||
await expectNoIntroStates(page);
|
||||
await page.reload();
|
||||
await settled(page, second);
|
||||
expect(await page.locator(article).innerHTML()).toBe(secondHTML);
|
||||
await expect(page).toHaveTitle(secondTitle);
|
||||
await expectMetadata(page, bookURL(second, base));
|
||||
await expectNoIntroStates(page);
|
||||
});
|
||||
|
||||
test('modified arrow keys remain unconsumed and do not move tree focus or fold directories', async ({ page }, testInfo) => {
|
||||
const path = '/3-protocol.md';
|
||||
await page.goto(bookURL(path, testInfo.project.use.baseURL!));
|
||||
await settled(page, path);
|
||||
const originalURL = page.url();
|
||||
const originalHTML = await page.locator(article).innerHTML();
|
||||
const book = nodeAt(page, '/book');
|
||||
await book.focus();
|
||||
// Synthetic cancelable events test shortcut ownership without depending on
|
||||
// headless Chrome implementing native Alt+Arrow history shortcuts.
|
||||
for (const modifier of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'] as const) {
|
||||
for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) {
|
||||
const result = await book.evaluate((el, { key, modifier }) => {
|
||||
const event = new KeyboardEvent('keydown', { key, [modifier]: true, bubbles: true, cancelable: true });
|
||||
return { uncanceled: el.dispatchEvent(event), defaultPrevented: event.defaultPrevented };
|
||||
}, { key, modifier });
|
||||
expect(result, `${modifier}+${key}`).toEqual({ uncanceled: true, defaultPrevented: false });
|
||||
await expect(book).toBeFocused();
|
||||
await expect(book).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(page.url()).toBe(originalURL);
|
||||
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
|
||||
expect(await page.locator(article).innerHTML()).toBe(originalHTML);
|
||||
}
|
||||
}
|
||||
// Positive controls: the handler must still own unmodified tree navigation.
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(book).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(book).toHaveAttribute('aria-expanded', 'true');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(nodeAt(page, '/1-concept.md')).toBeFocused();
|
||||
expect(page.url()).toBe(originalURL);
|
||||
});
|
||||
|
||||
test('history restores nested article scroll per entry, including repeated visits to the same URL', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
const first = '/3-protocol.md', second = '/4.2-text-view.md';
|
||||
if (testInfo.project.name === 'mobile') {
|
||||
// Natural mobile reading scroll belongs to Astro's window restoration.
|
||||
// Force only this test's article to overflow, before page-load restoration
|
||||
// runs on each new document; desktop exercises the unmodified layout.
|
||||
await page.addInitScript(() => {
|
||||
const constrainArticle = () => {
|
||||
const el = document.querySelector<HTMLElement>('[data-selected-content]');
|
||||
if (el) { el.style.maxHeight = '240px'; el.style.overflow = 'auto'; }
|
||||
};
|
||||
document.addEventListener('astro:after-swap', constrainArticle);
|
||||
document.addEventListener('astro:page-load', constrainArticle);
|
||||
});
|
||||
}
|
||||
const setPosition = async (fraction: number) => {
|
||||
const position = await page.locator(article).evaluate((el, fraction) => {
|
||||
const maximum = el.scrollHeight - el.clientHeight;
|
||||
el.scrollTop = Math.floor(maximum * fraction);
|
||||
return { maximum, top: el.scrollTop };
|
||||
}, fraction);
|
||||
expect(position.maximum).toBeGreaterThan(100);
|
||||
expect(position.top).toBeGreaterThan(0);
|
||||
return position.top;
|
||||
};
|
||||
const expectPosition = async (top: number) => {
|
||||
await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(top);
|
||||
};
|
||||
const navigate = async (path: string) => {
|
||||
await nodeAt(page, path).click();
|
||||
await expect(page).toHaveURL(bookURL(path, base));
|
||||
await settled(page, path);
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
||||
};
|
||||
await page.goto(bookURL(first, base));
|
||||
await settled(page, first);
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
||||
const pane = await page.locator('.tree-pane').elementHandle();
|
||||
const firstVisit = await setPosition(.2);
|
||||
await navigate(second);
|
||||
await expectPosition(0);
|
||||
const secondVisit = await setPosition(.3);
|
||||
await navigate(first);
|
||||
await expectPosition(0); // A new history entry must not reuse this URL's earlier position.
|
||||
const firstRevisit = await setPosition(.6);
|
||||
expect(firstRevisit).not.toBe(firstVisit);
|
||||
await navigate(second);
|
||||
await expectPosition(0);
|
||||
const secondRevisit = await setPosition(.7);
|
||||
expect(secondRevisit).not.toBe(secondVisit);
|
||||
for (const [path, top] of [[first, firstRevisit], [second, secondVisit], [first, firstVisit]] as const) {
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(bookURL(path, base));
|
||||
await settled(page, path);
|
||||
await expectPosition(top);
|
||||
}
|
||||
for (const [path, top] of [[second, secondVisit], [first, firstRevisit], [second, secondRevisit]] as const) {
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(bookURL(path, base));
|
||||
await settled(page, path);
|
||||
await expectPosition(top);
|
||||
}
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
});
|
||||
|
||||
test('directory anchors navigate independently from the sibling plus/minus toggle', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
const path = '/2-core-model.md';
|
||||
await page.goto(bookURL(path, base));
|
||||
await settled(page, path);
|
||||
const html = await page.locator(article).innerHTML();
|
||||
const title = await page.title();
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('href', new URL('book/', base).pathname);
|
||||
await nodeAt(page, '/book').locator('..').hover();
|
||||
await toggle(page).click();
|
||||
await expect(toggle(page)).toHaveText('+');
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(nodeAt(page, path)).toBeHidden();
|
||||
expect(page.url()).toBe(bookURL(path, base));
|
||||
expect(await page.locator(article).innerHTML()).toBe(html);
|
||||
await expect(page).toHaveTitle(title);
|
||||
await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true');
|
||||
await nodeAt(page, '/book').locator('..').hover();
|
||||
await toggle(page).click();
|
||||
await expect(toggle(page)).toHaveText('−');
|
||||
await expect(nodeAt(page, path)).toBeVisible();
|
||||
await nodeAt(page, '/book').click();
|
||||
await expect(page).toHaveURL(new URL('book/', base).href);
|
||||
await settled(page, '/book');
|
||||
await expect(page.locator('.landing a:not(.demo-node)[href*="/book/"]')).toHaveCount(14);
|
||||
for (const document of documentPaths) {
|
||||
await expect(page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(document, base)).pathname}"]`)).toBeVisible();
|
||||
}
|
||||
await nodeAt(page, '/').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(base);
|
||||
await settled(page, '/');
|
||||
await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.sidebar, .mobile-chapters, .mobile-toc, .page-toc, .pagination')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('persisted tree retains scroll and collapse, expanding only an ancestor needed by selection', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
const first = '/1-concept.md', second = '/2-core-model.md', section = '/4.1-ai-tools.md';
|
||||
await page.goto(bookURL(first, base));
|
||||
await settled(page, first);
|
||||
const pane = await page.locator('.tree-pane').elementHandle();
|
||||
const viewport = await page.locator('.tree-viewport').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('.tree-viewport').evaluate((el) => {
|
||||
(el as HTMLElement).style.height = '120px';
|
||||
el.scrollTop = 48;
|
||||
});
|
||||
const scroll = await page.locator('.tree-viewport').evaluate((el) => ({ top: el.scrollTop, left: el.scrollLeft }));
|
||||
expect(scroll.top).toBeGreaterThan(0);
|
||||
// Programmatic native anchor activation avoids Playwright scrolling the target into view first.
|
||||
await nodeAt(page, second).evaluate((el) => (el as HTMLAnchorElement).click());
|
||||
await settled(page, second);
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
expect(await viewport!.evaluate((el) => el === document.querySelector('.tree-viewport'))).toBe(true);
|
||||
expect(await root!.evaluate((el) => el === document.querySelector('.root-mark'))).toBe(true);
|
||||
expect(await page.locator('.tree-viewport').evaluate((el) => ({ top: el.scrollTop, left: el.scrollLeft }))).toEqual(scroll);
|
||||
await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false');
|
||||
const secondHTML = await page.locator(article).innerHTML();
|
||||
await nodeAt(page, '/').focus();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(1);
|
||||
expect(page.url()).toBe(bookURL(second, base));
|
||||
expect(await page.locator(article).innerHTML()).toBe(secondHTML);
|
||||
await page.locator('.site-header').getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await settled(page, '/book');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.goBack();
|
||||
await settled(page, second);
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.goBack();
|
||||
await settled(page, first);
|
||||
await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false');
|
||||
// A hidden file still has a real link; selecting it must reveal its collapsed ancestor.
|
||||
await nodeAt(page, section).evaluate((el) => (el as HTMLAnchorElement).click());
|
||||
await settled(page, section);
|
||||
await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(nodeAt(page, section)).toBeVisible();
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
});
|
||||
|
||||
test('home preserves a collapsed root until selecting a child requires expansion', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
const path = '/1-concept.md';
|
||||
await page.addInitScript(recordIntroStates);
|
||||
await page.goto(bookURL(path, base));
|
||||
await settled(page, path);
|
||||
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 nodeAt(page, '/book').locator('..').hover();
|
||||
await toggle(page).click();
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
|
||||
await nodeAt(page, '/').focus();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(base);
|
||||
await settled(page, '/');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(nodeAt(page, '/book')).toBeHidden();
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(1);
|
||||
await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
// The content Book link selects a root child, not its descendants.
|
||||
await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click();
|
||||
await settled(page, '/book');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false');
|
||||
await nodeAt(page, '/').click();
|
||||
await settled(page, '/');
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
|
||||
await nodeAt(page, '/').focus();
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'false');
|
||||
// Native hidden file activation must reveal only the ancestors needed by it.
|
||||
await nodeAt(page, path).evaluate((el) => (el as HTMLAnchorElement).click());
|
||||
await settled(page, path);
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(nodeAt(page, path)).toBeVisible();
|
||||
await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
await expectNoIntroStates(page);
|
||||
});
|
||||
|
||||
test('internal Markdown cross-page and fragment links use the same router and current tree', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
await page.goto(bookURL('/3-protocol.md', base));
|
||||
await settled(page, '/3-protocol.md');
|
||||
// Draft references in authored Markdown must not remain navigable after publication filtering.
|
||||
await expect(page.locator(article + ' a[href*="draft"]')).toHaveCount(0);
|
||||
const pane = await page.locator('.tree-pane').elementHandle();
|
||||
const link = page.locator(article).getByRole('link', { name: '4.2. Text View', exact: true }).first();
|
||||
const href = await link.getAttribute('href');
|
||||
expect(href).toContain('/book/4.2-text-view/#');
|
||||
await link.click();
|
||||
await expect(page).toHaveURL(new URL(href!, page.url()).href);
|
||||
await settled(page, '/4.2-text-view.md');
|
||||
const fragment = await page.evaluate(() => {
|
||||
const target = document.getElementById(decodeURIComponent(location.hash.slice(1)));
|
||||
return { exists: Boolean(target), inArticle: Boolean(target?.closest('article.prose')), top: target?.getBoundingClientRect().top };
|
||||
});
|
||||
expect(fragment.exists).toBe(true);
|
||||
expect(fragment.inArticle).toBe(true);
|
||||
await expect.poll(() => page.evaluate(() => document.getElementById(decodeURIComponent(location.hash.slice(1)))!.getBoundingClientRect().top)).toBeLessThan(page.viewportSize()!.height);
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
await expectMetadata(page, bookURL('/4.2-text-view.md', base));
|
||||
const currentArticle = await page.locator(article).elementHandle();
|
||||
const local = page.locator(article).getByRole('link', { name: '名前と文字列', exact: true }).first();
|
||||
const localHref = await local.getAttribute('href');
|
||||
expect(localHref).toContain('#');
|
||||
const localURL = new URL(localHref!, page.url()).href;
|
||||
await local.click();
|
||||
await expect(page).toHaveURL(localURL);
|
||||
await settled(page, '/4.2-text-view.md');
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const target = document.getElementById(decodeURIComponent(location.hash.slice(1)))!;
|
||||
const box = target.getBoundingClientRect();
|
||||
return box.bottom > 0 && box.top < innerHeight;
|
||||
})).toBe(true);
|
||||
expect(await currentArticle!.evaluate((el) => el === document.querySelector('article.prose'))).toBe(true);
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
});
|
||||
|
||||
test('modifier and target=_blank file links retain native new-tab behavior', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
await page.goto(bookURL('/1-concept.md', base));
|
||||
await settled(page, '/1-concept.md');
|
||||
const originalURL = page.url();
|
||||
const next = nodeAt(page, '/2-core-model.md');
|
||||
const [modified] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
next.click({ modifiers: ['ControlOrMeta'] }),
|
||||
]);
|
||||
await modified.waitForLoadState();
|
||||
await expect(modified).toHaveURL(bookURL('/2-core-model.md', base));
|
||||
expect(page.url()).toBe(originalURL);
|
||||
await modified.close();
|
||||
await next.evaluate((el) => el.setAttribute('target', '_blank'));
|
||||
const [blank] = await Promise.all([page.context().waitForEvent('page'), next.click()]);
|
||||
await blank.waitForLoadState();
|
||||
await expect(blank).toHaveURL(bookURL('/2-core-model.md', base));
|
||||
expect(page.url()).toBe(originalURL);
|
||||
await settled(page, '/1-concept.md');
|
||||
await blank.close();
|
||||
});
|
||||
|
||||
test('rapid navigation settles on the last file and repeated swaps do not duplicate handlers', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.goto(bookURL('/1-concept.md', base));
|
||||
await settled(page, '/1-concept.md');
|
||||
const pane = await page.locator('.tree-pane').elementHandle();
|
||||
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 settled(page, '/4-client.md');
|
||||
await expect(page.locator(article + ' h1')).toHaveText('4. クライアント');
|
||||
for (const path of ['/1-concept.md', '/2-core-model.md', '/1-concept.md', '/2-core-model.md']) {
|
||||
await nodeAt(page, path).click();
|
||||
await settled(page, path);
|
||||
}
|
||||
await nodeAt(page, '/1-concept.md').focus();
|
||||
const currentURL = page.url();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused();
|
||||
expect(page.url()).toBe(currentURL);
|
||||
await nodeAt(page, '/book').locator('..').hover();
|
||||
await toggle(page).click();
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(toggle(page)).toHaveText('+');
|
||||
await nodeAt(page, '/book').locator('..').hover();
|
||||
await toggle(page).click();
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(toggle(page)).toHaveText('−');
|
||||
// One activation must add one history entry, not one entry per prior page-load handler.
|
||||
await nodeAt(page, '/3-protocol.md').click();
|
||||
await settled(page, '/3-protocol.md');
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(currentURL);
|
||||
await settled(page, '/2-core-model.md');
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
await expect(page.locator('.tree-pane')).toHaveCount(1);
|
||||
await expect(page.locator('#landing-documents')).toHaveCount(1);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('client return home skips intro, while a hard reload home may arm it again', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
await page.goto('./');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/');
|
||||
await page.keyboard.press('Escape');
|
||||
await settled(page, '/');
|
||||
const homeHTML = await page.locator(article).innerHTML();
|
||||
const homeTitle = await page.title();
|
||||
await expectMetadata(page, base);
|
||||
await page.evaluate(recordIntroStates);
|
||||
const pane = await page.locator('.tree-pane').elementHandle();
|
||||
const tree = await page.locator('.demo-tree').elementHandle();
|
||||
const nodes = await page.locator('.demo-node').elementHandles();
|
||||
const expectHome = async () => {
|
||||
await expect(page).toHaveURL(base);
|
||||
await settled(page, '/');
|
||||
await expect(page.locator('.intro-skip')).toBeHidden();
|
||||
await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
expect(await page.locator(article).innerHTML()).toBe(homeHTML);
|
||||
await expect(page).toHaveTitle(homeTitle);
|
||||
await expectMetadata(page, base);
|
||||
};
|
||||
await nodeAt(page, '/2-core-model.md').click();
|
||||
await settled(page, '/2-core-model.md');
|
||||
expect(await page.locator(article).innerHTML()).not.toBe(homeHTML);
|
||||
await page.locator('.brand').click();
|
||||
await expectHome();
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
// The book directory has its own route; the resting slash returns to HOME.
|
||||
await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click();
|
||||
await expect(page).toHaveURL(new URL('book/', base).href);
|
||||
await settled(page, '/book');
|
||||
const bookHTML = await page.locator(article).innerHTML();
|
||||
const bookTitle = await page.title();
|
||||
expect(bookHTML).not.toBe(homeHTML);
|
||||
await expectMetadata(page, new URL('book/', base).href);
|
||||
await nodeAt(page, '/').click();
|
||||
await expectHome();
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(new URL('book/', base).href);
|
||||
await settled(page, '/book');
|
||||
expect(await page.locator(article).innerHTML()).toBe(bookHTML);
|
||||
await expect(page).toHaveTitle(bookTitle);
|
||||
await expectMetadata(page, new URL('book/', base).href);
|
||||
await page.goBack();
|
||||
await expectHome();
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(bookURL('/2-core-model.md', base));
|
||||
await settled(page, '/2-core-model.md');
|
||||
await expectMetadata(page, bookURL('/2-core-model.md', base));
|
||||
await page.goBack();
|
||||
await expectHome();
|
||||
for (const path of ['/2-core-model.md', '/', '/book', '/']) {
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(urlFor(path, base));
|
||||
await settled(page, path);
|
||||
await expectMetadata(page, urlFor(path, base));
|
||||
if (path === '/') await expectHome();
|
||||
}
|
||||
expect(await tree!.evaluate((el) => el === document.querySelector('.demo-tree'))).toBe(true);
|
||||
for (const node of nodes) {
|
||||
expect(await node.evaluate((el) => el === document.querySelector(`.demo-node[data-path="${(el as HTMLElement).dataset.path}"]`))).toBe(true);
|
||||
}
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
expect(await page.locator('.demo-node').evaluateAll((nodes) => nodes.map((node) => (node as HTMLElement).dataset.path))).toEqual(treePaths);
|
||||
await expectNoIntroStates(page);
|
||||
await page.reload();
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/');
|
||||
});
|
||||
|
||||
for (const action of ['skip-click', 'escape', 'root-click', 'key'] as const) {
|
||||
test(`slow-font boot ${action} works before font readiness without losing the initial input`, async ({ page }, testInfo) => {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
// Freeze application timers so neither the 2.5s font fallback nor the boot
|
||||
// fail-open timeout can make an ignored control appear to have worked.
|
||||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||||
await page.addInitScript(() => {
|
||||
const load = document.fonts.load.bind(document.fonts);
|
||||
document.fonts.load = (font, text) => {
|
||||
if (font === '14px "Lyth Mono"') {
|
||||
(window as Window & { introFontWaitStarted?: boolean }).introFontWaitStarted = true;
|
||||
}
|
||||
return load(font, text);
|
||||
};
|
||||
});
|
||||
let releaseFont!: () => void;
|
||||
const fontGate = new Promise<void>((resolve) => { releaseFont = resolve; });
|
||||
let requestedFont!: () => void;
|
||||
const fontRequested = new Promise<void>((resolve) => { requestedFont = resolve; });
|
||||
let released = false;
|
||||
await page.route('**/fonts/LythMono-Regular.woff2', async (route) => {
|
||||
requestedFont();
|
||||
await fontGate;
|
||||
await route.continue();
|
||||
});
|
||||
try {
|
||||
// Avoid waiting for full page/font readiness; observe initLanding reaching
|
||||
// its font wait so the input is sent after boot handlers are installed.
|
||||
await page.goto('./', { waitUntil: 'domcontentloaded' });
|
||||
await fontRequested;
|
||||
await expect.poll(() => page.evaluate(() => (window as Window & { introFontWaitStarted?: boolean }).introFontWaitStarted)).toBe(true);
|
||||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'boot');
|
||||
await expect(page.locator('.demo-tree')).not.toHaveAttribute('data-enhanced');
|
||||
expect(await page.evaluate(() => document.fonts.check('14px "Lyth Mono"'))).toBe(false);
|
||||
const beforeInput = await page.evaluate(() => Date.now());
|
||||
expect(released).toBe(false);
|
||||
if (action === 'skip-click') await page.locator('.intro-skip').click({ timeout: 1000 });
|
||||
if (action === 'escape') await page.keyboard.press('Escape');
|
||||
if (action === 'root-click') await nodeAt(page, '/').click({ timeout: 1000 });
|
||||
if (action === 'key') await page.keyboard.press('a');
|
||||
expect(page.url()).toBe(testInfo.project.use.baseURL!);
|
||||
const skipping = action === 'skip-click' || action === 'escape';
|
||||
if (skipping) {
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing', { timeout: 1000 });
|
||||
await expect(page.locator(article)).toBeVisible({ timeout: 1000 });
|
||||
await expect(page.locator('.intro-skip')).toBeHidden({ timeout: 1000 });
|
||||
} else {
|
||||
// Boot input queues playback; it must not activate the root's HOME link.
|
||||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'boot');
|
||||
}
|
||||
expect(await page.evaluate(() => Date.now()) - beforeInput).toBeLessThan(2500);
|
||||
expect(await page.evaluate(() => document.fonts.check('14px "Lyth Mono"'))).toBe(false);
|
||||
expect(released).toBe(false);
|
||||
released = true;
|
||||
releaseFont();
|
||||
if (skipping) {
|
||||
await settled(page, '/');
|
||||
await page.clock.runFor(4000);
|
||||
await settled(page, '/'); // Font completion must not replay an already-skipped intro.
|
||||
} else {
|
||||
// No second click/key: the input received during boot must start the timeline.
|
||||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
|
||||
expect(page.url()).toBe(testInfo.project.use.baseURL!);
|
||||
await page.clock.runFor(4000);
|
||||
await settled(page, '/');
|
||||
}
|
||||
expect(page.url()).toBe(testInfo.project.use.baseURL!);
|
||||
} finally {
|
||||
releaseFont();
|
||||
await page.unrouteAll({ behavior: 'wait' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('JavaScript-free directory and error routes', () => {
|
||||
test.use({ javaScriptEnabled: false });
|
||||
|
||||
test('directory index and 404 keep the public tree with ordinary usable anchors', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
await page.goto(new URL('book/', base).href);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/book');
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('.landing a:not(.demo-node)[href*="/book/"]')).toHaveCount(14);
|
||||
for (const path of documentPaths) {
|
||||
await expect(page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(path, base)).pathname}"]`)).toBeVisible();
|
||||
}
|
||||
await expect(page.locator('.demo-node[data-path^="/draft"], a[href*="/draft/"]')).toHaveCount(0);
|
||||
const target = '/1.1-interaction-abstraction.md';
|
||||
await page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(target, base)).pathname}"]`).click();
|
||||
await expect(page).toHaveURL(bookURL(target, base));
|
||||
await expect(page.locator(article + ' h1')).toBeVisible();
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
const response = await page.goto(new URL('missing-no-js-route/', base).href);
|
||||
expect(response!.status()).toBe(404);
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
await nodeAt(page, '/book').click();
|
||||
await expect(page).toHaveURL(new URL('book/', base).href);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/book');
|
||||
await nodeAt(page, '/').click();
|
||||
await expect(page).toHaveURL(base);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('404 uses the shared tree and its normal links recover to documents', async ({ page }, testInfo) => {
|
||||
const response = await page.goto('missing-tree-route/');
|
||||
expect(response!.status()).toBe(404);
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||||
const pane = await page.locator('.tree-pane').elementHandle();
|
||||
await nodeAt(page, '/1-concept.md').click();
|
||||
await expect(page).toHaveURL(bookURL('/1-concept.md', testInfo.project.use.baseURL!));
|
||||
await settled(page, '/1-concept.md');
|
||||
expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true);
|
||||
});
|
||||
@@ -1,12 +1,17 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { bookURL, documentPaths, treeNodeCount } from './documents';
|
||||
|
||||
test('home and book are usable without client JavaScript', async ({ page }, testInfo) => {
|
||||
test('home and book share the usable tree without client JavaScript', async ({ page }, testInfo) => {
|
||||
const failures: string[] = [];
|
||||
page.on('response', (response) => { if (response.status() >= 400) failures.push(response.url()); });
|
||||
await page.goto('./');
|
||||
await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator('.selected-content.prose[data-selected-content] .root-page[data-root-page]').getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-current-path', '/');
|
||||
await expect(page.locator('.root-mark')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('.root-mark')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator('[data-selected-content]')).not.toContainText('1. コンセプト');
|
||||
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 spine = await page.locator('.branch-vector').first().evaluate((element) => {
|
||||
const svg = element as SVGSVGElement;
|
||||
@@ -18,25 +23,31 @@ test('home and book are usable without client JavaScript', async ({ page }, test
|
||||
expect(Math.abs(footX - spine.x)).toBeLessThan(1);
|
||||
expect(Math.abs(root!.y + root!.height - spine.y)).toBeLessThan(1);
|
||||
await page.screenshot({ path: testInfo.outputPath('home.png'), fullPage: true });
|
||||
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('通信の約束まで');
|
||||
await page.getByRole('link', { name: '第1章から読む' }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('1. コンセプト');
|
||||
if (testInfo.project.name === 'mobile') {
|
||||
await page.locator('.mobile-chapters summary').click();
|
||||
await page.locator('.mobile-chapters').getByRole('link', { name: '3. Protocol', exact: true }).click();
|
||||
} else {
|
||||
await page.locator('.sidebar').getByRole('link', { name: '3. Protocol', exact: true }).click();
|
||||
await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click();
|
||||
await expect(page).toHaveURL(new URL('book/', testInfo.project.use.baseURL!).href);
|
||||
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-current-path', '/book');
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await expect(page.locator('.sidebar, .mobile-chapters, .mobile-toc, .page-toc, .pagination')).toHaveCount(0);
|
||||
await expect(page.getByText('世界の見方から、', { exact: false })).toHaveCount(0);
|
||||
// The directory index lists all 14 physical documents, independent of logical chapter nesting.
|
||||
await expect(page.locator('.landing a:not(.demo-node)[href*="/book/"]')).toHaveCount(documentPaths.length);
|
||||
for (const path of documentPaths) {
|
||||
await expect(page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(path, testInfo.project.use.baseURL!)).pathname}"]`)).toBeVisible();
|
||||
}
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('3. Protocol');
|
||||
await expect(page.locator('.demo-node[data-path^="/draft"], .landing a[href*="/draft/"]')).toHaveCount(0);
|
||||
await page.locator('.landing a:not(.demo-node)[href$="/book/1-concept/"]').click();
|
||||
await expect(page.locator('article.prose h1')).toHaveText('1. コンセプト');
|
||||
await page.locator('.demo-node[data-path="/3-protocol.md"]').click();
|
||||
await expect(page.locator('article.prose h1')).toHaveText('3. Protocol');
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await page.screenshot({ path: testInfo.outputPath('book.png'), fullPage: false });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
// Cross-chapter Japanese fragment links must reach the matching heading.
|
||||
await page.locator('article').getByRole('link', { name: '4.2. Text View' }).first().click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('4.2. Text View');
|
||||
await page.locator('article.prose').getByRole('link', { name: '4.2. Text View' }).first().click();
|
||||
await expect(page.locator('article.prose h1')).toHaveText('4.2. Text View');
|
||||
await expect(page).toHaveURL(/4\.2-text-view\/#/);
|
||||
await page.goto('book/draft/filesystem-discovery/');
|
||||
await expect(page.locator('.draft-notice')).toContainText('Draft');
|
||||
await expect(page.locator('a[href*="/draft/"]')).toHaveCount(0);
|
||||
await expect(page.locator('.draft-notice')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test';
|
||||
import { bookURL, treeNodeCount } from './documents';
|
||||
|
||||
// Deliberately independent of the default project preferences (including no-JS).
|
||||
test.use({ colorScheme: 'dark' });
|
||||
|
||||
const stage = '[data-landing-stage]';
|
||||
const article = 'article.selected-content.prose[data-selected-content]';
|
||||
const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`);
|
||||
type RGB = [number, number, number];
|
||||
|
||||
function luminance(rgb: RGB) {
|
||||
const linear = rgb.map((value) => {
|
||||
const channel = value / 255;
|
||||
return channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4;
|
||||
});
|
||||
return .2126 * linear[0] + .7152 * linear[1] + .0722 * linear[2];
|
||||
}
|
||||
function contrast(a: RGB, b: RGB) {
|
||||
const first = luminance(a), second = luminance(b);
|
||||
return (Math.max(first, second) + .05) / (Math.min(first, second) + .05);
|
||||
}
|
||||
|
||||
// Canvas resolves rgb(), color(srgb ...), and color-mix() consistently. Composite
|
||||
// translucent backgrounds through the ancestor chain, not against assumed white.
|
||||
async function paint(locator: Locator, property = 'color') {
|
||||
return locator.evaluate((element, property) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
const rgba = (color: string) => {
|
||||
if (!CSS.supports('color', color)) throw new Error(`Unsupported computed color: ${color}`);
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return Array.from(ctx.getImageData(0, 0, 1, 1).data);
|
||||
};
|
||||
const over = (front: number[], back: number[]) => front.slice(0, 3).map((channel, i) =>
|
||||
Math.round(channel * front[3] / 255 + back[i] * (1 - front[3] / 255)));
|
||||
const ancestors: Element[] = [];
|
||||
for (let current: Element | null = element; current; current = current.parentElement) ancestors.unshift(current);
|
||||
let background = [255, 255, 255];
|
||||
for (const ancestor of ancestors) background = over(rgba(getComputedStyle(ancestor).backgroundColor), background);
|
||||
const style = getComputedStyle(element);
|
||||
const raw = style.getPropertyValue(property);
|
||||
const color = over(rgba(raw === 'currentcolor' ? style.color : raw), background);
|
||||
return { color: color as [number, number, number], background: background as [number, number, number] };
|
||||
}, property);
|
||||
}
|
||||
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);
|
||||
expect(contrast(colors.color, colors.background), `${property} contrast for ${locator}`).toBeGreaterThanOrEqual(minimum);
|
||||
return colors;
|
||||
}
|
||||
async function darkPage(page: Page) {
|
||||
const colors = await readable(page.locator('body'));
|
||||
expect(luminance(colors.background)).toBeLessThan(.05);
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
|
||||
return colors;
|
||||
}
|
||||
async function noOverflow(page: Page) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'no page-level horizontal overflow').toBe(true);
|
||||
}
|
||||
async function complete(page: Page) {
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
}
|
||||
async function treeInk(page: Page) {
|
||||
await readable(page.locator('.trunk-line'), 3, 'fill');
|
||||
await expect(page.locator('.trunk-line')).toHaveCSS('opacity', '1');
|
||||
await readable(page.locator('.node-row:not([hidden]) .branch-vector use[data-cell="1"]').first(), 3, 'fill');
|
||||
await readable(nodeAt(page, '/book').locator('.node-label'));
|
||||
}
|
||||
|
||||
// Both are canonical public chapters. Fail clearly if the content moves instead
|
||||
// of silently skipping code/table assertions or manufacturing a test fixture.
|
||||
async function codeAndTableChapter(page: Page, base: string) {
|
||||
for (const path of ['/3-protocol.md', '/2-core-model.md']) {
|
||||
await page.goto(bookURL(path, base));
|
||||
if (await page.locator(`${article} pre.astro-code code span[style]`).count()
|
||||
&& await page.locator(`${article} table`).count()) return path;
|
||||
}
|
||||
throw new Error('Expected /book/3-protocol/ or /book/2-core-model/ to contain Shiki code and a table');
|
||||
}
|
||||
|
||||
function observeIntro() {
|
||||
const states: string[] = [];
|
||||
(window as Window & { themeIntroStates?: string[] }).themeIntroStates = states;
|
||||
new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
const element = mutation.target as Element;
|
||||
for (const value of [mutation.oldValue, element.getAttribute(mutation.attributeName!)]) {
|
||||
if (value && (mutation.attributeName === 'data-landing' || ['armed', 'flow'].includes(value))) states.push(value);
|
||||
}
|
||||
}
|
||||
}).observe(document, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['data-phase', 'data-landing'] });
|
||||
}
|
||||
|
||||
test.describe('automatic dark theme without JavaScript', () => {
|
||||
test.use({ javaScriptEnabled: false });
|
||||
|
||||
test('home, directory, code/table chapter and 404 remain readable and directly linked', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
await page.goto('./');
|
||||
await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
await darkPage(page);
|
||||
await readable(page.locator(article + ' p').first());
|
||||
await treeInk(page);
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: testInfo.outputPath('dark-home.png'), fullPage: true });
|
||||
|
||||
const nav = page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
|
||||
const book = nav.getByRole('link', { name: /^Book(?:\s|$)/ });
|
||||
await readable(book);
|
||||
await expect(book).toHaveAttribute('href', new URL('book/', base).pathname);
|
||||
await book.click();
|
||||
await expect(page).toHaveURL(new URL('book/', base).href);
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page');
|
||||
await darkPage(page);
|
||||
await readable(page.locator(article + ' a').first());
|
||||
await noOverflow(page);
|
||||
const chapterLink = page.locator(article + ' a[href$="/book/3-protocol/"]');
|
||||
await expect(chapterLink).toBeVisible();
|
||||
await chapterLink.click();
|
||||
await expect(page).toHaveURL(bookURL('/3-protocol.md', base));
|
||||
await expect(page.locator(article + ' h1')).toHaveText('3. Protocol');
|
||||
|
||||
await codeAndTableChapter(page, base);
|
||||
await darkPage(page);
|
||||
await readable(page.locator(article + ' p').first());
|
||||
await readable(page.locator(article + ' table th').first());
|
||||
await readable(page.locator(article + ' table td').first());
|
||||
await readable(page.locator(article + ' pre.astro-code').first());
|
||||
await treeInk(page);
|
||||
await noOverflow(page);
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
||||
await page.screenshot({ path: testInfo.outputPath('dark-book.png'), fullPage: false });
|
||||
|
||||
const response = await page.goto(new URL('missing-dark-theme-page/', base).href);
|
||||
expect(response!.status()).toBe(404);
|
||||
await expect(page.locator(article + ' h1')).toHaveText('このページは見つかりません。');
|
||||
await darkPage(page);
|
||||
await readable(page.locator(article + ' h1'));
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await noOverflow(page);
|
||||
await nodeAt(page, '/').click();
|
||||
await expect(page).toHaveURL(base);
|
||||
await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('screen palette and media-qualified browser chrome follow the preference with no script', async ({ page }) => {
|
||||
await page.goto('./');
|
||||
const dark = await darkPage(page);
|
||||
const metas = page.locator('meta[name="theme-color"]');
|
||||
await expect(metas).toHaveCount(2);
|
||||
const metaColors = await metas.evaluateAll((elements) => elements.map((element) => ({
|
||||
media: element.getAttribute('media')!, color: element.getAttribute('content')!,
|
||||
})));
|
||||
expect(metaColors.map(({ media }) => media).sort()).toEqual([
|
||||
'(prefers-color-scheme: dark)', '(prefers-color-scheme: light)',
|
||||
]);
|
||||
const matchingMetaColor = async () => page.evaluate(() => {
|
||||
const meta = [...document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')]
|
||||
.find((element) => matchMedia(element.media).matches)!;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = meta.content; ctx.fillRect(0, 0, 1, 1);
|
||||
return Array.from(ctx.getImageData(0, 0, 1, 1).data).slice(0, 3);
|
||||
});
|
||||
expect(await matchingMetaColor()).toEqual(dark.background);
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', 'light');
|
||||
const light = await readable(page.locator('body'));
|
||||
expect(luminance(light.background)).toBeGreaterThan(.8);
|
||||
expect(light.color).not.toEqual(dark.color);
|
||||
expect(await matchingMetaColor()).toEqual(light.background);
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
expect(await darkPage(page)).toEqual(dark);
|
||||
});
|
||||
|
||||
test('Shiki switches pre and token colors without flattening syntax or changing code', async ({ page }, testInfo) => {
|
||||
await codeAndTableChapter(page, testInfo.project.use.baseURL!);
|
||||
// The first fence may be plain text; exercise a genuinely highlighted block.
|
||||
const blockIndex = await page.locator(article + ' pre.astro-code').evaluateAll((blocks) => {
|
||||
const counts = blocks.map((block) => new Set([...block.querySelectorAll('code span[style]')]
|
||||
.filter((token) => token.textContent!.trim()).map((token) => getComputedStyle(token).color)).size);
|
||||
return counts.indexOf(Math.max(...counts));
|
||||
});
|
||||
const pre = page.locator(article + ' pre.astro-code').nth(blockIndex);
|
||||
const text = await pre.textContent();
|
||||
const sample = async () => ({
|
||||
pre: await paint(pre),
|
||||
tokens: await pre.locator('code span[style]').evaluateAll((elements) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
return elements.filter((element) => element.textContent!.trim()).map((element) => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = getComputedStyle(element).color;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return Array.from(ctx.getImageData(0, 0, 1, 1).data).slice(0, 3).join(',');
|
||||
});
|
||||
}),
|
||||
});
|
||||
const dark = await sample();
|
||||
expect(new Set(dark.tokens).size).toBeGreaterThan(1);
|
||||
await expect(pre.locator('[style*="--shiki-dark"]')).not.toHaveCount(0);
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
const light = await sample();
|
||||
expect(light.pre.background).not.toEqual(dark.pre.background);
|
||||
expect(light.pre.color).not.toEqual(dark.pre.color);
|
||||
expect(light.tokens.length).toBe(dark.tokens.length);
|
||||
expect(new Set(light.tokens).size).toBeGreaterThan(1);
|
||||
expect(dark.tokens.some((color, index) => color !== light.tokens[index])).toBe(true);
|
||||
expect(await pre.textContent()).toBe(text);
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
expect(await sample()).toEqual(dark);
|
||||
});
|
||||
|
||||
test('print retains the light paper, ink and code despite a dark screen preference', async ({ page }, testInfo) => {
|
||||
await codeAndTableChapter(page, testInfo.project.use.baseURL!);
|
||||
await page.emulateMedia({ media: 'print', colorScheme: 'light' });
|
||||
const lightPrint = await paint(page.locator('body'));
|
||||
const lightCode = await paint(page.locator(article + ' pre.astro-code').first());
|
||||
const tokenColors = () => page.locator(article + ' pre.astro-code code span[style]').evaluateAll((tokens) =>
|
||||
tokens.map((token) => getComputedStyle(token).color));
|
||||
const lightTokens = await tokenColors();
|
||||
await page.emulateMedia({ media: 'print', colorScheme: 'dark' });
|
||||
expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches)).toBe(true);
|
||||
const darkPrint = await readable(page.locator('body'));
|
||||
expect(darkPrint).toEqual(lightPrint);
|
||||
expect(luminance(darkPrint.background)).toBeGreaterThan(.8);
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', 'light');
|
||||
await readable(page.locator(article + ' p').first());
|
||||
expect(await paint(page.locator(article + ' pre.astro-code').first())).toEqual(lightCode);
|
||||
expect(await tokenColors()).toEqual(lightTokens);
|
||||
await page.emulateMedia({ media: 'screen', colorScheme: 'dark' });
|
||||
await darkPage(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('live CSS theme with JavaScript enabled', () => {
|
||||
test.use({ javaScriptEnabled: true, reducedMotion: 'no-preference' });
|
||||
|
||||
test('light → dark → light preserves route, article/tree identity, collapse and reading position without intro replay', async ({ page }, testInfo) => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.goto('./');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await page.keyboard.press('Escape');
|
||||
await complete(page);
|
||||
await page.evaluate(observeIntro);
|
||||
const tree = await page.locator('.demo-tree').elementHandle();
|
||||
await nodeAt(page, '/3-protocol.md').click();
|
||||
await expect(page).toHaveURL(bookURL('/3-protocol.md', testInfo.project.use.baseURL!));
|
||||
await complete(page);
|
||||
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 expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false');
|
||||
const content = await page.locator(article).elementHandle();
|
||||
const html = await page.locator(article).innerHTML();
|
||||
const url = page.url();
|
||||
const storage = await page.evaluate(() => ({ local: { ...localStorage }, session: { ...sessionStorage } }));
|
||||
const position = await page.locator(article).evaluate((element) => {
|
||||
const max = element.scrollHeight - element.clientHeight;
|
||||
if (max > 100) element.scrollTop = Math.min(240, max);
|
||||
else window.scrollTo(0, Math.min(320, document.documentElement.scrollHeight - innerHeight));
|
||||
return { article: element.scrollTop, window: scrollY, tree: document.querySelector('.tree-viewport')!.scrollTop };
|
||||
});
|
||||
expect(position.article + position.window, 'test must start partway through the real chapter').toBeGreaterThan(0);
|
||||
const light = await paint(page.locator('body'));
|
||||
for (const colorScheme of ['dark', 'light'] as const) {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', colorScheme);
|
||||
if (colorScheme === 'dark') await darkPage(page);
|
||||
else expect(await paint(page.locator('body'))).toEqual(light);
|
||||
expect(page.url()).toBe(url);
|
||||
expect(await content!.evaluate((element) => element === document.querySelector('article.prose'))).toBe(true);
|
||||
expect(await tree!.evaluate((element) => element === document.querySelector('.demo-tree'))).toBe(true);
|
||||
expect(await page.locator(article).innerHTML()).toBe(html);
|
||||
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeHidden();
|
||||
await expect(nodeAt(page, '/3-protocol.md')).toHaveAttribute('aria-current', 'page');
|
||||
expect(await page.locator(article).evaluate((element) => ({
|
||||
article: element.scrollTop, window: scrollY, tree: document.querySelector('.tree-viewport')!.scrollTop,
|
||||
}))).toEqual(position);
|
||||
await complete(page);
|
||||
}
|
||||
expect(await page.evaluate(() => (window as Window & { themeIntroStates?: string[] }).themeIntroStates)).toEqual([]);
|
||||
expect(await page.evaluate(() => ({ local: { ...localStorage }, session: { ...sessionStorage } }))).toEqual(storage);
|
||||
});
|
||||
|
||||
for (const finish of ['complete', 'skip'] as const) {
|
||||
test(`dark intro can ${finish}; switching while armed and playing does not reset or strand it`, async ({ page }) => {
|
||||
await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') });
|
||||
await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'));
|
||||
await page.goto('./');
|
||||
// Do not wait for the inert/revealed article or hidden tree to be visible.
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
const root = await page.locator('.root-shape').elementHandle();
|
||||
const trunk = page.locator('.trunk-line');
|
||||
const armedDark = await paint(trunk, 'fill');
|
||||
await readable(page.locator('.root-shape'), 3, 'fill');
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
const armedLight = await paint(trunk, 'fill');
|
||||
expect(armedLight.color).not.toEqual(armedDark.color);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
expect(await paint(trunk, 'fill')).toEqual(armedDark);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.clock.runFor(2600);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||||
const playingDark = await readable(trunk, 3, 'fill');
|
||||
await expect(trunk).toHaveCSS('opacity', '1');
|
||||
const reveal = await page.locator('.trunk-reveal').getAttribute('height');
|
||||
expect(Number(reveal)).toBeGreaterThan(0);
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
expect((await paint(trunk, 'fill')).color).not.toEqual(playingDark.color);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
expect(await paint(trunk, 'fill')).toEqual(playingDark);
|
||||
await expect(page.locator('.trunk-reveal')).toHaveAttribute('height', reveal!);
|
||||
expect(await root!.evaluate((element) => element === document.querySelector('.root-shape'))).toBe(true);
|
||||
if (finish === 'skip') await page.keyboard.press('Escape');
|
||||
await page.clock.runFor(4000);
|
||||
await complete(page);
|
||||
await darkPage(page);
|
||||
await treeInk(page);
|
||||
await readable(page.locator(article + ' p').first());
|
||||
await expect(page.locator('.document-pane')).not.toHaveAttribute('inert');
|
||||
await expect(page.locator('.intro-skip')).toBeHidden();
|
||||
await noOverflow(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('selected, hovered, keyboard-focused nodes and home navigation stay legible in dark mode', async ({ page }) => {
|
||||
await page.goto('./');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await page.keyboard.press('Escape');
|
||||
await complete(page);
|
||||
const book = nodeAt(page, '/book');
|
||||
await readable(book.locator('.node-label'));
|
||||
await book.hover();
|
||||
await readable(book.locator('.node-label'));
|
||||
await page.mouse.move(0, 0);
|
||||
await nodeAt(page, '/').focus();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(book).toBeFocused();
|
||||
await readable(book.locator('.node-label'));
|
||||
expect(await book.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return element.matches(':focus-visible') && style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0;
|
||||
})).toBe(true);
|
||||
const outline = await paint(book, 'outline-color');
|
||||
expect(contrast(outline.color, (await paint(page.locator('body'))).background)).toBeGreaterThanOrEqual(3);
|
||||
const nav = page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
|
||||
const homeBook = nav.getByRole('link', { name: /^Book(?:\s|$)/ });
|
||||
await readable(homeBook);
|
||||
await homeBook.hover();
|
||||
await readable(homeBook);
|
||||
await homeBook.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(homeBook).toBeFocused();
|
||||
await readable(homeBook);
|
||||
await homeBook.click();
|
||||
await complete(page);
|
||||
await expect(book).toHaveAttribute('aria-selected', 'true');
|
||||
await readable(book.locator('.node-label'));
|
||||
await book.hover();
|
||||
await readable(book.locator('.node-label'));
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -4,9 +4,11 @@ import test from 'node:test';
|
||||
import documentLinks, { contentsRoot, documentHref, normalizeBase } from '../plugins/document-links.mjs';
|
||||
|
||||
const source = path.join(contentsRoot, '3-protocol.md');
|
||||
test('canonical, draft and cross-directory links keep their fragments', () => {
|
||||
test('published links keep fragments and draft targets stay unpublished', () => {
|
||||
assert.equal(documentHref('4.2-text-view.md#参照表記', source), '/book/4.2-text-view/#参照表記');
|
||||
assert.equal(documentHref('draft/unresolved-issues.md', source), '/book/draft/unresolved-issues/');
|
||||
assert.equal(documentHref('draft/unresolved-issues.md', source), null);
|
||||
assert.equal(documentHref('./draft/unresolved-issues.md?from=book#detail', source, '/wip'), null);
|
||||
assert.equal(documentHref('%64raft/unresolved-issues.md', source), null);
|
||||
assert.equal(documentHref('../3-protocol.md#value', path.join(contentsRoot, 'draft/rust-api-model.md')), '/book/3-protocol/#value');
|
||||
});
|
||||
test('base prefix and query are preserved', () => {
|
||||
@@ -22,6 +24,22 @@ test('invalid base and links escaping contents are rejected', () => {
|
||||
for (const base of ['relative', '//example.test', '/foo?x', '/../x']) assert.throws(() => normalizeBase(base));
|
||||
assert.throws(() => documentHref('../secret.md', source));
|
||||
});
|
||||
test('draft links render only their text without changing source code examples', () => {
|
||||
const text = { type: 'text', value: '未解決事項' };
|
||||
const emphasis = { type: 'emphasis', children: [text] };
|
||||
const code = { type: 'code', value: '[example](draft/unresolved-issues.md)' };
|
||||
const tree = { type: 'root', children: [
|
||||
{ type: 'paragraph', children: [
|
||||
{ type: 'link', url: 'draft/unresolved-issues.md', children: [emphasis] },
|
||||
{ type: 'linkReference', identifier: 'draft-ref', referenceType: 'full', children: [text] },
|
||||
] },
|
||||
{ type: 'definition', identifier: 'draft-ref', url: 'draft/unresolved-issues.md#detail' },
|
||||
code,
|
||||
] };
|
||||
documentLinks()(tree, { path: source });
|
||||
assert.deepEqual(tree.children, [{ type: 'paragraph', children: [emphasis, text] }, code]);
|
||||
});
|
||||
|
||||
test('remark handles reference links but leaves code examples unchanged', () => {
|
||||
const tree = { type: 'root', children: [
|
||||
{ type: 'link', url: '4-client.md', children: [] },
|
||||
|
||||
@@ -53,7 +53,7 @@ test('Lyth Mono paths retain exact advance, row bounds and stroke dimensions', (
|
||||
test('cell layout bypasses shaping, preserves spaces and fails for missing glyphs', () => {
|
||||
close(box(glyphPath(' │')).x1, box(glyphPath('│')).x1 + 4 * cellWidth);
|
||||
close(box(glyphPath('── ')).x2, 2 * cellWidth);
|
||||
for (const prefix of ['├── ', '│ ├── ', '│ └── ', '│ └── ', ' └── ']) {
|
||||
for (const prefix of ['├─ ', '│ ├─ ', '│ └─ ', ' └─ ', '├ ', '│ ├ ', '│ └ ', '│ └ ', ' └ ', '├── ', '│ ├── ', '│ └── ', '│ └── ', ' └── ']) {
|
||||
const path = glyphPath(prefix);
|
||||
assert.ok(path.length > 0);
|
||||
assert.ok(!path.includes('NaN'));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { contentsRoot } from '../plugins/document-links.mjs';
|
||||
import { documentTree, 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 = '') {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
@@ -37,10 +37,98 @@ test('tree mirrors only real content files and their directory parents', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('existing titles, hrefs and content pass through without added descriptions or operations', () => {
|
||||
test('chapter display nests existing numbered sections while preserving paths and URLs', () => {
|
||||
const display = chapterTree(documents);
|
||||
for (const [chapter, sections] of [
|
||||
['/1-concept.md', ['/1.1-interaction-abstraction.md', '/1.2-existing-abstractions.md', '/1.3-design-principles.md', '/1.4-concepts-and-scope.md']],
|
||||
['/2-core-model.md', ['/2.1-object-tree-schema.md', '/2.2-operation-and-interface.md']],
|
||||
['/4-client.md', ['/4.1-ai-tools.md', '/4.2-text-view.md']],
|
||||
['/5-transports.md', ['/5.1-wip-over-http.md']],
|
||||
]) {
|
||||
assert.deepEqual(childrenOf(display, chapter).map((node) => node.path), sections);
|
||||
for (const section of sections) {
|
||||
const node = display.find((node) => node.path === section);
|
||||
assert.equal(node.depth, 3);
|
||||
assert.equal(treePrefix(node, display).length, 6);
|
||||
assert.ok(!visibleNodes(display, new Set([chapter])).includes(node));
|
||||
}
|
||||
}
|
||||
for (const node of display) {
|
||||
const original = nodes.find((candidate) => candidate.path === node.path);
|
||||
assert.equal(node.href, original.href);
|
||||
assert.equal(node.name, original.name);
|
||||
assert.equal(node.title, original.title);
|
||||
}
|
||||
assert.deepEqual(childrenOf(display, '/draft'), childrenOf(nodes, '/draft'));
|
||||
// The directory index still lists the physical files, not the display grouping.
|
||||
assert.equal(nodes.find((node) => node.path === '/1.1-interaction-abstraction.md').parent, '/');
|
||||
});
|
||||
|
||||
test('chapter grouping supports deeper numbering and never invents or crosses directory parents', () => {
|
||||
const source = ['1.2.3-leaf', '1.2-section', '1-chapter', '10-other', '10.1-section', '3.1-orphan', 'draft/1.1-independent']
|
||||
.map((id) => ({ id, title: id, href: `/book/${id}/` }));
|
||||
const display = chapterTree(source);
|
||||
const at = (id) => display.find((node) => node.path === `/${id}.md`);
|
||||
assert.equal(at('1.2-section').parent, '/1-chapter.md');
|
||||
assert.equal(at('1.2.3-leaf').parent, '/1.2-section.md');
|
||||
assert.equal(at('1.2.3-leaf').depth, 4);
|
||||
assert.equal(at('10.1-section').parent, '/10-other.md');
|
||||
assert.equal(at('3.1-orphan').parent, '/');
|
||||
assert.equal(at('draft/1.1-independent').parent, '/draft');
|
||||
assert.ok(display.indexOf(at('1-chapter')) < display.indexOf(at('1.2-section')));
|
||||
assert.ok(display.indexOf(at('1.2-section')) < display.indexOf(at('1.2.3-leaf')));
|
||||
});
|
||||
|
||||
test('public book container wraps the numbered chapters without changing document identity', () => {
|
||||
const published = documents.filter((document) => !document.id.startsWith('draft/'));
|
||||
const display = bookTree(published);
|
||||
assert.deepEqual(childrenOf(display, '/').map((node) => node.path), ['/book']);
|
||||
assert.equal(display[1].name, 'book');
|
||||
assert.equal(display[1].depth, 2);
|
||||
assert.equal(display.length, published.length + 2);
|
||||
assert.ok(!display.some((node) => node.path.startsWith('/draft')));
|
||||
assert.deepEqual(childrenOf(display, '/book').map((node) => node.path), ['/1-concept.md', '/2-core-model.md', '/3-protocol.md', '/4-client.md', '/5-transports.md']);
|
||||
const section = display.find((node) => node.path === '/1.1-interaction-abstraction.md');
|
||||
assert.equal(section.parent, '/1-concept.md');
|
||||
assert.equal(section.depth, 4);
|
||||
assert.equal(treePrefix(section, display), ' │ ├─ ');
|
||||
assert.deepEqual(visibleNodes(display, new Set(['/book'])), display.slice(0, 2));
|
||||
for (const node of display.filter((node) => node.href)) {
|
||||
assert.equal(node.href, nodes.find((original) => original.path === node.path).href);
|
||||
}
|
||||
});
|
||||
|
||||
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', () => {
|
||||
for (const document of documents) {
|
||||
const node = nodes.find((node) => node.path === '/' + document.id + '.md');
|
||||
assert.equal(node.html, document.html);
|
||||
assert.equal('html' in node, false);
|
||||
assert.equal('body' in node, false);
|
||||
assert.equal(node.title, document.title);
|
||||
assert.equal(node.href, document.href);
|
||||
assert.equal('description' in node, false);
|
||||
@@ -61,14 +149,14 @@ test('collapse hides descendants only and joins follow the visible tree', () =>
|
||||
for (const shown of [nodes, collapsed]) {
|
||||
for (const node of shown) {
|
||||
const prefix = treePrefix(node, nodes, shown);
|
||||
assert.equal(prefix.length, (node.depth - 1) * 4);
|
||||
if (node.parent) assert.ok(prefix.endsWith(childrenOf(shown, node.parent).at(-1) === node ? '└── ' : '├── '));
|
||||
assert.equal(prefix.length, (node.depth - 1) * 3);
|
||||
if (node.parent) assert.ok(prefix.endsWith(childrenOf(shown, node.parent).at(-1) === node ? '└─ ' : '├─ '));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('embedded data cannot close its JSON script and roundtrips exact content', () => {
|
||||
const input = documentTree([{ id: 'source', title: '<title>', href: '/book/source/', html: '</script><script>alert(1)</script>\u2028&' }]);
|
||||
test('embedded metadata cannot close its JSON script and roundtrips exactly', () => {
|
||||
const input = documentTree([{ id: 'source', title: '</script><script>alert(1)</script>\u2028&', href: '/book/source/' }]);
|
||||
const serialized = serializeTree(input);
|
||||
assert.equal(serialized.includes('<'), false);
|
||||
assert.deepEqual(JSON.parse(serialized), input);
|
||||
|
||||
@@ -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",
|
||||
"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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user