diff --git a/README.md b/README.md index bdd51f1..931ab2b 100644 --- a/README.md +++ b/README.md @@ -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配信です。 -公開文書と既存の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は別です。たとえば: @@ -40,9 +40,44 @@ Worldspace path、表示用の既存識別子、Web URLは別です。たとえ - `site/src/scripts/browser-wip.ts` — WASM初期化、session寿命、結果検証と本文反映。 - `site/src/scripts/landing.ts` — 共通ツリー、導入演出、ページ切替と読書位置。 +### Protocolモード + +右上の`Read` / `Protocol`で切り替えます。Protocolでは通常モードとは独立したClient session・WASM instanceを使い、`?mode=protocol`をツリー遷移と再読込で保持します。Protocol開始時とツリーでのObject選択時には、選択済みObjectをDepth=1で自動Observeします。直リンク・再読込・履歴移動でも現在のObjectを対象とし、Interface取得と本文Operationは自動実行しません。 + +1. Protocolを開くと、Rust `wip-text-view`が生成した選択済みObjectのText Viewを表示します。ツリーで別Objectを選ぶと、そのText Viewに切り替わります。同じObjectの再選択でもObserveします。 +2. 未取得のInterfaceは`? /::document`の形式で表示します。クリックするとDescriptorとInterface Text Viewを取得して`v /::document`となり、再クリックで`> /::document`に折りたたみます。取得済みの再展開はClientのfresh cacheを利用します。 +3. 展開した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を使います。 diff --git a/site/playwright.dev.config.ts b/site/playwright.dev.config.ts index 9e0e8ff..deaf94b 100644 --- a/site/playwright.dev.config.ts +++ b/site/playwright.dev.config.ts @@ -8,7 +8,7 @@ const baseURL = `http://127.0.0.1:${port}${base}/`; export default defineConfig({ ...preview, // Override rather than merge webServer: dev checks must not require dist/. - testMatch: ['wip-loading.spec.ts', 'fonts.spec.ts'], + testMatch: ['wip-loading.spec.ts', 'fonts.spec.ts', 'protocol.spec.ts', 'protocol-api.spec.ts', 'playground.spec.ts'], use: { ...preview.use, baseURL }, webServer: { command: `npm run dev -- --host 127.0.0.1 --port ${port} --ignore-lock`, diff --git a/site/src/components/CatalogPage.astro b/site/src/components/CatalogPage.astro new file mode 100644 index 0000000..21a4eef --- /dev/null +++ b/site/src/components/CatalogPage.astro @@ -0,0 +1,19 @@ +--- +import { catalogs, type CatalogName } from '../lib/catalog'; +interface Props { name: CatalogName; } +const { name } = Astro.props; +const catalog = catalogs[name]; +--- +
+

{catalog.title}

+

{catalog.description}

+ +
diff --git a/site/src/components/Landing.astro b/site/src/components/Landing.astro index be873e9..df70459 100644 --- a/site/src/components/Landing.astro +++ b/site/src/components/Landing.astro @@ -1,15 +1,16 @@ --- 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 TreePrefix from './TreePrefix.astro'; import TreeGlyphs from './TreeGlyphs.astro'; +import ProtocolPanel from './ProtocolPanel.astro'; import { trunkPath, stemWidth, branchY, branchThickness } from '../lib/landing-glyphs.mjs'; interface Props { currentPath?: string; label?: string; draft?: boolean; } const entries = await documents(); const { currentPath = '/', label, draft = false } = Astro.props; const folderHref = (path: string) => path === '/' ? href() : path === '/book' ? href('book/') : href(`book${path}/`); -const objects = 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) })); const current = objects.find((object) => object.path === currentPath); const initialContent = entries.find((entry) => `/${entry.id}.md` === currentPath)?.rendered?.html; @@ -72,6 +73,7 @@ const trunkLength = (objects.findLastIndex((object) => object.depth === 2) - 1)
+ {draft && }
diff --git a/site/src/components/PlaygroundPage.astro b/site/src/components/PlaygroundPage.astro new file mode 100644 index 0000000..b5ea229 --- /dev/null +++ b/site/src/components/PlaygroundPage.astro @@ -0,0 +1,14 @@ +--- +import { href } from '../lib/book'; +--- +

