test: enforce accessible portfolio interaction

This commit is contained in:
2026-09-02 07:01:33 +03:00
parent f953905a4b
commit 5abd1c19ae
8 changed files with 805 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect, test } from "../helpers/serve-dist";
type AccessibilityState =
| "default"
| "audio-running"
| "static"
| "reduced-motion"
| "media-failure"
| "missing-social";
const states: readonly AccessibilityState[] = [
"default",
"audio-running",
"static",
"reduced-motion",
"media-failure",
"missing-social",
];
async function enterState(
page: Page,
distUrl: string,
state: AccessibilityState,
): Promise<void> {
if (state === "reduced-motion") {
await page.emulateMedia({ reducedMotion: "reduce" });
}
if (state === "media-failure") {
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" });
if (state === "audio-running") {
await page.getByRole("button", { name: "START THE DRIVE" }).click();
await expect(
page.getByRole("button", { name: "Mute sound" }),
).toBeVisible();
} else if (state === "static") {
await page.getByLabel("Quality control").selectOption("static");
await expect(
page.getByRole("button", { name: "Enable motion" }),
).toBeVisible();
} else if (state === "reduced-motion") {
await expect(page.getByRole("status")).toContainText(
"reduced motion requested",
);
} else if (state === "media-failure") {
await expect(page.locator(".journey-world")).toHaveAttribute(
"data-scene-state",
"failed",
);
} else if (state === "missing-social") {
await page.locator('#contact a[target="_blank"]').evaluateAll((links) => {
for (const link of links) link.closest("li")?.remove();
});
await expect(page.locator('#contact a[target="_blank"]')).toHaveCount(0);
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
}
}
for (const state of states) {
test(`${state} has no WCAG 2.2 A or AA axe violations`, async ({
page,
distUrl,
}) => {
await enterState(page, distUrl, state);
const result = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
.analyze();
expect(result.violations).toEqual([]);
});
}

View File

@@ -0,0 +1,58 @@
import { expect, test } from "../helpers/serve-dist";
test("forced colors preserves visible focus, controls, content, and the Contact action", async ({
page,
distUrl,
}) => {
await page.emulateMedia({ forcedColors: "active" });
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
await page.keyboard.press("Tab");
const focus = await page.locator(".skip-link").evaluate((element) => {
const style = getComputedStyle(element);
return {
outlineStyle: style.outlineStyle,
outlineWidth: Number.parseFloat(style.outlineWidth),
forcedColorAdjust: style.forcedColorAdjust,
};
});
expect(focus.outlineStyle).not.toBe("none");
expect(focus.outlineWidth).toBeGreaterThanOrEqual(2);
expect(focus.forcedColorAdjust).toBe("auto");
const controls = await page
.locator(".drive-controls button, .drive-controls select")
.evaluateAll((elements) =>
elements
.filter(
(element) =>
!(element instanceof HTMLButtonElement && element.disabled),
)
.map((element) => {
const style = getComputedStyle(element);
return {
borderStyle: style.borderTopStyle,
visible:
element.getBoundingClientRect().width > 0 &&
element.getBoundingClientRect().height > 0,
};
}),
);
expect(controls.length).toBeGreaterThan(0);
expect(
controls.every(
(control) => control.visible && control.borderStyle !== "none",
),
).toBe(true);
await page.locator('nav a[href="#contact"]').click();
await expect(page.locator("#contact-heading")).toBeFocused();
await expect(page.locator('#contact a[href^="mailto:"]')).toBeVisible();
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
document.documentElement.clientWidth + 1,
),
).toBe(true);
});

View File

