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/world.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 }; 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 ` ${renderedPortfolio} `; } async function loadInformationLayer(page: Page): Promise { await page.setContent(informationLayerHarness()); } async function capture(page: Page, testInfo: TestInfo, name: string): Promise { 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 > section')).toHaveCount(8); expect(await page.locator('.portfolio-main > 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('.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 explicit desktop layout families while preserving the primary-project road corridor', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); await loadInformationLayer(page); expect(await page.locator('.portfolio-main > section').evaluateAll((sections) => sections.map((section) => section.getAttribute('data-layout')), )).toEqual(['hero', 'right-rail', 'left-timeline', 'full-diagram', 'project-stage', 'compact-rail', 'toolkit-field', 'destination']); const projects = await page.locator('#selected-builds [data-project-priority="primary"]').evaluateAll((articles) => articles.map((article) => { const rect = article.getBoundingClientRect(); return { left: rect.left, right: rect.right, width: rect.width }; }), ); expect(projects).toHaveLength(2); expect(projects[0]!.right).toBeLessThanOrEqual(1440 * 0.51); expect(projects[1]!.left).toBeGreaterThanOrEqual(1440 * 0.53); expect(projects.every((project) => project.width <= 1440 * 0.47)).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) => Object.fromEntries(sections.map((section) => [section.getAttribute('data-layout'), section.getBoundingClientRect().toJSON()])), ); expect(tabletPanels['right-rail'].left).toBeGreaterThan(768 * 0.3); expect(tabletPanels['left-timeline'].left).toBeLessThan(768 * 0.1); expect(tabletPanels['full-diagram'].width).toBeGreaterThan(768 * 0.8); expect(tabletPanels['compact-rail'].left).toBeGreaterThan(768 * 0.3); 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('.portfolio-main > .portfolio-section')] .map((section) => section.getBoundingClientRect()); const navigationTargets = [...document.querySelectorAll('.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('turns the infrastructure route from a horizontal trust flow into a readable mobile stack', async ({ page }, testInfo) => { await page.setViewportSize({ width: 1440, height: 900 }); await loadInformationLayer(page); const desktop = await page.locator('[data-testid="infrastructure-flow"] [data-infrastructure-node]').evaluateAll((nodes) => nodes.map((node) => { const rect = node.getBoundingClientRect(); return { id: node.getAttribute('data-infrastructure-node'), left: rect.left, top: rect.top, bottom: rect.bottom, width: rect.width }; }), ); expect(desktop.slice(0, 4).every((node) => Math.abs(node.top - desktop[0]!.top) <= 1)).toBe(true); expect(desktop[4]!.top).toBeGreaterThanOrEqual(desktop[3]!.bottom); expect(desktop.slice(0, 4).every((node, index, nodes) => index === 0 || node.left > nodes[index - 1]!.left)).toBe(true); await capture(page, testInfo, 'infrastructure-flow-desktop.png'); await page.setViewportSize({ width: 320, height: 800 }); await loadInformationLayer(page); const mobile = await page.locator('[data-testid="infrastructure-flow"] [data-infrastructure-node]').evaluateAll((nodes) => nodes.map((node) => { const rect = node.getBoundingClientRect(); return { left: rect.left, top: rect.top, bottom: rect.bottom, width: rect.width }; }), ); expect(mobile.every((node) => Math.abs(node.left - mobile[0]!.left) <= 1)).toBe(true); expect(mobile.every((node) => node.width >= 200)).toBe(true); expect(mobile.every((node, index, nodes) => index === 0 || node.top > nodes[index - 1]!.bottom)).toBe(true); await capture(page, testInfo, 'infrastructure-flow-mobile.png'); }); test('keeps the compact HUD in one safe lower row without covering the road corridor', async ({ page }) => { for (const viewport of [ { width: 320, height: 800 }, { width: 1440, height: 900 }, ]) { await page.setViewportSize(viewport); await loadInformationLayer(page); const geometry = await page.evaluate(() => { const controls = document.querySelector('.drive-controls'); const hud = document.querySelector('.journey-hud'); if (!controls || !hud) throw new Error('Missing vehicle HUD'); const controlRect = controls.getBoundingClientRect(); const hudRect = hud.getBoundingClientRect(); return { aligned: Math.abs(controlRect.bottom - hudRect.bottom) <= 1, separate: controlRect.right <= hudRect.left, controlsTargetSize: [...controls.querySelectorAll('button')] .every((button) => button.getBoundingClientRect().height >= 44), centerClear: controlRect.right < window.innerWidth * 0.42 && hudRect.left > window.innerWidth * 0.58, }; }); expect(geometry.aligned).toBe(true); expect(geometry.separate).toBe(true); expect(geometry.controlsTargetSize).toBe(true); if (viewport.width >= 768) expect(geometry.centerClear).toBe(true); } }); test('moves drive controls into document flow in short landscape', async ({ page }) => { await page.setViewportSize({ width: 800, height: 390 }); await loadInformationLayer(page); const geometry = await page.evaluate(() => { const controls = document.querySelector('.drive-controls'); const contact = document.querySelector('#contact'); if (!controls || !contact) throw new Error('Missing short-landscape controls'); const controlsRect = controls.getBoundingClientRect(); const contactRect = contact.getBoundingClientRect(); return { position: getComputedStyle(controls).position, afterContact: controlsRect.top >= contactRect.bottom, }; }); expect(geometry).toEqual({ position: 'relative', afterContact: true }); }); 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); });