550 lines
20 KiB
TypeScript
550 lines
20 KiB
TypeScript
import sharp from "sharp";
|
|
import type { Locator, Page, TestInfo } from "@playwright/test";
|
|
import { expect, test } from "../helpers/serve-dist";
|
|
|
|
interface VisualViewportCase {
|
|
readonly label: string;
|
|
readonly width: number;
|
|
readonly height: number;
|
|
}
|
|
|
|
interface SampledFrame {
|
|
readonly pixels: Uint8Array;
|
|
readonly width: number;
|
|
readonly height: number;
|
|
}
|
|
|
|
interface FlashEvent {
|
|
readonly direction: -1 | 1;
|
|
readonly frame: number;
|
|
readonly affectedRatio: number;
|
|
}
|
|
|
|
const viewports = [
|
|
{ label: "mobile-compact-portrait", width: 320, height: 568 },
|
|
{ label: "mobile-portrait", width: 390, height: 844 },
|
|
{ label: "mobile-landscape", width: 844, height: 390 },
|
|
{ label: "tablet-portrait", width: 768, height: 1024 },
|
|
{ label: "desktop-compact", width: 1280, height: 720 },
|
|
{ label: "desktop-standard", width: 1440, height: 900 },
|
|
{ label: "desktop-wide", width: 1920, height: 1080 },
|
|
{ label: "desktop-ultrawide", width: 2560, height: 1440 },
|
|
] as const satisfies readonly VisualViewportCase[];
|
|
|
|
const waitForRenderedPortfolio = async (
|
|
page: Page,
|
|
distUrl: string,
|
|
): Promise<void> => {
|
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
|
await page.evaluate(() => document.fonts.ready);
|
|
await expect(
|
|
page.getByRole("heading", { level: 1, name: "Luca Pîslaru" }),
|
|
).toBeVisible();
|
|
await expect(page.locator(".journey-world")).toHaveAttribute(
|
|
"data-scene-state",
|
|
"running",
|
|
);
|
|
};
|
|
|
|
const captureViewport = async (
|
|
page: Page,
|
|
testInfo: TestInfo,
|
|
name: string,
|
|
): Promise<Buffer> =>
|
|
page.screenshot({
|
|
animations: "allow",
|
|
path: testInfo.outputPath(`${name}.png`),
|
|
scale: "css",
|
|
});
|
|
|
|
const sampleFrame = async (
|
|
image: Buffer,
|
|
width = 160,
|
|
height = 90,
|
|
): Promise<SampledFrame> => {
|
|
const { data, info } = await sharp(image)
|
|
.resize(width, height, { fit: "fill" })
|
|
.removeAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
return { pixels: data, width: info.width, height: info.height };
|
|
};
|
|
|
|
const paletteRatios = async (
|
|
image: Buffer,
|
|
): Promise<{ dark: number; restrainedAccent: number }> => {
|
|
const frame = await sampleFrame(image, 120, 68);
|
|
let dark = 0;
|
|
let accent = 0;
|
|
for (let offset = 0; offset < frame.pixels.length; offset += 3) {
|
|
const red = frame.pixels[offset] ?? 0;
|
|
const green = frame.pixels[offset + 1] ?? 0;
|
|
const blue = frame.pixels[offset + 2] ?? 0;
|
|
const luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
const chroma = Math.max(red, green, blue) - Math.min(red, green, blue);
|
|
if (luminance < 86) dark += 1;
|
|
if (chroma >= 34 && luminance >= 35) accent += 1;
|
|
}
|
|
const pixelCount = frame.width * frame.height;
|
|
return { dark: dark / pixelCount, restrainedAccent: accent / pixelCount };
|
|
};
|
|
|
|
const averageNormalizedDifference = async (
|
|
first: Buffer,
|
|
second: Buffer,
|
|
): Promise<number> => {
|
|
const [a, b] = await Promise.all([sampleFrame(first), sampleFrame(second)]);
|
|
expect({ width: a.width, height: a.height }).toEqual({
|
|
width: b.width,
|
|
height: b.height,
|
|
});
|
|
let difference = 0;
|
|
for (let offset = 0; offset < a.pixels.length; offset += 1) {
|
|
difference += Math.abs((a.pixels[offset] ?? 0) - (b.pixels[offset] ?? 0));
|
|
}
|
|
return difference / (a.pixels.length * 255);
|
|
};
|
|
|
|
const captureWorldSequence = async (
|
|
world: Locator,
|
|
frameCount: number,
|
|
intervalMs: number,
|
|
): Promise<SampledFrame[]> => {
|
|
const frames: SampledFrame[] = [];
|
|
const clip = await world.boundingBox();
|
|
if (clip === null) throw new Error("Journey world is not visible.");
|
|
for (let index = 0; index < frameCount; index += 1) {
|
|
frames.push(
|
|
await sampleFrame(
|
|
await world
|
|
.page()
|
|
.screenshot({ animations: "allow", clip, scale: "css" }),
|
|
),
|
|
);
|
|
if (index + 1 < frameCount) await world.page().waitForTimeout(intervalMs);
|
|
}
|
|
return frames;
|
|
};
|
|
|
|
const relativeLuminance = (pixels: Uint8Array, offset: number): number =>
|
|
(0.2126 * (pixels[offset] ?? 0) +
|
|
0.7152 * (pixels[offset + 1] ?? 0) +
|
|
0.0722 * (pixels[offset + 2] ?? 0)) /
|
|
255;
|
|
|
|
const analyzeFlashSequence = (
|
|
frames: readonly SampledFrame[],
|
|
framesPerSecond: number,
|
|
): { events: FlashEvent[]; maximumFlashesPerSecond: number } => {
|
|
const events: FlashEvent[] = [];
|
|
for (let frameIndex = 1; frameIndex < frames.length; frameIndex += 1) {
|
|
const previous = frames[frameIndex - 1];
|
|
const current = frames[frameIndex];
|
|
if (previous === undefined || current === undefined) continue;
|
|
expect({ width: current.width, height: current.height }).toEqual({
|
|
width: previous.width,
|
|
height: previous.height,
|
|
});
|
|
|
|
let brightened = 0;
|
|
let darkened = 0;
|
|
const pixelCount = current.width * current.height;
|
|
for (let offset = 0; offset < current.pixels.length; offset += 3) {
|
|
const before = relativeLuminance(previous.pixels, offset);
|
|
const after = relativeLuminance(current.pixels, offset);
|
|
const delta = after - before;
|
|
// The WCAG general-flash luminance boundary: a >=0.1 change with the
|
|
// darker state below 0.8. A quarter-screen area is intentionally more
|
|
// conservative than the normative small-area exclusion.
|
|
if (Math.abs(delta) >= 0.1 && Math.min(before, after) < 0.8) {
|
|
if (delta > 0) brightened += 1;
|
|
else darkened += 1;
|
|
}
|
|
}
|
|
const direction = brightened >= darkened ? 1 : -1;
|
|
const affectedRatio = Math.max(brightened, darkened) / pixelCount;
|
|
if (affectedRatio >= 0.25)
|
|
events.push({ direction, frame: frameIndex, affectedRatio });
|
|
}
|
|
|
|
let maximumFlashesPerSecond = 0;
|
|
const windowFrames = Math.max(1, Math.round(framesPerSecond));
|
|
for (let startFrame = 1; startFrame < frames.length; startFrame += 1) {
|
|
const windowEvents = events.filter(
|
|
(event) =>
|
|
event.frame >= startFrame && event.frame < startFrame + windowFrames,
|
|
);
|
|
let reversals = 0;
|
|
for (let index = 1; index < windowEvents.length; index += 1) {
|
|
if (windowEvents[index]?.direction !== windowEvents[index - 1]?.direction)
|
|
reversals += 1;
|
|
}
|
|
maximumFlashesPerSecond = Math.max(
|
|
maximumFlashesPerSecond,
|
|
Math.ceil(reversals / 2),
|
|
);
|
|
}
|
|
return { events, maximumFlashesPerSecond };
|
|
};
|
|
|
|
test.describe("production visual composition", () => {
|
|
test.beforeEach(async ({}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== "desktop-chromium",
|
|
"Visual evidence is captured once in the available current Chromium engine.",
|
|
);
|
|
});
|
|
|
|
for (const viewport of viewports) {
|
|
test(`${viewport.label} keeps the professional information layer over the journey world`, async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize(viewport);
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
|
|
const composition = await page.evaluate(() => {
|
|
const root = document.documentElement;
|
|
const panels = [
|
|
...document.querySelectorAll<HTMLElement>(
|
|
".portfolio-main > .portfolio-section",
|
|
),
|
|
];
|
|
const firstPanel = panels[0];
|
|
if (firstPanel === undefined)
|
|
throw new Error("Missing portfolio panel.");
|
|
const firstPanelStyle = getComputedStyle(firstPanel);
|
|
const textStyles = [
|
|
...document.querySelectorAll<HTMLElement>(
|
|
".portfolio-main :is(h2,h3,p)",
|
|
),
|
|
].map((element) => getComputedStyle(element));
|
|
const boxes = [
|
|
...document.querySelectorAll<HTMLElement>(
|
|
".site-header, .portfolio-section, .drive-controls, .journey-hud",
|
|
),
|
|
]
|
|
.filter((element) => getComputedStyle(element).display !== "none")
|
|
.map((element) => element.getBoundingClientRect())
|
|
.filter((rect) => rect.width > 0 && rect.height > 0);
|
|
const fixedSurfaces = [
|
|
...document.querySelectorAll<HTMLElement>(
|
|
".site-navigation, .drive-controls, .journey-hud",
|
|
),
|
|
]
|
|
.filter(
|
|
(element) =>
|
|
getComputedStyle(element).position === "fixed" &&
|
|
getComputedStyle(element).display !== "none",
|
|
)
|
|
.map((element) => ({
|
|
selector: element.className,
|
|
rect: element.getBoundingClientRect(),
|
|
}));
|
|
const fixedSurfaceOverlaps = fixedSurfaces.flatMap((surface, index) =>
|
|
fixedSurfaces
|
|
.slice(index + 1)
|
|
.filter(
|
|
(candidate) =>
|
|
Math.max(
|
|
0,
|
|
Math.min(surface.rect.right, candidate.rect.right) -
|
|
Math.max(surface.rect.left, candidate.rect.left),
|
|
) *
|
|
Math.max(
|
|
0,
|
|
Math.min(surface.rect.bottom, candidate.rect.bottom) -
|
|
Math.max(surface.rect.top, candidate.rect.top),
|
|
) >
|
|
1,
|
|
)
|
|
.map(
|
|
(candidate) =>
|
|
`${surface.selector} overlaps ${candidate.selector}`,
|
|
),
|
|
);
|
|
return {
|
|
noHorizontalOverflow: root.scrollWidth <= root.clientWidth + 1,
|
|
boxesFitHorizontally: boxes.every(
|
|
(rect) => rect.left >= -1 && rect.right <= root.clientWidth + 1,
|
|
),
|
|
panelGeometry: panels.map((panel) => ({
|
|
layout: panel.dataset.layout ?? "",
|
|
width: panel.getBoundingClientRect().width,
|
|
widthRatio: panel.getBoundingClientRect().width / root.clientWidth,
|
|
})),
|
|
panelBackgroundImage: firstPanelStyle.backgroundImage,
|
|
panelFilter: firstPanelStyle.filter,
|
|
textShadows: textStyles.map((style) => style.textShadow),
|
|
publicProjectMediaCount: document.querySelectorAll(
|
|
".project-media img, .project-media video",
|
|
).length,
|
|
controlHeightRatio:
|
|
(document
|
|
.querySelector<HTMLElement>(".drive-controls")
|
|
?.getBoundingClientRect().height ?? 0) / window.innerHeight,
|
|
fixedSurfaceOverlaps,
|
|
};
|
|
});
|
|
|
|
const screenshot = await captureViewport(
|
|
page,
|
|
testInfo,
|
|
`visual-${viewport.label}-default`,
|
|
);
|
|
const palette = await paletteRatios(screenshot);
|
|
|
|
expect(composition.noHorizontalOverflow).toBe(true);
|
|
expect(composition.boxesFitHorizontally).toBe(true);
|
|
expect(composition.panelBackgroundImage).toBe("none");
|
|
expect(composition.panelFilter).toBe("none");
|
|
expect(composition.textShadows.every((shadow) => shadow === "none")).toBe(
|
|
true,
|
|
);
|
|
expect(composition.publicProjectMediaCount).toBe(0);
|
|
expect(composition.fixedSurfaceOverlaps).toEqual([]);
|
|
expect(palette.dark).toBeGreaterThan(0.58);
|
|
expect(palette.restrainedAccent).toBeGreaterThan(0.0005);
|
|
expect(palette.restrainedAccent).toBeLessThan(0.28);
|
|
|
|
if (viewport.width >= 1280) {
|
|
for (const panel of composition.panelGeometry) {
|
|
if (["right-rail", "compact-rail"].includes(panel.layout)) {
|
|
expect(panel.widthRatio).toBeGreaterThanOrEqual(0.4);
|
|
expect(panel.widthRatio).toBeLessThanOrEqual(0.45);
|
|
} else if (panel.layout === "left-timeline") {
|
|
expect(panel.widthRatio).toBeGreaterThanOrEqual(0.44);
|
|
expect(panel.widthRatio).toBeLessThanOrEqual(0.48);
|
|
} else if (["full-diagram", "toolkit-field"].includes(panel.layout)) {
|
|
expect(panel.widthRatio).toBeGreaterThanOrEqual(0.75);
|
|
expect(panel.widthRatio).toBeLessThanOrEqual(0.8);
|
|
} else if (panel.layout === "project-stage") {
|
|
expect(panel.widthRatio).toBeGreaterThanOrEqual(0.9);
|
|
expect(panel.widthRatio).toBeLessThanOrEqual(0.94);
|
|
} else if (panel.layout === "destination") {
|
|
expect(panel.width).toBeGreaterThanOrEqual(760);
|
|
expect(panel.width).toBeLessThanOrEqual(866);
|
|
}
|
|
}
|
|
} else if (viewport.width < 768) {
|
|
expect(
|
|
composition.panelGeometry.every(
|
|
(panel) => panel.widthRatio >= 0.85 && panel.widthRatio <= 1,
|
|
),
|
|
).toBe(true);
|
|
}
|
|
if (viewport.width <= 390 && viewport.height <= 568) {
|
|
expect(composition.controlHeightRatio).toBeLessThanOrEqual(0.34);
|
|
}
|
|
});
|
|
}
|
|
|
|
test("Static is screenshot-stable and retains the same professional content", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.getByRole("button", { name: "Drive settings" }).click();
|
|
await page.getByLabel("Quality control").selectOption("static");
|
|
await expect(page.locator(".journey-world")).toHaveAttribute(
|
|
"data-scene-state",
|
|
"static",
|
|
);
|
|
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
|
await expect(page.locator(".journey-poster")).toHaveCount(1);
|
|
await page.keyboard.press("Escape");
|
|
await page.evaluate(() =>
|
|
(document.activeElement as HTMLElement | null)?.blur(),
|
|
);
|
|
await page.mouse.move(1, 1);
|
|
|
|
const first = await captureViewport(page, testInfo, "visual-static-a");
|
|
await page.waitForTimeout(200);
|
|
const second = await captureViewport(page, testInfo, "visual-static-b");
|
|
expect(await averageNormalizedDifference(first, second)).toBeLessThan(
|
|
0.001,
|
|
);
|
|
});
|
|
|
|
test("reduced motion uses the stable poster without requesting the live scene", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
|
await page.evaluate(() => document.fonts.ready);
|
|
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
|
await expect(page.locator(".journey-poster")).toHaveCount(1);
|
|
await page.getByRole("button", { name: "Drive settings" }).click();
|
|
await expect(page.getByRole("status")).toContainText(
|
|
"reduced motion requested",
|
|
);
|
|
await page.keyboard.press("Escape");
|
|
await page.evaluate(() =>
|
|
(document.activeElement as HTMLElement | null)?.blur(),
|
|
);
|
|
await page.mouse.move(1, 1);
|
|
await captureViewport(page, testInfo, "visual-reduced-motion");
|
|
});
|
|
|
|
test("audio-running state stays visually restrained and exposes its controls", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.getByRole("button", { name: "PLAY SOUNDTRACK" }).click();
|
|
await expect(
|
|
page.getByRole("button", { name: "Mute sound" }),
|
|
).toBeVisible();
|
|
await page.getByRole("button", { name: "Sound volume" }).click();
|
|
await expect(page.getByLabel("Sound volume level")).toHaveValue("0.4");
|
|
await captureViewport(page, testInfo, "visual-audio-running");
|
|
});
|
|
|
|
test("media failure leaves one composed fallback and usable information", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await page.addInitScript(() => {
|
|
const nativeGetContext = HTMLCanvasElement.prototype.getContext;
|
|
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
|
|
configurable: true,
|
|
value(
|
|
this: HTMLCanvasElement,
|
|
contextId: string,
|
|
options?: unknown,
|
|
): RenderingContext | null {
|
|
if (
|
|
contextId === "webgl" ||
|
|
contextId === "webgl2" ||
|
|
contextId === "experimental-webgl"
|
|
)
|
|
return null;
|
|
return Reflect.apply(nativeGetContext, this, [
|
|
contextId,
|
|
options,
|
|
]) as RenderingContext | null;
|
|
},
|
|
});
|
|
});
|
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
|
await expect(page.locator(".journey-world")).toHaveAttribute(
|
|
"data-scene-state",
|
|
"failed",
|
|
);
|
|
await expect(page.locator(".journey-poster")).toHaveCount(1);
|
|
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
|
await expect(
|
|
page.getByRole("heading", { level: 1, name: "Luca Pîslaru" }),
|
|
).toBeVisible();
|
|
await captureViewport(page, testInfo, "visual-media-failure");
|
|
});
|
|
|
|
test("missing optional social profiles preserves the contact destination", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 768, height: 1024 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.locator('#contact a[target="_blank"]').evaluateAll((links) => {
|
|
for (const link of links) link.closest("li")?.remove();
|
|
});
|
|
await page.locator('nav a[href="#contact"]').click();
|
|
await expect(page.locator("#contact-heading")).toBeFocused();
|
|
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
|
await expect(page.locator('#contact a[target="_blank"]')).toHaveCount(0);
|
|
await captureViewport(page, testInfo, "visual-missing-social");
|
|
});
|
|
|
|
test("Contact arrival resolves the route, HUD, identity, and primary action together", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.locator('nav a[href="#contact"]').click();
|
|
await expect(page.locator("#contact-heading")).toBeFocused();
|
|
await expect(page.locator(".journey-hud__section")).toHaveText("Contact");
|
|
await expect(page.locator(".journey-hud__arrival")).toHaveText("ARRIVED");
|
|
await expect(page.locator("#contact")).toBeInViewport();
|
|
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
|
|
await captureViewport(page, testInfo, "visual-contact-arrival");
|
|
});
|
|
});
|
|
|
|
test.describe("three-flash safety", () => {
|
|
test.beforeEach(async ({}, testInfo) => {
|
|
test.skip(
|
|
testInfo.project.name !== "desktop-chromium",
|
|
"Automated flash evidence runs in the available current Chromium engine.",
|
|
);
|
|
});
|
|
|
|
test("high-speed live scene remains below the conservative three-flash threshold", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
test.setTimeout(60_000);
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.mouse.wheel(0, 900);
|
|
await page.mouse.wheel(0, 900);
|
|
const frames = await captureWorldSequence(
|
|
page.locator(".journey-world"),
|
|
20,
|
|
100,
|
|
);
|
|
const result = analyzeFlashSequence(frames, 10);
|
|
await testInfo.attach("live-scene-flash-analysis.json", {
|
|
body: Buffer.from(JSON.stringify(result, null, 2)),
|
|
contentType: "application/json",
|
|
});
|
|
expect(result.maximumFlashesPerSecond).toBeLessThanOrEqual(3);
|
|
});
|
|
|
|
test("Static and reduced-motion poster sequences contain no qualifying flash", async ({
|
|
page,
|
|
distUrl,
|
|
}, testInfo) => {
|
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
await waitForRenderedPortfolio(page, distUrl);
|
|
await page.getByRole("button", { name: "Drive settings" }).click();
|
|
await page.getByLabel("Quality control").selectOption("static");
|
|
await expect(page.locator(".journey-world")).toHaveAttribute(
|
|
"data-scene-state",
|
|
"static",
|
|
);
|
|
await page.keyboard.press("Escape");
|
|
await page.evaluate(() =>
|
|
(document.activeElement as HTMLElement | null)?.blur(),
|
|
);
|
|
await page.mouse.move(1, 1);
|
|
const staticResult = analyzeFlashSequence(
|
|
await captureWorldSequence(page.locator(".journey-world"), 8, 100),
|
|
10,
|
|
);
|
|
await testInfo.attach("static-flash-analysis.json", {
|
|
body: Buffer.from(JSON.stringify(staticResult, null, 2)),
|
|
contentType: "application/json",
|
|
});
|
|
expect(staticResult.maximumFlashesPerSecond).toBe(0);
|
|
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await expect(page.locator(".journey-canvas")).toHaveCount(0);
|
|
const reducedResult = analyzeFlashSequence(
|
|
await captureWorldSequence(page.locator(".journey-world"), 8, 100),
|
|
10,
|
|
);
|
|
await testInfo.attach("reduced-motion-flash-analysis.json", {
|
|
body: Buffer.from(JSON.stringify(reducedResult, null, 2)),
|
|
contentType: "application/json",
|
|
});
|
|
expect(reducedResult.maximumFlashesPerSecond).toBe(0);
|
|
});
|
|
});
|