feat: add dedicated home page and automatic dark mode
Reference site / build (push) Canceled after 0s
Reference site / build (push) Canceled after 0s
This commit is contained in:
@@ -31,7 +31,7 @@ test('self-hosted Lyth Mono and EB Garamond load with the configured base', asyn
|
||||
expect(external).toEqual([]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-phase', 'complete');
|
||||
await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator('[data-selected-content]').getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
expect(await page.locator('[data-selected-content]').evaluate((element) => getComputedStyle(element).fontFamily)).toContain('EB Garamond');
|
||||
await page.locator('[data-selected-content]').screenshot({ path: testInfo.outputPath('eb-garamond-selected-content.png') });
|
||||
const tree = await page.locator('.tree-pane').elementHandle();
|
||||
@@ -96,6 +96,25 @@ test.describe('static font and tree rendering', () => {
|
||||
expect(columns.slice(gapStart, gapEnd).every((ink) => ink > 710), JSON.stringify(columns)).toBe(true);
|
||||
}
|
||||
});
|
||||
test('node names start exactly one space after the branch', async ({ page }) => {
|
||||
await page.goto('./');
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const spacing = await page.locator('.demo-node:not(.root-mark)').evaluateAll((nodes) => nodes.map((node) => {
|
||||
const svg = node.parentElement!.querySelector<SVGSVGElement>('.branch-vector')!;
|
||||
const width = svg.viewBox.baseVal.width;
|
||||
const cell = Number(svg.dataset.cellWidth);
|
||||
const matrix = svg.getScreenCTM()!;
|
||||
const branchEnd = new DOMPoint(width - cell, 12).matrixTransform(matrix).x;
|
||||
const prefixEnd = new DOMPoint(width, 12).matrixTransform(matrix).x;
|
||||
const label = node.querySelector('.node-label')!.getBoundingClientRect();
|
||||
return { padding: getComputedStyle(node).paddingLeft, gap: label.left - branchEnd, space: prefixEnd - branchEnd };
|
||||
}));
|
||||
expect(spacing.length).toBeGreaterThan(0);
|
||||
for (const { padding, gap, space } of spacing) {
|
||||
expect(padding).toBe('0px');
|
||||
expect(Math.abs(gap - space)).toBeLessThan(.1);
|
||||
}
|
||||
});
|
||||
test('the same SVG trunk and Lyth Mono joins are visible without JavaScript', async ({ page }, testInfo) => {
|
||||
await page.goto('./');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
@@ -106,7 +125,7 @@ test.describe('static font and tree rendering', () => {
|
||||
await expect(page.locator('.branch-prefix').first().locator('use')).toHaveCount(3);
|
||||
await expect(page.locator('.branch-prefix').first().locator('.branch-spine use')).toHaveAttribute('href', '#tree-glyph-9492');
|
||||
await expect(page.locator('.tree-glyphs defs path')).toHaveCount(5);
|
||||
await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath('lyth-mono-static.png') });
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ async function complete(page: import('@playwright/test').Page) {
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
expect((await page.locator('.root-shape').boundingBox())!.height).toBeCloseTo(12.25, 4);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await expect(page.getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.site-header').getByRole('link', { name: 'Book', exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test('only the resting slash shrinks; intro ink and tree dimensions stay unchanged', async ({ page }, testInfo) => {
|
||||
@@ -460,7 +460,7 @@ test('continuous motion overlaps growth, travel and reveal without intermediate
|
||||
await expect(root).toHaveCount(1);
|
||||
await expect(page.locator('.document-pane[data-reveal]')).not.toHaveAttribute('inert');
|
||||
await expect(page.locator('[data-glitching]')).toHaveCount(0);
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.branch-lines')).toHaveCount(0);
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||||
@@ -557,7 +557,7 @@ test('landing contains only the real filename tree and selected canonical conten
|
||||
await expect(page.getByRole('button', { name: '本文を読む' })).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: 'Home', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('.site-header')).toContainText('WIP');
|
||||
await expect(page.getByRole('link', { name: 'Source', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.site-header').getByRole('link', { name: 'Source', exact: true })).toBeVisible();
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||||
expect(await page.locator('.demo-node').evaluateAll((nodes) => nodes.map((node) => (node as HTMLElement).dataset.path))).toEqual(treePaths);
|
||||
@@ -609,10 +609,14 @@ test('landing contains only the real filename tree and selected canonical conten
|
||||
expect(folder).not.toHaveProperty('html');
|
||||
expect(folder.href).toBe(new URL(path === '/' ? './' : 'book/', testInfo.project.use.baseURL!).pathname);
|
||||
}
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', initialPath);
|
||||
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', '/');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'false');
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator(selectedContent + ' .root-page[data-root-page]')).toHaveCount(1);
|
||||
await expect(page.locator(selectedContent)).not.toContainText('1. コンセプト');
|
||||
expect(await page.locator('.landing h1, .landing h2, .landing h3, .landing h4, .landing h5, .landing h6').evaluateAll((headings) => headings.every((heading) => heading.closest('[data-selected-content]')))).toBe(true);
|
||||
await checkGlyphRefs(page);
|
||||
});
|
||||
@@ -667,7 +671,10 @@ test('each file navigates to exactly its Book HTML and title, including links',
|
||||
documents.set(path, { html: await book.locator('article.prose').innerHTML(), title: await book.title() });
|
||||
}
|
||||
await book.close();
|
||||
expect(await page.locator(selectedContent).innerHTML()).toBe(documents.get(initialPath)!.html);
|
||||
expect(await page.locator(selectedContent).innerHTML()).not.toBe(documents.get(initialPath)!.html);
|
||||
await nodeAt(page, initialPath).click();
|
||||
await expect(page).toHaveURL(bookURL(initialPath, testInfo.project.use.baseURL!));
|
||||
await expect.poll(() => page.locator(selectedContent).innerHTML()).toBe(documents.get(initialPath)!.html);
|
||||
for (const path of documentPaths) {
|
||||
await nodeAt(page, path).click();
|
||||
await expect(page).toHaveURL(bookURL(path, testInfo.project.use.baseURL!));
|
||||
@@ -692,9 +699,15 @@ test('keyboard arrows only move focus or fold; Enter and Space activate route li
|
||||
const unchanged = async () => {
|
||||
expect(page.url()).toBe(originalURL);
|
||||
expect(await page.locator(selectedContent).innerHTML()).toBe(originalHTML);
|
||||
await expect(nodeAt(page, initialPath)).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(nodeAt(page, '/')).toHaveAttribute('aria-selected', 'true');
|
||||
};
|
||||
await expect(nodeAt(page, '/')).toBeFocused();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(nodeAt(page, '/book')).toBeFocused();
|
||||
await unchanged();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(nodeAt(page, initialPath)).toBeFocused();
|
||||
await unchanged();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeFocused();
|
||||
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toHaveAttribute('aria-selected', 'false');
|
||||
@@ -867,10 +880,19 @@ test.describe('glyph trunk regressions', () => {
|
||||
test.describe('JavaScript-free glyph tree', () => {
|
||||
test.use({ javaScriptEnabled: false });
|
||||
|
||||
test('initial canonical article and every file link remain usable without JavaScript', async ({ page }, testInfo) => {
|
||||
test('independent home article and every file link remain usable without JavaScript', async ({ page }, testInfo) => {
|
||||
await page.goto('./');
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
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(selectedContent)).not.toContainText('1. コンセプト');
|
||||
const home = page.locator(selectedContent + ' .root-page[data-root-page]');
|
||||
await expect(home).toContainText('Worldspace Interface Protocol');
|
||||
const nav = home.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
|
||||
await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', testInfo.project.use.baseURL!).pathname);
|
||||
await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', (await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href'))!);
|
||||
const initialHTML = await page.locator(selectedContent).innerHTML();
|
||||
await expect(page.locator('.static-guides, .guide-body, .ascii-panel')).toHaveCount(0);
|
||||
await expect(page.getByRole('treeitem')).toHaveCount(treeNodeCount);
|
||||
@@ -884,7 +906,10 @@ test.describe('JavaScript-free glyph tree', () => {
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await expect(nodeAt(page, path)).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-current-path', path);
|
||||
if (path === initialPath) expect(await page.locator('article.prose').innerHTML()).toBe(initialHTML);
|
||||
if (path === initialPath) {
|
||||
await expect(page.locator('article.prose h1')).toHaveText('1. コンセプト');
|
||||
expect(await page.locator('article.prose').innerHTML()).not.toBe(initialHTML);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -904,7 +929,7 @@ test.describe('JavaScript-free glyph tree', () => {
|
||||
expect(Math.abs(trunk.axis)).toBeLessThan(.1);
|
||||
expect(trunk.gapRatio).toBeCloseTo(1 / 14, 5);
|
||||
await page.locator('.demo-tree').screenshot({ path: testInfo.outputPath('no-js-glyph-tree.png') });
|
||||
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click();
|
||||
await expect(page).toHaveURL(/\/book\/$/);
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('.landing a[href$="/book/1-concept/"]').last()).toBeVisible();
|
||||
@@ -928,6 +953,12 @@ test('changing motion preference and resizing cannot strand the intro', async ({
|
||||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await complete(page);
|
||||
// The dedicated home is deliberately short; retain the mobile reading-scroll
|
||||
// regression on the long canonical chapter rather than requiring home filler.
|
||||
await nodeAt(page, initialPath).click();
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||||
// Keep the wheel outside the tree's overscroll-contained scroll viewport.
|
||||
await page.mouse.move(5, page.viewportSize()!.height / 2);
|
||||
await page.mouse.wheel(0, 650);
|
||||
await expect.poll(() => page.evaluate(() => scrollY)).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -936,14 +967,14 @@ test('blocked animation module fails open to readable content', async ({ page })
|
||||
await page.route('**/*.js', (route) => route.abort());
|
||||
await page.goto('./');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing', { timeout: 6000 });
|
||||
await expect(page.locator(selectedContent + ' h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator(selectedContent).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await nodeAt(page, '/2-core-model.md').click();
|
||||
await expect(page).toHaveURL(/\/book\/2-core-model\/$/);
|
||||
await expect(page.locator('article.prose h1')).toBeVisible();
|
||||
await page.goto('./');
|
||||
await expect(page.locator(selectedContent)).toBeVisible();
|
||||
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await page.locator('.site-header').getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/book\/$/);
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('.landing a[href$="/book/1-concept/"]').last()).toBeVisible();
|
||||
|
||||
@@ -55,6 +55,104 @@ async function expectMetadata(page: Page, expectedURL: string) {
|
||||
}
|
||||
}
|
||||
|
||||
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(2);
|
||||
expect(nav.find('a').filter((_, el) => $(el).text().includes('Book')).attr('href')).toBe(new URL('book/', base).pathname);
|
||||
const source = $('.site-header a').filter((_, el) => $(el).text().includes('Source')).attr('href');
|
||||
expect(source).toMatch(/^https:\/\//);
|
||||
expect(nav.find('a').filter((_, el) => $(el).text().includes('Source')).attr('href')).toBe(source);
|
||||
expect($(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(2);
|
||||
await expect(nav.getByRole('link', { name: /^Book(?:\s|$)/ })).toHaveAttribute('href', new URL('book/', base).pathname);
|
||||
const source = await page.locator('.site-header').getByRole('link', { name: 'Source', exact: true }).getAttribute('href');
|
||||
expect(source).toMatch(/^https:\/\//);
|
||||
await expect(nav.getByRole('link', { name: /^Source(?:\s|$)/ })).toHaveAttribute('href', source!);
|
||||
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(16);
|
||||
@@ -293,8 +391,8 @@ test('directory anchors navigate independently from the sibling plus/minus toggl
|
||||
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 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);
|
||||
});
|
||||
|
||||
@@ -327,7 +425,7 @@ test('persisted tree retains scroll and collapse, expanding only an ancestor nee
|
||||
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 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');
|
||||
@@ -346,6 +444,49 @@ test('persisted tree retains scroll and collapse, expanding only an ancestor nee
|
||||
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 page.locator('.node-toggle[data-toggle-path="/4-client.md"]').click();
|
||||
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));
|
||||
@@ -454,34 +595,72 @@ test('client return home skips intro, while a hard reload home may arm it again'
|
||||
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 expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||||
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 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. コンセプト');
|
||||
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 nodeAt(page, '/book').click();
|
||||
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 expect(page).toHaveURL(base);
|
||||
await settled(page, '/1-concept.md');
|
||||
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 expect(page).toHaveURL(base);
|
||||
await settled(page, '/1-concept.md');
|
||||
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) {
|
||||
@@ -541,16 +720,16 @@ for (const action of ['skip-click', 'escape', 'root-click', 'key'] as const) {
|
||||
released = true;
|
||||
releaseFont();
|
||||
if (skipping) {
|
||||
await settled(page, '/1-concept.md');
|
||||
await settled(page, '/');
|
||||
await page.clock.runFor(4000);
|
||||
await settled(page, '/1-concept.md'); // Font completion must not replay an already-skipped intro.
|
||||
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, '/1-concept.md');
|
||||
await settled(page, '/');
|
||||
}
|
||||
expect(page.url()).toBe(testInfo.project.use.baseURL!);
|
||||
} finally {
|
||||
@@ -589,7 +768,10 @@ test.describe('JavaScript-free directory and error routes', () => {
|
||||
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');
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ test('home and book share the usable tree without client JavaScript', async ({ p
|
||||
const failures: string[] = [];
|
||||
page.on('response', (response) => { if (response.status() >= 400) failures.push(response.url()); });
|
||||
await page.goto('./');
|
||||
await expect(page.locator('[data-selected-content] h1')).toHaveText('1. コンセプト');
|
||||
await expect(page.locator('.selected-content.prose[data-selected-content] .root-page[data-root-page]').getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-current-path', '/');
|
||||
await expect(page.locator('.root-mark')).toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.locator('.root-mark')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.locator('[data-selected-content]')).not.toContainText('1. コンセプト');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await expect(page.locator('.branch-prefix').first()).toHaveAttribute('data-prefix', '└─ ');
|
||||
const root = await page.locator('.root-mark').boundingBox();
|
||||
@@ -19,7 +23,7 @@ test('home and book share the usable tree without client JavaScript', async ({ p
|
||||
expect(Math.abs(footX - spine.x)).toBeLessThan(1);
|
||||
expect(Math.abs(root!.y + root!.height - spine.y)).toBeLessThan(1);
|
||||
await page.screenshot({ path: testInfo.outputPath('home.png'), fullPage: true });
|
||||
await page.getByRole('link', { name: 'Book', exact: true }).click();
|
||||
await page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true }).getByRole('link', { name: /^Book(?:\s|$)/ }).click();
|
||||
await expect(page).toHaveURL(new URL('book/', testInfo.project.use.baseURL!).href);
|
||||
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-current-path', '/book');
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test';
|
||||
import { bookURL, treeNodeCount } from './documents';
|
||||
|
||||
// Deliberately independent of the default project preferences (including no-JS).
|
||||
test.use({ colorScheme: 'dark' });
|
||||
|
||||
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}"]`);
|
||||
type RGB = [number, number, number];
|
||||
|
||||
function luminance(rgb: RGB) {
|
||||
const linear = rgb.map((value) => {
|
||||
const channel = value / 255;
|
||||
return channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4;
|
||||
});
|
||||
return .2126 * linear[0] + .7152 * linear[1] + .0722 * linear[2];
|
||||
}
|
||||
function contrast(a: RGB, b: RGB) {
|
||||
const first = luminance(a), second = luminance(b);
|
||||
return (Math.max(first, second) + .05) / (Math.min(first, second) + .05);
|
||||
}
|
||||
|
||||
// Canvas resolves rgb(), color(srgb ...), and color-mix() consistently. Composite
|
||||
// translucent backgrounds through the ancestor chain, not against assumed white.
|
||||
async function paint(locator: Locator, property = 'color') {
|
||||
return locator.evaluate((element, property) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
|
||||
const rgba = (color: string) => {
|
||||
if (!CSS.supports('color', color)) throw new Error(`Unsupported computed color: ${color}`);
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return Array.from(ctx.getImageData(0, 0, 1, 1).data);
|
||||
};
|
||||
const over = (front: number[], back: number[]) => front.slice(0, 3).map((channel, i) =>
|
||||
Math.round(channel * front[3] / 255 + back[i] * (1 - front[3] / 255)));
|
||||
const ancestors: Element[] = [];
|
||||
for (let current: Element | null = element; current; current = current.parentElement) ancestors.unshift(current);
|
||||
let background = [255, 255, 255];
|
||||
for (const ancestor of ancestors) background = over(rgba(getComputedStyle(ancestor).backgroundColor), background);
|
||||
const style = getComputedStyle(element);
|
||||
const raw = style.getPropertyValue(property);
|
||||
const color = over(rgba(raw === 'currentcolor' ? style.color : raw), background);
|
||||
return { color: color as [number, number, number], background: background as [number, number, number] };
|
||||
}, property);
|
||||
}
|
||||
async function readable(locator: Locator, minimum = 4.5, property = 'color') {
|
||||
const colors = await paint(locator, property);
|
||||
expect(contrast(colors.color, colors.background), `${property} contrast for ${locator}`).toBeGreaterThanOrEqual(minimum);
|
||||
return colors;
|
||||
}
|
||||
async function darkPage(page: Page) {
|
||||
const colors = await readable(page.locator('body'));
|
||||
expect(luminance(colors.background)).toBeLessThan(.05);
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', 'dark');
|
||||
return colors;
|
||||
}
|
||||
async function noOverflow(page: Page) {
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'no page-level horizontal overflow').toBe(true);
|
||||
}
|
||||
async function complete(page: Page) {
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'complete');
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
}
|
||||
async function treeInk(page: Page) {
|
||||
await readable(page.locator('.trunk-line'), 3, 'fill');
|
||||
await expect(page.locator('.trunk-line')).toHaveCSS('opacity', '1');
|
||||
await readable(page.locator('.node-row:not([hidden]) .branch-vector use[data-cell="1"]').first(), 3, 'fill');
|
||||
await readable(nodeAt(page, '/book').locator('.node-label'));
|
||||
}
|
||||
|
||||
// Both are canonical public chapters. Fail clearly if the content moves instead
|
||||
// of silently skipping code/table assertions or manufacturing a test fixture.
|
||||
async function codeAndTableChapter(page: Page, base: string) {
|
||||
for (const path of ['/3-protocol.md', '/2-core-model.md']) {
|
||||
await page.goto(bookURL(path, base));
|
||||
if (await page.locator(`${article} pre.astro-code code span[style]`).count()
|
||||
&& await page.locator(`${article} table`).count()) return path;
|
||||
}
|
||||
throw new Error('Expected /book/3-protocol/ or /book/2-core-model/ to contain Shiki code and a table');
|
||||
}
|
||||
|
||||
function observeIntro() {
|
||||
const states: string[] = [];
|
||||
(window as Window & { themeIntroStates?: string[] }).themeIntroStates = states;
|
||||
new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
const element = mutation.target as Element;
|
||||
for (const value of [mutation.oldValue, element.getAttribute(mutation.attributeName!)]) {
|
||||
if (value && (mutation.attributeName === 'data-landing' || ['armed', 'flow'].includes(value))) states.push(value);
|
||||
}
|
||||
}
|
||||
}).observe(document, { subtree: true, attributes: true, attributeOldValue: true, attributeFilter: ['data-phase', 'data-landing'] });
|
||||
}
|
||||
|
||||
test.describe('automatic dark theme without JavaScript', () => {
|
||||
test.use({ javaScriptEnabled: false });
|
||||
|
||||
test('home, directory, code/table chapter and 404 remain readable and directly linked', async ({ page }, testInfo) => {
|
||||
const base = testInfo.project.use.baseURL!;
|
||||
await page.goto('./');
|
||||
await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
await expect(page.locator('html')).not.toHaveAttribute('data-landing');
|
||||
await darkPage(page);
|
||||
await readable(page.locator(article + ' p').first());
|
||||
await treeInk(page);
|
||||
await noOverflow(page);
|
||||
await page.screenshot({ path: testInfo.outputPath('dark-home.png'), fullPage: true });
|
||||
|
||||
const nav = page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
|
||||
const book = nav.getByRole('link', { name: /^Book(?:\s|$)/ });
|
||||
await readable(book);
|
||||
await expect(book).toHaveAttribute('href', new URL('book/', base).pathname);
|
||||
await book.click();
|
||||
await expect(page).toHaveURL(new URL('book/', base).href);
|
||||
await expect(nodeAt(page, '/book')).toHaveAttribute('aria-current', 'page');
|
||||
await darkPage(page);
|
||||
await readable(page.locator(article + ' a').first());
|
||||
await noOverflow(page);
|
||||
const chapterLink = page.locator(article + ' a[href$="/book/3-protocol/"]');
|
||||
await expect(chapterLink).toBeVisible();
|
||||
await chapterLink.click();
|
||||
await expect(page).toHaveURL(bookURL('/3-protocol.md', base));
|
||||
await expect(page.locator(article + ' h1')).toHaveText('3. Protocol');
|
||||
|
||||
await codeAndTableChapter(page, base);
|
||||
await darkPage(page);
|
||||
await readable(page.locator(article + ' p').first());
|
||||
await readable(page.locator(article + ' table th').first());
|
||||
await readable(page.locator(article + ' table td').first());
|
||||
await readable(page.locator(article + ' pre.astro-code').first());
|
||||
await treeInk(page);
|
||||
await noOverflow(page);
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
||||
await page.screenshot({ path: testInfo.outputPath('dark-book.png'), fullPage: false });
|
||||
|
||||
const response = await page.goto(new URL('missing-dark-theme-page/', base).href);
|
||||
expect(response!.status()).toBe(404);
|
||||
await expect(page.locator(article + ' h1')).toHaveText('このページは見つかりません。');
|
||||
await darkPage(page);
|
||||
await readable(page.locator(article + ' h1'));
|
||||
await expect(page.locator('.demo-node')).toHaveCount(treeNodeCount);
|
||||
await noOverflow(page);
|
||||
await nodeAt(page, '/').click();
|
||||
await expect(page).toHaveURL(base);
|
||||
await expect(page.locator(article).getByRole('heading', { level: 1, name: 'WIP', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('screen palette and media-qualified browser chrome follow the preference with no script', async ({ page }) => {
|
||||
await page.goto('./');
|
||||
const dark = await darkPage(page);
|
||||
const metas = page.locator('meta[name="theme-color"]');
|
||||
await expect(metas).toHaveCount(2);
|
||||
const metaColors = await metas.evaluateAll((elements) => elements.map((element) => ({
|
||||
media: element.getAttribute('media')!, color: element.getAttribute('content')!,
|
||||
})));
|
||||
expect(metaColors.map(({ media }) => media).sort()).toEqual([
|
||||
'(prefers-color-scheme: dark)', '(prefers-color-scheme: light)',
|
||||
]);
|
||||
const matchingMetaColor = async () => page.evaluate(() => {
|
||||
const meta = [...document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]')]
|
||||
.find((element) => matchMedia(element.media).matches)!;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = meta.content; ctx.fillRect(0, 0, 1, 1);
|
||||
return Array.from(ctx.getImageData(0, 0, 1, 1).data).slice(0, 3);
|
||||
});
|
||||
expect(await matchingMetaColor()).toEqual(dark.background);
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', 'light');
|
||||
const light = await readable(page.locator('body'));
|
||||
expect(luminance(light.background)).toBeGreaterThan(.8);
|
||||
expect(light.color).not.toEqual(dark.color);
|
||||
expect(await matchingMetaColor()).toEqual(light.background);
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
expect(await darkPage(page)).toEqual(dark);
|
||||
});
|
||||
|
||||
test('Shiki switches pre and token colors without flattening syntax or changing code', async ({ page }, testInfo) => {
|
||||
await codeAndTableChapter(page, testInfo.project.use.baseURL!);
|
||||
// The first fence may be plain text; exercise a genuinely highlighted block.
|
||||
const blockIndex = await page.locator(article + ' pre.astro-code').evaluateAll((blocks) => {
|
||||
const counts = blocks.map((block) => new Set([...block.querySelectorAll('code span[style]')]
|
||||
.filter((token) => token.textContent!.trim()).map((token) => getComputedStyle(token).color)).size);
|
||||
return counts.indexOf(Math.max(...counts));
|
||||
});
|
||||
const pre = page.locator(article + ' pre.astro-code').nth(blockIndex);
|
||||
const text = await pre.textContent();
|
||||
const sample = async () => ({
|
||||
pre: await paint(pre),
|
||||
tokens: await pre.locator('code span[style]').evaluateAll((elements) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
return elements.filter((element) => element.textContent!.trim()).map((element) => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = getComputedStyle(element).color;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return Array.from(ctx.getImageData(0, 0, 1, 1).data).slice(0, 3).join(',');
|
||||
});
|
||||
}),
|
||||
});
|
||||
const dark = await sample();
|
||||
expect(new Set(dark.tokens).size).toBeGreaterThan(1);
|
||||
await expect(pre.locator('[style*="--shiki-dark"]')).not.toHaveCount(0);
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
const light = await sample();
|
||||
expect(light.pre.background).not.toEqual(dark.pre.background);
|
||||
expect(light.pre.color).not.toEqual(dark.pre.color);
|
||||
expect(light.tokens.length).toBe(dark.tokens.length);
|
||||
expect(new Set(light.tokens).size).toBeGreaterThan(1);
|
||||
expect(dark.tokens.some((color, index) => color !== light.tokens[index])).toBe(true);
|
||||
expect(await pre.textContent()).toBe(text);
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
expect(await sample()).toEqual(dark);
|
||||
});
|
||||
|
||||
test('print retains the light paper, ink and code despite a dark screen preference', async ({ page }, testInfo) => {
|
||||
await codeAndTableChapter(page, testInfo.project.use.baseURL!);
|
||||
await page.emulateMedia({ media: 'print', colorScheme: 'light' });
|
||||
const lightPrint = await paint(page.locator('body'));
|
||||
const lightCode = await paint(page.locator(article + ' pre.astro-code').first());
|
||||
const tokenColors = () => page.locator(article + ' pre.astro-code code span[style]').evaluateAll((tokens) =>
|
||||
tokens.map((token) => getComputedStyle(token).color));
|
||||
const lightTokens = await tokenColors();
|
||||
await page.emulateMedia({ media: 'print', colorScheme: 'dark' });
|
||||
expect(await page.evaluate(() => matchMedia('(prefers-color-scheme: dark)').matches)).toBe(true);
|
||||
const darkPrint = await readable(page.locator('body'));
|
||||
expect(darkPrint).toEqual(lightPrint);
|
||||
expect(luminance(darkPrint.background)).toBeGreaterThan(.8);
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', 'light');
|
||||
await readable(page.locator(article + ' p').first());
|
||||
expect(await paint(page.locator(article + ' pre.astro-code').first())).toEqual(lightCode);
|
||||
expect(await tokenColors()).toEqual(lightTokens);
|
||||
await page.emulateMedia({ media: 'screen', colorScheme: 'dark' });
|
||||
await darkPage(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('live CSS theme with JavaScript enabled', () => {
|
||||
test.use({ javaScriptEnabled: true, reducedMotion: 'no-preference' });
|
||||
|
||||
test('light → dark → light preserves route, article/tree identity, collapse and reading position without intro replay', async ({ page }, testInfo) => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.goto('./');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await page.keyboard.press('Escape');
|
||||
await complete(page);
|
||||
await page.evaluate(observeIntro);
|
||||
const tree = await page.locator('.demo-tree').elementHandle();
|
||||
await nodeAt(page, '/3-protocol.md').click();
|
||||
await expect(page).toHaveURL(bookURL('/3-protocol.md', testInfo.project.use.baseURL!));
|
||||
await complete(page);
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined));
|
||||
await page.locator('.node-toggle[data-toggle-path="/1-concept.md"]').click();
|
||||
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false');
|
||||
const content = await page.locator(article).elementHandle();
|
||||
const html = await page.locator(article).innerHTML();
|
||||
const url = page.url();
|
||||
const storage = await page.evaluate(() => ({ local: { ...localStorage }, session: { ...sessionStorage } }));
|
||||
const position = await page.locator(article).evaluate((element) => {
|
||||
const max = element.scrollHeight - element.clientHeight;
|
||||
if (max > 100) element.scrollTop = Math.min(240, max);
|
||||
else window.scrollTo(0, Math.min(320, document.documentElement.scrollHeight - innerHeight));
|
||||
return { article: element.scrollTop, window: scrollY, tree: document.querySelector('.tree-viewport')!.scrollTop };
|
||||
});
|
||||
expect(position.article + position.window, 'test must start partway through the real chapter').toBeGreaterThan(0);
|
||||
const light = await paint(page.locator('body'));
|
||||
for (const colorScheme of ['dark', 'light'] as const) {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', colorScheme);
|
||||
if (colorScheme === 'dark') await darkPage(page);
|
||||
else expect(await paint(page.locator('body'))).toEqual(light);
|
||||
expect(page.url()).toBe(url);
|
||||
expect(await content!.evaluate((element) => element === document.querySelector('article.prose'))).toBe(true);
|
||||
expect(await tree!.evaluate((element) => element === document.querySelector('.demo-tree'))).toBe(true);
|
||||
expect(await page.locator(article).innerHTML()).toBe(html);
|
||||
await expect(nodeAt(page, '/1-concept.md')).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(nodeAt(page, '/1.1-interaction-abstraction.md')).toBeHidden();
|
||||
await expect(nodeAt(page, '/3-protocol.md')).toHaveAttribute('aria-current', 'page');
|
||||
expect(await page.locator(article).evaluate((element) => ({
|
||||
article: element.scrollTop, window: scrollY, tree: document.querySelector('.tree-viewport')!.scrollTop,
|
||||
}))).toEqual(position);
|
||||
await complete(page);
|
||||
}
|
||||
expect(await page.evaluate(() => (window as Window & { themeIntroStates?: string[] }).themeIntroStates)).toEqual([]);
|
||||
expect(await page.evaluate(() => ({ local: { ...localStorage }, session: { ...sessionStorage } }))).toEqual(storage);
|
||||
});
|
||||
|
||||
for (const finish of ['complete', 'skip'] as const) {
|
||||
test(`dark intro can ${finish}; switching while armed and playing does not reset or strand it`, async ({ page }) => {
|
||||
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.goto('./');
|
||||
// Do not wait for the inert/revealed article or hidden tree to be visible.
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
const root = await page.locator('.root-shape').elementHandle();
|
||||
const trunk = page.locator('.trunk-line');
|
||||
const armedDark = await paint(trunk, 'fill');
|
||||
await readable(page.locator('.root-shape'), 3, 'fill');
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
const armedLight = await paint(trunk, 'fill');
|
||||
expect(armedLight.color).not.toEqual(armedDark.color);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
expect(await paint(trunk, 'fill')).toEqual(armedDark);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.clock.runFor(2600);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-landing', 'playing');
|
||||
const playingDark = await readable(trunk, 3, 'fill');
|
||||
await expect(trunk).toHaveCSS('opacity', '1');
|
||||
const reveal = await page.locator('.trunk-reveal').getAttribute('height');
|
||||
expect(Number(reveal)).toBeGreaterThan(0);
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
expect((await paint(trunk, 'fill')).color).not.toEqual(playingDark.color);
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'flow');
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
expect(await paint(trunk, 'fill')).toEqual(playingDark);
|
||||
await expect(page.locator('.trunk-reveal')).toHaveAttribute('height', reveal!);
|
||||
expect(await root!.evaluate((element) => element === document.querySelector('.root-shape'))).toBe(true);
|
||||
if (finish === 'skip') await page.keyboard.press('Escape');
|
||||
await page.clock.runFor(4000);
|
||||
await complete(page);
|
||||
await darkPage(page);
|
||||
await treeInk(page);
|
||||
await readable(page.locator(article + ' p').first());
|
||||
await expect(page.locator('.document-pane')).not.toHaveAttribute('inert');
|
||||
await expect(page.locator('.intro-skip')).toBeHidden();
|
||||
await noOverflow(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('selected, hovered, keyboard-focused nodes and home navigation stay legible in dark mode', async ({ page }) => {
|
||||
await page.goto('./');
|
||||
await expect(page.locator(stage)).toHaveAttribute('data-phase', 'armed');
|
||||
await page.keyboard.press('Escape');
|
||||
await complete(page);
|
||||
const book = nodeAt(page, '/book');
|
||||
await readable(book.locator('.node-label'));
|
||||
await book.hover();
|
||||
await readable(book.locator('.node-label'));
|
||||
await page.mouse.move(0, 0);
|
||||
await nodeAt(page, '/').focus();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(book).toBeFocused();
|
||||
await readable(book.locator('.node-label'));
|
||||
expect(await book.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return element.matches(':focus-visible') && style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0;
|
||||
})).toBe(true);
|
||||
const outline = await paint(book, 'outline-color');
|
||||
expect(contrast(outline.color, (await paint(page.locator('body'))).background)).toBeGreaterThanOrEqual(3);
|
||||
const nav = page.getByRole('navigation', { name: 'ルートページのナビゲーション', exact: true });
|
||||
const homeBook = nav.getByRole('link', { name: /^Book(?:\s|$)/ });
|
||||
await readable(homeBook);
|
||||
await homeBook.hover();
|
||||
await readable(homeBook);
|
||||
await homeBook.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(homeBook).toBeFocused();
|
||||
await readable(homeBook);
|
||||
await homeBook.click();
|
||||
await complete(page);
|
||||
await expect(book).toHaveAttribute('aria-selected', 'true');
|
||||
await readable(book.locator('.node-label'));
|
||||
await book.hover();
|
||||
await readable(book.locator('.node-label'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user