Playground

+

ProtocolのOperationでObjectを作り、状態を変えるメモリ内のデモです。Protocolで開くと、実際のObserveでカウンターを取得します。

+
    +
  • /::collection:create(name)でカウンターを追加し、remove(name)で削除します。
  • +
  • /::counter:get()で現在値、increment(amount)で増減、reset()で0に戻します。
  • +
+

初期状態はappleが3、orangeが0です。操作後に再Observeし、ツリーの子Objectと値を更新します。名前は1〜32文字の英数字・_・-、カウンターは最大32個です。

+

Argumentsの入力例:

+
{'{"name":{"kind":"string","value":"banana"}}'}
+
{'{"amount":{"kind":"integer","value":"2"}}'}
+

状態はこのタブのProtocol session内だけに保存され、再読み込みで初期状態に戻ります。文書Objectや他の利用者の状態は変更しません。JavaScriptやWASMが利用できない場合は、この説明のみ表示します。

diff --git a/site/src/components/ProtocolPanel.astro b/site/src/components/ProtocolPanel.astro new file mode 100644 index 0000000..33debb0 --- /dev/null +++ b/site/src/components/ProtocolPanel.astro @@ -0,0 +1,20 @@ +--- +import '../styles/protocol.css'; +--- + + diff --git a/site/src/components/RootPage.astro b/site/src/components/RootPage.astro index 257d119..d0add6f 100644 --- a/site/src/components/RootPage.astro +++ b/site/src/components/RootPage.astro @@ -1,5 +1,5 @@ --- -import { href, repository } from '../lib/book'; +import { href } from '../lib/book'; ---
@@ -14,10 +14,15 @@ import { href, repository } from '../lib/book'; Bookコンセプトと仕様 - + - Sourceソースと変更履歴 - + Sources仕様と実装のソース + + + + + PackagesRustパッケージ一覧 +
diff --git a/site/src/layouts/Site.astro b/site/src/layouts/Site.astro index b6f5298..6b5f191 100644 --- a/site/src/layouts/Site.astro +++ b/site/src/layouts/Site.astro @@ -14,7 +14,7 @@ const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefin '; + const session = new module.BrowserSession(JSON.stringify({ nodes: [{ path: '/', name: '', displayPath: '/', title, href: '/', html: '

body only on call

' }] })); + 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 ";\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 }); +}); diff --git a/site/tests/browser/protocol.spec.ts b/site/tests/browser/protocol.spec.ts new file mode 100644 index 0000000..768c4a7 --- /dev/null +++ b/site/tests/browser/protocol.spec.ts @@ -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(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 = ''; + 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(resolve => { releaseNormal = resolve; }); + const pageGate = new Promise(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(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(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 = ''; + data.nodes[0].html = '

UNSAFE RESULT

