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
+16 -12
View File
@@ -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('−');