diff --git a/.github/workflows/visual.yml b/.github/workflows/visual.yml new file mode 100644 index 0000000..9875a1e --- /dev/null +++ b/.github/workflows/visual.yml @@ -0,0 +1,43 @@ +name: Visual + +on: + push: + branches: + - master + pull_request: + +jobs: + visual: + name: Visual snapshot tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Build site + run: npm run build + + - name: Run visual snapshot tests + run: npm run test:visual + + - name: Upload failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: visual-test-results + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 6240da8..f2a3f5e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ pnpm-debug.log* # macOS-specific files .DS_Store + +# test artifacts +test-results/ +playwright-report/ diff --git a/README.md b/README.md index 909d7f2..567dcbe 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,56 @@ ## austinabbott.dev A minimalist blog built with Astro and hosted on Cloudflare Pages + +## Visual tests + +Visual snapshot tests catch unintended layout changes from Astro or dependency updates. They compare full-page screenshots of key routes against committed baselines in `tests/visual.spec.ts-snapshots/`. + +Baselines are produced and compared on Linux in CI. Screenshots generated on macOS may differ slightly. + +### First-time setup + +```bash +npm ci +npx playwright install chromium +``` + +Playwright browsers are downloaded separately from npm packages. + +### Run tests + +```bash +npm run test:visual +``` + +This builds the site, serves the production build with `astro preview`, and fails if any screenshot differs from the committed baseline. + +### When CI fails + +1. Open the failed GitHub Actions run for the **Visual snapshot tests** job. +2. Download the `visual-test-results` artifact. It contains actual, expected, and diff images for failed comparisons. +3. Decide whether the change is: + - **Unintended regression** — fix the CSS or layout and push again. + - **Intentional layout change** — update the snapshots (steps below). + +### Update snapshots + +Prefer refreshing baselines from CI so they match Linux rendering: + +1. Download the `visual-test-results` artifact from the failed job. +2. Copy each `*-actual.png` from `test-results/` over the matching file in `tests/visual.spec.ts-snapshots/`. +3. Commit the updated PNGs and push. + +For a local refresh (may still fail CI if fonts/rendering differ): + +```bash +npm run test:visual:update +``` + +Review the changed PNGs, commit them with your layout change, and push. If CI still fails, replace baselines from the CI artifact as above. + +Renovate or Astro dependency bumps may fail this check. Updating snapshots is how you approve a new visual baseline. + +### Required status check + +After this workflow is on `master`, mark **Visual snapshot tests** as a required status check in GitHub branch protection so pull requests cannot merge when screenshots differ. diff --git a/package-lock.json b/package-lock.json index 5cb4980..88e1ec9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,9 @@ "@astrojs/sitemap": "^3.6.0", "astro": "^6.0.0", "typescript": "^6.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.55.0" } }, "node_modules/@astrojs/check": { @@ -865,9 +868,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -884,9 +884,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -903,9 +900,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -922,9 +916,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -941,9 +932,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -960,9 +948,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -979,9 +964,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -998,9 +980,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1017,9 +996,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1042,9 +1018,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1067,9 +1040,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1092,9 +1062,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1117,9 +1084,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1142,9 +1106,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1167,9 +1128,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1192,9 +1150,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1347,6 +1302,22 @@ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", @@ -1460,9 +1431,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1476,9 +1444,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1492,9 +1457,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1508,9 +1470,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1524,9 +1483,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1540,9 +1496,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1556,9 +1509,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1572,9 +1522,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1588,9 +1535,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1604,9 +1548,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1620,9 +1561,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1636,9 +1574,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1652,9 +1587,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5023,6 +4955,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", diff --git a/package.json b/package.json index b795d0a..eb0073a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,12 @@ "build": "astro check && astro build", "preview": "astro preview", "astro": "astro", - "clean": "rm -rf node_modules package-lock.json" + "clean": "rm -rf node_modules package-lock.json", + "test:visual": "playwright test", + "test:visual:update": "playwright test --update-snapshots" + }, + "devDependencies": { + "@playwright/test": "^1.61.1" }, "dependencies": { "@astrojs/check": "^0.9.9", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..be8174a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from "@playwright/test"; + +// Dedicated port so tests never attach to `astro dev` on the default 4321. +const previewUrl = "http://127.0.0.1:4173"; +const previewCommand = "npx astro preview --host 127.0.0.1 --port 4173"; + +export default defineConfig({ + testDir: "./tests", + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [["html", { open: "never" }], ["list"]] : "list", + // Drop project/platform suffixes so macOS baselines match Linux CI. + snapshotPathTemplate: + "{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}", + use: { + baseURL: previewUrl, + trace: "on-first-retry", + }, + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.01, + animations: "disabled", + }, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + // CI builds in a prior workflow step; locally we build here. + command: process.env.CI + ? previewCommand + : `npm run build && ${previewCommand}`, + url: previewUrl, + reuseExistingServer: false, + timeout: 180_000, + }, +}); diff --git a/tests/visual.spec.ts b/tests/visual.spec.ts new file mode 100644 index 0000000..5e7862d --- /dev/null +++ b/tests/visual.spec.ts @@ -0,0 +1,33 @@ +import { expect, test, type Page } from "@playwright/test"; + +const routes = [ + { path: "/", name: "home" }, + { path: "/blog/", name: "blog-index" }, + { path: "/about/", name: "about" }, + { path: "/blog/react-key/", name: "blog-post" }, +] as const; + +const viewports = [ + { name: "desktop", width: 1280, height: 800 }, + { name: "mobile", width: 390, height: 844 }, +] as const; + +async function waitForPageReady(page: Page) { + await page.evaluate(() => document.fonts.ready); +} + +for (const route of routes) { + for (const viewport of viewports) { + test(`${route.name} ${viewport.name}`, async ({ page }) => { + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + await page.goto(route.path); + await waitForPageReady(page); + await expect(page).toHaveScreenshot(`${route.name}-${viewport.name}.png`, { + fullPage: true, + }); + }); + } +} diff --git a/tests/visual.spec.ts-snapshots/about-desktop.png b/tests/visual.spec.ts-snapshots/about-desktop.png new file mode 100644 index 0000000..f022180 Binary files /dev/null and b/tests/visual.spec.ts-snapshots/about-desktop.png differ diff --git a/tests/visual.spec.ts-snapshots/about-mobile.png b/tests/visual.spec.ts-snapshots/about-mobile.png new file mode 100644 index 0000000..020f315 Binary files /dev/null and b/tests/visual.spec.ts-snapshots/about-mobile.png differ diff --git a/tests/visual.spec.ts-snapshots/blog-index-desktop.png b/tests/visual.spec.ts-snapshots/blog-index-desktop.png new file mode 100644 index 0000000..3003980 Binary files /dev/null and b/tests/visual.spec.ts-snapshots/blog-index-desktop.png differ diff --git a/tests/visual.spec.ts-snapshots/blog-index-mobile.png b/tests/visual.spec.ts-snapshots/blog-index-mobile.png new file mode 100644 index 0000000..e5ba151 Binary files /dev/null and b/tests/visual.spec.ts-snapshots/blog-index-mobile.png differ diff --git a/tests/visual.spec.ts-snapshots/blog-post-desktop.png b/tests/visual.spec.ts-snapshots/blog-post-desktop.png new file mode 100644 index 0000000..f5a82f8 Binary files /dev/null and b/tests/visual.spec.ts-snapshots/blog-post-desktop.png differ diff --git a/tests/visual.spec.ts-snapshots/blog-post-mobile.png b/tests/visual.spec.ts-snapshots/blog-post-mobile.png new file mode 100644 index 0000000..c59d42b Binary files /dev/null and b/tests/visual.spec.ts-snapshots/blog-post-mobile.png differ diff --git a/tests/visual.spec.ts-snapshots/home-desktop.png b/tests/visual.spec.ts-snapshots/home-desktop.png new file mode 100644 index 0000000..7d05f16 Binary files /dev/null and b/tests/visual.spec.ts-snapshots/home-desktop.png differ diff --git a/tests/visual.spec.ts-snapshots/home-mobile.png b/tests/visual.spec.ts-snapshots/home-mobile.png new file mode 100644 index 0000000..e89133c Binary files /dev/null and b/tests/visual.spec.ts-snapshots/home-mobile.png differ