import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { createServer, type ViteDevServer } from "vite"; import type { Page } from "@playwright/test"; import { clickNavigationLink, expect, test } from "../helpers/serve-dist"; const textSpacingOverride = ` * { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-block-end: 2em !important; } `; 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", ]; type ServerRenderer = { render: (url: string, inputs: unknown) => Promise }; type InputValidator = { validateProductionInputs: (content: unknown, manifest: unknown, site: unknown, preparedAssetRoot: URL) => unknown }; let mediaVite: ViteDevServer; let syntheticMediaDocument = ""; function mediaStyles(): string { return stylesheetFiles .map((file) => { const path = resolve(process.cwd(), file); return existsSync(path) ? readFileSync(path, "utf8") : ""; }) .join("\n"); } function withSyntheticProjectMedia(contentInput: unknown, manifestInput: unknown): { content: unknown; manifest: unknown } { const content = structuredClone(contentInput) as { projects: { selected: Array<{ assetIds: string[]; media?: unknown; mediaLayout?: unknown }> }; }; const manifest = structuredClone(manifestInput) as { assets: Array> }; const media = [ { kind: "image", assetId: "synthetic-reflow-one", alt: "Synthetic profile evidence.", caption: "Fictional demo profile." }, { kind: "image", assetId: "synthetic-reflow-two", alt: "Synthetic settings evidence.", caption: "Fictional demo settings." }, { kind: "image", assetId: "synthetic-reflow-three", alt: "Synthetic schedule evidence.", caption: "Fictional demo schedule." }, ] as const; const project = content.projects.selected[0]!; project.assetIds = media.map((presentation) => presentation.assetId); project.media = media; project.mediaLayout = "portrait-strip"; manifest.assets.push(...media.map((presentation, index) => ({ id: presentation.assetId, fileName: `${presentation.assetId}.webp`, sha256: `${index + 1}`.repeat(64), mediaType: "image/webp", width: 800, height: 1200, durationMs: null, rightsHolder: "Synthetic test fixture", license: "Test-only synthetic asset", attribution: "Synthetic test fixture", }))); return { content, manifest }; } test.beforeAll(async () => { mediaVite = await createServer({ server: { middlewareMode: true, hmr: false, ws: false }, appType: "custom" }); const { render } = await mediaVite.ssrLoadModule("/src/entry-server.ts") as ServerRenderer; const { validateProductionInputs } = await mediaVite.ssrLoadModule("/src/content/schemas.ts") as InputValidator; const fixture = (file: string): unknown => JSON.parse(readFileSync(resolve(process.cwd(), file), "utf8")) as unknown; const syntheticFixture = withSyntheticProjectMedia( fixture("content/public/portfolio-content.json"), fixture("content/public/asset-manifest.json"), ); const inputs = validateProductionInputs( syntheticFixture.content, syntheticFixture.manifest, fixture("config/site.preview.json"), new URL("file:///prepared-assets/"), ); syntheticMediaDocument = `${await render("/", inputs)}`; }); test.afterAll(async () => { await mediaVite.close(); }); async function assertReflowGeometry(page: Page): Promise { const result = await page.evaluate(() => { const root = document.documentElement; const horizontalCandidates = [ ...document.querySelectorAll( ".site-header, .portfolio-main, .portfolio-section, .site-footer, .drive-controls, .journey-hud, h1, h2, h3, p, a, button, input, select, figure, img, video, table", ), ].filter((element) => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return ( style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0 ); }); const clippedText = [ ...document.querySelectorAll( "h1, h2, h3, p, li, dt, dd, a, button, label, figcaption, caption, th, td", ), ].filter((element) => { if (element.classList.contains("visually-hidden")) return false; const style = getComputedStyle(element); return style.overflow === "hidden" || style.textOverflow === "ellipsis"; }); const horizontalFailures = horizontalCandidates.flatMap((element) => { const rect = element.getBoundingClientRect(); if (rect.left >= -1 && rect.right <= root.clientWidth + 1) return []; return [ { element: element.outerHTML.slice(0, 140), left: rect.left, right: rect.right, clientWidth: root.clientWidth, }, ]; }); return { clientWidth: root.clientWidth, scrollWidth: root.scrollWidth, horizontalFailures, clippedText: clippedText.map((element) => element.outerHTML.slice(0, 140), ), }; }); expect(result.scrollWidth).toBeLessThanOrEqual(result.clientWidth + 1); expect(result.horizontalFailures).toEqual([]); expect(result.clippedText).toEqual([]); await clickNavigationLink(page, "#contact"); await expect(page.locator("#contact-heading")).toBeFocused(); await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible(); } test("reflows at a 320 CSS pixel mobile viewport without lost content or controls", async ({ page, distUrl, }, testInfo) => { test.skip( testInfo.project.name !== "mobile-chromium", "Mobile CSS viewport gate.", ); await page.setViewportSize({ width: 320, height: 800 }); await page.goto(distUrl, { waitUntil: "domcontentloaded" }); await assertReflowGeometry(page); }); test("reflows in desktop Chromium at the effective 320 CSS pixel width produced by 400 percent zoom", async ({ page, distUrl, }, testInfo) => { test.skip( testInfo.project.name !== "desktop-chromium", "Desktop zoom-equivalent gate.", ); await page.setViewportSize({ width: 320, height: 225 }); await page.goto(distUrl, { waitUntil: "domcontentloaded" }); await assertReflowGeometry(page); }); test("preserves content and controls with the WCAG text-spacing override", async ({ page, distUrl, }) => { await page.setViewportSize({ width: 320, height: 800 }); await page.goto(distUrl, { waitUntil: "domcontentloaded" }); await page.addStyleTag({ content: textSpacingOverride }); await assertReflowGeometry(page); }); test("keeps synthetic portrait media in DOM order at 320 CSS pixels", async ({ page }) => { await page.setViewportSize({ width: 320, height: 800 }); await page.setContent(syntheticMediaDocument); const gallery = page.locator('.project-media-gallery[data-media-layout="portrait-strip"]'); await expect(gallery).toHaveCount(1); expect(await gallery.evaluate((node) => node.scrollWidth <= node.clientWidth || getComputedStyle(node).overflowX === "auto")).toBe(true); expect(await gallery.locator("figure").evaluateAll((figures) => figures.map((figure) => figure.textContent))) .toEqual(["Fictional demo profile.", "Fictional demo settings.", "Fictional demo schedule."]); expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true); });