Files
Portfolio/tests/browser/responsive-layout.spec.ts

185 lines
8.4 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
const stylesheetFiles = [
'src/styles/tokens.css',
'src/styles/base.css',
'src/styles/layout.css',
'src/styles/components.css',
'src/styles/print.css',
];
function informationLayerCss(): string {
return stylesheetFiles
.map((file) => {
const path = resolve(process.cwd(), file);
return existsSync(path) ? readFileSync(path, 'utf8') : '';
})
.join('\n');
}
type ServerRenderer = { render: (url: string, inputs: unknown) => Promise<string> };
type InputValidator = { validateProductionInputs: (content: unknown, manifest: unknown, site: unknown, preparedAssetRoot: URL) => unknown };
let vite: ViteDevServer;
let renderedPortfolio = '';
test.beforeAll(async () => {
vite = await createServer({ server: { middlewareMode: true, hmr: false, ws: false }, appType: 'custom' });
const { render } = await vite.ssrLoadModule('/src/entry-server.ts') as ServerRenderer;
const { validateProductionInputs } = await vite.ssrLoadModule('/src/content/schemas.ts') as InputValidator;
const fixture = (file: string): unknown => JSON.parse(readFileSync(resolve(process.cwd(), file), 'utf8')) as unknown;
const inputs = validateProductionInputs(
fixture('content/public/portfolio-content.json'),
fixture('content/public/asset-manifest.json'),
fixture('config/site.preview.json'),
new URL('file:///prepared-assets/'),
);
renderedPortfolio = await render('/', inputs);
});
test.afterAll(async () => {
await vite.close();
});
function informationLayerHarness(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>${informationLayerCss()}</style>
</head>
<body>
${renderedPortfolio}
</body>
</html>`;
}
async function loadInformationLayer(page: Page): Promise<void> {
await page.setContent(informationLayerHarness());
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
await page.screenshot({ path: testInfo.outputPath(name), fullPage: true });
}
test('renders the validated eight-section portfolio structure before responsive checks', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await loadInformationLayer(page);
await expect(page.locator('.portfolio-main > .portfolio-section')).toHaveCount(8);
expect(await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) => sections.map((section) => section.id)))
.toEqual(['start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact']);
expect(await page.locator('.site-navigation a').evaluateAll((links) => links.map((link) => link.getAttribute('href'))))
.toEqual(['#start', '#profile', '#experience', '#infrastructure', '#selected-builds', '#additional-work', '#toolkit', '#contact']);
await expect(page.locator('#selected-builds article, #additional-work article')).not.toHaveCount(0);
await expect(page.locator('#toolkit [data-testid="toolkit-technology"]')).not.toHaveCount(0);
await expect(page.locator('#contact a[href^="mailto:"]')).toHaveCount(1);
await expect(page.locator('#contact a[target="_blank"]')).not.toHaveCount(0);
});
for (const viewport of [
{ width: 320, height: 800 },
{ width: 768, height: 1024 },
{ width: 1440, height: 900 },
{ width: 2560, height: 1440 },
]) {
test(`keeps information content within the viewport at ${viewport.width}px`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport);
await loadInformationLayer(page);
const geometry = await page.evaluate(() => {
const root = document.documentElement;
const boxes = [...document.querySelectorAll<HTMLElement>('.portfolio-shell :is(h1, h2, h3, p, a, article, section)')]
.map((element) => element.getBoundingClientRect())
.filter((rect) => rect.width > 0 && rect.height > 0);
return {
noOverflow: root.scrollWidth <= root.clientWidth + 1,
allBoxesFit: boxes.every((rect) => rect.left >= -1 && rect.right <= root.clientWidth + 1),
};
});
await capture(page, testInfo, `information-layer-${viewport.width}.png`);
expect(geometry.noOverflow).toBe(true);
expect(geometry.allBoxesFit).toBe(true);
});
}
test('uses alternating desktop panels while preserving a clear central road corridor', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await loadInformationLayer(page);
const panels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections, viewportWidth) =>
sections.map((section) => {
const rect = section.getBoundingClientRect();
return { left: rect.left, right: rect.right, widthRatio: rect.width / Number(viewportWidth) };
}), 1440,
);
expect(panels.every((panel) => panel.widthRatio >= 0.4 && panel.widthRatio <= 0.45)).toBe(true);
expect(panels.every((panel, index) => index % 2 === 0 ? panel.right <= 1440 * 0.461 : panel.left >= 1440 * 0.539)).toBe(true);
await capture(page, testInfo, 'information-layer-desktop-corridor.png');
});
test('uses a stable tablet rhythm and a single readable mobile column with usable navigation targets', async ({ page }, testInfo) => {
await page.setViewportSize({ width: 768, height: 1024 });
await loadInformationLayer(page);
const tabletPanels = await page.locator('.portfolio-main > .portfolio-section').evaluateAll((sections) =>
sections.map((section) => section.getBoundingClientRect()),
);
expect(tabletPanels.every((panel) => panel.width < 768 * 0.7)).toBe(true);
expect(tabletPanels.every((panel, index) => index % 2 === 0 ? panel.left < 768 / 2 : panel.left >= 768 / 2)).toBe(true);
await capture(page, testInfo, 'information-layer-tablet.png');
await page.setViewportSize({ width: 320, height: 800 });
await loadInformationLayer(page);
const mobile = await page.evaluate(() => {
const root = document.documentElement;
const panels = [...document.querySelectorAll<HTMLElement>('.portfolio-main > .portfolio-section')]
.map((section) => section.getBoundingClientRect());
const navigationTargets = [...document.querySelectorAll<HTMLElement>('.site-navigation a')]
.map((link) => link.getBoundingClientRect());
return {
singleColumn: panels.every((panel) => panel.left >= 0 && panel.right <= root.clientWidth && panel.width >= root.clientWidth * 0.85),
touchTargets: navigationTargets.every((target) => target.width >= 44 && target.height >= 44),
};
});
expect(mobile.singleColumn).toBe(true);
expect(mobile.touchTargets).toBe(true);
await capture(page, testInfo, 'information-layer-mobile.png');
});
test('keeps actual standalone project and contact destinations at least 44 by 44px on mobile', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await loadInformationLayer(page);
const targets = await page.locator('#selected-builds article > a, #additional-work article > a, #contact a').evaluateAll((links) =>
links.map((link) => {
const rect = link.getBoundingClientRect();
return { href: link.getAttribute('href'), width: rect.width, height: rect.height };
}),
);
expect(targets.length).toBeGreaterThan(0);
expect(targets.every((target) => target.width >= 44 && target.height >= 44)).toBe(true);
});
test('removes nonessential information-layer motion when reduced motion is requested', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await loadInformationLayer(page);
const duration = await page.locator('.portfolio-section').first().evaluate((element) => getComputedStyle(element).transitionDuration);
expect(duration).toBe('0.001s');
});
test('creates a clean print portfolio with a visible label but no resume packaging', async ({ page }) => {
await page.emulateMedia({ media: 'print' });
await loadInformationLayer(page);
await expect(page.locator('.site-navigation')).toBeHidden();
await expect(page.locator('.print-portfolio-label')).toBeVisible();
await expect(page.locator('.portfolio-section').first()).toHaveCSS('break-inside', 'avoid');
const printCopy = await page.locator('body').innerText();
expect(printCopy).not.toMatch(/résumé|resume/iu);
await expect(page.locator('a[download]')).toHaveCount(0);
});