Files
kandev/apps/web/e2e/helpers/layout-assertions.ts
T
2026-08-15 02:28:37 +01:00

224 lines
8.2 KiB
TypeScript

import { expect, type Locator } from "@playwright/test";
export type ElementBox = { x: number; y: number; width: number; height: number };
export async function requireBox(locator: Locator, label: string): Promise<ElementBox> {
const box = await locator.boundingBox();
expect(box, `${label}: locator has no bounding box`).not.toBeNull();
if (!box) throw new Error(`${label}: locator has no bounding box`);
return box;
}
// Shared layout assertions for mobile / responsive specs. Extracted because
// both onboarding mobile specs need the same overflow + padding checks and
// duplicating the DOM walk caused review churn.
/**
* Asserts that no visible descendant of `root` has a right edge past `root`'s
* right edge. DOM bounds that are clipped inside a nested scroll container do
* not constitute a visual overflow. `label` shows up in the failure message
* so the offending step is identifiable.
*/
export async function assertNoDescendantOverflowsRight(
root: Locator,
label = "container",
): Promise<void> {
const rootBox = await root.boundingBox();
expect(rootBox, `${label}: root has no bounding box`).not.toBeNull();
if (!rootBox) return;
const rootRight = rootBox.x + rootBox.width;
// One round-trip to keep this cheap on a deep DOM.
const overflowing: { tag: string; text: string; right: number }[] = await root.evaluate(
(node, rightArg) => {
const limit = rightArg as number;
const results: { tag: string; text: string; right: number }[] = [];
// SVG elements in HTML documents report `tagName` lowercase (per the
// SVG namespace) while HTML elements report uppercase, so normalize.
const skip = new Set(["svg", "path", "circle", "rect", "line", "g"]);
const clippingOverflow = new Set(["auto", "clip", "hidden", "scroll"]);
const isClippedBeforeLimit = (element: Element): boolean => {
for (
let parent = element.parentElement;
parent && parent !== node;
parent = parent.parentElement
) {
const overflowX = getComputedStyle(parent).overflowX;
if (!clippingOverflow.has(overflowX)) continue;
if (parent.getBoundingClientRect().right <= limit + 1) return true;
}
return false;
};
const all = node.querySelectorAll("*");
for (const el of all) {
if (skip.has(el.tagName.toLowerCase())) continue;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
if (rect.right > limit + 1 && !isClippedBeforeLimit(el)) {
results.push({
tag: el.tagName.toLowerCase(),
text: (el.textContent ?? "").trim().slice(0, 80),
right: rect.right,
});
}
}
return results;
},
rootRight,
);
expect(
overflowing,
`${label}: ${overflowing.length} element(s) overflow the right edge (${rootRight.toFixed(
1,
)}). First few:\n${overflowing
.slice(0, 8)
.map((o) => ` <${o.tag}> right=${o.right.toFixed(1)} text="${o.text}"`)
.join("\n")}`,
).toHaveLength(0);
}
/**
* Asserts that the first element matching `selector` inside `root` has
* equal left and right horizontal gaps to `root`'s edges (within 1 px of
* sub-pixel rounding). Useful for catching scrollbar-gutter style fixes
* that leave the padding asymmetric.
*/
export async function assertHorizontalPaddingSymmetric(
root: Locator,
selector: string,
label = selector,
): Promise<void> {
const result = await root.evaluate((node, sel) => {
const rootRect = node.getBoundingClientRect();
const el = node.querySelector(sel as string) as HTMLElement | null;
if (!el) return { missing: true as const };
const r = el.getBoundingClientRect();
return {
missing: false as const,
leftGap: Math.round(r.left - rootRect.left),
rightGap: Math.round(rootRect.right - r.right),
};
}, selector);
expect(result.missing, `${label}: selector "${selector}" matched nothing`).toBe(false);
if (result.missing) return;
expect(
Math.abs(result.leftGap - result.rightGap),
`${label}: leftGap=${result.leftGap}px, rightGap=${result.rightGap}px (selector "${selector}")`,
).toBeLessThanOrEqual(1);
}
/**
* Asserts that the document scrollWidth does not exceed clientWidth — i.e.
* the page is not horizontally scrollable.
*/
export async function assertNoDocumentHorizontalOverflow(
page: { evaluate: <T>(fn: () => T) => Promise<T> },
label = "document",
): Promise<void> {
const widths = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
client: document.documentElement.clientWidth,
}));
expect(
widths.scroll,
`${label}: scrollWidth (${widths.scroll}) exceeds clientWidth (${widths.client})`,
).toBeLessThanOrEqual(widths.client + 1);
}
/**
* Asserts that an element's rendered contents do not exceed its own horizontal
* content box. Unlike bounding-box checks, this catches overflowing text glyphs.
*/
export async function assertNoElementHorizontalOverflow(
locator: Locator,
label = "element",
): Promise<void> {
const widths = await locator.evaluate((node) => ({
scroll: node.scrollWidth,
client: node.clientWidth,
}));
expect(
widths.scroll,
`${label}: scrollWidth (${widths.scroll}) exceeds clientWidth (${widths.client})`,
).toBeLessThanOrEqual(widths.client + 1);
}
/**
* Asserts that text occupies multiple rendered lines without overflowing its
* own horizontal content box. Useful for titles whose complete text must stay
* readable instead of being clipped with an ellipsis.
*/
export async function assertTextWrapsNaturallyWithoutHorizontalOverflow(
locator: Locator,
label = "text",
): Promise<void> {
const metrics = await locator.evaluate((node) => {
const style = getComputedStyle(node);
const range = document.createRange();
range.selectNodeContents(node);
const lineWidths = Array.from(range.getClientRects(), (rect) => rect.width).filter(
(width) => width > 0,
);
return {
height: node.getBoundingClientRect().height,
lineHeight: Number.parseFloat(style.lineHeight),
scrollWidth: node.scrollWidth,
clientWidth: node.clientWidth,
lineWidths,
};
});
expect(
metrics.height,
`${label}: height (${metrics.height}) does not span multiple ${metrics.lineHeight}px lines`,
).toBeGreaterThan(metrics.lineHeight * 1.5);
expect(
metrics.scrollWidth,
`${label}: scrollWidth (${metrics.scrollWidth}) exceeds clientWidth (${metrics.clientWidth})`,
).toBeLessThanOrEqual(metrics.clientWidth + 1);
const [firstLineWidth] = metrics.lineWidths;
if (firstLineWidth === undefined) {
throw new Error(`${label}: text has no rendered line rectangles`);
}
expect(
firstLineWidth,
`${label}: first line should consume available width before wrapping`,
).toBeGreaterThan(metrics.clientWidth * 0.8);
}
/**
* Asserts that a visible locator fits inside the viewport horizontally.
* Useful for popovers that portal outside their dialog/container.
*/
export async function assertLocatorWithinViewportX(
locator: Locator,
label = "element",
): Promise<void> {
const box = await locator.boundingBox();
expect(box, `${label}: locator has no bounding box`).not.toBeNull();
if (!box) return;
const viewport = locator.page().viewportSize();
expect(viewport, `${label}: page has no viewport`).not.toBeNull();
if (!viewport) return;
expect(
box.x,
`${label}: left edge ${box.x.toFixed(1)} is outside viewport`,
).toBeGreaterThanOrEqual(-1);
expect(
box.x + box.width,
`${label}: right edge ${(box.x + box.width).toFixed(1)} exceeds viewport ${viewport.width}`,
).toBeLessThanOrEqual(viewport.width + 1);
}
export async function expectElementsNotToIntersect(first: Locator, second: Locator): Promise<void> {
const [firstBox, secondBox] = await Promise.all([first.boundingBox(), second.boundingBox()]);
expect(firstBox).not.toBeNull();
expect(secondBox).not.toBeNull();
const intersects =
firstBox!.x < secondBox!.x + secondBox!.width &&
firstBox!.x + firstBox!.width > secondBox!.x &&
firstBox!.y < secondBox!.y + secondBox!.height &&
firstBox!.y + firstBox!.height > secondBox!.y;
expect(intersects).toBe(false);
}