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(); }); }