794 lines
42 KiB
TypeScript
794 lines
42 KiB
TypeScript
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);
|
||
});
|