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 }; 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 > .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('.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('.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('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); });