diff --git a/Makefile b/Makefile index d5f5003e..83f96c25 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,27 @@ else FixPath = $1 endif +E2E_CONTAINER = e2e + serve: up $(SOURCE_DIR)/node_modules @docker compose exec $(CONTAINER) sh -c "npm run dev" +test-e2e: up-e2e + @echo Installing JS dependencies in the e2e container if needed. + @docker compose exec $(E2E_CONTAINER) sh -c "[ -d node_modules/@playwright/test ] || npm install" + @echo Running Playwright end-to-end tests. + @docker compose exec $(E2E_CONTAINER) sh -c "npm run test:e2e"; status=$$?; \ + printf '\nTo view the HTML report from Docker, run: make report-e2e\n'; \ + printf '(Ignore the "npx playwright show-report" hint above -- that is the non-Docker command.)\n'; \ + exit $$status + +report-e2e: up-e2e + @echo Serving the Playwright HTML report at http://localhost:9323 -- press Ctrl-C to stop. + @docker compose exec $(E2E_CONTAINER) sh -c "npx playwright show-report --host 0.0.0.0 --port 9323" + +up-e2e: + @docker compose --profile e2e up --detach $(E2E_CONTAINER) + up: @docker compose up --detach @@ -76,6 +94,6 @@ clean-js-modules: clean: clean-js-dist clean-js-modules -.PHONY: serve up down build shell dist version \ +.PHONY: serve test-e2e report-e2e up up-e2e down build shell dist version \ clean clean-astro-content clean-js-dist clean-js-modules \ .FORCE diff --git a/README.md b/README.md index a36d621e..602964be 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,66 @@ The next step is to determine which development environment you would like to us * For instance, [`Makefile` support for VS Code](https://devblogs.microsoft.com/cppblog/now-announcing-makefile-support-in-visual-studio-code/) 3. Run `make serve` to launch the container, install the dependencies and run the development server. +#### Running the end-to-end tests in Docker + +The Playwright tests run in a dedicated container (the official Playwright image, with the browsers and their system libraries baked in). It is gated behind the `e2e` Compose profile, so it is not built or started by `make serve` — you only pay for it when you run the tests. You do **not** need the site running (locally or via `make serve`) first; the tests start their own dev server inside the container. + +1. Run `make test-e2e`. This builds/starts the `e2e` container, installs its dependencies the first time, and runs the Playwright suite. +2. Run `make report-e2e` to view the HTML report from the last run. It is served from the container at (press Ctrl-C to stop) — no local Playwright install required. + +The e2e container keeps its own `node_modules` in a Docker volume rather than sharing the host's. This is required because Rollup/esbuild ship per-OS native binaries: a macOS host `node_modules` cannot run in the Linux container. It also means running the tests will not disturb the `node_modules` you use for local development. + ### Setting up a Development Environment using Node. 1. Install the [LTS version of Node](https://nodejs.org/en/download/prebuilt-installer/current) on your development machine. 2. Run `npm install` from the `site` directory to install the JS dependencies. -3. Run `npm run dev` from the `site` directory to run the development server. \ No newline at end of file +3. Run `npm run dev` from the `site` directory to run the development server. + +#### Running the end-to-end tests with Node + +The Playwright tests need a browser. Without Docker, install it once with: + +```sh +npm run playwright:check +``` + +This runs `playwright install --with-deps`, which downloads the browser binaries. The `--with-deps` flag also installs the required system libraries on Linux (it is a no-op on macOS/Windows and may prompt for `sudo` on Linux). Then run the suite with: + +```sh +npm run test:e2e # run the tests headless +npm run test:e2e:ui # run with the Playwright UI +npm run test:e2e:report # open the HTML report from the last run +``` + +## Troubleshooting + +### Switching between `make serve` (Docker) and `npm run dev` (local) breaks the build + +If you run the app **both** ways, you may hit an error like: + +``` +Error: Cannot find module @rollup/rollup-linux-arm64-gnu +``` + +(or the `darwin`/`win32` equivalent, or `sh: 1: astro: not found`). + +**Why:** `make serve` runs the app in a Linux container, while `npm run dev` runs it +directly on your machine (e.g. macOS). Both share the **same** `astro/node_modules` +folder, but Rollup and esbuild install **per-OS native binaries**. Whichever +environment installed dependencies last wins, so the other one can no longer find the +binary built for its platform. + +**Fix:** reinstall dependencies for the environment you are switching *to* — you do +not need to (and on macOS often cannot) delete `node_modules` first; reinstalling +overwrites the platform-specific packages in place. + +- Switching to **local** (`npm run dev`): run `npm install` from the `astro` directory. +- Switching to **Docker** (`make serve`): reinstall inside the container, e.g. + `docker compose exec website sh -c "npm install"`. + +If you really want to clear `node_modules` and Docker Desktop reports "Permission +denied" deleting it from macOS, remove its contents from inside the container instead: +`docker compose exec website sh -c "rm -rf node_modules/*"`, then reinstall. + +> The Playwright e2e container is **not** affected — it keeps its own `node_modules` +> in a Docker volume, so `make test-e2e` works regardless of what is installed locally. diff --git a/astro/.gitignore b/astro/.gitignore index 03f20c64..9f31a541 100644 --- a/astro/.gitignore +++ b/astro/.gitignore @@ -24,3 +24,8 @@ pnpm-debug.log* .vscode # Local Netlify folder .netlify + +# Playwright +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/astro/Dockerfile b/astro/Dockerfile index f0f786dd..c9fa34d7 100644 --- a/astro/Dockerfile +++ b/astro/Dockerfile @@ -8,3 +8,16 @@ WORKDIR /website/astro EXPOSE 4321 ENTRYPOINT ["tail", "-f", "/dev/null"] + +############################### +# End-to-end test image. Uses the official Playwright image so the browsers and +# their system libraries are baked in and version-matched to @playwright/test. +# Keep this tag in sync with the resolved @playwright/test version in +# astro/package-lock.json (currently 1.61.1). If a test run reports a version +# mismatch, bump this tag to match and rebuild (`make build`). +FROM mcr.microsoft.com/playwright:v1.61.1-noble AS e2e + +WORKDIR /website/astro + +EXPOSE 4321 +ENTRYPOINT ["tail", "-f", "/dev/null"] diff --git a/astro/package-lock.json b/astro/package-lock.json index 9e8951e9..c483bdc5 100644 --- a/astro/package-lock.json +++ b/astro/package-lock.json @@ -31,6 +31,8 @@ }, "devDependencies": { "@astrojs/check": "^0.9.9", + "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.61.1", "@types/lodash-es": "^4.17.12", "prettier": "^3.2.5", "prettier-plugin-astro": "^0.14.0" @@ -321,6 +323,19 @@ "yaml": "^2.8.3" } }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -3743,6 +3758,22 @@ "integrity": "sha512-fz0NOYUEYXtg1TBaPEEvtcBq3FfmLFuTe1VZw4M8sTWX129br5dguu3M15+plOQnc181ShYe67RfwhKgK89VnA==", "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/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -5341,6 +5372,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -11777,6 +11818,53 @@ "pathe": "^2.0.3" } }, + "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/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/astro/package.json b/astro/package.json index e485e4e7..06fa8673 100644 --- a/astro/package.json +++ b/astro/package.json @@ -8,7 +8,11 @@ "build": "astro build", "preview": "astro preview", "astro": "astro", - "astro:check": "astro check --tsconfig tsconfig.json" + "astro:check": "astro check --tsconfig tsconfig.json", + "playwright:check": "npx playwright install --with-deps", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "npx playwright show-report" }, "dependencies": { "@astrojs/mdx": "^5.0.6", @@ -34,6 +38,8 @@ }, "devDependencies": { "@astrojs/check": "^0.9.9", + "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.61.1", "@types/lodash-es": "^4.17.12", "prettier": "^3.2.5", "prettier-plugin-astro": "^0.14.0" diff --git a/astro/playwright.config.ts b/astro/playwright.config.ts new file mode 100644 index 00000000..aa7420cd --- /dev/null +++ b/astro/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + // In the Docker e2e container, never auto-open/serve the report after a run: + // it would bind to the container's localhost (unreachable via the port map) + // and block the run. View it explicitly there with `make report-e2e`. Local + // (non-Docker) runs keep the convenient auto-open on failure. + reporter: [["html", { open: process.env.DOCKER_E2E ? "never" : "on-failure" }]], + use: { + baseURL: "http://localhost:4321", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npm run dev", + url: "http://localhost:4321", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/astro/tests/e2e/blog-authors.spec.ts b/astro/tests/e2e/blog-authors.spec.ts new file mode 100644 index 00000000..cb92c7be --- /dev/null +++ b/astro/tests/e2e/blog-authors.spec.ts @@ -0,0 +1,51 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +test.describe("Blog authors index (/blog/authors/)", () => { + test("renders author names as strings, not [object Object]", async ({ page }) => { + await page.goto("/blog/authors/"); + + await expect(page.locator("body")).not.toContainText("[object Object]"); + await expect( + page.getByRole("link", { name: /Rachael Bradley Montgomery/i }), + ).toBeVisible(); + }); + + test("axe accessibility scan (informational, not gating)", async ({ page }) => { + await page.goto("/blog/authors/"); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + }); +}); + +test.describe("Author detail page (/blog/authors/rachael-bradley-montgomery/)", () => { + const path = "/blog/authors/rachael-bradley-montgomery/"; + + test("header shows formatted author name, not [object Object]", async ({ page }) => { + await page.goto(path); + + await expect(page.locator("body")).not.toContainText("[object Object]"); + await expect(page.locator("body")).toContainText( + "Blogs by Rachael Bradley Montgomery", + ); + }); + + test("breadcrumbs show ancestors and formatted author name as current page", async ({ page }) => { + await page.goto(path); + + const nav = page.getByRole("navigation", { name: "Breadcrumb" }); + await expect(nav).toBeVisible(); + + await expect(nav.getByRole("link")).toHaveText(["Home", "Blog", "Authors"]); + + const current = nav.locator('[aria-current="page"]'); + await expect(current).toHaveText("Blogs by Rachael Bradley Montgomery"); + await expect(current).not.toContainText("[object Object]"); + }); + + test("axe accessibility scan (informational, not gating)", async ({ page }) => { + await page.goto(path); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + }); +}); diff --git a/docker-compose.yml b/docker-compose.yml index a3294f18..c2a8916b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,3 +10,30 @@ services: - 4322:4322 volumes: - '.:/website' + + # End-to-end (Playwright) tests. Gated behind the `e2e` profile so it is not + # built or started by a plain `docker compose up` / `make serve`. The tests + # start their own dev server inside this container. + e2e: + container_name: website-e2e + profiles: + - e2e + build: + context: ./astro + target: e2e + # Tells playwright.config.ts not to auto-open/serve the report after a run + # (it would bind container-localhost and block); use `make report-e2e`. + environment: + - DOCKER_E2E=1 + # 9323 is the port `playwright show-report` serves on (see `make report-e2e`). + ports: + - 9323:9323 + volumes: + - '.:/website' + # Isolate node_modules in a container-private volume so the host's macOS + # binaries don't leak in (rollup/esbuild ship per-OS native modules). The + # e2e container installs its own Linux deps here via `make test-e2e`. + - 'e2e-node-modules:/website/astro/node_modules' + +volumes: + e2e-node-modules: