From 280f71a57ab3b801e672158b607acc301bc22377 Mon Sep 17 00:00:00 2001
From: Patrick Kollitsch <davidsneighbourdev+gh@gmail.com>
Date: Sat, 06 Jun 2026 13:03:47 +0000
Subject: [PATCH] test: scaffold Playwright + axe testing PoC (#1004)

---
 tests/fixtures/site/content/_index.md       |    5 
 tests/catalog.yaml                          |   22 ++
 tests/coverage-gate.mjs                     |  103 +++++++++
 tests/e2e/copy-button.spec.ts               |   51 ++++
 package-lock.json                           |  120 ++++++++++
 layouts/_partials/site-scripts.html         |   16 +
 .markdownlintignore                         |    2 
 playwright.config.ts                        |   37 +++
 tests/fixtures/site/hugo.toml               |   26 ++
 tests/.gitignore                            |    5 
 tests/support/prepare-site.mjs              |   55 +++++
 tests/README.md                             |   61 +++++
 tests/fixtures/site/content/posts/hello.md  |   16 +
 tests/fixtures/site/content/posts/second.md |    9 
 tests/support/dev-server.mjs                |   62 +++++
 package.json                                |    8 
 tests/e2e/accessibility.spec.ts             |   47 ++++
 tests/fixtures/site/content/posts/_index.md |    6 
 tests/fixtures/site/content/about.md        |    6 
 19 files changed, 643 insertions(+), 14 deletions(-)

diff --git a/.markdownlintignore b/.markdownlintignore
index 1b763b1..faa67a8 100644
--- a/.markdownlintignore
+++ b/.markdownlintignore
@@ -1 +1,3 @@
 CHANGELOG.md
+tests/.playwright/
+tests/fixtures/site/public/
diff --git a/layouts/_partials/site-scripts.html b/layouts/_partials/site-scripts.html
index 9569ead..7b420bd 100644
--- a/layouts/_partials/site-scripts.html
+++ b/layouts/_partials/site-scripts.html
@@ -14,10 +14,18 @@
   (function () {
     "use strict";
 
-    // Reveal the copy buttons now that JavaScript is available.
-    var buttons = document.querySelectorAll(".code-block .code-copy[hidden]");
-    for (var i = 0; i < buttons.length; i++) {
-      buttons[i].hidden = false;
+    // Reveal the copy buttons once the DOM is ready. This script is included in
+    // the <head>, so the code blocks may not exist yet when it first runs.
+    function revealButtons() {
+      var buttons = document.querySelectorAll(".code-block .code-copy[hidden]");
+      for (var i = 0; i < buttons.length; i++) {
+        buttons[i].hidden = false;
+      }
+    }
+    if (document.readyState === "loading") {
+      document.addEventListener("DOMContentLoaded", revealButtons);
+    } else {
+      revealButtons();
     }
 
     function flash(button) {
diff --git a/package-lock.json b/package-lock.json
index 33d8c96..97298a4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,8 +15,10 @@
 				"tachyons": "4.12.0"
 			},
 			"devDependencies": {
+				"@axe-core/playwright": "4.10.2",
 				"@biomejs/biome": "2.4.16",
 				"@github/markdownlint-github": "0.8.0",
+				"@playwright/test": "1.60.0",
 				"@release-it/conventional-changelog": "11.0.0",
 				"@types/node": "25.9.1",
 				"dotenv": "17.4.2",
@@ -31,7 +33,21 @@
 				"markdownlint-rule-title-case-style": "0.4.3",
 				"release-it": "20.0.1",
 				"simple-git-hooks": "2.13.1",
-				"typescript": "6.0.3"
+				"typescript": "6.0.3",
+				"yaml": "2.7.0"
+			}
+		},
+		"node_modules/@axe-core/playwright": {
+			"version": "4.10.2",
+			"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.10.2.tgz",
+			"integrity": "sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==",
+			"dev": true,
+			"license": "MPL-2.0",
+			"dependencies": {
+				"axe-core": "~4.10.3"
+			},
+			"peerDependencies": {
+				"playwright-core": ">= 1.0.0"
 			}
 		},
 		"node_modules/@babel/code-frame": {
@@ -2213,6 +2229,22 @@
 				"url": "https://github.com/phun-ky/typeof?sponsor=1"
 			}
 		},
+		"node_modules/@playwright/test": {
+			"version": "1.60.0",
+			"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
+			"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
+			"dev": true,
+			"license": "Apache-2.0",
+			"dependencies": {
+				"playwright": "1.60.0"
+			},
+			"bin": {
+				"playwright": "cli.js"
+			},
+			"engines": {
+				"node": ">=18"
+			}
+		},
 		"node_modules/@release-it/conventional-changelog": {
 			"version": "11.0.0",
 			"resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-11.0.0.tgz",
@@ -2508,6 +2540,16 @@
 				"postcss": "^8.1.0"
 			}
 		},
+		"node_modules/axe-core": {
+			"version": "4.10.3",
+			"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
+			"integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==",
+			"dev": true,
+			"license": "MPL-2.0",
+			"engines": {
+				"node": ">=4"
+			}
+		},
 		"node_modules/baseline-browser-mapping": {
 			"version": "2.10.21",
 			"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz",
@@ -3906,6 +3948,21 @@
 				"url": "https://github.com/sponsors/rawify"
 			}
 		},
+		"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/get-caller-file": {
 			"version": "2.0.5",
 			"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -4527,6 +4584,23 @@
 				"yaml": "^2.8.4"
 			}
 		},
+		"node_modules/lint-staged/node_modules/yaml": {
+			"version": "2.9.0",
+			"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+			"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+			"dev": true,
+			"license": "ISC",
+			"optional": true,
+			"bin": {
+				"yaml": "bin.mjs"
+			},
+			"engines": {
+				"node": ">= 14.6"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/eemeli"
+			}
+		},
 		"node_modules/listr2": {
 			"version": "10.2.1",
 			"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
@@ -6065,6 +6139,38 @@
 				"pathe": "^2.0.3"
 			}
 		},
+		"node_modules/playwright": {
+			"version": "1.60.0",
+			"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+			"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+			"dev": true,
+			"license": "Apache-2.0",
+			"dependencies": {
+				"playwright-core": "1.60.0"
+			},
+			"bin": {
+				"playwright": "cli.js"
+			},
+			"engines": {
+				"node": ">=18"
+			},
+			"optionalDependencies": {
+				"fsevents": "2.3.2"
+			}
+		},
+		"node_modules/playwright-core": {
+			"version": "1.60.0",
+			"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+			"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+			"dev": true,
+			"license": "Apache-2.0",
+			"bin": {
+				"playwright-core": "cli.js"
+			},
+			"engines": {
+				"node": ">=18"
+			}
+		},
 		"node_modules/postcss": {
 			"version": "8.5.15",
 			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -8150,20 +8256,16 @@
 			}
 		},
 		"node_modules/yaml": {
-			"version": "2.9.0",
-			"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
-			"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+			"version": "2.7.0",
+			"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
+			"integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
 			"dev": true,
 			"license": "ISC",
-			"optional": true,
 			"bin": {
 				"yaml": "bin.mjs"
 			},
 			"engines": {
-				"node": ">= 14.6"
-			},
-			"funding": {
-				"url": "https://github.com/sponsors/eemeli"
+				"node": ">= 14"
 			}
 		},
 		"node_modules/yargs": {
diff --git a/package.json b/package.json
index f447d24..896d02c 100644
--- a/package.json
+++ b/package.json
@@ -31,8 +31,10 @@
 		"tachyons": "4.12.0"
 	},
 	"devDependencies": {
+		"@axe-core/playwright": "4.10.2",
 		"@biomejs/biome": "2.4.16",
 		"@github/markdownlint-github": "0.8.0",
+		"@playwright/test": "1.60.0",
 		"@release-it/conventional-changelog": "11.0.0",
 		"@types/node": "25.9.1",
 		"dotenv": "17.4.2",
@@ -47,7 +49,8 @@
 		"markdownlint-rule-title-case-style": "0.4.3",
 		"release-it": "20.0.1",
 		"simple-git-hooks": "2.13.1",
-		"typescript": "6.0.3"
+		"typescript": "6.0.3",
+		"yaml": "2.7.0"
 	},
 	"scripts": {
 		"hook:commit": "lint-staged --config .lintstagedrc.js",
@@ -63,6 +66,9 @@
 		"test": "node scripts/test-hugo-quickstart.ts",
 		"test:quickstart": "node scripts/test-hugo-quickstart.ts",
 		"test:quickstart:submodule": "node scripts/test-hugo-quickstart.ts --use-submodule",
+		"test:e2e": "playwright test",
+		"test:e2e:ui": "playwright test --ui",
+		"test:coverage-gate": "node tests/coverage-gate.mjs",
 		"update:docs": "git add docs/ && (git diff --cached --quiet || git commit -m \"chore(git): update documentation submodule\")"
 	},
 	"cspell": {
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..c8f8fec
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,37 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const PORT = Number(process.env["ANANKE_TEST_PORT"] || 4321);
+const baseURL = `http://localhost:${PORT}`;
+const isCI = !!process.env["CI"];
+
+/**
+ * Playwright configuration for the Ananke theme.
+ *
+ * The `webServer` builds the test fixture site against the local theme working
+ * tree and serves it statically, so the suite always runs against the current
+ * branch. See tests/support/dev-server.mjs.
+ */
+export default defineConfig({
+	testDir: "./tests/e2e",
+	outputDir: "./tests/.playwright/results",
+	fullyParallel: true,
+	forbidOnly: isCI,
+	retries: isCI ? 2 : 0,
+	reporter: isCI ? [["github"], ["list"]] : "list",
+	use: {
+		baseURL,
+		trace: "on-first-retry",
+	},
+	projects: [
+		{
+			name: "chromium",
+			use: { ...devices["Desktop Chrome"] },
+		},
+	],
+	webServer: {
+		command: "node tests/support/dev-server.mjs",
+		url: baseURL,
+		reuseExistingServer: !isCI,
+		timeout: 120_000,
+	},
+});
diff --git a/tests/.gitignore b/tests/.gitignore
new file mode 100644
index 0000000..479f291
--- /dev/null
+++ b/tests/.gitignore
@@ -0,0 +1,5 @@
+# Built fixture site and Playwright artifacts
+fixtures/site/public/
+fixtures/site/resources/
+fixtures/site/.hugo_build.lock
+.playwright/
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..12a0a7b
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,61 @@
+# Tests
+
+This directory holds the theme's automated tests. It is the proof-of-concept
+for the testing system tracked in the testing epic.
+
+## Layout
+
+```text
+tests/
+├── fixtures/site/      # a minimal Hugo site that uses the theme
+├── e2e/                # Playwright specs (E2E, accessibility, no-JS)
+├── support/            # build-the-fixture + static-server helpers
+├── catalog.yaml        # feature -> test manifest for the coverage gate
+└── coverage-gate.mjs   # fails CI when a param/shortcode has no test
+```
+
+The legacy build/HTML harness still lives at
+`scripts/test-hugo-quickstart.ts` and is being migrated into this structure.
+
+## Running
+
+```bash
+# Browser end-to-end, accessibility, and progressive-enhancement (no-JS) tests.
+# Builds the fixture site against the local theme working tree, serves it, and
+# runs Playwright. Requires browsers: `npx playwright install chromium`.
+npm run test:e2e
+
+# Feature -> test coverage gate: fails if an [ananke] param or a shortcode has
+# no entry in tests/catalog.yaml.
+npm run test:coverage-gate
+
+# Legacy quickstart build/assertion harness.
+npm test
+```
+
+## How the fixture is built
+
+`tests/support/prepare-site.mjs` creates a temporary themes directory with a
+symlink to the repository root and runs Hugo against `tests/fixtures/site`, so
+the suite always exercises the **current** theme code (including uncommitted
+changes) without committing an absolute-path symlink. The built site is served
+by `tests/support/dev-server.mjs`, which Playwright launches via
+`webServer` in `playwright.config.ts`.
+
+## Adding a test for a feature
+
+When you add or change a feature:
+
+1. If it has a config parameter or shortcode, add fixture content that
+   exercises it under `tests/fixtures/site/`.
+2. Add a spec under `tests/e2e/` (interaction, output, or accessibility).
+3. Register it in `tests/catalog.yaml` so the coverage gate is satisfied.
+
+The coverage gate (`npm run test:coverage-gate`) fails if a new `[ananke]`
+parameter or shortcode is not listed in the catalog with at least one test, so
+tests stay in sync with features.
+
+## Accessibility note
+
+The axe suite catches roughly 30-40% of WCAG issues. It is a floor, not a
+guarantee — pair it with manual keyboard and screen-reader checks.
diff --git a/tests/catalog.yaml b/tests/catalog.yaml
new file mode 100644
index 0000000..4e77890
--- /dev/null
+++ b/tests/catalog.yaml
@@ -0,0 +1,22 @@
+# Feature -> test catalog (coverage gate).
+#
+# Every [ananke] parameter and every shortcode in layouts/_shortcodes must be
+# listed here with at least one test reference, or `npm run test:coverage-gate`
+# fails. This forces a test to accompany each new or changed feature.
+#
+# `tests` entries are free-form references (spec files or fixture folders) for
+# humans; the gate only checks that the key is present and has a non-empty list.
+
+params:
+  ananke.show_recent_posts:
+    tests: ["scripts/test-hugo-quickstart.ts"]
+  ananke.show_categories:
+    tests: ["scripts/test-hugo-quickstart.ts"]
+  ananke.copy_code:
+    tests: ["tests/e2e/copy-button.spec.ts"]
+
+shortcodes:
+  form-contact:
+    tests: ["scripts/test-hugo-quickstart.ts"] # TODO: add a dedicated form-contact test
+  page-index:
+    tests: ["scripts/test-hugo-quickstart.ts"] # TODO: add a dedicated page-index test
diff --git a/tests/coverage-gate.mjs b/tests/coverage-gate.mjs
new file mode 100644
index 0000000..540e2a9
--- /dev/null
+++ b/tests/coverage-gate.mjs
@@ -0,0 +1,103 @@
+#!/usr/bin/env node
+/**
+ * Feature -> test coverage gate.
+ *
+ * Fails when a user-facing feature has no test catalogued:
+ *   - every direct key under the [ananke] table in config/_default/params.toml
+ *   - every shortcode in layouts/_shortcodes/
+ * must appear in tests/catalog.yaml with a non-empty `tests` list.
+ *
+ * It also reports catalog entries that no longer match a real param/shortcode
+ * (stale entries), so the catalog stays in sync as features are added, renamed,
+ * or removed.
+ *
+ * Scope note: only the direct [ananke] toggles are gated here (not the deep
+ * [ananke.social.networks.*] catalog). Widen `collectAnankeParams` as the gate
+ * matures.
+ */
+import { readdirSync, readFileSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { parse } from "yaml";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(here, "..");
+
+/** Direct `key = value` entries under the top-level [ananke] table. */
+function collectAnankeParams() {
+	const toml = readFileSync(
+		join(repoRoot, "config", "_default", "params.toml"),
+		"utf8",
+	);
+	const keys = new Set();
+	let inAnanke = false;
+	for (const raw of toml.split("\n")) {
+		const line = raw.trim();
+		if (line.startsWith("[")) {
+			inAnanke = line === "[ananke]";
+			continue;
+		}
+		if (!inAnanke || line === "" || line.startsWith("#")) continue;
+		const eq = line.indexOf("=");
+		if (eq > 0) keys.add(`ananke.${line.slice(0, eq).trim()}`);
+	}
+	return keys;
+}
+
+/** Shortcode names from layouts/_shortcodes/*.html. */
+function collectShortcodes() {
+	const dir = join(repoRoot, "layouts", "_shortcodes");
+	return new Set(
+		readdirSync(dir)
+			.filter((f) => f.endsWith(".html"))
+			.map((f) => f.replace(/\.html$/, "")),
+	);
+}
+
+function catalogued(section) {
+	const out = new Set();
+	for (const [name, value] of Object.entries(section || {})) {
+		if (value && Array.isArray(value.tests) && value.tests.length > 0) {
+			out.add(name);
+		}
+	}
+	return out;
+}
+
+const catalog = parse(readFileSync(join(here, "catalog.yaml"), "utf8")) || {};
+const params = collectAnankeParams();
+const shortcodes = collectShortcodes();
+const catParams = catalogued(catalog.params);
+const catShortcodes = catalogued(catalog.shortcodes);
+
+const errors = [];
+for (const p of params) {
+	if (!catParams.has(p))
+		errors.push(`param '${p}' has no test in tests/catalog.yaml`);
+}
+for (const s of shortcodes) {
+	if (!catShortcodes.has(s))
+		errors.push(`shortcode '${s}' has no test in tests/catalog.yaml`);
+}
+// Stale catalog entries (kept as warnings so renames are noticed promptly).
+const warnings = [];
+for (const p of Object.keys(catalog.params || {})) {
+	if (!params.has(p)) warnings.push(`catalog param '${p}' no longer exists`);
+}
+for (const s of Object.keys(catalog.shortcodes || {})) {
+	if (!shortcodes.has(s))
+		warnings.push(`catalog shortcode '${s}' no longer exists`);
+}
+
+for (const w of warnings) console.warn(`warning: ${w}`);
+if (errors.length > 0) {
+	console.error("\nCoverage gate failed:");
+	for (const e of errors) console.error(`  - ${e}`);
+	console.error(
+		"\nAdd the feature and at least one test reference to tests/catalog.yaml.",
+	);
+	process.exit(1);
+}
+console.log(
+	`Coverage gate passed: ${params.size} ananke param(s), ${shortcodes.size} shortcode(s) all have tests.`,
+);
diff --git a/tests/e2e/accessibility.spec.ts b/tests/e2e/accessibility.spec.ts
new file mode 100644
index 0000000..2821d38
--- /dev/null
+++ b/tests/e2e/accessibility.spec.ts
@@ -0,0 +1,47 @@
+import AxeBuilder from "@axe-core/playwright";
+import { expect, test } from "@playwright/test";
+
+/**
+ * Automated WCAG 2.2 AA accessibility audits across the theme's page types.
+ *
+ * Note: automated tools catch roughly 30-40% of WCAG issues, so this suite is a
+ * floor, not a guarantee — it is paired with a manual checklist (see the
+ * accessibility sub-issue under the testing epic).
+ */
+const PAGES: Record<string, string> = {
+	homepage: "/",
+	"section list": "/posts/",
+	"single page": "/posts/hello/",
+	"standalone page": "/about/",
+	"taxonomy terms": "/tags/",
+	"taxonomy term": "/tags/alpha/",
+	"404": "/404.html",
+};
+
+const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
+
+/**
+ * Page types with a known, tracked WCAG AA contrast failure (Tachyons `gray`
+ * #777 on summary cards and single-page bylines). Marked as expected failures
+ * so the suite is green while the bug is open; when #1015 is fixed these will
+ * start passing and Playwright will flag the stale annotation for removal.
+ */
+const KNOWN_FAILURES = new Set([
+	"section list",
+	"single page",
+	"taxonomy terms",
+	"taxonomy term",
+]);
+
+for (const [name, path] of Object.entries(PAGES)) {
+	test(`${name} has no automatically detectable a11y violations`, async ({
+		page,
+	}) => {
+		if (KNOWN_FAILURES.has(name)) {
+			test.fail(true, "Known WCAG AA contrast issue, tracked in #1015");
+		}
+		await page.goto(path);
+		const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
+		expect(results.violations).toEqual([]);
+	});
+}
diff --git a/tests/e2e/copy-button.spec.ts b/tests/e2e/copy-button.spec.ts
new file mode 100644
index 0000000..e1f2876
--- /dev/null
+++ b/tests/e2e/copy-button.spec.ts
@@ -0,0 +1,51 @@
+import { expect, test } from "@playwright/test";
+
+const POST = "/posts/hello/";
+
+test.describe("copy-to-clipboard code button (#986)", () => {
+	test("wraps fenced code blocks but not inline code", async ({ page }) => {
+		await page.goto(POST);
+		// Exactly one fenced block on this page → one .code-block wrapper.
+		await expect(page.locator(".code-block")).toHaveCount(1);
+		// Inline `code` lives outside a .code-block wrapper.
+		await expect(page.locator(".code-block code")).toHaveCount(1);
+	});
+
+	test("reveals the button when JavaScript runs", async ({ page }) => {
+		await page.goto(POST);
+		const button = page.locator(".code-block .code-copy");
+		await expect(button).toBeVisible();
+		await expect(button).toHaveAttribute("aria-label", /copy/i);
+	});
+
+	test("copies the code and shows feedback", async ({ page, context }) => {
+		await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+		await page.goto(POST);
+
+		const button = page.locator(".code-block .code-copy");
+		await button.click();
+
+		// Feedback state.
+		await expect(button).toHaveClass(/is-copied/);
+		await expect(button.locator(".code-copy-label")).toHaveText("Copied");
+
+		// The clipboard actually holds the code (localhost is a secure context).
+		const clipboard = await page.evaluate(() => navigator.clipboard.readText());
+		expect(clipboard).toContain("copy_code = true");
+	});
+});
+
+test.describe("without JavaScript (progressive enhancement)", () => {
+	test.use({ javaScriptEnabled: false });
+
+	test("shows no copy button but keeps the code", async ({ page }) => {
+		await page.goto(POST);
+		// The button is rendered with the `hidden` attribute and never revealed,
+		// so it is not visible to the user.
+		await expect(page.locator(".code-block .code-copy")).toBeHidden();
+		// The code itself is still present and selectable.
+		await expect(page.locator(".code-block pre")).toContainText(
+			"copy_code = true",
+		);
+	});
+});
diff --git a/tests/fixtures/site/content/_index.md b/tests/fixtures/site/content/_index.md
new file mode 100644
index 0000000..05781f9
--- /dev/null
+++ b/tests/fixtures/site/content/_index.md
@@ -0,0 +1,5 @@
+---
+title: "Ananke Test Fixture"
+---
+
+Homepage introduction for the test fixture site. Recent posts appear below.
diff --git a/tests/fixtures/site/content/about.md b/tests/fixtures/site/content/about.md
new file mode 100644
index 0000000..edca3fe
--- /dev/null
+++ b/tests/fixtures/site/content/about.md
@@ -0,0 +1,6 @@
+---
+title: "About"
+description: "About the test fixture site."
+---
+
+A standalone page used by the accessibility tests.
diff --git a/tests/fixtures/site/content/posts/_index.md b/tests/fixtures/site/content/posts/_index.md
new file mode 100644
index 0000000..f548d9d
--- /dev/null
+++ b/tests/fixtures/site/content/posts/_index.md
@@ -0,0 +1,6 @@
+---
+title: "Posts"
+description: "All posts on the test fixture site."
+---
+
+The posts section, used to exercise list-page rendering.
diff --git a/tests/fixtures/site/content/posts/hello.md b/tests/fixtures/site/content/posts/hello.md
new file mode 100644
index 0000000..e36088a
--- /dev/null
+++ b/tests/fixtures/site/content/posts/hello.md
@@ -0,0 +1,16 @@
+---
+title: "Hello World"
+date: 2026-01-01
+description: "A post with a fenced code block for the copy-button tests."
+categories: ["News"]
+tags: ["alpha", "beta"]
+---
+
+A post body with a fenced code block, used by the copy-button E2E tests.
+
+```toml
+[params.ananke]
+copy_code = true
+```
+
+Inline `code` must not receive a copy button.
diff --git a/tests/fixtures/site/content/posts/second.md b/tests/fixtures/site/content/posts/second.md
new file mode 100644
index 0000000..93bc2c7
--- /dev/null
+++ b/tests/fixtures/site/content/posts/second.md
@@ -0,0 +1,9 @@
+---
+title: "Second Post"
+date: 2026-01-02
+description: "A second post so list and taxonomy pages have more than one entry."
+categories: ["Tech"]
+tags: ["alpha"]
+---
+
+A second post so the homepage list and taxonomy pages have multiple entries.
diff --git a/tests/fixtures/site/hugo.toml b/tests/fixtures/site/hugo.toml
new file mode 100644
index 0000000..158a3b1
--- /dev/null
+++ b/tests/fixtures/site/hugo.toml
@@ -0,0 +1,26 @@
+baseURL = "http://localhost:4321/"
+title = "Ananke Test Fixture"
+theme = "ananke"
+enableRobotsTXT = true
+
+[taxonomies]
+category = "categories"
+tag = "tags"
+
+[params]
+mainSections = ["posts"]
+
+[params.ananke]
+show_recent_posts = true
+show_categories = true
+copy_code = true
+
+[[menus.main]]
+name = "Posts"
+pageRef = "/posts/"
+weight = 10
+
+[[menus.main]]
+name = "About"
+pageRef = "/about/"
+weight = 20
diff --git a/tests/support/dev-server.mjs b/tests/support/dev-server.mjs
new file mode 100644
index 0000000..d70632d
--- /dev/null
+++ b/tests/support/dev-server.mjs
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+/**
+ * Build the fixture site, then serve it as a static site for Playwright.
+ *
+ * Used as the Playwright `webServer.command`: it builds first (so tests always
+ * run against the current theme), then serves `tests/fixtures/site/public`
+ * until the process is killed.
+ */
+import { createReadStream, existsSync, statSync } from "node:fs";
+import { createServer } from "node:http";
+import { extname, join, normalize } from "node:path";
+import { buildFixtureSite } from "./prepare-site.mjs";
+
+const PORT = Number(process.env.ANANKE_TEST_PORT || 4321);
+
+const TYPES = {
+	".html": "text/html; charset=utf-8",
+	".css": "text/css; charset=utf-8",
+	".js": "text/javascript; charset=utf-8",
+	".json": "application/json; charset=utf-8",
+	".svg": "image/svg+xml",
+	".xml": "application/xml; charset=utf-8",
+	".txt": "text/plain; charset=utf-8",
+	".woff2": "font/woff2",
+};
+
+const root = buildFixtureSite();
+
+function resolvePath(urlPath) {
+	const clean = normalize(decodeURIComponent(urlPath.split("?")[0])).replace(
+		/^(\.\.[/\\])+/,
+		"",
+	);
+	let filePath = join(root, clean);
+	if (existsSync(filePath) && statSync(filePath).isDirectory()) {
+		filePath = join(filePath, "index.html");
+	}
+	return filePath;
+}
+
+const server = createServer((req, res) => {
+	let filePath = resolvePath(req.url || "/");
+	if (!existsSync(filePath)) {
+		// Serve Hugo's generated 404 page so /404.html and unknown routes work.
+		filePath = join(root, "404.html");
+		if (!existsSync(filePath)) {
+			res.statusCode = 404;
+			res.end("Not found");
+			return;
+		}
+		res.statusCode = req.url === "/404.html" ? 200 : 404;
+	}
+	res.setHeader(
+		"Content-Type",
+		TYPES[extname(filePath)] || "application/octet-stream",
+	);
+	createReadStream(filePath).pipe(res);
+});
+
+server.listen(PORT, () => {
+	console.log(`Fixture site served at http://localhost:${PORT}/`);
+});
diff --git a/tests/support/prepare-site.mjs b/tests/support/prepare-site.mjs
new file mode 100644
index 0000000..35606f3
--- /dev/null
+++ b/tests/support/prepare-site.mjs
@@ -0,0 +1,55 @@
+#!/usr/bin/env node
+/**
+ * Build the test fixture site against the *local* theme working tree.
+ *
+ * Hugo resolves a classic theme from `<themesDir>/<themeName>`. We create a
+ * temporary themes directory containing a symlink `ananke -> <repo root>` so
+ * the build exercises the current branch (including uncommitted changes)
+ * without committing an absolute-path symlink. Output goes to
+ * `tests/fixtures/site/public`, which the static server then serves.
+ */
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(here, "..", "..");
+const siteDir = resolve(here, "..", "fixtures", "site");
+const publicDir = join(siteDir, "public");
+
+export function buildFixtureSite() {
+	const themesDir = mkdtempSync(join(tmpdir(), "ananke-test-themes-"));
+	try {
+		symlinkSync(repoRoot, join(themesDir, "ananke"), "dir");
+		rmSync(publicDir, { recursive: true, force: true });
+		const result = spawnSync(
+			"hugo",
+			[
+				"--source",
+				siteDir,
+				"--themesDir",
+				themesDir,
+				"--destination",
+				publicDir,
+				"--environment",
+				"production",
+				"--logLevel",
+				"warn",
+			],
+			{ stdio: "inherit" },
+		);
+		if (result.status !== 0) {
+			throw new Error(`hugo build failed with code ${result.status}`);
+		}
+	} finally {
+		rmSync(themesDir, { recursive: true, force: true });
+	}
+	return publicDir;
+}
+
+// Allow running directly: `node tests/support/prepare-site.mjs`
+if (import.meta.url === `file://${process.argv[1]}`) {
+	buildFixtureSite();
+}

--
Gitblit v1.10.0