-
Notifications
You must be signed in to change notification settings - Fork 0
Add Playwright test infrastructure #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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", | ||
|
|
||
| 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, | ||
| }, | ||
| }); | ||
| 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This test is named 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); | ||
| }); | ||
| }); | ||
| 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"); | ||
| } | ||
| }); | ||
| }); |
| 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(); | ||
| } | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The project exposes
qa:report(playwright show-report), but this config sets the reporter tolistlocally andgithubin CI, which do not generate the HTML report thatshow-reportserves (npx playwright show-report --helpsays it "show HTML report"). In this setup,npm run qa:reportfails with "No report found" unless users manually override reporters, so the documented report workflow is broken by default.Useful? React with 👍 / 👎.