197 lines
7.6 KiB
TypeScript
197 lines
7.6 KiB
TypeScript
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<string> };
|
|
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<Record<string, unknown>> };
|
|
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 = `<!doctype html><html lang="en"><head><meta name="viewport" content="width=device-width, initial-scale=1"><style>${mediaStyles()}</style></head><body>${await render("/", inputs)}</body></html>`;
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
await mediaVite.close();
|
|
});
|
|
|
|
async function assertReflowGeometry(page: Page): Promise<void> {
|
|
const result = await page.evaluate(() => {
|
|
const root = document.documentElement;
|
|
const horizontalCandidates = [
|
|
...document.querySelectorAll<HTMLElement>(
|
|
".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<HTMLElement>(
|
|
"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);
|
|
});
|