'; + 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(' (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 }).__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'); + }); +}); diff --git a/site/tests/browser/routing.spec.ts b/site/tests/browser/routing.spec.ts index 2a8aeba..d540961 100644 --- a/site/tests/browser/routing.spec.ts +++ b/site/tests/browser/routing.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from '@playwright/test'; 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 }); @@ -8,8 +8,7 @@ const stage = '[data-landing-stage]'; const article = 'article.selected-content.prose[data-selected-content]'; const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`); const toggle = (page: Page) => page.locator('button.node-toggle[data-toggle-path="/book"]'); -const urlFor = (path: string, base: string) => path === '/' ? new URL('./', base).href - : path === '/book' ? new URL('book/', base).href : bookURL(path, base); +const urlFor = routeURL; async function settled(page: Page, path: string) { 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(/インタフェース/); const nav = home.find('nav[aria-label="ルートページのナビゲーション"]'); 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); - const source = $('.site-header a').filter((_, el) => $(el).text().includes('Source')).attr('href'); - expect(source).toMatch(/^https:\/\//); - expect(nav.find('a').filter((_, el) => $(el).text().includes('Source')).attr('href')).toBe(source); + expect(nav.find('a').filter((_, el) => $(el).text().includes('Sources')).attr('href')).toBe(new URL('sources/', base).pathname); + expect(nav.find('a').filter((_, el) => $(el).text().includes('Packages')).attr('href')).toBe(new URL('packages/', base).pathname); expect($(article).html()).not.toBe(chapter(article).html()); expect($(article).text()).not.toContain('1. コンセプト'); const chapterCopy = chapter(article).find('p').filter((_, el) => Boolean(chapter(el).text().trim())).first().text().trim(); @@ -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(page.locator(article)).not.toContainText('1. コンセプト'); 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); - const source = await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href'); - expect(source).toMatch(/^https:\/\//); - await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', source!); + await expect(nav.getByRole('link', { name: /^Sources(?:\s|$)/ })).toHaveAttribute('href', new URL('sources/', base).pathname); + await expect(nav.getByRole('link', { name: /^Packages(?:\s|$)/ })).toHaveAttribute('href', new URL('packages/', base).pathname); if (testInfo.project.name === 'mobile') { for (const width of [320, 360, 390]) { await page.setViewportSize({ width, height: 740 }); @@ -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) => { expect(documentPaths).toHaveLength(14); - expect(treeNodeCount).toBe(16); + expect(treeNodeCount).toBe(19); for (const path of documentPaths) { const url = bookURL(path, testInfo.project.use.baseURL!); 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 title = await page.title(); await expect(nodeAt(page, '/book')).toHaveAttribute('href', new URL('book/', base).pathname); + await nodeAt(page, '/book').locator('..').hover(); await toggle(page).click(); await expect(toggle(page)).toHaveText('+'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); @@ -378,6 +376,7 @@ test('directory anchors navigate independently from the sibling plus/minus toggl expect(await page.locator(article).innerHTML()).toBe(html); await expect(page).toHaveTitle(title); await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true'); + await nodeAt(page, '/book').locator('..').hover(); await toggle(page).click(); await expect(toggle(page)).toHaveText('−'); await expect(nodeAt(page, path)).toBeVisible(); @@ -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 viewport = await page.locator('.tree-viewport').elementHandle(); const root = await nodeAt(page, '/').elementHandle(); + await nodeAt(page, '/4-client.md').locator('..').hover(); await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click(); await page.locator('.tree-viewport').evaluate((el) => { (el as HTMLElement).style.height = '120px'; @@ -451,7 +451,9 @@ test('home preserves a collapsed root until selecting a child requires expansion await page.goto(bookURL(path, base)); await settled(page, path); const pane = await page.locator('.tree-pane').elementHandle(); + await nodeAt(page, '/4-client.md').locator('..').hover(); await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click(); + await nodeAt(page, '/book').locator('..').hover(); await toggle(page).click(); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await nodeAt(page, '/').focus(); @@ -573,9 +575,11 @@ test('rapid navigation settles on the last file and repeated swaps do not duplic await page.keyboard.press('ArrowDown'); await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused(); expect(page.url()).toBe(currentURL); + await nodeAt(page, '/book').locator('..').hover(); await toggle(page).click(); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await expect(toggle(page)).toHaveText('+'); + await nodeAt(page, '/book').locator('..').hover(); await toggle(page).click(); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true'); await expect(toggle(page)).toHaveText('−'); diff --git a/site/tests/browser/site.spec.ts b/site/tests/browser/site.spec.ts index c8f389c..75d0a85 100644 --- a/site/tests/browser/site.spec.ts +++ b/site/tests/browser/site.spec.ts @@ -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('[data-selected-content]')).not.toContainText('1. コンセプト'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); - await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ '); + await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '├─ '); const root = await page.locator('.root-mark').boundingBox(); const spine = await page.locator('.branch-vector').first().evaluate((element) => { const svg = element as SVGSVGElement; diff --git a/site/tests/browser/theme.spec.ts b/site/tests/browser/theme.spec.ts index b87eaf9..b952106 100644 --- a/site/tests/browser/theme.spec.ts +++ b/site/tests/browser/theme.spec.ts @@ -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 complete(page); await page.evaluate(() => document.fonts.ready.then(() => undefined)); + await nodeAt(page, '/1-concept.md').locator('..').hover(); await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click(); await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false'); const content = await page.locator(article).elementHandle(); diff --git a/site/tests/browser/tree-toggle.spec.ts b/site/tests/browser/tree-toggle.spec.ts new file mode 100644 index 0000000..1b45d03 --- /dev/null +++ b/site/tests/browser/tree-toggle.spec.ts @@ -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'); + }); +}); diff --git a/site/tests/browser/wip.spec.ts b/site/tests/browser/wip.spec.ts index b48b325..cb473bf 100644 --- a/site/tests/browser/wip.spec.ts +++ b/site/tests/browser/wip.spec.ts @@ -1,13 +1,13 @@ import { expect, test, type Page } from '@playwright/test'; 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' }); const stage = '[data-landing-stage]'; const article = '[data-selected-content]'; const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`); -const routeURL = (path: string, base: string) => path === '/' ? base : path === '/book' ? new URL('book/', base).href : bookURL(path, base); async function ready(page: Page, path: string) { await expect(page.locator(stage)).toHaveAttribute('data-current-path', path); @@ -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('title', node.title); if (node.displayPath !== '/') { - const label = node.displayPath === '/book' ? `${node.name.replace(/\/$/, '')}/` : node.name; - await expect(nodeAt(page, node.displayPath)).toHaveText(label); + await expect(nodeAt(page, node.displayPath)).toHaveText(node.name); } } 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); }); +test('catalog root leaves share static and real WASM bodies, links and history', async ({ page, request }, testInfo) => { + const base = testInfo.project.use.baseURL!; + for (const path of catalogPaths) { + const name = path.slice(1) as CatalogName; + const response = await request.get(routeURL(path, base)); + expect(response.ok()).toBe(true); + const $ = load(await response.text()); + const staticBody = $(article); + expect(staticBody.find('h1').text()).toBe(catalogs[name].title); + expect(staticBody.find('.catalog-list > li').length).toBe(catalogs[name].entries.length); + const links = staticBody.find('a').map((_, el) => $(el).attr('href')).get(); + expect(links).toEqual(catalogs[name].entries.flatMap((entry) => entry.links.map((link) => link.href))); + await page.goto(routeURL(path, base)); + await ready(page, path); + const result = await probeBridge(page, base, path); + const body = load(result.html, {}, false); + expect(body.text().replace(/\s+/g, ' ').trim()).toBe(staticBody.text().replace(/\s+/g, ' ').trim()); + expect(body('a').map((_, el) => body(el).attr('href')).get()).toEqual(links); + expect(await page.locator(article).innerHTML()).toBe(result.html); + await expect(nodeAt(page, path)).not.toHaveAttribute('aria-expanded'); + await expect(nodeAt(page, path)).toHaveAttribute('aria-level', '2'); + await expect(page.locator(`.node-toggle[data-toggle-path="${path}"]`)).toHaveCount(0); + await expect(page.locator(`.demo-node[data-path^="${path}/"]`)).toHaveCount(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + } + await nodeAt(page, '/sources').click(); + await ready(page, '/sources'); + await nodeAt(page, '/packages').click(); + await ready(page, '/packages'); + await page.goBack(); + await ready(page, '/sources'); + await expect(page).toHaveURL(routeURL('/sources', base)); + await page.goForward(); + await ready(page, '/packages'); + await expect(page).toHaveURL(routeURL('/packages', base)); +}); + +test.describe('catalog static fallback', () => { + test.use({ javaScriptEnabled: false }); + test('root catalog anchors and direct pages work without JavaScript', async ({ page }, testInfo) => { + await page.goto('./'); + for (const path of catalogPaths) { + await nodeAt(page, path).click(); + await expect(page).toHaveURL(routeURL(path, testInfo.project.use.baseURL!)); + await expect(page.locator(article + ' h1')).toHaveText(catalogs[path.slice(1) as CatalogName].title); + await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page'); + await expect(nodeAt(page, path)).not.toHaveAttribute('aria-expanded'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + } + }); +}); + +test('catalog bodies and native navigation remain readable when the WASM module fails', async ({ page }, testInfo) => { + await page.route('**/wip-wasm/wip_browser.js', (route) => route.abort()); + await page.goto(routeURL('/sources', testInfo.project.use.baseURL!)); + await expect(page.locator(article + ' h1')).toHaveText('Sources'); + await nodeAt(page, '/packages').click(); + await expect(page).toHaveURL(routeURL('/packages', testInfo.project.use.baseURL!)); + await expect(page.locator(article + ' h1')).toHaveText('Packages'); +}); + test('WIP navigation retains collapse, history reading position, fragments and single handlers', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; // Exercise nested scroll even on mobile, whose normal scroll belongs to window. await useNestedArticleScroll(page); await page.goto(bookURL('/3-protocol.md', base)); await ready(page, '/3-protocol.md'); + await nodeAt(page, '/1-concept.md').locator('..').hover(); await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click(); const position = await page.locator(article).evaluate((el) => { el.scrollTop = 180; return el.scrollTop; }); expect(position).toBe(180); @@ -193,8 +254,10 @@ test('WIP navigation retains collapse, history reading position, fragments and s await nodeAt(page, path).click(); await ready(page, path); } + await nodeAt(page, '/book').locator('..').hover(); await page.locator('.node-toggle[data-toggle-path="/book"]').click(); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); + await nodeAt(page, '/book').locator('..').hover(); await page.locator('.node-toggle[data-toggle-path="/book"]').click(); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true'); await nodeAt(page, '/1-concept.md').focus(); diff --git a/site/tests/catalog.test.mjs b/site/tests/catalog.test.mjs new file mode 100644 index 0000000..4b38da3 --- /dev/null +++ b/site/tests/catalog.test.mjs @@ -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')); +}); diff --git a/site/tests/landing-tree.test.mjs b/site/tests/landing-tree.test.mjs index 642b9bc..5bf0b93 100644 --- a/site/tests/landing-tree.test.mjs +++ b/site/tests/landing-tree.test.mjs @@ -3,7 +3,7 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import test from 'node:test'; import { contentsRoot } from '../plugins/document-links.mjs'; -import { documentTree, 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 = '') { 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 display = bookTree(published); 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.length, published.length + 2); 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', () => { for (const document of documents) { const node = nodes.find((node) => node.path === '/' + document.id + '.md'); diff --git a/site/tests/worldspace-data.test.mjs b/site/tests/worldspace-data.test.mjs index 9b2f939..0b81c6e 100644 --- a/site/tests/worldspace-data.test.mjs +++ b/site/tests/worldspace-data.test.mjs @@ -5,12 +5,15 @@ import test from 'node:test'; import { createMarkdownProcessor } from '@astrojs/markdown-remark'; import { load } from 'cheerio'; import documentLinks, { contentsRoot } from '../plugins/document-links.mjs'; -import { bookTree } from '../src/lib/landing-tree.ts'; +import { publicTree } from '../src/lib/landing-tree.ts'; import { createWorldspaceData } from '../src/lib/worldspace-data.ts'; const pages = { '/': { title: 'Worldspace Interface Protocol', href: '/', html: '

Home

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

Book

' }, + '/sources': { title: 'Sources', href: '/sources/', html: '

Sources

' }, + '/packages': { title: 'Packages', href: '/packages/', html: '

Packages

' }, + '/playground': { title: 'Playground', href: '/playground/', html: '

Playground

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

${id}

` }); @@ -49,15 +52,15 @@ function checkIdentity(data) { test('actual public documents keep Web and display identities but acquire real parent paths', () => { const data = createWorldspaceData(documents, pages); checkIdentity(data); - const display = bookTree(documents); - assert.equal(data.nodes.length, documents.length + 2); + const display = publicTree(documents); + assert.equal(data.nodes.length, documents.length + 5); assert.deepEqual(data.nodes.map((node) => node.displayPath), display.map((node) => node.path)); for (const node of data.nodes) { const legacy = display.find((item) => item.path === node.displayPath); assert.equal(node.parent, legacy.parent === undefined ? undefined : data.nodes.find((item) => item.displayPath === legacy.parent).path); if (legacy.href) { assert.equal(node.href, legacy.href); - assert.equal(node.html, documents.find((entry) => `/${entry.id}.md` === node.displayPath).html); + assert.equal(node.html, (documents.find((entry) => `/${entry.id}.md` === node.displayPath) ?? pages[node.displayPath]).html); assert.match(node.html, /

{ + 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', () => { @@ -86,6 +102,9 @@ test('nested numbering, real directories and site base prefixes remain unambiguo const data = createWorldspaceData(docs, { '/': { ...pages['/'], href: '/reference/' }, '/book': { ...pages['/book'], href: '/reference/book/' }, + '/sources': { ...pages['/sources'], href: '/reference/sources/' }, + '/packages': { ...pages['/packages'], href: '/reference/packages/' }, + '/playground': { ...pages['/playground'], href: '/reference/playground/' }, '/other': { title: 'other/', href: '/reference/book/other/', html: '

other/

' }, }); 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/); }); -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); assert.deepEqual(data.nodes, [ { path: '/', name: '', displayPath: '/', ...pages['/'] }, { path: '/book', name: 'book', displayPath: '/book', parent: '/', ...pages['/book'] }, + { path: '/sources', name: 'sources', displayPath: '/sources', parent: '/', ...pages['/sources'] }, + { path: '/packages', name: 'packages', displayPath: '/packages', parent: '/', ...pages['/packages'] }, + { path: '/playground', name: 'playground', displayPath: '/playground', parent: '/', ...pages['/playground'] }, ]); }); @@ -122,10 +144,11 @@ test('built artifact contains exactly public routes and the same rendered articl { skip: process.env.WIP_TEST_GENERATED !== '1' }, async () => { const data = JSON.parse(await readFile(new URL('../dist/worldspace.json', import.meta.url), 'utf8')); checkIdentity(data); - assert.equal(data.nodes.length, documents.length + 2); + assert.equal(data.nodes.length, documents.length + 5); assert.deepEqual(new Set(data.nodes.filter((node) => node.displayPath.endsWith('.md')).map((node) => node.displayPath.slice(1))), new Set(publicFiles)); for (const node of data.nodes) { const relative = node.displayPath === '/' ? 'index.html' : node.displayPath === '/book' ? 'book/index.html' + : ['/sources', '/packages', '/playground'].includes(node.displayPath) ? `${node.displayPath.slice(1)}/index.html` : `book/${node.displayPath.slice(1, -3)}/index.html`; const page = load(await readFile(new URL(`../dist/${relative}`, import.meta.url), 'utf8')); // Astro adds hydration/render markers outside the actual article fragment. diff --git a/site/worldspace/tests/worldspace.rs b/site/worldspace/tests/worldspace.rs index 2c282be..14b3d1e 100644 --- a/site/worldspace/tests/worldspace.rs +++ b/site/worldspace/tests/worldspace.rs @@ -56,6 +56,8 @@ fn data() -> WorldspaceData { "/2-second.md", "/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()); let root = observe("/", 1); let children = root.children.unwrap(); - assert_eq!(children.len(), 1); + assert_eq!(children.len(), 3); 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!("

{path}

")); + } let root = observe("/", 3); let book = &root.children.as_ref().unwrap()[0]; 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", "/draft/private.md", "/book/draft/private.md", + "/sources/repository", + "/packages/crate", ] { assert_eq!( world @@ -398,7 +418,26 @@ fn generated_real_public_documents_support_all_interactions() { depth: MAX_DEPTH, }) .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!["book", "sources", "packages", "playground"] + ); + for path in ["/sources", "/packages"] { + assert_eq!( + world + .observe(ObserveRequest { + path: path.into(), + depth: 1, + }) + .unwrap() + .children, + Some(vec![]) + ); + } let descriptor = world .fetch_interface(FetchInterfaceRequest { interface: Worldspace::document_interface(),