feat: add persistent book navigation and public-only pages

This commit is contained in:
2026-10-15 17:53:48 +09:00
parent f6905074b1
commit 839eb2a6a4
22 changed files with 1191 additions and 269 deletions
+607
View File
@@ -0,0 +1,607 @@
import { expect, test, type Page } from '@playwright/test';
import { load } from 'cheerio';
import { bookURL, documentPaths, draftPaths, treeNodeCount, treePaths } 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 = (path: string, base: string) => path === '/' ? new URL('./', base).href
: path === '/book' ? new URL('book/', base).href : bookURL(path, base);
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('all 14 document responses contain the current article and metadata-only public tree', async ({ request }, testInfo) => {
expect(documentPaths).toHaveLength(14);
expect(treeNodeCount).toBe(16);
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 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 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, '/1-concept.md');
await expect(page.locator(article + ' h1')).toHaveText('1. コンセプト');
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 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.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('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 toggle(page).click();
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-expanded', 'false');
await expect(toggle(page)).toHaveText('+');
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 page.keyboard.press('Escape');
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
await page.evaluate(recordIntroStates);
const pane = await page.locator('.tree-pane').elementHandle();
await nodeAt(page, '/2-core-model.md').click();
await settled(page, '/2-core-model.md');
await page.locator('.brand').click();
await expect(page).toHaveURL(base);
await settled(page, '/1-concept.md');
await expect(page.locator('.intro-skip')).toBeHidden();
await expect(page.locator(article + ' h1')).toHaveText('1. コンセプト');
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 nodeAt(page, '/book').click();
await expect(page).toHaveURL(new URL('book/', base).href);
await settled(page, '/book');
await nodeAt(page, '/').click();
await expect(page).toHaveURL(base);
await settled(page, '/1-concept.md');
await page.goBack();
await expect(page).toHaveURL(new URL('book/', base).href);
await settled(page, '/book');
await page.goBack();
await expect(page).toHaveURL(base);
await settled(page, '/1-concept.md');
await expectNoIntroStates(page);
await page.reload();
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
});
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, '/1-concept.md');
await page.clock.runFor(4000);
await settled(page, '/1-concept.md'); // 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, '/1-concept.md');
}
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', '/1-concept.md');
});
});
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);
});