feat: add protocol explorer with catalogs and live playground

This commit is contained in:
2026-10-19 18:21:30 +09:00
parent b82c1d0a70
commit 75a36f2298
38 changed files with 1836 additions and 83 deletions
+37 -2
View File
@@ -22,7 +22,7 @@ Worldspace Interface Protocolの仕様文書と、その文書を実際のWIPで
ClientとHostは同じWASM内にあります。三つのinteraction(observe、fetch_interface、call_operation)にHTTP binding、JSON wire envelope、socketは使いません。WASMと`worldspace.json`の取得、Astroによる静的ページ遷移は通常のasset/page配信です。 ClientとHostは同じWASM内にあります。三つのinteraction(observe、fetch_interface、call_operation)にHTTP binding、JSON wire envelope、socketは使いません。WASMと`worldspace.json`の取得、Astroによる静的ページ遷移は通常のasset/page配信です。
公開文書と既存のhome/indexコンポーネントから`worldspace.json`をビルド時に生成します。本文の別コピーを編集する必要はありません。Rust ProviderはそれをimmutableなWorldspaceとして公開し、本文はroot scopeの`document.read()`で取得します。章Objectは本文と子の節を同時に持ちます。 公開文書と既存のhome/indexコンポーネントから`worldspace.json`をビルド時に生成します。本文の別コピーを編集する必要はありません。Rust Providerはそれを変更不可の文書Objectとして公開し、本文はroot scopeの`document.read()`で取得します。章Objectは本文と子の節を同時に持ちます。
Worldspace path、表示用の既存識別子、Web URLは別です。たとえば: Worldspace path、表示用の既存識別子、Web URLは別です。たとえば:
@@ -40,9 +40,44 @@ Worldspace path、表示用の既存識別子、Web URLは別です。たとえ
- `site/src/scripts/browser-wip.ts` — WASM初期化、session寿命、結果検証と本文反映。 - `site/src/scripts/browser-wip.ts` — WASM初期化、session寿命、結果検証と本文反映。
- `site/src/scripts/landing.ts` — 共通ツリー、導入演出、ページ切替と読書位置。 - `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. 展開したInterfaceのDescriptorにあるOperationを選び、引数を入力して`Execute Operation`を押します。文書の`document.read()`は引数なしなので、Argumentsは`{}`です。Playgroundの操作ではDescriptorに応じて引数を入力します。
Argumentsはパラメーター名からtagged ValueへのJSON recordです。たとえば`{"name":{"kind":"string","value":"example"}}`。`integer`は精度を失わない10進文字列(Protocol Integerの範囲は±9,007,199,254,740,991)、`bytes`は0〜255の配列、`record`と`list`は再帰的なtagged Value、`unit`は`{"kind":"unit"}`を使います。入力上限は64KiB・32階層・4096値、path/scope/name/operationは各4096 UTF-8 bytesです。ClientはDescriptorに基づいて引数と操作の前提を検証します。
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名ではなく、Observeされたdescriptionをツリー横に表示します。
- 操作成功後に親と選択中Objectを再Observeします。変更可能なPlaygroundのObserveはClientの`refresh_observed`で実取得し、Call結果から子一覧や値を捏造しません。再観測に失敗しても成功済みOperationを再実行しません。
- 子の選択は`/playground/?mode=protocol&object=%2Fplayground%2Fapple`のようなURLで保持します。ページ・履歴・Read/Protocol往復ではProtocol sessionが残り、ブラウザの再読み込みで初期化します。削除済みObjectへの履歴移動はエラーを表示し、復活させません。
- 通常Readは静的な操作説明を表示します。状態の永続化・外部送信・文書変更はありません。
例:createのArgumentsは`{"name":{"kind":"string","value":"banana"}}`、incrementは`{"amount":{"kind":"integer","value":"2"}}`です。
### 実行上の境界 ### 実行上の境界
このサイトの操作は同期・メモリ内・読み取り専用です。入力JSONは16MiB、Object数4096、階層32を上限とし、Clientにもstate/history上限を設けています。JS APIにdeadlineや並行実行中のcancellation設定はなく、HostにはdeadlineなしのExecutionContextを渡します。ブラウザ非対応の`std::time::Instant::now()`を呼ぶdeadline経路は利用しません。将来deadlineを公開する場合はブラウザ対応時計と実行テストが必要です。 このサイトの操作は同期・メモリ内です。文書は読み取り専用で、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を使います。 release WASMは`panic=abort`です。Rustのpanic/trapは`catch_unwind`でProtocol failureへ回復できるとは扱いません。通常の入力・ProtocolエラーはJS例外として返り、サイトは静的表示へfallbackします。trapしたinstanceは以後呼び出し・解放せず、そのブラウザdocumentの残りの寿命は静的表示へfallbackします。ページ切替でOperationを再実行せず、復帰には再読み込みによる新instanceを使います。
+1 -1
View File
@@ -8,7 +8,7 @@ const baseURL = `http://127.0.0.1:${port}${base}/`;
export default defineConfig({ export default defineConfig({
...preview, ...preview,
// Override rather than merge webServer: dev checks must not require dist/. // Override rather than merge webServer: dev checks must not require dist/.
testMatch: ['wip-loading.spec.ts', 'fonts.spec.ts'], testMatch: ['wip-loading.spec.ts', 'fonts.spec.ts', 'protocol.spec.ts', 'protocol-api.spec.ts', 'playground.spec.ts'],
use: { ...preview.use, baseURL }, use: { ...preview.use, baseURL },
webServer: { webServer: {
command: `npm run dev -- --host 127.0.0.1 --port ${port} --ignore-lock`, command: `npm run dev -- --host 127.0.0.1 --port ${port} --ignore-lock`,
+19
View File
@@ -0,0 +1,19 @@
---
import { catalogs, type CatalogName } from '../lib/catalog';
interface Props { name: CatalogName; }
const { name } = Astro.props;
const catalog = catalogs[name];
---
<div class="catalog-page" data-catalog={name}>
<h1>{catalog.title}</h1>
<p>{catalog.description}</p>
<ul class="catalog-list">
{catalog.entries.map((entry) => <li>
<h2>{entry.name}</h2>
<p>{entry.description}</p>
<ul aria-label={`${entry.name} links`}>
{entry.links.map((link) => <li><a href={link.href}>{link.label} <span aria-hidden="true">↗</span></a></li>)}
</ul>
</li>)}
</ul>
</div>
+4 -2
View File
@@ -1,15 +1,16 @@
--- ---
import { documents, bookHref, title, href } from '../lib/book'; import { documents, bookHref, title, href } from '../lib/book';
import { bookTree, childrenOf, treePrefix, serializeTree } from '../lib/landing-tree'; import { publicTree, childrenOf, treePrefix, serializeTree } from '../lib/landing-tree';
import '../styles/landing.css'; import '../styles/landing.css';
import TreePrefix from './TreePrefix.astro'; import TreePrefix from './TreePrefix.astro';
import TreeGlyphs from './TreeGlyphs.astro'; import TreeGlyphs from './TreeGlyphs.astro';
import ProtocolPanel from './ProtocolPanel.astro';
import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs'; import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs';
interface Props { currentPath?: string; label?: string; draft?: boolean; } interface Props { currentPath?: string; label?: string; draft?: boolean; }
const entries = await documents(); const entries = await documents();
const { currentPath = '/', label, draft = false } = Astro.props; const { currentPath = '/', label, draft = false } = Astro.props;
const folderHref = (path: string) => path === '/' ? href() : path === '/book' ? href('book/') : href(`book${path}/`); const folderHref = (path: string) => path === '/' ? href() : path === '/book' ? href('book/') : href(`book${path}/`);
const objects = bookTree(entries.map((entry) => ({ id: entry.id, title: title(entry), href: bookHref(entry.id) }))) const objects = publicTree(entries.map((entry) => ({ id: entry.id, title: title(entry), href: bookHref(entry.id) })), import.meta.env.BASE_URL)
.map((object) => ({ ...object, href: object.href ?? folderHref(object.path) })); .map((object) => ({ ...object, href: object.href ?? folderHref(object.path) }));
const current = objects.find((object) => object.path === currentPath); const current = objects.find((object) => object.path === currentPath);
const initialContent = entries.find((entry) => `/${entry.id}.md` === currentPath)?.rendered?.html; const initialContent = entries.find((entry) => `/${entry.id}.md` === currentPath)?.rendered?.html;
@@ -72,6 +73,7 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1)
</div> </div>
</div> </div>
<div class="document-pane" data-reveal> <div class="document-pane" data-reveal>
<ProtocolPanel />
{draft && <aside class="draft-notice"><strong>Draft / 検討中の文書</strong><p>このページは草案です。確定したProtocolの契約は正本を参照してください。</p></aside>} {draft && <aside class="draft-notice"><strong>Draft / 検討中の文書</strong><p>このページは草案です。確定したProtocolの契約は正本を参照してください。</p></aside>}
<article class="selected-content prose" data-selected-content aria-label={label ?? current?.title ?? currentPath} tabindex="0"> <article class="selected-content prose" data-selected-content aria-label={label ?? current?.title ?? currentPath} tabindex="0">
<slot><Fragment set:html={initialContent} /></slot> <slot><Fragment set:html={initialContent} /></slot>
+14
View File
@@ -0,0 +1,14 @@
---
import { href } from '../lib/book';
---
<h1>Playground</h1>
<p>ProtocolのOperationでObjectを作り、状態を変えるメモリ内のデモです。<a href={`${href('playground/')}?mode=protocol`}>Protocolで開く</a>と、実際のObserveでカウンターを取得します。</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と値を更新します。名前は1〜32文字の英数字・<code>_</code>・<code>-</code>、カウンターは最大32個です。</p>
<p>Argumentsの入力例:</p>
<pre><code>{'{"name":{"kind":"string","value":"banana"}}'}</code></pre>
<pre><code>{'{"amount":{"kind":"integer","value":"2"}}'}</code></pre>
<p>状態はこのタブのProtocol session内だけに保存され、再読み込みで初期状態に戻ります。文書Objectや他の利用者の状態は変更しません。JavaScriptやWASMが利用できない場合は、この説明のみ表示します。</p>
+20
View File
@@ -0,0 +1,20 @@
---
import '../styles/protocol.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>
<div class="protocol-operation" data-protocol-operation-controls hidden>
<label>Operation <select data-protocol-operation disabled aria-label="Operation"><option value="">未選択</option></select></label>
<label>Arguments (tagged JSON record)
<textarea data-protocol-arguments rows="4" spellcheck="false" maxlength="65536" disabled>{'{}'}</textarea>
</label>
<button type="button" data-protocol-call disabled>Execute Operation</button>
</div>
<pre tabindex="0" data-protocol-result aria-label="Operation result" hidden></pre>
</section>
+9 -4
View File
@@ -1,5 +1,5 @@
--- ---
import { href, repository } from '../lib/book'; import { href } from '../lib/book';
--- ---
<div class="root-page" data-root-page> <div class="root-page" data-root-page>
<header class="root-heading"> <header class="root-heading">
@@ -14,10 +14,15 @@ import { href, repository } from '../lib/book';
<span class="root-destination">Book<span class="root-detail">コンセプトと仕様</span></span> <span class="root-destination">Book<span class="root-detail">コンセプトと仕様</span></span>
<span class="root-arrow" aria-hidden="true">→</span> <span class="root-arrow" aria-hidden="true">→</span>
</a> </a>
<a href={repository}> <a href={href('sources/')}>
<span class="root-number" aria-hidden="true">02</span> <span class="root-number" aria-hidden="true">02</span>
<span class="root-destination">Source<span class="root-detail">ソースと変更履歴</span></span> <span class="root-destination">Sources<span class="root-detail">仕様と実装のソース</span></span>
<span class="root-arrow" aria-hidden="true">↗</span> <span class="root-arrow" aria-hidden="true">→</span>
</a>
<a href={href('packages/')}>
<span class="root-number" aria-hidden="true">03</span>
<span class="root-destination">Packages<span class="root-detail">Rustパッケージ一覧</span></span>
<span class="root-arrow" aria-hidden="true">→</span>
</a> </a>
</nav> </nav>
</div> </div>
+1 -1
View File
@@ -14,7 +14,7 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin
<script is:inline define:vars={{ intro }}> <script is:inline define:vars={{ intro }}>
// Only a fresh visit to the landing route plays the introduction. // Only a fresh visit to the landing route plays the introduction.
// The window flag survives client navigation, but not a full reload. // The window flag survives client navigation, but not a full reload.
if (intro && !window.__wipVisited && !matchMedia('(prefers-reduced-motion: reduce)').matches && !location.hash) { if (intro && new URL(location.href).searchParams.get('mode') !== 'protocol' && !window.__wipVisited && !matchMedia('(prefers-reduced-motion: reduce)').matches && !location.hash) {
document.documentElement.dataset.landing = 'boot'; document.documentElement.dataset.landing = 'boot';
setTimeout(() => { setTimeout(() => {
if (document.documentElement.dataset.landing === 'boot') delete document.documentElement.dataset.landing; if (document.documentElement.dataset.landing === 'boot') delete document.documentElement.dataset.landing;
+64
View File
@@ -0,0 +1,64 @@
/** Root Objects are documents, not containers for the links in their bodies. */
export const catalogPages = [
{ name: 'sources', path: '/sources', title: 'Sources' },
{ name: 'packages', path: '/packages', title: 'Packages' },
] as const;
export type CatalogName = typeof catalogPages[number]['name'];
export interface CatalogEntry {
name: string;
description: string;
links: readonly { label: string; href: string }[];
}
export interface Catalog {
title: string;
description: string;
entries: readonly CatalogEntry[];
}
const rustRepository = 'https://gitea.hareworks.net/Hare/wip-rs';
// README's published-crate order; descriptions are copied from each Cargo manifest.
// These manifests permit publication. Registry availability/version is not inferred.
const rustCrates = [
{ name: 'wip-protocol', description: 'Transport-independent logical model and validation for WIP' },
{ name: 'wip-interface-macros', description: 'Procedural macros for WIP interface declarations and DTO codecs' },
{ name: 'wip-interface', description: 'Public Rust authoring API for WIP interfaces and operation value codecs' },
{ name: 'wip-host', description: 'Host-side object publication and operation dispatch for WIP' },
{ name: 'wip-http', description: 'Framework-independent standard WIP over HTTP v1 binding' },
{ name: 'wip-client', description: 'Presentation-independent stateful WIP client runtime' },
{ name: 'wip-text-view', description: 'Standalone Object and Interface Text View signatures for WIP' },
];
/** Entries are verified against source manifests/READMEs, not registry versions. */
export const catalogs: Record<CatalogName, Catalog> = {
sources: {
title: 'Sources',
description: 'WIPの仕様文書と実装のソースリポジトリ。',
entries: [
{
name: 'wip-reference',
description: 'WIPのコンセプト・仕様文書と、この文書サイト。',
links: [{ label: 'Source', href: 'https://gitea.hareworks.net/Hare/wip-reference' }],
},
{
name: 'wip-rs',
description: 'WIPのProtocol・Interface・Host・HTTP・Client・Text ViewのRust実装。',
links: [{ label: 'Source', href: rustRepository }],
},
],
},
packages: {
title: 'Packages',
description: 'Rust実装のCargo manifestで公開対象になっているcrateの一覧です。レジストリでの公開状況・公開バージョンを示すものではありません。',
entries: rustCrates.map((crate) => ({
...crate,
links: [
{ label: 'Source', href: `${rustRepository}/src/branch/master/crates/${crate.name}` },
{ label: 'Workspace README', href: `${rustRepository}/src/branch/master/README.md` },
],
})),
},
};
+13 -2
View File
@@ -1,3 +1,6 @@
import { catalogPages } from './catalog.ts';
import { playgroundPage } from './playground.ts';
export interface DocumentSource { export interface DocumentSource {
id: string; id: string;
title: string; title: string;
@@ -53,14 +56,22 @@ export function chapterTree(documents: readonly DocumentSource[]): TreeNode[] {
return [nodes[0], ...visit(nodes[0])]; return [nodes[0], ...visit(nodes[0])];
} }
// The public explorer has a single book/ container below the site root. // The public explorer has a book Object below the site root.
export function bookTree(documents: readonly DocumentSource[]): TreeNode[] { export function bookTree(documents: readonly DocumentSource[]): TreeNode[] {
const [root, ...chapters] = chapterTree(documents); const [root, ...chapters] = chapterTree(documents);
return [root, { name: 'book/', path: '/book', parent: '/', depth: 2 }, ...chapters.map((node) => ({ return [root, { name: 'book', path: '/book', parent: '/', depth: 2 }, ...chapters.map((node) => ({
...node, parent: node.parent === '/' ? '/book' : node.parent, depth: node.depth + 1, ...node, parent: node.parent === '/' ? '/book' : node.parent, depth: node.depth + 1,
}))]; }))];
} }
/** Catalogs are leaves; Playground children are discovered through live Observe. */
export function publicTree(documents: readonly DocumentSource[], base = '/'): TreeNode[] {
return [...bookTree(documents), ...[...catalogPages, playgroundPage].map((page) => ({
...page, parent: '/', depth: 2,
href: `${base.replace(/\/$/, '')}${page.path}/`,
}))];
}
export const childrenOf = (nodes: readonly TreeNode[], path: string) => nodes.filter((node) => node.parent === path); export const childrenOf = (nodes: readonly TreeNode[], path: string) => nodes.filter((node) => node.parent === path);
export function visibleNodes(nodes: readonly TreeNode[], collapsed: ReadonlySet<string>): TreeNode[] { export function visibleNodes(nodes: readonly TreeNode[], collapsed: ReadonlySet<string>): TreeNode[] {
+2
View File
@@ -0,0 +1,2 @@
export const playgroundPage = { name: 'playground', path: '/playground', title: 'Playground' } as const;
export const isPlaygroundPath = (path: string) => path === '/playground' || /^\/playground\/[A-Za-z0-9_-]{1,32}$/.test(path);
+2 -2
View File
@@ -1,4 +1,4 @@
import { bookTree, type DocumentSource } from './landing-tree.ts'; import { publicTree, type DocumentSource } from './landing-tree.ts';
export interface RenderedDocument extends DocumentSource { export interface RenderedDocument extends DocumentSource {
html: string; html: string;
@@ -45,7 +45,7 @@ export function createWorldspaceData(
} }
const paths = new Map<string, string>(); const paths = new Map<string, string>();
const hrefs = new Set<string>(); const hrefs = new Set<string>();
const nodes = bookTree(published).map((node): WorldspaceNode => { const nodes = publicTree(published).map((node): WorldspaceNode => {
if (paths.has(node.path)) throw new Error(`Duplicate display path: ${node.path}`); if (paths.has(node.path)) throw new Error(`Duplicate display path: ${node.path}`);
const parent = node.parent === undefined ? undefined : paths.get(node.parent); const parent = node.parent === undefined ? undefined : paths.get(node.parent);
if (node.parent !== undefined && parent === undefined) throw new Error(`Missing parent: ${node.parent}`); if (node.parent !== undefined && parent === undefined) throw new Error(`Missing parent: ${node.parent}`);
+9
View File
@@ -0,0 +1,9 @@
---
import Site from '../../layouts/Site.astro';
import Landing from '../../components/Landing.astro';
import CatalogPage from '../../components/CatalogPage.astro';
import { catalogs } from '../../lib/catalog';
---
<Site title={catalogs.packages.title} description={catalogs.packages.description}>
<main id="main"><Landing currentPath="/packages" label="Packages"><CatalogPage name="packages" /></Landing></main>
</Site>
+8
View File
@@ -0,0 +1,8 @@
---
import Site from '../../layouts/Site.astro';
import Landing from '../../components/Landing.astro';
import PlaygroundPage from '../../components/PlaygroundPage.astro';
---
<Site title="Playground" description="実ProtocolでObjectとカウンターを操作するデモ。">
<main id="main"><Landing currentPath="/playground" label="Playground"><PlaygroundPage /></Landing></main>
</Site>
+9
View File
@@ -0,0 +1,9 @@
---
import Site from '../../layouts/Site.astro';
import Landing from '../../components/Landing.astro';
import CatalogPage from '../../components/CatalogPage.astro';
import { catalogs } from '../../lib/catalog';
---
<Site title={catalogs.sources.title} description={catalogs.sources.description}>
<main id="main"><Landing currentPath="/sources" label="Sources"><CatalogPage name="sources" /></Landing></main>
</Site>
+14
View File
@@ -3,6 +3,10 @@ import { render } from 'astro:content';
import { experimental_AstroContainer as AstroContainer } from 'astro/container'; import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import RootPage from '../components/RootPage.astro'; import RootPage from '../components/RootPage.astro';
import DirectoryIndex from '../components/DirectoryIndex.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 { documents, bookHref, title, href } from '../lib/book';
import { bookTree } from '../lib/landing-tree'; import { bookTree } from '../lib/landing-tree';
import { createWorldspaceData, type RenderedPage } from '../lib/worldspace-data'; import { createWorldspaceData, type RenderedPage } from '../lib/worldspace-data';
@@ -25,6 +29,10 @@ export const GET: APIRoute = async () => {
title: 'Worldspace Interface Protocol', href: href(), title: 'Worldspace Interface Protocol', href: href(),
html: await container.renderToString(RootPage), html: await container.renderToString(RootPage),
}, },
[playgroundPage.path]: {
title: playgroundPage.title, href: href('playground/'),
html: await container.renderToString(PlaygroundPage),
},
'/book': { '/book': {
title: 'Book', href: href('book/'), title: 'Book', href: href('book/'),
html: await container.renderToString(DirectoryIndex), html: await container.renderToString(DirectoryIndex),
@@ -36,6 +44,12 @@ export const GET: APIRoute = async () => {
html: await container.renderToString(DirectoryIndex, { props: { path: 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)), { return new Response(JSON.stringify(createWorldspaceData(rendered, pages)), {
headers: { 'Content-Type': 'application/json; charset=utf-8' }, headers: { 'Content-Type': 'application/json; charset=utf-8' },
}); });
+1 -3
View File
@@ -128,9 +128,7 @@ export async function readWipPage(displayPath: string | undefined, fallback: rea
const node = displayed.get(old.path); const node = displayed.get(old.path);
const parent = node?.parent === undefined ? undefined : canonical.get(node.parent)?.displayPath; 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'); if (!node || (node.parent !== undefined && !parent) || parent !== old.parent) throw new Error('Incompatible WIP tree topology');
// The canonical segment is `book`; the existing folder label includes `/`. return { path: node.displayPath, parent, depth: old.depth, name: node.name, title: node.title, href: node.href };
const name = node.displayPath === '/book' ? `${node.name.replace(/\/$/, '')}/` : node.name;
return { path: node.displayPath, parent, depth: old.depth, name, title: node.title, href: node.href };
}); });
const selected = displayPath === undefined ? undefined : displayed.get(displayPath); const selected = displayPath === undefined ? undefined : displayed.get(displayPath);
if (!selected) throw new Error('Route is not a public WIP document'); if (!selected) throw new Error('Route is not a public WIP document');
+81 -15
View File
@@ -2,6 +2,8 @@ import { animate, createTimeline, cubicBezier } from 'animejs';
import { childrenOf, visibleNodes, treePrefix, type TreeNode } from '../lib/landing-tree'; import { childrenOf, visibleNodes, treePrefix, type TreeNode } from '../lib/landing-tree';
import { glitchText } from '../lib/landing-ascii.mjs'; import { glitchText } from '../lib/landing-ascii.mjs';
import { applyWipDocument, readWipPage } from './browser-wip'; import { applyWipDocument, readWipPage } from './browser-wip';
import { isProtocolMode, setupProtocol } from './protocol';
import { playgroundNodes, playgroundRow, type PlaygroundChild } from './playground-tree';
const clamp = (value: number) => Math.max(0, Math.min(1, value)); const clamp = (value: number) => Math.max(0, Math.min(1, value));
const progress = (time: number, start: number, duration: number) => clamp((time - start) / duration); const progress = (time: number, start: number, duration: number) => clamp((time - start) / duration);
@@ -28,7 +30,8 @@ const circularTrack = 'M0 -180 A180 180 0 1 1 0 180 A180 180 0 1 1 0 -180';
export async function initLanding(lifetime: AbortSignal) { export async function initLanding(lifetime: AbortSignal) {
const element = document.querySelector<HTMLElement>('[data-landing-stage]'); const element = document.querySelector<HTMLElement>('[data-landing-stage]');
if (!element) return; if (!element) return;
element.dataset.wipState = 'loading'; element.dataset.wipState = isProtocolMode() ? 'manual' : 'loading';
if (isProtocolMode()) delete document.documentElement.dataset.landing;
// Keep the visible boot controls usable while font metrics are loading. // Keep the visible boot controls usable while font metrics are loading.
const boot = new AbortController(); const boot = new AbortController();
let bootStart = false; let bootStart = false;
@@ -62,6 +65,11 @@ export async function initLanding(lifetime: AbortSignal) {
lifetime.removeEventListener('abort', abortBoot); lifetime.removeEventListener('abort', abortBoot);
if (lifetime.aborted || !element.isConnected) return; if (lifetime.aborted || !element.isConnected) return;
const stage: HTMLElement = element; const stage: HTMLElement = element;
// The persisted pane may contain live rows from the outgoing Protocol page.
// Rebuild them from that session's observation after restoring the static tree.
let pendingPlaygroundFocus = stage.contains(document.activeElement)
? (document.activeElement as HTMLElement)?.closest<HTMLElement>('[data-playground-row] .demo-node')?.dataset.path : undefined;
stage.querySelectorAll('[data-playground-row], [data-playground-toggle]').forEach(row => row.remove());
const objects: TreeNode[] = JSON.parse(stage.querySelector('#landing-documents')!.textContent!); const objects: TreeNode[] = JSON.parse(stage.querySelector('#landing-documents')!.textContent!);
const currentPath = stage.dataset.currentPath; const currentPath = stage.dataset.currentPath;
const html = document.documentElement; const html = document.documentElement;
@@ -214,6 +222,13 @@ export async function initLanding(lifetime: AbortSignal) {
indicator.textContent = expanded ? '−' : '+'; indicator.textContent = expanded ? '−' : '+';
indicator.setAttribute('aria-expanded', String(expanded)); indicator.setAttribute('aria-expanded', String(expanded));
} }
} else {
nodes[index].removeAttribute('aria-expanded');
}
if (object.parent) {
const siblings = childrenOf(objects, object.parent);
nodes[index].setAttribute('aria-setsize', String(siblings.length));
nodes[index].setAttribute('aria-posinset', String(siblings.findIndex(sibling => sibling.path === object.path) + 1));
} }
const prefixElement = rows[index].querySelector<HTMLElement>('.branch-prefix'); const prefixElement = rows[index].querySelector<HTMLElement>('.branch-prefix');
if (prefixElement && paths.has(object.path)) { if (prefixElement && paths.has(object.path)) {
@@ -234,20 +249,22 @@ export async function initLanding(lifetime: AbortSignal) {
} }
} }
// Ordinary anchors own navigation (including modifier clicks and no-JS). // Delegate tree interaction so observed Playground children share keyboard/focus behavior.
nodes.forEach((node, index) => node.addEventListener('click', (event) => { tree.addEventListener('click', (event) => {
if (state !== 'complete') { event.preventDefault(); return; } const node = (event.target as Element).closest<HTMLElement>('.demo-node');
focusNode(index); if (node) {
}, { signal: lifetime })); if (state !== 'complete') { event.preventDefault(); return; }
stage.querySelectorAll<HTMLButtonElement>('[data-toggle-path]').forEach((toggle) => { const index = nodes.indexOf(node);
toggle.addEventListener('click', () => { if (index >= 0) focusNode(index);
if (state !== 'complete') return; }
const toggle = (event.target as Element).closest<HTMLElement>('[data-toggle-path]');
if (toggle && state === 'complete') {
const path = toggle.dataset.togglePath!; const path = toggle.dataset.togglePath!;
collapsed.has(path) ? collapsed.delete(path) : collapsed.add(path); collapsed.has(path) ? collapsed.delete(path) : collapsed.add(path);
updateExpanded(); updateExpanded();
focusNode(objects.findIndex((object) => object.path === path), true); focusNode(objects.findIndex((object) => object.path === path), true);
}, { signal: lifetime }); }
}); }, { signal: lifetime });
tree.addEventListener('keydown', (event) => { tree.addEventListener('keydown', (event) => {
if (state !== 'complete' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; if (state !== 'complete' || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
@@ -448,8 +465,13 @@ export async function initLanding(lifetime: AbortSignal) {
// Enhancement never blocks the intro, static article, or native link routing. // Enhancement never blocks the intro, static article, or native link routing.
// Each route owns this continuation, while browser-wip owns the shared WASM. // Each route owns this continuation, while browser-wip owns the shared WASM.
void readWipPage(currentPath, objects, lifetime).then((page) => { let enhancement: AbortController | undefined;
if (!page || lifetime.aborted || !stage.isConnected) return; 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]'); const article = get<HTMLElement>('[data-selected-content]');
applyWipDocument(article, page); applyWipDocument(article, page);
objects.splice(0, objects.length, ...page.objects); objects.splice(0, objects.length, ...page.objects);
@@ -482,11 +504,55 @@ export async function initLanding(lifetime: AbortSignal) {
stage.dataset.wipCalls = String(page.stats.calls); stage.dataset.wipCalls = String(page.stats.calls);
stage.dataset.wipState = 'ready'; stage.dataset.wipState = 'ready';
}).catch(() => { }).catch(() => {
if (lifetime.aborted || !stage.isConnected) return; if (normalSignal.aborted || !stage.isConnected || isProtocolMode()) return;
// A failed enhancement is not WIP success. SSR and ordinary anchors remain // A failed enhancement is not WIP success. SSR and ordinary anchors remain
// usable, including unknown routes whose static 404 must not become HOME. // usable, including unknown routes whose static 404 must not become HOME.
stage.dataset.wipState = 'fallback'; 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; let navigationInstalled = false;
+36
View File
@@ -0,0 +1,36 @@
import type { TreeNode } from '../lib/landing-tree';
export interface PlaygroundChild { object: { name: string; description?: string | null }; }
/** Only Observe data supplies names and values. Never project an Operation result. */
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)
|| !/^-?\d+$/.test(object.description ?? '')) 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, title: object.description!, 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}, ${node.title}`);
const label = document.createElement('span'); label.className = 'node-label'; label.textContent = node.name;
const value = document.createElement('span'); value.className = 'node-value'; value.textContent = node.title!;
anchor.append(label, value); row.append(prefix, anchor);
return row;
}
+338
View File
@@ -0,0 +1,338 @@
import type { TreeNode } from '../lib/landing-tree';
import { isPlaygroundPath } from '../lib/playground';
import type { PlaygroundChild } from './playground-tree';
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: { name: string; parameters: { name: string; required: boolean; type: { kind: string } }[] }[]; }
interface Observed { path: string; observation: Observation; textView: string; cached: boolean; stats: Stats; }
interface Fetched { reference: Reference; descriptor: Descriptor; textView: string; 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: HTMLPreElement }[] = [];
const operations = find<HTMLSelectElement>('[data-protocol-operation]');
const args = find<HTMLTextAreaElement>('[data-protocol-arguments]');
const callButton = find<HTMLButtonElement>('[data-protocol-call]');
const objectView = find<HTMLElement>('[data-object-view]');
const result = find<HTMLElement>('[data-protocol-result]');
const status = find<HTMLElement>('[data-protocol-status]');
const operationControls = find<HTMLElement>('[data-protocol-operation-controls]');
const showStatus = (text: string) => { status.textContent = text; status.hidden = !text; };
const disableControls = () => panel.querySelectorAll<HTMLButtonElement | HTMLSelectElement | HTMLTextAreaElement>('button, 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 = () => {
fetched = undefined;
operationControls.hidden = true;
for (const { reference, button, view } of interfaceRows) {
button.textContent = `${knownInterfaces.has(referenceKey(reference)) ? '>' : '?'} ${reference.scope}::${reference.name}`;
button.setAttribute('aria-expanded', 'false');
view.hidden = true;
view.textContent = '';
}
operations.replaceChildren(new Option('未選択', ''));
operations.disabled = true;
args.value = '{}'; args.disabled = true; callButton.disabled = true;
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 });
operations.addEventListener('change', () => {
resetResult();
const parameters = fetched?.descriptor.operations.find(operation => operation.name === operations.value)?.parameters ?? [];
const template = Object.fromEntries(parameters.filter(parameter => parameter.required).flatMap(parameter => {
if (parameter.type.kind === 'string') return [[parameter.name, { kind: 'string', value: '' }]];
if (parameter.type.kind === 'integer') return [[parameter.name, { kind: 'integer', value: '1' }]];
return [];
}));
args.value = JSON.stringify(template, null, 2); callButton.disabled = !operations.value;
}, { signal });
args.addEventListener('input', resetResult, { signal });
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;
objectView.textContent = observed.textView; 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('pre');
view.className = 'protocol-signature'; view.dataset.interfaceView = '';
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();
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');
view.textContent = value.textView; view.hidden = false;
operations.replaceChildren(new Option('Operationを選択', ''));
for (const operation of value.descriptor.operations) operations.add(new Option(operation.name, operation.name));
operationControls.hidden = false;
operations.disabled = false; args.disabled = 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 });
callButton.addEventListener('click', () => run((session) => {
resetResult();
if (!fetched || !operations.value || selectedPath !== observedPath) throw new Error('Object/Interface/Operationを選択してください。');
const ref = fetched.reference;
const called: Called = JSON.parse(invoke(() => session.call_operation(selectedPath, ref.scope, ref.name, operations.value, args.value)));
result.textContent = 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)));
objectView.textContent = observed.textView; 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 });
signal.addEventListener('abort', () => { generation++; }, { once: true });
showMode();
}
+4
View File
@@ -37,6 +37,10 @@ html[data-landing] .tree-viewport { overflow: visible; }
.node-indicator { color: var(--muted); } .node-indicator { color: var(--muted); }
.node-toggle { display: none; border: 0; padding: 0 1ch; height: var(--row); background: transparent; cursor: pointer; } .node-toggle { display: none; border: 0; padding: 0 1ch; height: var(--row); background: transparent; cursor: pointer; }
.demo-tree[data-enhanced] .node-toggle { display: block; } .demo-tree[data-enhanced] .node-toggle { display: block; }
@media (hover: hover) {
.node-toggle { opacity: 0; pointer-events: none; }
.node-row:hover .node-toggle, .node-row:focus-within .node-toggle { opacity: 1; pointer-events: auto; }
}
.node-toggle:hover { background: var(--node-hover); } .node-toggle:hover { background: var(--node-hover); }
html[data-landing] .node-toggle { visibility: hidden; } html[data-landing] .node-toggle { visibility: hidden; }
.document-pane { min-width: 0; } .document-pane { min-width: 0; }
+19
View File
@@ -0,0 +1,19 @@
.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, select, textarea, pre):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 { 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-operation, .protocol-panel [data-protocol-result] { margin-top: 1ch; }
.protocol-interface { margin-block: .5ch; }
.protocol-panel .protocol-interface > button { border: 0; background: transparent; padding: .25ch .5ch; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
.protocol-interface > button:hover { background: var(--node-hover); }
[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; } }
+19
View File
@@ -0,0 +1,19 @@
import { expect, test } from '@playwright/test';
for (const javaScriptEnabled of [false, true]) {
test.describe(javaScriptEnabled ? 'WASM tree' : 'static tree', () => {
test.use({ javaScriptEnabled, reducedMotion: 'reduce' });
test('book uses its Object name without a directory suffix', async ({ page }, info) => {
await page.goto('./');
if (javaScriptEnabled) await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
const book = page.locator('.demo-node[data-path="/book"]');
await expect(book.locator('.node-label')).toHaveText('book');
await expect(book).toHaveAttribute('aria-label', 'book');
await expect(book).toHaveAttribute('href', new URL('book/', info.project.use.baseURL!).pathname);
await book.click();
await expect(page).toHaveURL(new URL('book/', info.project.use.baseURL!).href);
if (javaScriptEnabled) await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await expect(book.locator('.node-label')).toHaveText('book');
});
});
}
+6 -2
View File
@@ -23,7 +23,8 @@ export const draftPaths = [
'/draft/typescript-api-model.md', '/draft/typescript-api-model.md',
'/draft/unresolved-issues.md', '/draft/unresolved-issues.md',
]; ];
export const treePaths = ['/', '/book', ...documentPaths]; export const catalogPaths = ['/sources', '/packages'];
export const treePaths = ['/', '/book', ...documentPaths, ...catalogPaths, '/playground'];
export const treeNodeCount = treePaths.length; export const treeNodeCount = treePaths.length;
export const chapterSections: Record<string, string[]> = { export const chapterSections: Record<string, string[]> = {
'/1-concept.md': ['/1.1-interaction-abstraction.md', '/1.2-existing-abstractions.md', '/1.3-design-principles.md', '/1.4-concepts-and-scope.md'], '/1-concept.md': ['/1.1-interaction-abstraction.md', '/1.2-existing-abstractions.md', '/1.3-design-principles.md', '/1.4-concepts-and-scope.md'],
@@ -31,9 +32,12 @@ export const chapterSections: Record<string, string[]> = {
'/4-client.md': ['/4.1-ai-tools.md', '/4.2-text-view.md'], '/4-client.md': ['/4.1-ai-tools.md', '/4.2-text-view.md'],
'/5-transports.md': ['/5.1-wip-over-http.md'], '/5-transports.md': ['/5.1-wip-over-http.md'],
}; };
export const treeParents: Record<string, string> = Object.fromEntries(treePaths.filter((path) => path !== '/').map((path) => [path, path === '/book' ? '/' : '/book'])); export const treeParents: Record<string, string> = Object.fromEntries(treePaths.filter((path) => path !== '/').map((path) => [path, path === '/book' || catalogPaths.includes(path) || path === '/playground' ? '/' : '/book']));
for (const [chapter, sections] of Object.entries(chapterSections)) { for (const [chapter, sections] of Object.entries(chapterSections)) {
for (const section of sections) treeParents[section] = chapter; for (const section of sections) treeParents[section] = chapter;
} }
export const rootChildCount = treePaths.filter((path) => treeParents[path] === '/').length; export const rootChildCount = treePaths.filter((path) => treeParents[path] === '/').length;
export const routeURL = (path: string, base: string) => path === '/' ? new URL('./', base).href
: path === '/book' || catalogPaths.includes(path) || path === '/playground' ? new URL(`${path.slice(1)}/`, base).href : bookURL(path, base);
export const bookURL = (path: string, base: string) => new URL(`book/${path.slice(1, -3).split('/').map(encodeURIComponent).join('/')}/`, base).href; export const bookURL = (path: string, base: string) => new URL(`book/${path.slice(1, -3).split('/').map(encodeURIComponent).join('/')}/`, base).href;
+3 -3
View File
@@ -92,7 +92,7 @@ test.describe('static font and tree rendering', () => {
const gapEnd = new DOMPoint((3 * (level - 2) + 3) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!); const gapEnd = new DOMPoint((3 * (level - 2) + 3) * Number(svg.dataset.cellWidth), 12).matrixTransform(svg.getScreenCTM()!);
return { x: p.x, y: p.y, end: end.x, gapEnd: gapEnd.x, level }; return { x: p.x, y: p.y, end: end.x, gapEnd: gapEnd.x, level };
})); }));
expect(rootChildCount).toBe(1); expect(rootChildCount).toBe(4);
expect(joins.filter(({ level }) => level === 2)).toHaveLength(rootChildCount); expect(joins.filter(({ level }) => level === 2)).toHaveLength(rootChildCount);
expect(joins.filter(({ level }) => level === 3)).toHaveLength(Object.values(treeParents).filter((parent) => parent === '/book').length); expect(joins.filter(({ level }) => level === 3)).toHaveLength(Object.values(treeParents).filter((parent) => parent === '/book').length);
for (const [index, join] of joins.entries()) { for (const [index, join] of joins.entries()) {
@@ -149,10 +149,10 @@ test.describe('static font and tree rendering', () => {
await expect(page.locator('html')).not.toHaveAttribute('data-landing'); await expect(page.locator('html')).not.toHaveAttribute('data-landing');
await expect(page.locator('.trunk-line')).toBeVisible(); await expect(page.locator('.trunk-line')).toBeVisible();
await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none'); await expect(page.locator('.trunk-line')).toHaveCSS('stroke', 'none');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(0); await expect(page.locator('.branch-prefix').first().locator('path')).toHaveCount(0);
await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(3); await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(3);
await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9492'); await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9500');
await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5); await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5);
await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible(); await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
+21 -17
View File
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { documentPaths, treePaths, treeNodeCount, bookURL, chapterSections, treeParents } from './documents'; import { documentPaths, treePaths, treeNodeCount, bookURL, routeURL, chapterSections, treeParents } from './documents';
test.use({ javaScriptEnabled: true }); test.use({ javaScriptEnabled: true });
@@ -11,7 +11,7 @@ const visiblePrefixes = '.node-row:not([hidden]) .branch-prefix';
async function expectNodeGlitch(page: import('@playwright/test').Page) { async function expectNodeGlitch(page: import('@playwright/test').Page) {
expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.some((node) => expect(await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.some((node) =>
node.querySelector('.node-label')!.textContent !== ((node as HTMLElement).dataset.path === '/book' ? 'book/' : (node as HTMLElement).dataset.path!.split('/').at(-1)) node.querySelector('.node-label')!.textContent !== ((node as HTMLElement).dataset.path === '/book' ? 'book' : (node as HTMLElement).dataset.path!.split('/').at(-1))
))).toBe(true); ))).toBe(true);
} }
@@ -511,7 +511,7 @@ for (const time of [1500, 2600]) {
for (const row of await page.locator('.node-row').all()) await expect(row).toHaveCSS('transform', 'none'); for (const row of await page.locator('.node-row').all()) await expect(row).toHaveCSS('transform', 'none');
await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none'); await expect(page.locator('.root-mark')).toHaveCSS('transform', 'none');
await expect(page.locator('.site-header')).toHaveCSS('opacity', '1'); await expect(page.locator('.site-header')).toHaveCSS('opacity', '1');
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
await expect(page.locator('.intro-art')).toBeHidden(); await expect(page.locator('.intro-art')).toBeHidden();
await page.clock.runFor(4000); await page.clock.runFor(4000);
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
@@ -533,7 +533,7 @@ test('skipping an active node glitch restores exact filenames without altering d
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');
await complete(page); await complete(page);
await expect(page.locator('[data-glitching]')).toHaveCount(0); await expect(page.locator('[data-glitching]')).toHaveCount(0);
for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path === '/book' ? 'book/' : path.split('/').at(-1)!); for (const path of treePaths.slice(1)) await expect(nodeAt(page, path).locator('.node-label')).toHaveText(path === '/book' ? 'book' : path.split('/').at(-1)!);
await page.clock.runFor(2000); await page.clock.runFor(2000);
expect(await page.locator(selectedContent).innerHTML()).toBe(html); expect(await page.locator(selectedContent).innerHTML()).toBe(html);
await page.locator('.root-mark').hover(); await page.locator('.root-mark').hover();
@@ -578,7 +578,7 @@ test('landing contains only the real filename tree and selected canonical conten
await expect(folder).toHaveAttribute('aria-expanded', 'true'); await expect(folder).toHaveAttribute('aria-expanded', 'true');
await expect(folder).toHaveAttribute('aria-level', path === '/' ? '1' : '2'); await expect(folder).toHaveAttribute('aria-level', path === '/' ? '1' : '2');
if (path === '/book') { if (path === '/book') {
await expect(folder.locator('.node-label')).toHaveText('book/'); await expect(folder.locator('.node-label')).toHaveText('book');
const toggle = page.locator('.node-toggle[data-toggle-path="/book"]'); const toggle = page.locator('.node-toggle[data-toggle-path="/book"]');
await expect(toggle).toHaveText('−'); await expect(toggle).toHaveText('−');
expect(await toggle.evaluate((button) => button.tagName)).toBe('BUTTON'); expect(await toggle.evaluate((button) => button.tagName)).toBe('BUTTON');
@@ -617,7 +617,8 @@ test('landing contains only the real filename tree and selected canonical conten
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
await expect(page.locator(selectedContent + ' .root-page[data-root-page]')).toHaveCount(1); await expect(page.locator(selectedContent + ' .root-page[data-root-page]')).toHaveCount(1);
await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト'); await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト');
expect(await page.locator('.landing h1, .landing h2, .landing h3, .landing h4, .landing h5, .landing h6').evaluateAll((headings) => headings.every((heading) => heading.closest('[data-selected-content]')))).toBe(true); await expect(page.locator('[data-protocol-panel]')).toBeHidden();
expect(await page.locator('.landing :is(h1, h2, h3, h4, h5, h6):visible').evaluateAll((headings) => headings.every((heading) => heading.closest('[data-selected-content]')))).toBe(true);
await checkGlyphRefs(page); await checkGlyphRefs(page);
}); });
@@ -630,9 +631,10 @@ test('numbered chapters nest visually, fold independently, and retain section UR
await expect(chapter).toHaveAttribute('aria-level', '3'); await expect(chapter).toHaveAttribute('aria-level', '3');
await expect(child).toHaveAttribute('aria-level', '4'); await expect(child).toHaveAttribute('aria-level', '4');
const prefix = child.locator('..').locator('.branch-prefix'); const prefix = child.locator('..').locator('.branch-prefix');
await expect(prefix).toHaveAttribute('data-prefix', ' │ ├─ '); await expect(prefix).toHaveAttribute('data-prefix', '│ │ ├─ ');
await expect(nodeAt(page, '/1.4-concepts-and-scope.md').locator('..').locator('.branch-prefix')).toHaveAttribute('data-prefix', ' │ └─ '); await expect(nodeAt(page, '/1.4-concepts-and-scope.md').locator('..').locator('.branch-prefix')).toHaveAttribute('data-prefix', '│ │ └─ ');
const before = await page.locator(selectedContent).innerHTML(); const before = await page.locator(selectedContent).innerHTML();
await nodeAt(page, '/1-concept.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click(); await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
for (const path of chapterSections[initialPath]) await expect(nodeAt(page, path)).toBeHidden(); for (const path of chapterSections[initialPath]) await expect(nodeAt(page, path)).toBeHidden();
await expect(nodeAt(page, '/2-core-model.md')).toBeVisible(); await expect(nodeAt(page, '/2-core-model.md')).toBeVisible();
@@ -717,7 +719,7 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft');
await expect(book).toHaveAttribute('aria-expanded', 'false'); await expect(book).toHaveAttribute('aria-expanded', 'false');
for (const path of documentPaths) await expect(nodeAt(page, path)).toBeHidden(); for (const path of documentPaths) await expect(nodeAt(page, path)).toBeHidden();
await expect(page.getByRole('treeitem')).toHaveCount(2); await expect(page.getByRole('treeitem')).toHaveCount(5);
await unchanged(); await unchanged();
await checkGlyphRefs(page); await checkGlyphRefs(page);
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight');
@@ -743,7 +745,7 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount); await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
await checkGlyphRefs(page); await checkGlyphRefs(page);
await page.keyboard.press('End'); await page.keyboard.press('End');
const finalPath = documentPaths.at(-1)!; const finalPath = '/playground';
const finalLeaf = nodeAt(page, finalPath); const finalLeaf = nodeAt(page, finalPath);
await expect(finalLeaf).toBeFocused(); await expect(finalLeaf).toBeFocused();
await expect(finalLeaf).toHaveAttribute('aria-selected', 'false'); await expect(finalLeaf).toHaveAttribute('aria-selected', 'false');
@@ -754,19 +756,19 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li
if (scroll.needsScroll) expect(scroll.top).toBeGreaterThan(0); if (scroll.needsScroll) expect(scroll.top).toBeGreaterThan(0);
expect(scroll.inside).toBe(true); expect(scroll.inside).toBe(true);
await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp');
const previousPath = '/5-transports.md'; const previousPath = '/packages';
await expect(nodeAt(page, previousPath)).toBeFocused(); await expect(nodeAt(page, previousPath)).toBeFocused();
await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown');
await expect(finalLeaf).toBeFocused(); await expect(finalLeaf).toBeFocused();
await unchanged(); await unchanged();
await expect(page.locator('.demo-node[tabindex="0"]')).toHaveCount(1); await expect(page.locator('.demo-node[tabindex="0"]')).toHaveCount(1);
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await expect(page).toHaveURL(bookURL(finalPath, testInfo.project.use.baseURL!)); await expect(page).toHaveURL(routeURL(finalPath, testInfo.project.use.baseURL!));
await expect(finalLeaf).toHaveAttribute('aria-current', 'page'); await expect(finalLeaf).toHaveAttribute('aria-current', 'page');
await expect(page.locator(selectedContent)).toBeVisible(); await expect(page.locator(selectedContent)).toBeVisible();
await nodeAt(page, previousPath).focus(); await nodeAt(page, previousPath).focus();
await page.keyboard.press('Space'); await page.keyboard.press('Space');
await expect(page).toHaveURL(bookURL(previousPath, testInfo.project.use.baseURL!)); await expect(page).toHaveURL(routeURL(previousPath, testInfo.project.use.baseURL!));
await expect(nodeAt(page, previousPath)).toHaveAttribute('aria-current', 'page'); await expect(nodeAt(page, previousPath)).toHaveAttribute('aria-current', 'page');
await expect(page.locator(selectedContent)).toBeVisible(); await expect(page.locator(selectedContent)).toBeVisible();
}); });
@@ -863,14 +865,15 @@ test.describe('glyph trunk regressions', () => {
await book.focus(); await book.focus();
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft');
await expect(book).toHaveAttribute('aria-expanded', 'false'); await expect(book).toHaveAttribute('aria-expanded', 'false');
// Book is the sole root child: its descendants never extend the first-column spine. // Collapsing Book moves the following catalog leaves up and shortens the root spine.
expect(await checkEnd('book')).toBe(expanded); const collapsed = await checkEnd('book');
expect(collapsed).toBeLessThan(expanded);
await page.keyboard.press('Home'); await page.keyboard.press('Home');
await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft');
await expect(page.getByRole('treeitem')).toHaveCount(1); await expect(page.getByRole('treeitem')).toHaveCount(1);
await checkEnd('root'); await checkEnd('root');
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight');
expect(await checkEnd('root-reopened')).toBe(expanded); expect(await checkEnd('root-reopened')).toBe(collapsed);
await book.focus(); await book.focus();
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight');
expect(await checkEnd('all-reopened')).toBe(expanded); expect(await checkEnd('all-reopened')).toBe(expanded);
@@ -892,7 +895,8 @@ test.describe('JavaScript-free glyph tree', () => {
await expect(home).toContainText('Worldspace Interface Protocol'); await expect(home).toContainText('Worldspace Interface Protocol');
const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }); const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', testInfo.project.use.baseURL!).pathname); await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', testInfo.project.use.baseURL!).pathname);
await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', (await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href'))!); await expect(nav.getByRole('link', { name: /^Sources(?:\s|$)/ })).toHaveAttribute('href', new URL('sources/', testInfo.project.use.baseURL!).pathname);
await expect(nav.getByRole('link', { name: /^Packages(?:\s|$)/ })).toHaveAttribute('href', new URL('packages/', testInfo.project.use.baseURL!).pathname);
const initialHTML = await page.locator(selectedContent).innerHTML(); const initialHTML = await page.locator(selectedContent).innerHTML();
await expect(page.locator('.static-guides, .guide-body, .ascii-panel')).toHaveCount(0); await expect(page.locator('.static-guides, .guide-body, .ascii-panel')).toHaveCount(0);
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount); await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
+193
View File
@@ -0,0 +1,193 @@
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 value = (page: Page, name: string) => node(page, name).locator('.node-value');
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 execute(page: Page, iface: string, operation: string, args?: object) {
const toggle = page.getByRole('button', { name: new RegExp(`^[?>v] /::${iface}$`) });
if (await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click();
await page.getByLabel('Operation', { exact: true }).selectOption(operation);
if (args) await page.getByLabel('Arguments (tagged JSON record)').fill(JSON.stringify(args));
const calls = Number(await panel(page).getAttribute('data-calls'));
await page.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('collection and counter mutations update the tree through actual Observe', 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(value(page, 'apple')).toHaveText('3');
await expect(value(page, 'orange')).toHaveText('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(value(page, 'banana')).toHaveText('0');
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();
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(value(page, 'banana')).toHaveText('2');
await expect(node(page, 'banana').locator('.node-label')).toHaveText('banana');
await expect(page.getByLabel('Object Text View')).toContainText('description: "2"');
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' });
await execute(page, 'counter', 'reset');
await expect(value(page, 'banana')).toHaveText('0');
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(value(page, 'banana')).toHaveText('0');
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');
await execute(page, 'counter', 'increment', amountArg('4'));
await expect(value(page, 'apple')).toHaveText('7');
await node(page, 'orange').click(); await ready(page, '/playground/orange');
await page.goBack(); await ready(page, '/playground/apple');
await expect(value(page, 'apple')).toHaveText('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(value(page, 'apple')).toHaveText('7');
await page.locator('.demo-node[data-path="/book"]').click();
await ready(page, '/book');
await expect(value(page, 'apple')).toHaveText('7');
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await expect(value(page, 'apple')).toHaveText('7');
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await page.reload(); await ready(page, '/playground/apple');
await expect(value(page, 'apple')).toHaveText('3');
await expect(panel(page)).toHaveAttribute('data-calls', '0');
});
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)>']) {
await page.getByLabel('Operation', { exact: true }).selectOption('create');
await page.getByLabel('Arguments (tagged JSON record)').fill(JSON.stringify(nameArg(invalid)));
await page.getByRole('button', { name: 'Execute Operation', 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(value(page, 'apple')).toHaveText('3');
}
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();
await page.getByLabel('Operation', { exact: true }).selectOption('increment');
await page.getByLabel('Arguments (tagged JSON record)').fill(JSON.stringify(amountArg('9223372036854775807')));
await page.getByRole('button', { name: 'Execute Operation', exact: true }).click();
await expect(page.locator('[data-protocol-status]')).toBeVisible();
await expect(value(page, 'apple')).toHaveText('3');
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
});
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); 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();
await page.getByLabel('Operation', { exact: true }).selectOption('increment');
expect(JSON.parse(await page.getByLabel('Arguments (tagged JSON record)').inputValue())).toEqual(amountArg('1'));
await page.getByRole('button', { name: 'Execute Operation', 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(value(page, 'apple')).toHaveText('4');
await expect(page.getByRole('button', { name: 'Execute Operation', exact: true })).toBeDisabled();
await expect(page.getByLabel('Operation', { 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(value(page, 'apple')).toHaveText('4');
await node(page, '').click(); await ready(page);
await node(page, 'apple').click(); await ready(page, '/playground/apple');
await expect(value(page, 'apple')).toHaveText('4');
await expect(panel(page)).toHaveAttribute('data-calls', '1');
await expect(page.locator('[data-protocol-status]')).toBeHidden();
});
test.describe('static explanation', () => {
test.use({ javaScriptEnabled: false });
test('Playground is readable without claiming live counter state', async ({ page }, info) => {
await page.goto(url(info.project.use.baseURL!, 'apple'));
await expect(page.locator('[data-selected-content] h1')).toHaveText('Playground');
await expect(page.locator('[data-selected-content]')).toContainText('再読み込みで初期状態');
await expect(panel(page)).toBeHidden();
await expect(page.locator('[data-playground-row]')).toHaveCount(0);
});
});
+139
View File
@@ -0,0 +1,139 @@
import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true });
test('manual exchanges are explicit, cached, session-local, and return genuine signatures', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
await module.default();
const normal = new module.BrowserSession(manifest);
const manual = new module.BrowserSession(manifest);
const data = JSON.parse(manifest);
const node = data.nodes.find((n: { path: string }) => n.path !== '/');
const originalFetch = window.fetch;
window.fetch = () => { throw new Error('Manual protocol must not use a network transport'); };
try {
normal.tree();
const initial = JSON.parse(manual.stats());
let missing;
try { manual.call_operation(node.path, '/', 'document', 'read', '{}'); }
catch (error) { missing = JSON.parse(String(error)); }
const observed = JSON.parse(manual.observe(node.path, 0));
const cachedObject = JSON.parse(manual.observe(node.path, 0));
const descriptor = JSON.parse(manual.fetch_interface('/', 'document'));
const cachedInterface = JSON.parse(manual.fetch_interface('/', 'document'));
const called = JSON.parse(manual.call_operation(node.path, '/', 'document', 'read', '{}'));
const second = JSON.parse(manual.call_operation(node.path, '/', 'document', 'read', '{}'));
const result = { initial, missing, observed, cachedObject, descriptor, cachedInterface, called, second, node };
normal.free();
manual.free();
return result;
} finally { window.fetch = originalFetch; }
}, base);
expect(result.initial).toEqual({ observes: 0, interfaces: 0, calls: 0 });
expect(result.missing).toMatchObject({ kind: 'client', code: 'MissingObservation' });
expect(result.observed).toMatchObject({ path: result.node.path, cached: false, stats: { observes: 1, interfaces: 0, calls: 0 } });
expect(result.observed.observation.object).toMatchObject({ name: result.node.name, description: result.node.title, interfaces: [{ scope: '/', name: 'document' }] });
expect(result.observed.observation.children).toBeNull();
expect(result.observed.observation.object).not.toHaveProperty('html');
expect(result.observed.textView).toContain('interfaces: ["/::document"];');
expect(result.observed.textView).not.toContain('validator:');
expect(result.observed.textView).not.toContain('ref:');
expect(result.cachedObject).toEqual({ ...result.observed, cached: true });
expect(result.descriptor).toMatchObject({ reference: { scope: '/', name: 'document' }, cached: false, stats: { observes: 1, interfaces: 1, calls: 0 } });
expect(result.descriptor.descriptor.operations).toEqual([expect.objectContaining({ name: 'read', parameters: [] })]);
expect(result.descriptor.textView).toMatch(/^interface "\/::document" \{\n/m);
expect(result.descriptor.textView).toContain('read(');
expect(result.cachedInterface).toEqual({ ...result.descriptor, cached: true });
expect(result.called).toMatchObject({ path: result.node.path, interface: { scope: '/', name: 'document' }, operation: 'read', result: { kind: 'record', value: { path: { kind: 'string', value: result.node.path }, html: { kind: 'string', value: result.node.html } } }, stats: { observes: 1, interfaces: 1, calls: 1 } });
expect(result.called).not.toHaveProperty('textView');
expect(result.second.stats).toEqual({ observes: 1, interfaces: 1, calls: 2 });
});
test('manual errors distinguish input, Client, Protocol and renderer failures without automatic work', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const manifest = await (await fetch(new URL('worldspace.json', base))).text();
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
await module.default();
const session = new module.BrowserSession(manifest);
const attempt = (fn: () => unknown) => {
try { fn(); return { kind: 'unexpected success' }; }
catch (error) {
if (error instanceof WebAssembly.RuntimeError) throw error;
return JSON.parse(String(error));
}
};
const missingObject = attempt(() => session.call_operation('/', '/', 'document', 'read', '{}'));
const depth = [-1, 0.1, 33, NaN, Infinity, 2 ** 32].map((d) => attempt(() => session.observe('/', d)));
const oversizePath = attempt(() => session.observe('/'.repeat(4097), 0));
const malformedPath = attempt(() => session.observe('relative', 0));
const unknownPath = attempt(() => session.observe('/not-published-protocol-test', 0));
const unknownInterface = attempt(() => session.fetch_interface('/', 'not-published-protocol-test'));
const afterMissing = JSON.parse(session.stats());
const retainedPath = attempt(() => session.observe('/not-published-protocol-test', 0));
const retainedInterface = attempt(() => session.fetch_interface('/', 'not-published-protocol-test'));
const afterRetained = JSON.parse(session.stats());
session.observe('/', 0);
const missingInterface = attempt(() => session.call_operation('/', '/', 'document', 'read', '{}'));
session.fetch_interface('/', 'document');
const unknownOperation = attempt(() => session.call_operation('/', '/', 'document', 'unknown', '{}'));
const mismatchedInterface = attempt(() => session.call_operation('/', '/', 'other', 'read', '{}'));
const invalidArguments = attempt(() => session.call_operation('/', '/', 'document', 'read', '{"unexpected":{"kind":"integer","value":"9007199254740993"}}'));
const malformedArguments = ['{', '[]', '{"x":42}', '{"x":{"kind":"integer","value":9007199254740993}}', '{"x":{"kind":"bytes","value":[256]}}', ' '.repeat(65537)].map((args) => attempt(() => session.call_operation('/', '/', 'document', 'read', args)));
const beforeCall = JSON.parse(session.stats());
const called = JSON.parse(session.call_operation('/', '/', 'document', 'read', '{}'));
session.free();
const data = JSON.parse(manifest);
data.nodes.find((n: { path: string }) => n.path === '/').title = 'x'.repeat(1024 * 1024 + 1);
const large = new module.BrowserSession(JSON.stringify(data));
const render = attempt(() => large.observe('/', 0));
const renderStats = JSON.parse(large.stats());
large.free();
return { missingObject, missingInterface, depth, oversizePath, malformedPath, unknownPath, unknownInterface, afterMissing, retainedPath, retainedInterface, afterRetained, unknownOperation, mismatchedInterface, invalidArguments, malformedArguments, beforeCall, called, render, renderStats };
}, base);
expect(result.missingObject).toMatchObject({ kind: 'client', code: 'MissingObservation' });
expect(result.missingInterface).toMatchObject({ kind: 'client', code: 'MissingObservation' });
for (const error of [...result.depth, result.oversizePath, ...result.malformedArguments]) expect(error.kind).toBe('input');
expect(result.malformedPath).toMatchObject({ kind: 'client', code: 'InvalidRequest' });
expect(result.unknownPath).toMatchObject({ kind: 'protocol', code: 'NotFound' });
expect(result.unknownInterface).toMatchObject({ kind: 'protocol', code: 'InterfaceNotFound' });
expect(result.afterMissing).toEqual({ observes: 1, interfaces: 1, calls: 0 });
expect(result.afterRetained).toEqual(result.afterMissing);
expect(result.retainedPath.kind).toBe('client');
expect(result.retainedInterface.kind).toBe('client');
for (const error of [result.unknownOperation, result.mismatchedInterface, result.invalidArguments]) expect(error.kind).toBe('client');
expect(result.beforeCall.calls).toBe(0);
expect(result.called.stats.calls).toBe(1);
expect(result.render.kind).toBe('render');
expect(result.renderStats).toEqual({ observes: 1, interfaces: 0, calls: 0 });
});
test('Rust Text View preserves Unicode, whitespace and quoted escapes without becoming HTML', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!;
await page.goto(new URL('worldspace.json', base).href);
const result = await page.evaluate(async (base) => {
const module = await import(new URL('wip-wasm/wip_browser.js', base).href);
await module.default();
const title = ' 引用 "quote" \\ space\nnext\tline <script>window.__textViewExecuted=true</script>';
const session = new module.BrowserSession(JSON.stringify({ nodes: [{ path: '/', name: '', displayPath: '/', title, href: '/', html: '<p>body only on call</p>' }] }));
const observed = JSON.parse(session.observe('/', 0));
const fetched = JSON.parse(session.fetch_interface('/', 'document'));
const pre = document.createElement('pre');
pre.textContent = observed.textView;
document.body.append(pre);
session.free();
return { observed, fetched, title, displayed: pre.textContent, scripts: pre.querySelectorAll('script').length, executed: '__textViewExecuted' in window };
}, base);
// Golden output is also checked against render_object directly in native tests.
expect(result.observed.textView).toBe('object {\n name: "";\n description: " 引用 \\"quote\\" \\\\ space\\nnext\\tline <script>window.__textViewExecuted=true</script>";\n interfaces: ["/::document"];\n}\n');
expect(result.observed.observation.object.description).toBe(result.title);
expect(result.displayed).toBe(result.observed.textView);
expect(result.scripts).toBe(0);
expect(result.executed).toBe(false);
expect(result.fetched.stats).toEqual({ observes: 1, interfaces: 1, calls: 0 });
});
+484
View File
@@ -0,0 +1,484 @@
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.getByLabel('Operation', { exact: true }).selectOption('read');
}
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 }]);
});
}
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]')).toBeHidden();
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.locator('[data-protocol-operation-controls]')).toBeVisible();
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]')).toBeHidden();
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-protocol-call]')).toBeDisabled();
expected.push({ path: canonicalPath, depth: 1 });
expect(await observedCalls(page)).toEqual(expected);
}
await page.locator('[data-interface-toggle]').first().click();
await page.getByLabel('Operation', { exact: true }).selectOption('read');
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.getByLabel('Operation', { 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.getByLabel('Operation', { exact: true })).toBeDisabled();
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.getByLabel('Operation', { exact: true }).selectOption('read');
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-protocol-call]')).toBeDisabled();
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.getByLabel('Operation', { 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-protocol-call]')).toBeDisabled();
}
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-protocol-call]')).toBeDisabled();
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 descriptor(page);
await page.locator('[data-protocol-arguments]').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]')).toBeHidden();
await expect(page.locator('[data-protocol-status]')).toBeHidden();
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
});
test('Text View and returned markup are displayed as text, including in dark mode', async ({ page }, info) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.route('**/worldspace.json', async route => {
const response = await route.fetch(); const data = await response.json();
data.nodes[0].title = '<img src=x onerror="window.__executed=1">';
data.nodes[0].html = '<script>window.__executed=1</script><p>UNSAFE RESULT</p>';
await route.fulfill({ response, json: data });
});
await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
await descriptor(page);
await expect(page.getByLabel('Object Text View')).toContainText('<img');
await page.locator('[data-protocol-call]').click();
await expect(page.getByLabel('Operation result')).toContainText('UNSAFE RESULT');
await expect(panel(page).locator('img, script')).toHaveCount(0);
expect(await page.evaluate(() => (window as Window & { __executed?: number }).__executed)).toBeUndefined();
await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
await page.unroute('**/worldspace.json');
await page.getByRole('button', { name: 'Read', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(panel(page)).toBeHidden();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
});
test('asset failure leaves a clear unavailable state and readable static fallback', async ({ page }, info) => {
await page.route('**/wip-wasm/wip_browser.js*', route => route.abort());
await page.goto(protocolURL(info.project.use.baseURL!));
await expect(panel(page)).toHaveAttribute('data-state', 'unavailable');
await expect(page.getByLabel('Object Text View')).toBeHidden();
await page.getByRole('button', { name: 'Read', exact: true }).click();
await expect(page.locator('[data-selected-content]').getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'fallback');
});
test('a trapped manual session is quarantined across routes and normal mode uses an independent instance', async ({ page }, info) => {
const asset = (name: string) => readFile(new URL(`../../test-assets/wip-wasm/${name}`, import.meta.url));
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
const source = (await asset('wip_browser.js')).toString();
await route.fulfill({ contentType: 'text/javascript', body: `${source}\nBrowserSession.prototype.observe = function () { window.__trapCalls = (window.__trapCalls || 0) + 1; window.__trappedManual = new WeakRef(this); return this.panic_probe(); };` });
});
await page.route('**/wip-wasm/*.wasm', async route => route.fulfill({ contentType: 'application/wasm', body: await asset('wip_browser_bg.wasm') }));
await page.goto(protocolURL(info.project.use.baseURL!));
await expect(panel(page)).toHaveAttribute('data-state', 'trapped');
await page.locator('.demo-node[data-path="/sources"]').click();
await expect(panel(page)).toHaveAttribute('data-state', 'trapped');
await expect(page.getByLabel('Object Text View')).toBeHidden();
const cdp = await page.context().newCDPSession(page);
await cdp.send('HeapProfiler.collectGarbage');
expect(await page.evaluate(() => Boolean((window as unknown as { __trappedManual: WeakRef<object> }).__trappedManual.deref()))).toBe(true);
expect(await page.evaluate(() => (window as Window & { __trapCalls?: number }).__trapCalls)).toBe(1);
await page.getByRole('button', { name: 'Read', exact: true }).click();
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
await cdp.detach();
});
test.describe('without JavaScript', () => {
test.use({ javaScriptEnabled: false });
test('protocol URL retains static reading and does not claim execution', async ({ page }, info) => {
await page.goto(protocolURL(info.project.use.baseURL!, 'sources/'));
await expect(page.locator('[data-selected-content]')).toBeVisible();
await expect(page.locator('[data-mode-switch]')).toBeHidden();
await expect(panel(page)).toBeHidden();
await expect(page.locator('[data-landing-stage]')).not.toHaveAttribute('data-wip-state');
});
});
+16 -12
View File
@@ -1,6 +1,6 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { bookURL, documentPaths, draftPaths, treeNodeCount, treePaths } from './documents'; import { bookURL, documentPaths, draftPaths, treeNodeCount, treePaths, routeURL } from './documents';
test.use({ javaScriptEnabled: true }); test.use({ javaScriptEnabled: true });
@@ -8,8 +8,7 @@ const stage = '[data-landing-stage]';
const article = 'article.selected-content.prose[data-selected-content]'; const article = 'article.selected-content.prose[data-selected-content]';
const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`); const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`);
const toggle = (page: Page) => page.locator('button.node-toggle[data-toggle-path="/book"]'); const toggle = (page: Page) => page.locator('button.node-toggle[data-toggle-path="/book"]');
const urlFor = (path: string, base: string) => path === '/' ? new URL('./', base).href const urlFor = routeURL;
: path === '/book' ? new URL('book/', base).href : bookURL(path, base);
async function settled(page: Page, path: string) { async function settled(page: Page, path: string) {
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path); await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
@@ -82,11 +81,10 @@ test('home response contains independent root content and the unchanged public t
expect(copy).toMatch(/インタフェース/); expect(copy).toMatch(/インタフェース/);
const nav = home.find('nav[aria-label="ルートページのナビゲーション"]'); const nav = home.find('nav[aria-label="ルートページのナビゲーション"]');
expect(nav.length).toBe(1); expect(nav.length).toBe(1);
expect(nav.find('a').length).toBe(2); expect(nav.find('a').length).toBe(3);
expect(nav.find('a').filter((_, el) => $(el).text().includes('Book')).attr('href')).toBe(new URL('book/', base).pathname); expect(nav.find('a').filter((_, el) => $(el).text().includes('Book')).attr('href')).toBe(new URL('book/', base).pathname);
const source = $('.site-header a').filter((_, el) => $(el).text().includes('Source')).attr('href'); expect(nav.find('a').filter((_, el) => $(el).text().includes('Sources')).attr('href')).toBe(new URL('sources/', base).pathname);
expect(source).toMatch(/^https:\/\//); expect(nav.find('a').filter((_, el) => $(el).text().includes('Packages')).attr('href')).toBe(new URL('packages/', base).pathname);
expect(nav.find('a').filter((_, el) => $(el).text().includes('Source')).attr('href')).toBe(source);
expect($(article).html()).not.toBe(chapter(article).html()); expect($(article).html()).not.toBe(chapter(article).html());
expect($(article).text()).not.toContain('1. コンセプト'); expect($(article).text()).not.toContain('1. コンセプト');
const chapterCopy = chapter(article).find('p').filter((_, el) => Boolean(chapter(el).text().trim())).first().text().trim(); const chapterCopy = chapter(article).find('p').filter((_, el) => Boolean(chapter(el).text().trim())).first().text().trim();
@@ -131,11 +129,10 @@ test('root content navigation works without changing the tree or overflowing nar
await expect(home.locator('p').filter({ hasText: 'Worldspace Interface Protocol' })).toBeVisible(); await expect(home.locator('p').filter({ hasText: 'Worldspace Interface Protocol' })).toBeVisible();
await expect(page.locator(article)).not.toContainText('1. コンセプト'); await expect(page.locator(article)).not.toContainText('1. コンセプト');
const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }); const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
await expect(nav.getByRole('link')).toHaveCount(2); await expect(nav.getByRole('link')).toHaveCount(3);
await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', base).pathname); await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', base).pathname);
const source = await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href'); await expect(nav.getByRole('link', { name: /^Sources(?:\s|$)/ })).toHaveAttribute('href', new URL('sources/', base).pathname);
expect(source).toMatch(/^https:\/\//); await expect(nav.getByRole('link', { name: /^Packages(?:\s|$)/ })).toHaveAttribute('href', new URL('packages/', base).pathname);
await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', source!);
if (testInfo.project.name === 'mobile') { if (testInfo.project.name === 'mobile') {
for (const width of [320, 360, 390]) { for (const width of [320, 360, 390]) {
await page.setViewportSize({ width, height: 740 }); await page.setViewportSize({ width, height: 740 });
@@ -155,7 +152,7 @@ test('root content navigation works without changing the tree or overflowing nar
test('all 14 document responses contain the current article and metadata-only public tree', async ({ request }, testInfo) => { test('all 14 document responses contain the current article and metadata-only public tree', async ({ request }, testInfo) => {
expect(documentPaths).toHaveLength(14); expect(documentPaths).toHaveLength(14);
expect(treeNodeCount).toBe(16); expect(treeNodeCount).toBe(19);
for (const path of documentPaths) { for (const path of documentPaths) {
const url = bookURL(path, testInfo.project.use.baseURL!); const url = bookURL(path, testInfo.project.use.baseURL!);
const response = await request.get(url); const response = await request.get(url);
@@ -370,6 +367,7 @@ test('directory anchors navigate independently from the sibling plus/minus toggl
const html = await page.locator(article).innerHTML(); const html = await page.locator(article).innerHTML();
const title = await page.title(); const title = await page.title();
await expect(nodeAt(page, '/book')).toHaveAttribute('href', new URL('book/', base).pathname); await expect(nodeAt(page, '/book')).toHaveAttribute('href', new URL('book/', base).pathname);
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click(); await toggle(page).click();
await expect(toggle(page)).toHaveText('+'); await expect(toggle(page)).toHaveText('+');
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
@@ -378,6 +376,7 @@ test('directory anchors navigate independently from the sibling plus/minus toggl
expect(await page.locator(article).innerHTML()).toBe(html); expect(await page.locator(article).innerHTML()).toBe(html);
await expect(page).toHaveTitle(title); await expect(page).toHaveTitle(title);
await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true'); await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true');
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click(); await toggle(page).click();
await expect(toggle(page)).toHaveText('−'); await expect(toggle(page)).toHaveText('−');
await expect(nodeAt(page, path)).toBeVisible(); await expect(nodeAt(page, path)).toBeVisible();
@@ -404,6 +403,7 @@ test('persisted tree retains scroll and collapse, expanding only an ancestor nee
const pane = await page.locator('.tree-pane').elementHandle(); const pane = await page.locator('.tree-pane').elementHandle();
const viewport = await page.locator('.tree-viewport').elementHandle(); const viewport = await page.locator('.tree-viewport').elementHandle();
const root = await nodeAt(page, '/').elementHandle(); const root = await nodeAt(page, '/').elementHandle();
await nodeAt(page, '/4-client.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click(); await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click();
await page.locator('.tree-viewport').evaluate((el) => { await page.locator('.tree-viewport').evaluate((el) => {
(el as HTMLElement).style.height = '120px'; (el as HTMLElement).style.height = '120px';
@@ -451,7 +451,9 @@ test('home preserves a collapsed root until selecting a child requires expansion
await page.goto(bookURL(path, base)); await page.goto(bookURL(path, base));
await settled(page, path); await settled(page, path);
const pane = await page.locator('.tree-pane').elementHandle(); const pane = await page.locator('.tree-pane').elementHandle();
await nodeAt(page, '/4-client.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click(); await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click();
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click(); await toggle(page).click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await nodeAt(page, '/').focus(); await nodeAt(page, '/').focus();
@@ -573,9 +575,11 @@ test('rapid navigation settles on the last file and repeated swaps do not duplic
await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowDown');
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused(); await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused();
expect(page.url()).toBe(currentURL); expect(page.url()).toBe(currentURL);
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click(); await toggle(page).click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await expect(toggle(page)).toHaveText('+'); await expect(toggle(page)).toHaveText('+');
await nodeAt(page, '/book').locator('..').hover();
await toggle(page).click(); await toggle(page).click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true');
await expect(toggle(page)).toHaveText('−'); await expect(toggle(page)).toHaveText('−');
+1 -1
View File
@@ -11,7 +11,7 @@ test('home and book share the usable tree without client JavaScript', async ({ p
await expect(page.locator('.root-mark')).toHaveAttribute('aria-selected', 'true'); await expect(page.locator('.root-mark')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('[data-selected-content]')).not.toContainText('1. コンセプト'); await expect(page.locator('[data-selected-content]')).not.toContainText('1. コンセプト');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ ');
const root = await page.locator('.root-mark').boundingBox(); const root = await page.locator('.root-mark').boundingBox();
const spine = await page.locator('.branch-vector').first().evaluate((element) => { const spine = await page.locator('.branch-vector').first().evaluate((element) => {
const svg = element as SVGSVGElement; const svg = element as SVGSVGElement;
+1
View File
@@ -261,6 +261,7 @@ test.describe('live CSS theme with JavaScript enabled', () => {
await expect(page).toHaveURL(bookURL('/3-protocol.md', testInfo.project.use.baseURL!)); await expect(page).toHaveURL(bookURL('/3-protocol.md', testInfo.project.use.baseURL!));
await complete(page); await complete(page);
await page.evaluate(() => document.fonts.ready.then(() => undefined)); await page.evaluate(() => document.fonts.ready.then(() => undefined));
await nodeAt(page, '/1-concept.md').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click(); await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false');
const content = await page.locator(article).elementHandle(); const content = await page.locator(article).elementHandle();
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from '@playwright/test';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
test('fold controls appear on row hover or keyboard focus without shifting the tree', async ({ page }) => {
await page.goto('./book/');
await expect(page.locator('.demo-tree')).toHaveAttribute('data-enhanced');
const node = page.locator('.demo-node[data-path="/book"]');
const toggle = page.locator('[data-toggle-path="/book"]');
await page.mouse.move(0, 0);
await expect(toggle).toHaveCSS('opacity', '0');
await expect(toggle).toHaveCSS('pointer-events', 'none');
const before = await node.boundingBox();
await node.hover();
await expect(toggle).toHaveCSS('opacity', '1');
expect(await node.boundingBox()).toEqual(before);
await toggle.click();
await expect(node).toHaveAttribute('aria-expanded', 'false');
await toggle.click();
await expect(node).toHaveAttribute('aria-expanded', 'true');
await page.locator('[data-selected-content]').focus();
await page.mouse.move(0, 0);
await expect(toggle).toHaveCSS('opacity', '0');
await node.focus();
await expect(toggle).toHaveCSS('opacity', '1');
await page.keyboard.press('ArrowLeft');
await expect(node).toHaveAttribute('aria-expanded', 'false');
await page.keyboard.press('ArrowRight');
await expect(node).toHaveAttribute('aria-expanded', 'true');
});
test.describe('touch without hover', () => {
test.use({ hasTouch: true, isMobile: true });
test('fold controls remain visible and tappable', async ({ page }) => {
await page.goto('./book/');
await expect(page.locator('.demo-tree')).toHaveAttribute('data-enhanced');
expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(true);
const toggle = page.locator('[data-toggle-path="/book"]');
await expect(toggle).toHaveCSS('opacity', '1');
await toggle.tap();
await expect(page.locator('.demo-node[data-path="/book"]')).toHaveAttribute('aria-expanded', 'false');
await toggle.tap();
await expect(page.locator('.demo-node[data-path="/book"]')).toHaveAttribute('aria-expanded', 'true');
});
});
+67 -4
View File
@@ -1,13 +1,13 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from '@playwright/test';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { bookURL, documentPaths, draftPaths, treePaths } from './documents'; import { bookURL, documentPaths, draftPaths, treePaths, routeURL, catalogPaths } from './documents';
import { catalogs, type CatalogName } from '../../src/lib/catalog';
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' }); test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
const stage = '[data-landing-stage]'; const stage = '[data-landing-stage]';
const article = '[data-selected-content]'; const article = '[data-selected-content]';
const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`); const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`);
const routeURL = (path: string, base: string) => path === '/' ? base : path === '/book' ? new URL('book/', base).href : bookURL(path, base);
async function ready(page: Page, path: string) { async function ready(page: Page, path: string) {
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path); await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
@@ -96,8 +96,7 @@ test('real WASM results replace poisoned SSR labels and article, rather than jus
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('href', node.href); await expect(nodeAt(page, node.displayPath)).toHaveAttribute('href', node.href);
await expect(nodeAt(page, node.displayPath)).toHaveAttribute('title', node.title); await expect(nodeAt(page, node.displayPath)).toHaveAttribute('title', node.title);
if (node.displayPath !== '/') { if (node.displayPath !== '/') {
const label = node.displayPath === '/book' ? `${node.name.replace(/\/$/, '')}/` : node.name; await expect(nodeAt(page, node.displayPath)).toHaveText(node.name);
await expect(nodeAt(page, node.displayPath)).toHaveText(label);
} }
} }
expect(expected.stats.observes).toBeGreaterThan(0); expect(expected.stats.observes).toBeGreaterThan(0);
@@ -148,12 +147,74 @@ test('every public route uses one persistent WASM session with only static asset
await expect(page.locator('.demo-node[data-path*="draft"], a[href*="/draft/"]')).toHaveCount(0); 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) => { test('WIP navigation retains collapse, history reading position, fragments and single handlers', async ({ page }, testInfo) => {
const base = testInfo.project.use.baseURL!; const base = testInfo.project.use.baseURL!;
// Exercise nested scroll even on mobile, whose normal scroll belongs to window. // Exercise nested scroll even on mobile, whose normal scroll belongs to window.
await useNestedArticleScroll(page); await useNestedArticleScroll(page);
await page.goto(bookURL('/3-protocol.md', base)); await page.goto(bookURL('/3-protocol.md', base));
await ready(page, '/3-protocol.md'); 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(); 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; }); const position = await page.locator(article).evaluate((el) => { el.scrollTop = 180; return el.scrollTop; });
expect(position).toBe(180); expect(position).toBe(180);
@@ -193,8 +254,10 @@ test('WIP navigation retains collapse, history reading position, fragments and s
await nodeAt(page, path).click(); await nodeAt(page, path).click();
await ready(page, path); await ready(page, path);
} }
await nodeAt(page, '/book').locator('..').hover();
await page.locator('.node-toggle[data-toggle-path="/book"]').click(); await page.locator('.node-toggle[data-toggle-path="/book"]').click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); 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 page.locator('.node-toggle[data-toggle-path="/book"]').click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true');
await nodeAt(page, '/1-concept.md').focus(); await nodeAt(page, '/1-concept.md').focus();
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { catalogPages, catalogs } from '../src/lib/catalog.ts';
const rustRepository = 'https://gitea.hareworks.net/Hare/wip-rs';
test('Sources lists only the confirmed documentation and Rust repositories', () => {
assert.deepEqual(catalogs.sources.entries.map((entry) => [entry.name, entry.links]), [
['wip-reference', [{ label: 'Source', href: 'https://gitea.hareworks.net/Hare/wip-reference' }]],
['wip-rs', [{ label: 'Source', href: rustRepository }]],
]);
assert.deepEqual(catalogPages.map((page) => [page.path, page.title]), [['/sources', 'Sources'], ['/packages', 'Packages']]);
});
test('Packages retains the seven publishable Rust crates in README order with manifest descriptions', () => {
assert.deepEqual(catalogs.packages.entries.map((entry) => [entry.name, entry.description]), [
['wip-protocol', 'Transport-independent logical model and validation for WIP'],
['wip-interface-macros', 'Procedural macros for WIP interface declarations and DTO codecs'],
['wip-interface', 'Public Rust authoring API for WIP interfaces and operation value codecs'],
['wip-host', 'Host-side object publication and operation dispatch for WIP'],
['wip-http', 'Framework-independent standard WIP over HTTP v1 binding'],
['wip-client', 'Presentation-independent stateful WIP client runtime'],
['wip-text-view', 'Standalone Object and Interface Text View signatures for WIP'],
]);
for (const entry of catalogs.packages.entries) {
assert.deepEqual(entry.links, [
{ label: 'Source', href: `${rustRepository}/src/branch/master/crates/${entry.name}` },
{ label: 'Workspace README', href: `${rustRepository}/src/branch/master/README.md` },
]);
assert.equal('version' in entry, false);
assert.ok(!['wip-fs-server', 'wip-tui', 'wip-public-api-example', 'wip-site-worldspace', 'wip-browser'].includes(entry.name));
}
assert.match(catalogs.packages.description, /公開状況・公開バージョンを示すものではありません/);
assert.ok(!JSON.stringify(catalogs).includes('crates.io'));
assert.ok(!JSON.stringify(catalogs).includes('docs.rs'));
});
+28 -2
View File
@@ -3,7 +3,7 @@ import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { contentsRoot } from '../plugins/document-links.mjs'; import { contentsRoot } from '../plugins/document-links.mjs';
import { documentTree, chapterTree, bookTree, childrenOf, visibleNodes, treePrefix, serializeTree } from '../src/lib/landing-tree.ts'; import { documentTree, chapterTree, bookTree, publicTree, childrenOf, visibleNodes, treePrefix, serializeTree } from '../src/lib/landing-tree.ts';
async function sourceFiles(directory, prefix = '') { async function sourceFiles(directory, prefix = '') {
const entries = await readdir(directory, { withFileTypes: true }); const entries = await readdir(directory, { withFileTypes: true });
@@ -83,7 +83,7 @@ test('public book container wraps the numbered chapters without changing documen
const published = documents.filter((document) => !document.id.startsWith('draft/')); const published = documents.filter((document) => !document.id.startsWith('draft/'));
const display = bookTree(published); const display = bookTree(published);
assert.deepEqual(childrenOf(display, '/').map((node) => node.path), ['/book']); assert.deepEqual(childrenOf(display, '/').map((node) => node.path), ['/book']);
assert.equal(display[1].name, 'book/'); assert.equal(display[1].name, 'book');
assert.equal(display[1].depth, 2); assert.equal(display[1].depth, 2);
assert.equal(display.length, published.length + 2); assert.equal(display.length, published.length + 2);
assert.ok(!display.some((node) => node.path.startsWith('/draft'))); assert.ok(!display.some((node) => node.path.startsWith('/draft')));
@@ -98,6 +98,32 @@ test('public book container wraps the numbered chapters without changing documen
} }
}); });
test('public root catalogs and playground are leaves alongside the unchanged book subtree', () => {
const published = documents.filter((document) => !document.id.startsWith('draft/'));
for (const base of ['/', '/reference/']) {
const display = publicTree(published, base);
assert.deepEqual(display.slice(0, -3), bookTree(published));
assert.deepEqual(childrenOf(display, '/').map((node) => node.path), ['/book', '/sources', '/packages', '/playground']);
assert.deepEqual(visibleNodes(display, new Set(['/book'])).map((node) => node.path), ['/', '/book', '/sources', '/packages', '/playground']);
assert.deepEqual(visibleNodes(display, new Set(['/'])), [display[0]]);
for (const name of ['sources', 'packages', 'playground']) {
const leaf = display.find((node) => node.path === `/${name}`);
assert.equal(leaf.name, name);
assert.equal(leaf.depth, 2);
assert.equal(leaf.href, `${base}${name}/`);
assert.ok(leaf.title);
assert.deepEqual(childrenOf(display, leaf.path), []);
assert.deepEqual(visibleNodes(display, new Set([leaf.path])), display);
}
assert.equal(treePrefix(display.find((node) => node.path === '/book'), display), '├─ ');
assert.equal(treePrefix(display.find((node) => node.path === '/1.1-interaction-abstraction.md'), display), '│ │ ├─ ');
assert.equal(treePrefix(display.find((node) => node.path === '/packages'), display), '├─ ');
assert.equal(display.at(-1).path, '/playground');
assert.equal(treePrefix(display.at(-1), display), '└─ ');
assert.ok(!display.some((node) => node.path.includes('/draft')));
}
});
test('existing titles and hrefs pass through without embedding document bodies', () => { test('existing titles and hrefs pass through without embedding document bodies', () => {
for (const document of documents) { for (const document of documents) {
const node = nodes.find((node) => node.path === '/' + document.id + '.md'); const node = nodes.find((node) => node.path === '/' + document.id + '.md');
+30 -7
View File
@@ -5,12 +5,15 @@ import test from 'node:test';
import { createMarkdownProcessor } from '@astrojs/markdown-remark'; import { createMarkdownProcessor } from '@astrojs/markdown-remark';
import { load } from 'cheerio'; import { load } from 'cheerio';
import documentLinks, { contentsRoot } from '../plugins/document-links.mjs'; import documentLinks, { contentsRoot } from '../plugins/document-links.mjs';
import { bookTree } from '../src/lib/landing-tree.ts'; import { publicTree } from '../src/lib/landing-tree.ts';
import { createWorldspaceData } from '../src/lib/worldspace-data.ts'; import { createWorldspaceData } from '../src/lib/worldspace-data.ts';
const pages = { const pages = {
'/': { title: 'Worldspace Interface Protocol', href: '/', html: '<h1>Home</h1>' }, '/': { title: 'Worldspace Interface Protocol', href: '/', html: '<h1>Home</h1>' },
'/book': { title: 'Book', href: '/book/', html: '<h1>Book</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>` }); const source = (id) => ({ id, title: id, href: `/book/${id}/`, html: `<h1>${id}</h1>` });
@@ -49,15 +52,15 @@ function checkIdentity(data) {
test('actual public documents keep Web and display identities but acquire real parent paths', () => { test('actual public documents keep Web and display identities but acquire real parent paths', () => {
const data = createWorldspaceData(documents, pages); const data = createWorldspaceData(documents, pages);
checkIdentity(data); checkIdentity(data);
const display = bookTree(documents); const display = publicTree(documents);
assert.equal(data.nodes.length, documents.length + 2); assert.equal(data.nodes.length, documents.length + 5);
assert.deepEqual(data.nodes.map((node) => node.displayPath), display.map((node) => node.path)); assert.deepEqual(data.nodes.map((node) => node.displayPath), display.map((node) => node.path));
for (const node of data.nodes) { for (const node of data.nodes) {
const legacy = display.find((item) => item.path === node.displayPath); 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); assert.equal(node.parent, legacy.parent === undefined ? undefined : data.nodes.find((item) => item.displayPath === legacy.parent).path);
if (legacy.href) { if (legacy.href) {
assert.equal(node.href, legacy.href); assert.equal(node.href, legacy.href);
assert.equal(node.html, documents.find((entry) => `/${entry.id}.md` === node.displayPath).html); assert.equal(node.html, (documents.find((entry) => `/${entry.id}.md` === node.displayPath) ?? pages[node.displayPath]).html);
assert.match(node.html, /<h1/); assert.match(node.html, /<h1/);
} }
} }
@@ -66,7 +69,20 @@ test('actual public documents keep Web and display identities but acquire real p
assert.equal(section.parent, '/book/1-concept.md'); assert.equal(section.parent, '/book/1-concept.md');
assert.equal(section.href, '/book/1.1-interaction-abstraction/'); assert.equal(section.href, '/book/1.1-interaction-abstraction/');
assert.equal(data.nodes[1].name, 'book'); assert.equal(data.nodes[1].name, 'book');
assert.equal(display[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', () => { test('drafts are removed before hierarchy, body and URL generation', () => {
@@ -86,6 +102,9 @@ test('nested numbering, real directories and site base prefixes remain unambiguo
const data = createWorldspaceData(docs, { const data = createWorldspaceData(docs, {
'/': { ...pages['/'], href: '/reference/' }, '/': { ...pages['/'], href: '/reference/' },
'/book': { ...pages['/book'], href: '/reference/book/' }, '/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>' }, '/other': { title: 'other/', href: '/reference/book/other/', html: '<h1>other/</h1>' },
}); });
checkIdentity(data); checkIdentity(data);
@@ -108,11 +127,14 @@ test('invalid, duplicate and incomplete generated inputs fail rather than lose d
assert.throws(() => createWorldspaceData([source('dir/a')], pages), /Missing rendered page: \/dir/); assert.throws(() => createWorldspaceData([source('dir/a')], pages), /Missing rendered page: \/dir/);
}); });
test('root and book index content is supplied by their existing page renderers', () => { test('root, book, catalog and playground content is supplied by their shared page renderers', () => {
const data = createWorldspaceData([], pages); const data = createWorldspaceData([], pages);
assert.deepEqual(data.nodes, [ assert.deepEqual(data.nodes, [
{ path: '/', name: '', displayPath: '/', ...pages['/'] }, { path: '/', name: '', displayPath: '/', ...pages['/'] },
{ path: '/book', name: 'book', displayPath: '/book', parent: '/', ...pages['/book'] }, { 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'] },
]); ]);
}); });
@@ -122,10 +144,11 @@ test('built artifact contains exactly public routes and the same rendered articl
{ skip: process.env.WIP_TEST_GENERATED !== '1' }, async () => { { skip: process.env.WIP_TEST_GENERATED !== '1' }, async () => {
const data = JSON.parse(await readFile(new URL('../dist/worldspace.json', import.meta.url), 'utf8')); const data = JSON.parse(await readFile(new URL('../dist/worldspace.json', import.meta.url), 'utf8'));
checkIdentity(data); checkIdentity(data);
assert.equal(data.nodes.length, documents.length + 2); 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)); 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) { for (const node of data.nodes) {
const relative = node.displayPath === '/' ? 'index.html' : node.displayPath === '/book' ? 'book/index.html' 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`; : `book/${node.displayPath.slice(1, -3)}/index.html`;
const page = load(await readFile(new URL(`../dist/${relative}`, import.meta.url), 'utf8')); const page = load(await readFile(new URL(`../dist/${relative}`, import.meta.url), 'utf8'));
// Astro adds hydration/render markers outside the actual article fragment. // Astro adds hydration/render markers outside the actual article fragment.
+42 -3
View File
@@ -56,6 +56,8 @@ fn data() -> WorldspaceData {
"/2-second.md", "/2-second.md",
"/book/2-second/", "/book/2-second/",
), ),
node("/sources", Some("/"), "/sources", "/sources/"),
node("/packages", Some("/"), "/packages", "/packages/"),
], ],
} }
} }
@@ -134,9 +136,25 @@ fn depth_boundaries_empty_children_and_order_are_protocol_observations() {
assert!(observe("/", 0).children.is_none()); assert!(observe("/", 0).children.is_none());
let root = observe("/", 1); let root = observe("/", 1);
let children = root.children.unwrap(); let children = root.children.unwrap();
assert_eq!(children.len(), 1); assert_eq!(children.len(), 3);
assert_eq!(children[0].object.name, "book"); assert_eq!(children[0].object.name, "book");
assert!(children[0].children.is_none()); assert_eq!(children[1].object.name, "sources");
assert_eq!(children[2].object.name, "packages");
assert!(children.iter().all(|child| child.children.is_none()));
for path in ["/sources", "/packages"] {
assert!(observe(path, 0).children.is_none());
assert_eq!(observe(path, 1).children, Some(vec![]));
let leaf = observe(path, 1);
assert_eq!(
leaf.object.interfaces,
vec![Worldspace::document_interface()]
);
let content =
DocumentContent::decode(world.call_operation(read(path)).unwrap().result).unwrap();
assert_eq!(content.path, path);
assert_eq!(content.href, format!("{path}/"));
assert_eq!(content.html, format!("<h1>{path}</h1>"));
}
let root = observe("/", 3); let root = observe("/", 3);
let book = &root.children.as_ref().unwrap()[0]; let book = &root.children.as_ref().unwrap()[0];
let chapters = book.children.as_ref().unwrap(); let chapters = book.children.as_ref().unwrap();
@@ -190,6 +208,8 @@ fn canonical_host_failures_are_not_replaced_by_direct_body_lookup() {
"/1-chapter.md", "/1-chapter.md",
"/draft/private.md", "/draft/private.md",
"/book/draft/private.md", "/book/draft/private.md",
"/sources/repository",
"/packages/crate",
] { ] {
assert_eq!( assert_eq!(
world world
@@ -398,7 +418,26 @@ fn generated_real_public_documents_support_all_interactions() {
depth: MAX_DEPTH, depth: MAX_DEPTH,
}) })
.unwrap(); .unwrap();
assert_eq!(full.children.unwrap()[0].object.name, "book"); assert_eq!(
full.children
.unwrap()
.iter()
.map(|node| node.object.name.as_str())
.collect::<Vec<_>>(),
vec!["book", "sources", "packages", "playground"]
);
for path in ["/sources", "/packages"] {
assert_eq!(
world
.observe(ObserveRequest {
path: path.into(),
depth: 1,
})
.unwrap()
.children,
Some(vec![])
);
}
let descriptor = world let descriptor = world
.fetch_interface(FetchInterfaceRequest { .fetch_interface(FetchInterfaceRequest {
interface: Worldspace::document_interface(), interface: Worldspace::document_interface(),