Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,9 @@ pnpm-debug.log*

# CSpell cache
.cspellcache
/test-results

# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
31 changes: 30 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -101,4 +106,28 @@ lint-fix:

# Validate: runs all validation checks (lint + spellcheck + build + links)
validate:
npm run validate:all
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
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Configure an HTML reporter for qa:report

The project exposes qa:report (playwright show-report), but this config sets the reporter to list locally and github in CI, which do not generate the HTML report that show-report serves (npx playwright show-report --help says it "show HTML report"). In this setup, npm run qa:report fails with "No report found" unless users manually override reporters, so the documented report workflow is broken by default.

Useful? React with 👍 / 👎.


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,
},
});
84 changes: 84 additions & 0 deletions tests/accessibility.spec.ts
Original file line number Diff line number Diff line change
@@ -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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Assert skip link presence before validating its target

This test is named skip to content link exists, but it only performs assertions inside if (skipLinkExists), so a page with no skip link passes without failure. That creates a false negative in the accessibility suite and can let a regression of a core keyboard-navigation affordance ship undetected.

Useful? React with 👍 / 👎.

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);
});
});
94 changes: 94 additions & 0 deletions tests/content.spec.ts
Original file line number Diff line number Diff line change
@@ -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("<?xml");
expect(content).toContain("<rss");
});

test("sitemap exists and is valid XML", async ({ page }) => {
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("<?xml");
expect(content).toContain("urlset");
});

test("external links have security attributes", async ({ page }) => {
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");
}
});
});
53 changes: 53 additions & 0 deletions tests/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading
Loading