From ebefa43ba7d598167456bdf6e01b114391e3adb3 Mon Sep 17 00:00:00 2001 From: plx Date: Sun, 15 Mar 2026 13:55:57 -0500 Subject: [PATCH] Add Playwright test infrastructure Add end-to-end test suite covering navigation, accessibility, content rendering, and responsive design. Tests run across Chromium, Firefox, WebKit, and mobile viewports. Includes: - playwright.config.ts with multi-browser + mobile setup - Navigation tests (page loads, routing, 404, consistency) - Accessibility tests (heading structure, alt text, links, lang, skip link, duplicate IDs) - Content tests (page content, RSS, sitemap, external link security) - Responsive tests (horizontal scroll, font size, touch targets, reflow) - npm scripts (qa, qa:headed, qa:ui, qa:debug, qa:report, qa:codegen) - justfile commands (qa, qa-headed, qa-ui, qa-debug, qa-report, qa-codegen, setup) - Updated .gitignore for Playwright artifacts Co-Authored-By: Claude Opus 4.6 --- .gitignore | 7 ++- justfile | 31 +++++++++++- package.json | 8 +++- playwright.config.ts | 52 ++++++++++++++++++++ tests/accessibility.spec.ts | 84 +++++++++++++++++++++++++++++++++ tests/content.spec.ts | 94 +++++++++++++++++++++++++++++++++++++ tests/navigation.spec.ts | 53 +++++++++++++++++++++ tests/responsive.spec.ts | 83 ++++++++++++++++++++++++++++++++ 8 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/accessibility.spec.ts create mode 100644 tests/content.spec.ts create mode 100644 tests/navigation.spec.ts create mode 100644 tests/responsive.spec.ts diff --git a/.gitignore b/.gitignore index 860981e..a44363d 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,9 @@ pnpm-debug.log* # CSpell cache .cspellcache -/test-results + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/justfile b/justfile index 4276b5a..8962449 100644 --- a/justfile +++ b/justfile @@ -79,6 +79,11 @@ clean: install: npm install +# Setup: full project setup including dependencies and Playwright browsers +setup: + npm install + npx playwright install + # Spellcheck: checks spelling in source files spellcheck: npm run spellcheck @@ -101,4 +106,28 @@ lint-fix: # Validate: runs all validation checks (lint + spellcheck + build + links) validate: - npm run validate:all \ No newline at end of file + npm run validate:all + +# QA: runs all Playwright QA tests +qa: + npm run qa + +# QA-headed: runs Playwright tests with visible browser +qa-headed: + npm run qa:headed + +# QA-ui: opens Playwright UI for interactive testing +qa-ui: + npm run qa:ui + +# QA-debug: runs Playwright tests in debug mode +qa-debug: + npm run qa:debug + +# QA-report: shows Playwright test report +qa-report: + npm run qa:report + +# QA-codegen: opens Playwright code generator +qa-codegen: + npm run qa:codegen \ No newline at end of file diff --git a/package.json b/package.json index e44d20d..3e665c0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,13 @@ "validate:links": "node scripts/validate-links.js", "validate:all": "npm run lint && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links", "test:ci": "npm run lint && npm run spellcheck && npm run build && npm run spellcheck:html && npm run validate:links", - "test:ci:verbose": "echo '🔍 Running CI validation locally...' && npm run lint && echo '✓ Linting passed' && npm run spellcheck && echo '✓ Source spell check passed' && npm run build && echo '✓ Build succeeded' && npm run spellcheck:html && echo '✓ HTML spell check passed' && npm run validate:links && echo '✓ Link validation passed' && echo '✅ All CI checks passed!'" + "test:ci:verbose": "echo '🔍 Running CI validation locally...' && npm run lint && echo '✓ Linting passed' && npm run spellcheck && echo '✓ Source spell check passed' && npm run build && echo '✓ Build succeeded' && npm run spellcheck:html && echo '✓ HTML spell check passed' && npm run validate:links && echo '✓ Link validation passed' && echo '✅ All CI checks passed!'", + "qa": "playwright test --ignore-snapshots", + "qa:headed": "playwright test --headed --ignore-snapshots", + "qa:ui": "playwright test --ui", + "qa:debug": "playwright test --debug", + "qa:report": "playwright show-report", + "qa:codegen": "playwright codegen http://localhost:4321" }, "dependencies": { "@astrojs/check": "^0.9.4", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a6c23c4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,52 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + + use: { + baseURL: process.env.BASE_URL || "http://localhost:4321", + trace: "on-first-retry", + screenshot: "only-on-failure", + launchOptions: { + args: [ + "--disable-dev-shm-usage", + ...(process.env.CI ? ["--no-sandbox", "--disable-setuid-sandbox"] : []), + ], + }, + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + { + name: "Mobile Chrome", + use: { ...devices["Pixel 5"] }, + }, + { + name: "Mobile Safari", + use: { ...devices["iPhone 12"] }, + }, + ], + + webServer: { + command: "npm run build && npm run preview", + url: "http://localhost:4321", + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/tests/accessibility.spec.ts b/tests/accessibility.spec.ts new file mode 100644 index 0000000..4901d61 --- /dev/null +++ b/tests/accessibility.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Accessibility", () => { + test("home page has proper heading structure", async ({ page }) => { + await page.goto("/"); + + const h1Count = await page.locator("h1").count(); + expect(h1Count).toBe(1); + + const headingLevels = await page.evaluate(() => { + const headings = Array.from(document.querySelectorAll("h1, h2, h3, h4, h5, h6")); + return headings.map((h) => parseInt(h.tagName.substring(1))); + }); + + expect(headingLevels.length).toBeGreaterThan(0); + + for (let i = 1; i < headingLevels.length; i++) { + const levelDiff = headingLevels[i] - headingLevels[i - 1]; + expect(levelDiff).toBeLessThanOrEqual(1); + } + }); + + test("all images have alt text", async ({ page }) => { + await page.goto("/"); + + const images = await page.locator("img").all(); + for (const img of images) { + const alt = await img.getAttribute("alt"); + expect(alt).toBeDefined(); + } + }); + + test("links have descriptive text", async ({ page }) => { + await page.goto("/"); + + const links = await page.locator("a").all(); + for (const link of links) { + const text = await link.textContent(); + const ariaLabel = await link.getAttribute("aria-label"); + const title = await link.getAttribute("title"); + + expect( + (text && text.trim().length > 0) || + (ariaLabel && ariaLabel.trim().length > 0) || + (title && title.trim().length > 0), + ).toBeTruthy(); + } + }); + + test("page has proper language attribute", async ({ page }) => { + await page.goto("/"); + + const htmlLang = await page.locator("html").getAttribute("lang"); + expect(htmlLang).toBe("en"); + }); + + test("skip to content link exists", async ({ page }) => { + await page.goto("/"); + + const skipLink = page.locator("a.skip-link, a[href=\"#main-content\"]").first(); + const skipLinkExists = await skipLink.count() > 0; + + if (skipLinkExists) { + const href = await skipLink.getAttribute("href"); + const targetId = href?.replace("#", ""); + if (targetId) { + const target = page.locator(`#${targetId}`); + await expect(target).toBeAttached(); + } + } + }); + + test("no duplicate IDs on page", async ({ page }) => { + await page.goto("/"); + + const ids = await page.evaluate(() => { + const elements = Array.from(document.querySelectorAll("[id]")); + return elements.map((el) => el.id); + }); + + const uniqueIds = new Set(ids); + expect(ids.length).toBe(uniqueIds.size); + }); +}); diff --git a/tests/content.spec.ts b/tests/content.spec.ts new file mode 100644 index 0000000..39972de --- /dev/null +++ b/tests/content.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Content", () => { + test("home page displays expected content", async ({ page }) => { + await page.goto("/"); + + const h1 = page.locator("h1").first(); + await expect(h1).toBeVisible(); + + const bodyText = await page.locator("body").textContent(); + expect(bodyText?.length).toBeGreaterThan(100); + }); + + test("blog page lists blog posts", async ({ page }) => { + await page.goto("/blog"); + await expect(page.locator("h1")).toContainText("Blog"); + + const bodyText = await page.locator("body").textContent(); + expect(bodyText?.length).toBeGreaterThan(50); + }); + + test("briefs page shows categories", async ({ page }) => { + await page.goto("/briefs"); + await expect(page.locator("h1")).toContainText("Briefs"); + + const bodyText = await page.locator("body").textContent(); + expect(bodyText?.length).toBeGreaterThan(50); + }); + + test("projects page displays projects", async ({ page }) => { + await page.goto("/projects"); + await expect(page.locator("h1")).toContainText("Projects"); + + const bodyText = await page.locator("body").textContent(); + expect(bodyText?.length).toBeGreaterThan(50); + }); + + test("about page has content", async ({ page }) => { + await page.goto("/about"); + await expect(page.locator("h1")).toContainText("About"); + + const bodyText = await page.locator("body").textContent(); + expect(bodyText?.length).toBeGreaterThan(100); + }); + + test("RSS feed exists and is valid XML", async ({ page }) => { + const response = await page.goto("/rss.xml"); + expect(response?.status()).toBe(200); + + const contentType = response?.headers()["content-type"]; + expect(contentType).toMatch(/xml|rss/); + + const content = await response?.text(); + expect(content).toContain(" { + const response = await page.goto("/sitemap-0.xml"); + expect(response?.status()).toBe(200); + + const contentType = response?.headers()["content-type"]; + expect(contentType).toMatch(/xml/); + + const content = await response?.text(); + expect(content).toContain(" { + await page.goto("/"); + + const siteHostname = new URL(page.url()).hostname; + const externalLinks = await page.locator("a[href^=\"http\"]").all(); + + for (const link of externalLinks) { + const href = await link.getAttribute("href"); + if (href) { + try { + const linkHostname = new URL(href).hostname; + if (linkHostname === siteHostname) continue; + } catch { + // Invalid URL + } + } + + const target = await link.getAttribute("target"); + expect(target).toBe("_blank"); + + const rel = await link.getAttribute("rel"); + expect(rel).toContain("noopener"); + } + }); +}); diff --git a/tests/navigation.spec.ts b/tests/navigation.spec.ts new file mode 100644 index 0000000..7819acf --- /dev/null +++ b/tests/navigation.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Navigation", () => { + test("home page loads successfully", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/Dispatches/i); + }); + + test("can navigate to blog", async ({ page }) => { + await page.goto("/"); + await page.click("a[href=\"/blog\"]"); + await expect(page).toHaveURL(/.*\/blog/); + await expect(page.locator("h1")).toContainText("Blog"); + }); + + test("can navigate to briefs", async ({ page }) => { + await page.goto("/"); + await page.click("a[href=\"/briefs\"]"); + await expect(page).toHaveURL(/.*\/briefs/); + await expect(page.locator("h1")).toContainText("Briefs"); + }); + + test("can navigate to projects", async ({ page }) => { + await page.goto("/"); + await page.click("a[href=\"/projects\"]"); + await expect(page).toHaveURL(/.*\/projects/); + await expect(page.locator("h1")).toContainText("Projects"); + }); + + test("can navigate to about", async ({ page }) => { + await page.goto("/"); + await page.click("a[href=\"/about\"]"); + await expect(page).toHaveURL(/.*\/about/); + await expect(page.locator("h1")).toContainText("About"); + }); + + test("404 page exists", async ({ page }) => { + const response = await page.goto("/nonexistent-page"); + expect(response?.status()).toBe(404); + }); + + test("navigation is consistent across pages", async ({ page }) => { + const pages = ["/", "/blog", "/briefs", "/projects", "/about"]; + + for (const pagePath of pages) { + await page.goto(pagePath); + await expect(page.locator("nav a[href=\"/blog\"]")).toBeVisible(); + await expect(page.locator("nav a[href=\"/briefs\"]")).toBeVisible(); + await expect(page.locator("nav a[href=\"/projects\"]")).toBeVisible(); + await expect(page.locator("nav a[href=\"/about\"]")).toBeVisible(); + } + }); +}); diff --git a/tests/responsive.spec.ts b/tests/responsive.spec.ts new file mode 100644 index 0000000..363b043 --- /dev/null +++ b/tests/responsive.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Responsive Design", () => { + const viewports = [ + { name: "mobile", width: 375, height: 667 }, + { name: "tablet", width: 768, height: 1024 }, + { name: "desktop", width: 1920, height: 1080 }, + ]; + + for (const viewport of viewports) { + test(`home page renders without horizontal scroll on ${viewport.name}`, async ({ page }) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto("/"); + + await expect(page.locator("body")).toBeVisible(); + + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > document.documentElement.clientWidth; + }); + + expect(hasHorizontalScroll).toBeFalsy(); + }); + + test(`navigation works on ${viewport.name}`, async ({ page }) => { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto("/"); + + const navLinks = page.locator("nav a, header a"); + const navLinkCount = await navLinks.count(); + expect(navLinkCount).toBeGreaterThan(0); + }); + } + + test("text is readable on mobile", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto("/"); + + const bodyFontSize = await page.evaluate(() => { + const body = document.body; + const fontSize = window.getComputedStyle(body).fontSize; + return parseInt(fontSize); + }); + + expect(bodyFontSize).toBeGreaterThanOrEqual(14); + }); + + test("touch targets are appropriately sized on mobile", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto("/"); + + const buttons = await page.locator("button").all(); + + for (const element of buttons) { + const box = await element.boundingBox(); + + if (box && box.width > 0 && box.height > 0) { + const minSize = 32; + expect(box.width).toBeGreaterThanOrEqual(minSize); + expect(box.height).toBeGreaterThanOrEqual(minSize); + } + } + }); + + test("content reflows properly on narrow viewports", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + await page.goto("/"); + + const overflowElements = await page.evaluate(() => { + const allElements = Array.from(document.querySelectorAll("*")); + return allElements + .filter((el) => { + const rect = el.getBoundingClientRect(); + return rect.right > window.innerWidth; + }) + .map((el) => ({ + tag: el.tagName, + class: el.className, + })); + }); + + expect(overflowElements.length).toBeLessThan(3); + }); +});