@@ -0,0 +1,188 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../helpers/serve-dist";
interface FocusGeometry {
readonly description: string;
readonly focusVisible: boolean;
readonly insideSafeBounds: boolean;
readonly overlapArea: number;
}
async function currentFocusGeometry(page: Page): Promise<FocusGeometry> {
return page.evaluate(() => {
const active = document.activeElement;
if (!(active instanceof HTMLElement)) {
throw new Error(
"Keyboard traversal did not leave an HTMLElement focused.",
);
}
const rect = active.getBoundingClientRect();
const style = getComputedStyle(active);
const viewport = window.visualViewport;
const left = viewport?.offsetLeft ?? 0;
const top = viewport?.offsetTop ?? 0;
const right = left + (viewport?.width ?? window.innerWidth);
const bottom = top + (viewport?.height ?? window.innerHeight);
const outlineWidth = Number.parseFloat(style.outlineWidth) || 0;
const outlineOffset = Number.parseFloat(style.outlineOffset) || 0;
const hasInsetIndicator = /\binset\b/iu.test(style.boxShadow);
const indicatorExtent = hasInsetIndicator
? 0
: Math.max(0, outlineWidth + outlineOffset);
const fixedSurfaces = [
...document.querySelectorAll<HTMLElement>(
".journey-hud, .drive-controls",
),
].filter((surface) => !surface.contains(active));
const overlapArea = fixedSurfaces.reduce((total, surface) => {
const fixed = surface.getBoundingClientRect();
const width = Math.max(
0,
Math.min(rect.right, fixed.right) - Math.max(rect.left, fixed.left),
);
const height = Math.max(
0,
Math.min(rect.bottom, fixed.bottom) - Math.max(rect.top, fixed.top),
);
return total + width * height;
}, 0);
return {
description:
active.getAttribute("href") ??
active.getAttribute("aria-label") ??
active.textContent?.trim() ??
active.tagName,
focusVisible:
((style.outlineStyle !== "none" && outlineWidth >= 2) ||
hasInsetIndicator) &&
rect.width > 0 &&
rect.height > 0,
insideSafeBounds:
rect.left - indicatorExtent >= left - 1 &&
rect.right + indicatorExtent <= right + 1 &&
rect.top - indicatorExtent >= top - 1 &&
rect.bottom + indicatorExtent <= bottom + 1,
overlapArea,
};
});
}
test("every enabled author pointer target is 44 by 44 CSS pixels or a valid inline sentence link", async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
const failures = await page
.locator("a[href], button, input, select, textarea")
.evaluateAll((targets) =>
targets.flatMap((target) => {
if (!(target instanceof HTMLElement)) return [];
if (
(target instanceof HTMLButtonElement ||
target instanceof HTMLInputElement ||
target instanceof HTMLSelectElement ||
target instanceof HTMLTextAreaElement) &&
target.disabled
)
return [];
if (target.getAttribute("aria-disabled") === "true") return [];
const style = getComputedStyle(target);
const rect = target.getBoundingClientRect();
if (
style.display === "none" ||
style.visibility === "hidden" ||
rect.width === 0 ||
rect.height === 0
)
return [];
const exception = target.getAttribute("data-target-size-exception");
const validInlineException =
exception === "inline-link" &&
target instanceof HTMLAnchorElement &&
target.closest("p") !== null;
const invalidException = exception !== null && !validInlineException;
if (
!invalidException &&
(validInlineException || (rect.width >= 44 && rect.height >= 44))
) {
return [];
}
return [
{
target: target.outerHTML.slice(0, 180),
width: rect.width,
height: rect.height,
exception,
},
];
}),
);
expect(failures).toEqual([]);
});
test("keyboard traversal reaches Contact in document order with visible unobscured focus", async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
const visited: string[] = [];
for (let index = 0; index < 64; index += 1) {
await page.keyboard.press("Tab");
await expect
.poll(async () => {
const geometry = await currentFocusGeometry(page);
return {
focusVisible: geometry.focusVisible,
insideSafeBounds: geometry.insideSafeBounds,
overlapArea: geometry.overlapArea,
};
})
.toEqual({
focusVisible: true,
insideSafeBounds: true,
overlapArea: 0,
});
const geometry = await currentFocusGeometry(page);
visited.push(geometry.description);
if (geometry.description.startsWith("mailto:")) break;
}
expect(visited.at(0)).toBe("#portfolio-content");
expect(visited.slice(1, 9)).toEqual([
"#start",
"#profile",
"#experience",
"#infrastructure",
"#selected-builds",
"#additional-work",
"#toolkit",
"#contact",
]);
expect(visited.some((description) => description.startsWith("mailto:"))).toBe(
true,
);
});
test("explicit keyboard navigation focuses a section heading without a fixed-surface collision", async ({
page,
distUrl,
}) => {
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
for (let index = 0; index < 9; index += 1) await page.keyboard.press("Tab");
await expect(page.locator('nav a[href="#contact"]')).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.locator("#contact-heading")).toBeFocused();
const geometry = await currentFocusGeometry(page);
expect(geometry.focusVisible).toBe(true);
expect(geometry.insideSafeBounds).toBe(true);
expect(geometry.overlapArea).toBe(0);
});

View File

@@ -0,0 +1,104 @@
import type { Page } from "@playwright/test";
import { 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; }
`;
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 page.locator('nav a[href="#contact"]').click();
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);
});