import { expect, test, type Page } from '@playwright/test'; import { load } from 'cheerio'; import { bookURL, documentPaths, draftPaths, treeNodeCount, treePaths, routeURL } from './documents'; test.use({ javaScriptEnabled: true }); const stage = '[data-landing-stage]'; const article = 'article.selected-content.prose[data-selected-content]'; const nodeAt = (page: Page, path: string) => page.locator(`.demo-node[data-path="${path}"]`); const toggle = (page: Page) => page.locator('button.node-toggle[data-toggle-path="/book"]'); const urlFor = routeURL; async function settled(page: Page, path: string) { await expect(page.locator(stage)).toHaveAttribute('data-current-path', path); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); await expect(page.locator('html')).not.toHaveAttribute('data-landing'); await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page'); await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true'); await expect(page.locator('.demo-node[aria-current="page"]')).toHaveCount(1); } // Catch even a transient intro state, rather than merely waiting for it to finish. function recordIntroStates() { const states: string[] = []; (window as Window & { introStates?: string[] }).introStates = states; new MutationObserver((mutations) => { for (const mutation of mutations) { const target = mutation.target as Element; const values = [mutation.oldValue, target.getAttribute(mutation.attributeName!)]; for (const value of values) { if (value && (mutation.attributeName === 'data-landing' || ['armed', 'flow'].includes(value))) states.push(value); } } }).observe(document, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['data-landing', 'data-phase'] }); } async function expectNoIntroStates(page: Page) { expect(await page.evaluate(() => (window as Window & { introStates?: string[] }).introStates)).toEqual([]); } async function expectMetadata(page: Page, expectedURL: string) { const title = await page.title(); expect(title).toBeTruthy(); await expect(page.locator('meta[property="og:title"]')).toHaveAttribute('content', title); const description = await page.locator('meta[name="description"]').getAttribute('content'); expect(description).toBeTruthy(); await expect(page.locator('meta[property="og:description"]')).toHaveAttribute('content', description!); const canonical = page.locator('link[rel="canonical"]'); if (process.env.SITE_URL) { await expect(canonical).toHaveCount(1); await expect(canonical).toHaveAttribute('href', new URL(new URL(expectedURL).pathname, process.env.SITE_URL).href); } else { await expect(canonical).toHaveCount(0); } } test('home response contains independent root content and the unchanged public tree', async ({ request }, testInfo) => { const base = testInfo.project.use.baseURL!; const response = await request.get(base); expect(response.ok()).toBe(true); const $ = load(await response.text()); const chapterResponse = await request.get(bookURL('/1-concept.md', base)); expect(chapterResponse.ok()).toBe(true); const chapter = load(await chapterResponse.text()); expect($(stage).attr('data-current-path')).toBe('/'); expect($(article).length).toBe(1); expect($('article.prose').length).toBe(1); const home = $(article).find('.root-page[data-root-page]'); expect(home.length).toBe(1); expect($(article).find('h1').length).toBe(1); expect(home.find('h1').attr('aria-label')).toBe('WIP'); const heading = home.find('h1').clone(); heading.find('[aria-hidden="true"]').remove(); expect(heading.text().trim()).toBe('WIP'); expect(home.find('p').text()).toContain('Worldspace Interface Protocol'); const copy = home.find('p').text(); expect(copy).toMatch(/人(?:間)?/); expect(copy).toMatch(/AI/); expect(copy).toMatch(/システム/); expect(copy).toMatch(/共通/); expect(copy).toMatch(/インタフェース/); const nav = home.find('nav[aria-label="ルートページのナビゲーション"]'); expect(nav.length).toBe(1); expect(nav.find('a').length).toBe(3); expect(nav.find('a').filter((_, el) => $(el).text().includes('Book')).attr('href')).toBe(new URL('book/', base).pathname); expect(nav.find('a').filter((_, el) => $(el).text().includes('Sources')).attr('href')).toBe(new URL('sources/', base).pathname); expect(nav.find('a').filter((_, el) => $(el).text().includes('Packages')).attr('href')).toBe(new URL('packages/', base).pathname); expect($(article).html()).not.toBe(chapter(article).html()); expect($(article).text()).not.toContain('1. コンセプト'); const chapterCopy = chapter(article).find('p').filter((_, el) => Boolean(chapter(el).text().trim())).first().text().trim(); expect(chapterCopy).toBeTruthy(); expect($(article).text()).not.toContain(chapterCopy); expect($('.root-mark').attr('aria-current')).toBe('page'); expect($('.root-mark').attr('aria-selected')).toBe('true'); expect($('.demo-node[aria-current="page"]').length).toBe(1); const treeShape = (document: ReturnType) => document('.demo-node').map((_, el) => { const node = document(el); return { tag: node.prop('tagName'), path: node.attr('data-path'), href: node.attr('href'), role: node.attr('role'), level: node.attr('aria-level'), parent: node.parent().prop('tagName') }; }).get(); expect(treeShape($)).toEqual(treeShape(chapter)); expect($('.demo-node').map((_, el) => $(el).attr('data-path')).get()).toEqual(treePaths); const serialized = $('#landing-documents').text(); expect(serialized).not.toContain('<'); const nodes = JSON.parse(serialized) as Record[]; expect(nodes.map((node) => node.path)).toEqual(treePaths); for (const node of nodes) { expect(node).not.toHaveProperty('html'); expect(node).not.toHaveProperty('body'); } expect($('meta[property="og:title"]').attr('content')).toBe($('title').text()); expect($('meta[name="description"]').attr('content')).toBeTruthy(); expect($('meta[property="og:description"]').attr('content')).toBe($('meta[name="description"]').attr('content')); if (process.env.SITE_URL) { expect($('link[rel="canonical"]').attr('href')).toBe(new URL(new URL(base).pathname, process.env.SITE_URL).href); } else { expect($('link[rel="canonical"]').length).toBe(0); } }); test('root content navigation works without changing the tree or overflowing narrow mobile viewports', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('./'); await settled(page, '/'); const home = page.locator(article + ' .root-page[data-root-page]'); await expect(home).toBeVisible(); await expect(home.getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); await expect(page.locator(article + ' h1')).toHaveCount(1); await expect(home.locator('p').filter({ hasText: 'Worldspace Interface Protocol' })).toBeVisible(); await expect(page.locator(article)).not.toContainText('1. コンセプト'); const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }); await expect(nav.getByRole('link')).toHaveCount(3); await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', base).pathname); await expect(nav.getByRole('link', { name: /^Sources(?:\s|$)/ })).toHaveAttribute('href', new URL('sources/', base).pathname); await expect(nav.getByRole('link', { name: /^Packages(?:\s|$)/ })).toHaveAttribute('href', new URL('packages/', base).pathname); if (testInfo.project.name === 'mobile') { for (const width of [320, 360, 390]) { await page.setViewportSize({ width, height: 740 }); await expect(home).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), `${width}px home has no horizontal overflow`).toBe(true); const bounds = await home.boundingBox(); expect(bounds!.x).toBeGreaterThanOrEqual(0); expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width); } } await nav.getByRole('link', { name: /^Book(?:\s|$)/ }).click(); await expect(page).toHaveURL(new URL('book/', base).href); await settled(page, '/book'); await expect(page.locator('[data-root-page]')).toHaveCount(0); await expect(page.locator(article).locator('a[href$="/book/1-concept/"]')).toBeVisible(); }); test('all 14 document responses contain the current article and metadata-only public tree', async ({ request }, testInfo) => { expect(documentPaths).toHaveLength(14); expect(treeNodeCount).toBe(19); for (const path of documentPaths) { const url = bookURL(path, testInfo.project.use.baseURL!); const response = await request.get(url); expect(response.ok(), path).toBe(true); const $ = load(await response.text()); expect($('.demo-node').length, path).toBe(treeNodeCount); expect($(stage).attr('data-current-path')).toBe(path); expect($(article).length).toBe(1); expect($('article.prose').length).toBe(1); expect($(article).find('h1').text()).toBeTruthy(); expect($(`.demo-node[data-path="${path}"]`).attr('aria-current')).toBe('page'); expect($(`.demo-node[data-path="${path}"]`).attr('href')).toBe(new URL(url).pathname); expect($('.sidebar, .mobile-chapters, .mobile-toc, .page-toc, .pagination').length).toBe(0); const payload = $('#landing-documents'); expect(payload.length).toBe(1); const serialized = payload.text(); expect(serialized).not.toContain('<'); const nodes = JSON.parse(serialized) as Record[]; expect(nodes.map((node) => node.path)).toEqual(treePaths); for (const node of nodes) { expect(node).not.toHaveProperty('html'); expect(node).not.toHaveProperty('body'); expect(node.href).toBe(new URL(urlFor(node.path as string, testInfo.project.use.baseURL!)).pathname); } expect($('title').text()).toContain($(article).find('h1').first().text()); expect($('meta[property="og:title"]').attr('content')).toBe($('title').text()); expect($('meta[name="description"]').attr('content')).toBeTruthy(); if (process.env.SITE_URL) { expect($('link[rel="canonical"]').attr('href')).toBe(new URL(new URL(url).pathname, process.env.SITE_URL).href); } else { expect($('link[rel="canonical"]').length).toBe(0); } expect($('.demo-node[data-path^="/draft"], a[href*="/draft/"], .draft-notice').length).toBe(0); expect(serialized).not.toContain('/draft'); } }); test('draft directory and all five formerly public draft documents return 404 without publishing content', async ({ request }, testInfo) => { const base = testInfo.project.use.baseURL!; expect(draftPaths).toHaveLength(5); const missing = await request.get(new URL('missing-public-document/', base).href); const notFound = load(await missing.text()); expect(missing.status()).toBe(404); for (const url of [new URL('book/draft/', base).href, ...draftPaths.map((path) => bookURL(path, base))]) { const response = await request.get(url); expect(response.status(), url).toBe(404); const $ = load(await response.text()); expect($('.demo-node').length).toBe(treeNodeCount); expect($('.demo-node[data-path^="/draft"], a[href*="/draft/"], .draft-notice').length).toBe(0); expect($(article).length).toBe(1); expect($(article).html()).toBe(notFound(article).html()); expect($(article).find('h1').text()).toBe('このページは見つかりません。'); expect($(stage).attr('data-current-path') ?? '').not.toMatch(/^\/draft/); const nodes = JSON.parse($('#landing-documents').text()) as { path: string; html?: string; body?: string }[]; expect(nodes.map((node) => node.path)).toEqual(treePaths); for (const node of nodes) { expect(node).not.toHaveProperty('html'); expect(node).not.toHaveProperty('body'); } } }); test('direct documents never arm the intro; history and reload restore route body and metadata', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; const first = '/1-concept.md', second = '/2-core-model.md'; await page.addInitScript(recordIntroStates); await page.goto(bookURL(first, base)); await settled(page, first); const firstHTML = await page.locator(article).innerHTML(); const firstTitle = await page.title(); await expectMetadata(page, bookURL(first, base)); const pane = await page.locator('.tree-pane').elementHandle(); const oldStage = await page.locator(stage).elementHandle(); const oldArticle = await page.locator(article).elementHandle(); await nodeAt(page, second).click(); await expect(page).toHaveURL(bookURL(second, base)); await settled(page, second); const secondHTML = await page.locator(article).innerHTML(); const secondTitle = await page.title(); expect(secondHTML).not.toBe(firstHTML); expect(secondTitle).not.toBe(firstTitle); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); expect(await oldStage!.evaluate((el) => el === document.querySelector('[data-landing-stage]'))).toBe(false); expect(await oldArticle!.evaluate((el) => el === document.querySelector('article.prose'))).toBe(false); await expectMetadata(page, bookURL(second, base)); await page.goBack(); await expect(page).toHaveURL(bookURL(first, base)); await settled(page, first); expect(await page.locator(article).innerHTML()).toBe(firstHTML); await expect(page).toHaveTitle(firstTitle); await expectMetadata(page, bookURL(first, base)); await page.goForward(); await settled(page, second); expect(await page.locator(article).innerHTML()).toBe(secondHTML); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); await expectMetadata(page, bookURL(second, base)); await expectNoIntroStates(page); await page.reload(); await settled(page, second); expect(await page.locator(article).innerHTML()).toBe(secondHTML); await expect(page).toHaveTitle(secondTitle); await expectMetadata(page, bookURL(second, base)); await expectNoIntroStates(page); }); test('modified arrow keys remain unconsumed and do not move tree focus or fold directories', async ({ page }, testInfo) => { const path = '/3-protocol.md'; await page.goto(bookURL(path, testInfo.project.use.baseURL!)); await settled(page, path); const originalURL = page.url(); const originalHTML = await page.locator(article).innerHTML(); const book = nodeAt(page, '/book'); await book.focus(); // Synthetic cancelable events test shortcut ownership without depending on // headless Chrome implementing native Alt+Arrow history shortcuts. for (const modifier of ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'] as const) { for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) { const result = await book.evaluate((el, { key, modifier }) => { const event = new KeyboardEvent('keydown', { key, [modifier]: true, bubbles: true, cancelable: true }); return { uncanceled: el.dispatchEvent(event), defaultPrevented: event.defaultPrevented }; }, { key, modifier }); expect(result, `${modifier}+${key}`).toEqual({ uncanceled: true, defaultPrevented: false }); await expect(book).toBeFocused(); await expect(book).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); expect(page.url()).toBe(originalURL); await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page'); expect(await page.locator(article).innerHTML()).toBe(originalHTML); } } // Positive controls: the handler must still own unmodified tree navigation. await page.keyboard.press('ArrowLeft'); await expect(book).toHaveAttribute('aria-expanded', 'false'); await page.keyboard.press('ArrowRight'); await expect(book).toHaveAttribute('aria-expanded', 'true'); await page.keyboard.press('ArrowDown'); await expect(nodeAt(page, '/1-concept.md')).toBeFocused(); expect(page.url()).toBe(originalURL); }); test('history restores nested article scroll per entry, including repeated visits to the same URL', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; const first = '/3-protocol.md', second = '/4.2-text-view.md'; if (testInfo.project.name === 'mobile') { // Natural mobile reading scroll belongs to Astro's window restoration. // Force only this test's article to overflow, before page-load restoration // runs on each new document; desktop exercises the unmodified layout. await page.addInitScript(() => { const constrainArticle = () => { const el = document.querySelector('[data-selected-content]'); if (el) { el.style.maxHeight = '240px'; el.style.overflow = 'auto'; } }; document.addEventListener('astro:after-swap', constrainArticle); document.addEventListener('astro:page-load', constrainArticle); }); } const setPosition = async (fraction: number) => { const position = await page.locator(article).evaluate((el, fraction) => { const maximum = el.scrollHeight - el.clientHeight; el.scrollTop = Math.floor(maximum * fraction); return { maximum, top: el.scrollTop }; }, fraction); expect(position.maximum).toBeGreaterThan(100); expect(position.top).toBeGreaterThan(0); return position.top; }; const expectPosition = async (top: number) => { await expect.poll(() => page.locator(article).evaluate((el) => el.scrollTop)).toBe(top); }; const navigate = async (path: string) => { await nodeAt(page, path).click(); await expect(page).toHaveURL(bookURL(path, base)); await settled(page, path); await page.evaluate(() => document.fonts.ready.then(() => undefined)); }; await page.goto(bookURL(first, base)); await settled(page, first); await page.evaluate(() => document.fonts.ready.then(() => undefined)); const pane = await page.locator('.tree-pane').elementHandle(); const firstVisit = await setPosition(.2); await navigate(second); await expectPosition(0); const secondVisit = await setPosition(.3); await navigate(first); await expectPosition(0); // A new history entry must not reuse this URL's earlier position. const firstRevisit = await setPosition(.6); expect(firstRevisit).not.toBe(firstVisit); await navigate(second); await expectPosition(0); const secondRevisit = await setPosition(.7); expect(secondRevisit).not.toBe(secondVisit); for (const [path, top] of [[first, firstRevisit], [second, secondVisit], [first, firstVisit]] as const) { await page.goBack(); await expect(page).toHaveURL(bookURL(path, base)); await settled(page, path); await expectPosition(top); } for (const [path, top] of [[second, secondVisit], [first, firstRevisit], [second, secondRevisit]] as const) { await page.goForward(); await expect(page).toHaveURL(bookURL(path, base)); await settled(page, path); await expectPosition(top); } expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); }); test('directory anchors navigate independently from the sibling plus/minus toggle', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; const path = '/2-core-model.md'; await page.goto(bookURL(path, base)); await settled(page, path); const html = await page.locator(article).innerHTML(); const title = await page.title(); await expect(nodeAt(page, '/book')).toHaveAttribute('href', new URL('book/', base).pathname); await nodeAt(page, '/book').locator('..').hover(); await toggle(page).click(); await expect(toggle(page)).toHaveText('+'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, path)).toBeHidden(); expect(page.url()).toBe(bookURL(path, base)); expect(await page.locator(article).innerHTML()).toBe(html); await expect(page).toHaveTitle(title); await expect(nodeAt(page, path)).toHaveAttribute('aria-selected', 'true'); await nodeAt(page, '/book').locator('..').hover(); await toggle(page).click(); await expect(toggle(page)).toHaveText('−'); await expect(nodeAt(page, path)).toBeVisible(); await nodeAt(page, '/book').click(); await expect(page).toHaveURL(new URL('book/', base).href); await settled(page, '/book'); await expect(page.locator('.landing a:not(.demo-node)[href*="/book/"]')).toHaveCount(14); for (const document of documentPaths) { await expect(page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(document, base)).pathname}"]`)).toBeVisible(); } await nodeAt(page, '/').focus(); await page.keyboard.press('Enter'); await expect(page).toHaveURL(base); await settled(page, '/'); await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); await expect(page.locator('.sidebar, .mobile-chapters, .mobile-toc, .page-toc, .pagination')).toHaveCount(0); }); test('persisted tree retains scroll and collapse, expanding only an ancestor needed by selection', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; const first = '/1-concept.md', second = '/2-core-model.md', section = '/4.1-ai-tools.md'; await page.goto(bookURL(first, base)); await settled(page, first); const pane = await page.locator('.tree-pane').elementHandle(); const viewport = await page.locator('.tree-viewport').elementHandle(); const root = await nodeAt(page, '/').elementHandle(); await nodeAt(page, '/4-client.md').locator('..').hover(); await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click(); await page.locator('.tree-viewport').evaluate((el) => { (el as HTMLElement).style.height = '120px'; el.scrollTop = 48; }); const scroll = await page.locator('.tree-viewport').evaluate((el) => ({ top: el.scrollTop, left: el.scrollLeft })); expect(scroll.top).toBeGreaterThan(0); // Programmatic native anchor activation avoids Playwright scrolling the target into view first. await nodeAt(page, second).evaluate((el) => (el as HTMLAnchorElement).click()); await settled(page, second); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); expect(await viewport!.evaluate((el) => el === document.querySelector('.tree-viewport'))).toBe(true); expect(await root!.evaluate((el) => el === document.querySelector('.root-mark'))).toBe(true); expect(await page.locator('.tree-viewport').evaluate((el) => ({ top: el.scrollTop, left: el.scrollLeft }))).toEqual(scroll); await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); const secondHTML = await page.locator(article).innerHTML(); await nodeAt(page, '/').focus(); await page.keyboard.press('ArrowLeft'); await expect(page.getByRole('treeitem')).toHaveCount(1); expect(page.url()).toBe(bookURL(second, base)); expect(await page.locator(article).innerHTML()).toBe(secondHTML); await page.locator('.site-header').getByRole('link', { name: 'Book', exact: true }).click(); await settled(page, '/book'); await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); await page.goBack(); await settled(page, second); await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); await page.goBack(); await settled(page, first); await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); // A hidden file still has a real link; selecting it must reveal its collapsed ancestor. await nodeAt(page, section).evaluate((el) => (el as HTMLAnchorElement).click()); await settled(page, section); await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, section)).toBeVisible(); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); }); test('home preserves a collapsed root until selecting a child requires expansion', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; const path = '/1-concept.md'; await page.addInitScript(recordIntroStates); await page.goto(bookURL(path, base)); await settled(page, path); const pane = await page.locator('.tree-pane').elementHandle(); await nodeAt(page, '/4-client.md').locator('..').hover(); await page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click(); await nodeAt(page, '/book').locator('..').hover(); await toggle(page).click(); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await nodeAt(page, '/').focus(); await page.keyboard.press('ArrowLeft'); await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'false'); await page.keyboard.press('Enter'); await expect(page).toHaveURL(base); await settled(page, '/'); await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/book')).toBeHidden(); await expect(page.getByRole('treeitem')).toHaveCount(1); await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); // The content Book link selects a root child, not its descendants. await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click(); await settled(page, '/book'); await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); await nodeAt(page, '/').click(); await settled(page, '/'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await nodeAt(page, '/').focus(); await page.keyboard.press('ArrowLeft'); await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'false'); // Native hidden file activation must reveal only the ancestors needed by it. await nodeAt(page, path).evaluate((el) => (el as HTMLAnchorElement).click()); await settled(page, path); await expect(nodeAt(page, '/')).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true'); await expect(nodeAt(page, path)).toBeVisible(); await expect(nodeAt(page, '/4-client.md')).toHaveAttribute('aria-expanded', 'false'); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); await expectNoIntroStates(page); }); test('internal Markdown cross-page and fragment links use the same router and current tree', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; await page.goto(bookURL('/3-protocol.md', base)); await settled(page, '/3-protocol.md'); // Draft references in authored Markdown must not remain navigable after publication filtering. await expect(page.locator(article + ' a[href*="draft"]')).toHaveCount(0); const pane = await page.locator('.tree-pane').elementHandle(); const link = page.locator(article).getByRole('link', { name: '4.2. Text View', exact: true }).first(); const href = await link.getAttribute('href'); expect(href).toContain('/book/4.2-text-view/#'); await link.click(); await expect(page).toHaveURL(new URL(href!, page.url()).href); await settled(page, '/4.2-text-view.md'); const fragment = await page.evaluate(() => { const target = document.getElementById(decodeURIComponent(location.hash.slice(1))); return { exists: Boolean(target), inArticle: Boolean(target?.closest('article.prose')), top: target?.getBoundingClientRect().top }; }); expect(fragment.exists).toBe(true); expect(fragment.inArticle).toBe(true); await expect.poll(() => page.evaluate(() => document.getElementById(decodeURIComponent(location.hash.slice(1)))!.getBoundingClientRect().top)).toBeLessThan(page.viewportSize()!.height); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); await expectMetadata(page, bookURL('/4.2-text-view.md', base)); const currentArticle = await page.locator(article).elementHandle(); const local = page.locator(article).getByRole('link', { name: '名前と文字列', exact: true }).first(); const localHref = await local.getAttribute('href'); expect(localHref).toContain('#'); const localURL = new URL(localHref!, page.url()).href; await local.click(); await expect(page).toHaveURL(localURL); await settled(page, '/4.2-text-view.md'); await expect.poll(() => page.evaluate(() => { const target = document.getElementById(decodeURIComponent(location.hash.slice(1)))!; const box = target.getBoundingClientRect(); return box.bottom > 0 && box.top < innerHeight; })).toBe(true); expect(await currentArticle!.evaluate((el) => el === document.querySelector('article.prose'))).toBe(true); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); }); test('modifier and target=_blank file links retain native new-tab behavior', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; await page.goto(bookURL('/1-concept.md', base)); await settled(page, '/1-concept.md'); const originalURL = page.url(); const next = nodeAt(page, '/2-core-model.md'); const [modified] = await Promise.all([ page.context().waitForEvent('page'), next.click({ modifiers: ['ControlOrMeta'] }), ]); await modified.waitForLoadState(); await expect(modified).toHaveURL(bookURL('/2-core-model.md', base)); expect(page.url()).toBe(originalURL); await modified.close(); await next.evaluate((el) => el.setAttribute('target', '_blank')); const [blank] = await Promise.all([page.context().waitForEvent('page'), next.click()]); await blank.waitForLoadState(); await expect(blank).toHaveURL(bookURL('/2-core-model.md', base)); expect(page.url()).toBe(originalURL); await settled(page, '/1-concept.md'); await blank.close(); }); test('rapid navigation settles on the last file and repeated swaps do not duplicate handlers', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; const errors: string[] = []; page.on('pageerror', (error) => errors.push(error.message)); await page.goto(bookURL('/1-concept.md', base)); await settled(page, '/1-concept.md'); const pane = await page.locator('.tree-pane').elementHandle(); await page.evaluate(() => { for (const path of ['/2-core-model.md', '/3-protocol.md', '/4-client.md']) { document.querySelector(`.demo-node[data-path="${path}"]`)!.click(); } }); await expect(page).toHaveURL(bookURL('/4-client.md', base)); await settled(page, '/4-client.md'); await expect(page.locator(article + ' h1')).toHaveText('4. クライアント'); for (const path of ['/1-concept.md', '/2-core-model.md', '/1-concept.md', '/2-core-model.md']) { await nodeAt(page, path).click(); await settled(page, path); } await nodeAt(page, '/1-concept.md').focus(); const currentURL = page.url(); await page.keyboard.press('ArrowDown'); await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused(); expect(page.url()).toBe(currentURL); await nodeAt(page, '/book').locator('..').hover(); await toggle(page).click(); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false'); await expect(toggle(page)).toHaveText('+'); await nodeAt(page, '/book').locator('..').hover(); await toggle(page).click(); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'true'); await expect(toggle(page)).toHaveText('−'); // One activation must add one history entry, not one entry per prior page-load handler. await nodeAt(page, '/3-protocol.md').click(); await settled(page, '/3-protocol.md'); await page.goBack(); await expect(page).toHaveURL(currentURL); await settled(page, '/2-core-model.md'); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); await expect(page.locator('.tree-pane')).toHaveCount(1); await expect(page.locator('#landing-documents')).toHaveCount(1); expect(errors).toEqual([]); }); test('client return home skips intro, while a hard reload home may arm it again', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; await page.goto('./'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/'); await page.keyboard.press('Escape'); await settled(page, '/'); const homeHTML = await page.locator(article).innerHTML(); const homeTitle = await page.title(); await expectMetadata(page, base); await page.evaluate(recordIntroStates); const pane = await page.locator('.tree-pane').elementHandle(); const tree = await page.locator('.demo-tree').elementHandle(); const nodes = await page.locator('.demo-node').elementHandles(); const expectHome = async () => { await expect(page).toHaveURL(base); await settled(page, '/'); await expect(page.locator('.intro-skip')).toBeHidden(); await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); expect(await page.locator(article).innerHTML()).toBe(homeHTML); await expect(page).toHaveTitle(homeTitle); await expectMetadata(page, base); }; await nodeAt(page, '/2-core-model.md').click(); await settled(page, '/2-core-model.md'); expect(await page.locator(article).innerHTML()).not.toBe(homeHTML); await page.locator('.brand').click(); await expectHome(); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); // The book directory has its own route; the resting slash returns to HOME. await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click(); await expect(page).toHaveURL(new URL('book/', base).href); await settled(page, '/book'); const bookHTML = await page.locator(article).innerHTML(); const bookTitle = await page.title(); expect(bookHTML).not.toBe(homeHTML); await expectMetadata(page, new URL('book/', base).href); await nodeAt(page, '/').click(); await expectHome(); await page.goBack(); await expect(page).toHaveURL(new URL('book/', base).href); await settled(page, '/book'); expect(await page.locator(article).innerHTML()).toBe(bookHTML); await expect(page).toHaveTitle(bookTitle); await expectMetadata(page, new URL('book/', base).href); await page.goBack(); await expectHome(); await page.goBack(); await expect(page).toHaveURL(bookURL('/2-core-model.md', base)); await settled(page, '/2-core-model.md'); await expectMetadata(page, bookURL('/2-core-model.md', base)); await page.goBack(); await expectHome(); for (const path of ['/2-core-model.md', '/', '/book', '/']) { await page.goForward(); await expect(page).toHaveURL(urlFor(path, base)); await settled(page, path); await expectMetadata(page, urlFor(path, base)); if (path === '/') await expectHome(); } expect(await tree!.evaluate((el) => el === document.querySelector('.demo-tree'))).toBe(true); for (const node of nodes) { expect(await node.evaluate((el) => el === document.querySelector(`.demo-node[data-path="${(el as HTMLElement).dataset.path}"]`))).toBe(true); } await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); expect(await page.locator('.demo-node').evaluateAll((nodes) => nodes.map((node) => (node as HTMLElement).dataset.path))).toEqual(treePaths); await expectNoIntroStates(page); await page.reload(); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed'); await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/'); }); for (const action of ['skip-click', 'escape', 'root-click', 'key'] as const) { test(`slow-font boot ${action} works before font readiness without losing the initial input`, async ({ page }, testInfo) => { await page.emulateMedia({ reducedMotion: 'no-preference' }); // Freeze application timers so neither the 2.5s font fallback nor the boot // fail-open timeout can make an ignored control appear to have worked. await page.clock.install({ time: new Date('2026-01-01T00:00:00Z') }); await page.clock.pauseAt(new Date('2026-01-01T00:00:01Z')); await page.addInitScript(() => { const load = document.fonts.load.bind(document.fonts); document.fonts.load = (font, text) => { if (font === '14px "Lyth Mono"') { (window as Window & { introFontWaitStarted?: boolean }).introFontWaitStarted = true; } return load(font, text); }; }); let releaseFont!: () => void; const fontGate = new Promise((resolve) => { releaseFont = resolve; }); let requestedFont!: () => void; const fontRequested = new Promise((resolve) => { requestedFont = resolve; }); let released = false; await page.route('**/fonts/LythMono-Regular.woff2', async (route) => { requestedFont(); await fontGate; await route.continue(); }); try { // Avoid waiting for full page/font readiness; observe initLanding reaching // its font wait so the input is sent after boot handlers are installed. await page.goto('./', { waitUntil: 'domcontentloaded' }); await fontRequested; await expect.poll(() => page.evaluate(() => (window as Window & { introFontWaitStarted?: boolean }).introFontWaitStarted)).toBe(true); await expect(page.locator('html')).toHaveAttribute('data-landing', 'boot'); await expect(page.locator('.demo-tree')).not.toHaveAttribute('data-enhanced'); expect(await page.evaluate(() => document.fonts.check('14px "Lyth Mono"'))).toBe(false); const beforeInput = await page.evaluate(() => Date.now()); expect(released).toBe(false); if (action === 'skip-click') await page.locator('.intro-skip').click({ timeout: 1000 }); if (action === 'escape') await page.keyboard.press('Escape'); if (action === 'root-click') await nodeAt(page, '/').click({ timeout: 1000 }); if (action === 'key') await page.keyboard.press('a'); expect(page.url()).toBe(testInfo.project.use.baseURL!); const skipping = action === 'skip-click' || action === 'escape'; if (skipping) { await expect(page.locator('html')).not.toHaveAttribute('data-landing', { timeout: 1000 }); await expect(page.locator(article)).toBeVisible({ timeout: 1000 }); await expect(page.locator('.intro-skip')).toBeHidden({ timeout: 1000 }); } else { // Boot input queues playback; it must not activate the root's HOME link. await expect(page.locator('html')).toHaveAttribute('data-landing', 'boot'); } expect(await page.evaluate(() => Date.now()) - beforeInput).toBeLessThan(2500); expect(await page.evaluate(() => document.fonts.check('14px "Lyth Mono"'))).toBe(false); expect(released).toBe(false); released = true; releaseFont(); if (skipping) { await settled(page, '/'); await page.clock.runFor(4000); await settled(page, '/'); // Font completion must not replay an already-skipped intro. } else { // No second click/key: the input received during boot must start the timeline. await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow'); expect(page.url()).toBe(testInfo.project.use.baseURL!); await page.clock.runFor(4000); await settled(page, '/'); } expect(page.url()).toBe(testInfo.project.use.baseURL!); } finally { releaseFont(); await page.unrouteAll({ behavior: 'wait' }); } }); } test.describe('JavaScript-free directory and error routes', () => { test.use({ javaScriptEnabled: false }); test('directory index and 404 keep the public tree with ordinary usable anchors', async ({ page }, testInfo) => { const base = testInfo.project.use.baseURL!; await page.goto(new URL('book/', base).href); await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/book'); await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); await expect(page.locator('html')).not.toHaveAttribute('data-landing'); await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page'); await expect(page.locator('.landing a:not(.demo-node)[href*="/book/"]')).toHaveCount(14); for (const path of documentPaths) { await expect(page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(path, base)).pathname}"]`)).toBeVisible(); } await expect(page.locator('.demo-node[data-path^="/draft"], a[href*="/draft/"]')).toHaveCount(0); const target = '/1.1-interaction-abstraction.md'; await page.locator(`.landing a:not(.demo-node)[href="${new URL(bookURL(target, base)).pathname}"]`).click(); await expect(page).toHaveURL(bookURL(target, base)); await expect(page.locator(article + ' h1')).toBeVisible(); await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); const response = await page.goto(new URL('missing-no-js-route/', base).href); expect(response!.status()).toBe(404); await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); await expect(page.locator('html')).not.toHaveAttribute('data-landing'); await nodeAt(page, '/book').click(); await expect(page).toHaveURL(new URL('book/', base).href); await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/book'); await nodeAt(page, '/').click(); await expect(page).toHaveURL(base); await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/'); await expect(nodeAt(page, '/')).toHaveAttribute('aria-current', 'page'); await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true'); await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible(); }); }); test('404 uses the shared tree and its normal links recover to documents', async ({ page }, testInfo) => { const response = await page.goto('missing-tree-route/'); expect(response!.status()).toBe(404); await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount); await expect(page.locator('html')).not.toHaveAttribute('data-landing'); await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete'); const pane = await page.locator('.tree-pane').elementHandle(); await nodeAt(page, '/1-concept.md').click(); await expect(page).toHaveURL(bookURL('/1-concept.md', testInfo.project.use.baseURL!)); await settled(page, '/1-concept.md'); expect(await pane!.evaluate((el) => el === document.querySelector('.tree-pane'))).toBe(true); });