Files

794 lines
42 KiB
TypeScript
Raw Permalink Blame History

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