Files
CWC/tests/responsive-audit.spec.js
Keith Solomon ae8a80484f
Deploy to Dreamhost (dev) / build (push) Successful in 37s
Sync TODOs with Issues / sync_todos (push) Successful in 7s
🐞 fix: Update media query breakpoints for navigation styles and add responsive tests
2026-07-08 18:55:19 -05:00

302 lines
10 KiB
JavaScript

import { expect, test } from "@playwright/test";
const site = "http://community-works-collaborative.test/";
// Viewports to audit (mobile / tablet / desktop / wide)
const viewports = [
{ name: "xs-360", width: 360, height: 800 },
{ name: "sm-402", width: 402, height: 874 },
{ name: "md-768", width: 768, height: 1024 },
{ name: "lg-1024", width: 1024, height: 768 },
{ name: "xl-1280", width: 1280, height: 800 },
{ name: "2xl-1440", width: 1440, height: 900 },
{ name: "3xl-1920", width: 1920, height: 1080 },
];
// Common selectors to audit for overflow / alignment
const selectors = {
header: ".site-header",
logo: ".site-header__logo",
navToggle: ".nav-main__toggle",
main: "main, .site-main, .page-content",
body: "body",
hero: ".homepage-hero, .page-hero",
cards: ".card, .post-card",
sections: "section",
footer: ".site-footer, footer",
grid: ".grid, [class*='grid']",
};
// Check for horizontal overflow at every viewport
for (const vp of viewports) {
test(`no horizontal overflow at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const overflow = await page.evaluate(() => {
const docWidth = document.documentElement.scrollWidth;
const winWidth = window.innerWidth;
const offenders = [];
// Find any element wider than the viewport or that pushes the doc to overflow
if (docWidth > winWidth + 1) {
const all = document.querySelectorAll("*");
for (const el of all) {
const rect = el.getBoundingClientRect();
if (rect.right > winWidth + 1) {
offenders.push({
tag: el.tagName.toLowerCase(),
class: el.className?.toString?.().slice(0, 80),
right: Math.round(rect.right),
width: Math.round(rect.width),
});
if (offenders.length >= 10) break;
}
}
}
return { docWidth, winWidth, offenders };
});
expect(
overflow.docWidth,
`Doc width ${overflow.docWidth} > viewport ${overflow.winWidth}. Offenders: ${JSON.stringify(overflow.offenders)}`,
).toBeLessThanOrEqual(overflow.winWidth + 1);
await context.close();
});
}
// Header alignment per viewport
for (const vp of viewports) {
test(`header alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const header = page.locator(selectors.header).first();
await expect(header).toBeVisible();
const box = await header.boundingBox();
expect(box).not.toBeNull();
// Header must start at the left edge (allow 1px sub-pixel)
expect(Math.abs(box.x)).toBeLessThanOrEqual(1);
expect(box.width).toBeGreaterThan(vp.width * 0.8);
await context.close();
});
}
// Main content padding/alignment per viewport
for (const vp of viewports) {
test(`main content padding at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const main = page.locator(selectors.main).first();
const mainCount = await page.locator(selectors.main).count();
if (mainCount > 0) {
const box = await main.boundingBox();
expect(box).not.toBeNull();
// Check for symmetric horizontal padding (allow 1px)
const leftPad = box.x;
const rightPad = vp.width - (box.x + box.width);
expect(Math.abs(leftPad - rightPad)).toBeLessThanOrEqual(2);
}
await context.close();
});
}
// Section spacing audit
for (const vp of viewports) {
test(`section vertical rhythm at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
// Gather all top-level sections and report top/bottom paddings
const sectionInfo = await page.evaluate(() => {
const sections = Array.from(
document.querySelectorAll("body > * section, main > section"),
);
return sections.slice(0, 15).map((s) => {
const cs = getComputedStyle(s);
const rect = s.getBoundingClientRect();
return {
class: s.className?.toString?.().slice(0, 60),
top: Math.round(rect.top),
height: Math.round(rect.height),
paddingTop: cs.paddingTop,
paddingBottom: cs.paddingBottom,
marginTop: cs.marginTop,
marginBottom: cs.marginBottom,
};
});
});
// No section should have 0 vertical padding (visual breathing room)
for (const s of sectionInfo) {
const pt = parseFloat(s.paddingTop) || 0;
const pb = parseFloat(s.paddingBottom) || 0;
// At least one of top/bottom padding should be > 8px for breathing room
// (except narrow edge cases)
if (s.height > 50) {
expect(
Math.max(pt, pb),
`Section "${s.class}" has insufficient vertical padding (pt=${pt}, pb=${pb})`,
).toBeGreaterThan(8);
}
}
await context.close();
});
}
// Card grid alignment at common breakpoints
for (const vp of viewports) {
test(`card alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const cardCount = await page.locator(selectors.cards).count();
if (cardCount < 2) {
test.skip();
return;
}
const boxes = await page
.locator(selectors.cards)
.evaluateAll((els) =>
els
.slice(0, 12)
.map((e) => {
const r = e.getBoundingClientRect();
return {
x: Math.round(r.x),
y: Math.round(r.y),
w: Math.round(r.width),
h: Math.round(r.height),
};
}),
);
// All visible cards should be within the viewport
for (const b of boxes) {
expect(b.x).toBeGreaterThanOrEqual(0);
expect(b.x + b.w).toBeLessThanOrEqual(vp.width + 1);
}
// Group by row (similar y) and verify alignment
const rows = {};
for (const b of boxes) {
const rowKey = Math.round(b.y / 10); // tolerance for sub-row alignment
if (!rows[rowKey]) rows[rowKey] = [];
rows[rowKey].push(b);
}
for (const row of Object.values(rows)) {
if (row.length < 2) continue;
// Same-row items should share a common left or right edge
// (allow 2px tolerance for grid gap math)
const xs = row.map((b) => b.x);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
// All should be left-aligned to the same x OR span the row
// We just want to ensure they're not wildly misaligned
expect(
maxX - minX,
`Cards in same row misaligned: ${JSON.stringify(row)}`,
).toBeLessThanOrEqual(vp.width * 0.95);
}
await context.close();
});
}
// Footer alignment
for (const vp of viewports) {
test(`footer alignment at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const footer = page.locator(selectors.footer).first();
const count = await page.locator(selectors.footer).count();
if (count === 0) {
test.skip();
return;
}
const box = await footer.boundingBox();
expect(box).not.toBeNull();
// Footer should not overflow horizontally
expect(box.x + box.width).toBeLessThanOrEqual(vp.width + 1);
await context.close();
});
}
// Image / media overflow
for (const vp of viewports) {
test(`images fit viewport at ${vp.name} (${vp.width}x${vp.height})`, async ({
browser,
}) => {
const context = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
});
const page = await context.newPage();
await page.goto(site, { waitUntil: "networkidle" });
const overflows = await page.evaluate(() => {
const imgs = Array.from(document.querySelectorAll("img"));
const out = [];
for (const img of imgs) {
const r = img.getBoundingClientRect();
if (r.right > window.innerWidth + 1 || r.width > window.innerWidth + 1) {
out.push({
src: img.src.slice(-60),
width: Math.round(r.width),
right: Math.round(r.right),
});
}
}
return out;
});
expect(overflows.length, `Images overflow: ${JSON.stringify(overflows)}`).toBe(0);
await context.close();
});
}