Skip to content

Commit 30fbe4d

Browse files
committed
Improve a11y test suite and add CI workflow
- Fix path detail URL bug (posts/ → paths/) in a11y.test.ts - Filter axe-core to WCAG 2.0/2.1/2.2 AA rules only - Use soft assertions so all violations surface per page - Add manual-trigger GitHub Actions workflow (.github/workflows/a11y.yml) - Add test results report (docs/a11y-test-results.md) - Update CLAUDE.md with multi-tenant env var and TinaCMS gotchas
1 parent 20fa2bc commit 30fbe4d

File tree

4 files changed

+277
-3
lines changed

4 files changed

+277
-3
lines changed

.github/workflows/a11y.yml

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: Accessibility Tests
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
url:
7+
description: 'URL to test (e.g., https://uss-staging.netlify.app). Leave empty to build and test locally.'
8+
required: false
9+
type: string
10+
11+
jobs:
12+
a11y:
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 30
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: actions/setup-node@v4
20+
with:
21+
node-version: 22
22+
cache: npm
23+
24+
- name: Install dependencies
25+
run: npm ci
26+
27+
- name: Install Playwright chromium
28+
run: npx playwright install --with-deps chromium
29+
30+
# When testing a deployed URL, just fetch config for test scaffolding
31+
- name: Fetch config
32+
if: ${{ inputs.url != '' }}
33+
env:
34+
CONFIG_URL: ${{ secrets.CONFIG_URL }}
35+
run: curl -fsSL "$CONFIG_URL" -o public/config.json
36+
37+
# When no URL provided, do a full static build
38+
- name: Build static site
39+
if: ${{ inputs.url == '' }}
40+
env:
41+
STATIC_BUILD: 'true'
42+
CONFIG_URL: ${{ secrets.CONFIG_URL }}
43+
GITHUB_OWNER: ${{ secrets.CONTENT_GITHUB_OWNER }}
44+
GITHUB_REPO: ${{ secrets.CONTENT_GITHUB_REPO }}
45+
GITHUB_BRANCH: ${{ secrets.CONTENT_GITHUB_BRANCH }}
46+
GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.CONTENT_GITHUB_PERSONAL_ACCESS_TOKEN }}
47+
MONGODB_URI: ${{ secrets.MONGODB_URI }}
48+
MONGODB_NAME: ${{ secrets.MONGODB_NAME }}
49+
MONGODB_COLLECTION_NAME: ${{ secrets.MONGODB_COLLECTION_NAME }}
50+
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
51+
run: npm run build
52+
53+
- name: Start preview server
54+
if: ${{ inputs.url == '' }}
55+
run: npx astro preview &
56+
57+
- name: Wait for server
58+
if: ${{ inputs.url == '' }}
59+
run: |
60+
for i in $(seq 1 30); do
61+
curl -sf http://localhost:4321 > /dev/null 2>&1 && exit 0
62+
sleep 1
63+
done
64+
echo "Server failed to start" && exit 1
65+
66+
- name: Run accessibility tests
67+
env:
68+
A11Y_HOST: ${{ inputs.url || 'http://localhost:4321' }}
69+
STATIC_BUILD: ${{ inputs.url != '' && 'false' || 'true' }}
70+
run: npx playwright test --project=chromium
71+
72+
- name: Upload report
73+
if: always()
74+
uses: actions/upload-artifact@v4
75+
with:
76+
name: a11y-report
77+
path: |
78+
playwright-report/
79+
test-results/
80+
retention-days: 30

CLAUDE.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## What This Is
6+
7+
Core Data Places is a config-driven, multi-tenant Astro website for searching and exploring cultural heritage data. Each deployment is customized via `public/config.json` and a separate GitHub content repo (for TinaCMS-managed pages, posts, paths, branding, and i18n). The same codebase powers multiple projects (USS, ArchNet, etc.) with different configs and content.
8+
9+
## Commands
10+
11+
```bash
12+
npm start # Full dev server (build script + TinaCMS + Astro on port 4321)
13+
npm run build # Production build (validates config, builds TinaCMS admin, builds Astro)
14+
npm run vitest # Run unit tests (vitest)
15+
npm run test-config # Validate config.json schema only
16+
npm run playwright # Run Playwright e2e + a11y tests (requires A11Y_HOST env var)
17+
npx vitest run test/config.test.ts # Single vitest file
18+
npx playwright test --grep "Home page" # Single Playwright test by name
19+
```
20+
21+
`npm start` runs `scripts/build.mjs` first, which: fetches remote config, fetches Core Data field descriptors, clones the GitHub content repo, builds search.json, and copies custom components. This runs on every start.
22+
23+
## Environment Setup
24+
25+
Copy `.env.example` to `.env`. Key variables:
26+
- `CONFIG_URL` or `CONFIG_FILE` — where to get config.json
27+
- `GITHUB_OWNER`/`GITHUB_REPO`/`GITHUB_BRANCH`/`GITHUB_PERSONAL_ACCESS_TOKEN` — content repo
28+
- `TINA_PUBLIC_IS_LOCAL=true` — use local TinaCMS datalayer (recommended for dev)
29+
- `A11Y_HOST` — base URL for Playwright tests (e.g., `http://localhost:4321`)
30+
- `USE_CONTENT_CACHE=true` — skip content loaders on restart (faster dev iteration)
31+
32+
TinaCMS uses port 9000 for its datalayer. Kill stale processes on 9000/4321 if `npm start` fails.
33+
34+
### Multi-Tenant Env Var Gotchas
35+
36+
This repo deploys many client sites (USS, ArchNet, etc.) to Netlify. When `netlify dev` runs, it injects the linked site's env vars into the shell, and `dotenv` won't override them. If your `.env` targets one project but the linked Netlify site targets another, the Netlify values win silently.
37+
38+
**Proper fix:** Use deploy context-scoped env vars. Set `dev` context values on the Netlify site via `netlify env:set VAR --context dev` or the Netlify UI. See the `netlify` skill for details.
39+
40+
**Quick workaround:** Run `npm start` directly instead of `netlify dev` to avoid Netlify env var injection.
41+
42+
### TinaCMS Content Path
43+
44+
Do **not** set `TINA_LOCAL_CONTENT_PATH` when the content repo has no `tina/` directory — TinaCMS will fail with "Unable to find Tina folder". The build script (`build.content.mjs`) clones the content repo automatically; `TINA_LOCAL_CONTENT_PATH` is only needed when developing TinaCMS schemas in a local content repo checkout that includes its own `tina/` directory.
45+
46+
### Switching Projects
47+
48+
When switching between projects (e.g., USS to ArchNet), always clean generated content first:
49+
```bash
50+
rm -rf content/ .tina/
51+
```
52+
`build.content.mjs` uses `fs.cpSync` which merges rather than replaces, so stale files from the previous project persist otherwise.
53+
54+
## Architecture
55+
56+
### Config-Driven Features
57+
58+
`public/config.json` (aliased as `@config`) controls everything:
59+
- `core_data.url` + `core_data.project_ids` — Core Data API endpoint
60+
- `search[]` — each entry creates a `/[lang]/search/[name]` route with its own Typesense index
61+
- `detail_pages.models` — which Core Data models get detail pages (places, people, events, works, items, instances, organizations)
62+
- `content.collections` — which TinaCMS collections are enabled (`paths`, `posts`)
63+
- `layers[]` — map layers (raster, vector, geojson, georeference)
64+
- `i18n.locales` — drives Astro i18n routing
65+
66+
Validated on every build by `test/config.test.ts`.
67+
68+
### Routing
69+
70+
All user-facing routes are under `[lang]/` (Astro i18n, `prefixDefaultLocale: true`):
71+
- `/[lang]/` — home page
72+
- `/[lang]/search/[name]/` — search pages (one per `config.search[]` entry)
73+
- `/[lang]/{places,people,events,works,items,instances,organizations}/[uuid]/` — Core Data detail pages
74+
- `/[lang]/pages/[slug]`, `/[lang]/posts/[slug]`, `/[lang]/paths/[slug]` — TinaCMS content
75+
- `/api/[model]/[uuid]/` — Core Data proxy API
76+
77+
### Key Modules
78+
79+
- **`src/apps/search/map/`** — Map-based search (Peripleo + MapLibre + Typesense). `MapSearchContext.tsx` manages state.
80+
- **`src/apps/search/list/`** — List-based search with pagination.
81+
- **`src/apps/detailPages/`** — Astro components per Core Data model. `RecordDetail.astro` is the shared wrapper.
82+
- **`src/apps/pages/`** — TinaCMS page builder components (Banner, MultiColumn, FreeText, etc.)
83+
- **`src/backend/tina/`** — TinaCMS GraphQL query wrappers (`fetchBranding`, `fetchPages`, `fetchPosts`, etc.)
84+
- **`src/services/coreData/`** — Per-model service classes for Core Data API. `factory.ts` has `getService(name)`.
85+
- **`src/loaders/`** — Astro content collection loaders (only used in static builds).
86+
- **`src/store/`** — Nanostores atoms (`pages.ts`, `notifications.ts`).
87+
88+
### Build Pipeline (`scripts/`)
89+
90+
`build.mjs` orchestrates pre-build steps:
91+
1. `build.config.mjs` — resolves config.json from `CONFIG_URL`, `CONFIG_FILE`, or `config.dev.json`
92+
2. `build.fields.mjs` — fetches Core Data descriptors → writes `src/i18n/userDefinedFields.json`
93+
3. `build.content.mjs` — clones `GITHUB_REPO` → copies `content/` directory (uses `fs.cpSync`, merges not replaces)
94+
4. `build.search.mjs` — writes `src/i18n/search.json` from config
95+
5. `build.components.mjs` — copies `content/components/``src/components/custom/project/`
96+
97+
### Content Repo Pattern
98+
99+
TinaCMS content lives in a **separate GitHub repo** (e.g., `uss-content`). The build script clones it and copies the `content/` directory into the project. In local dev, `TINA_LOCAL_CONTENT_PATH` points to a local checkout. The `content/` directory in this project is gitignored.
100+
101+
Custom hit components can be provided via `content/components/` in the content repo, overriding defaults in `src/components/custom/default/`.
102+
103+
### Generated/Gitignored Files
104+
105+
These are built by `scripts/build.mjs` and should not be committed:
106+
- `content/` — cloned from content repo
107+
- `src/i18n/userDefinedFields.json`, `src/i18n/search.json`
108+
- `src/components/custom/project/`
109+
- `public/config.dev.json`
110+
111+
## TypeScript Path Aliases
112+
113+
`@config``public/config.json`, `@apps/*``src/apps/*`, `@backend/*``src/backend/*`, `@components/*``src/components/*`, `@services/*``src/services/*`, `@store/*``src/store/*`, `@utils/*``src/utils/*`, `@types``src/types.ts`, `@tina/*``tina/__generated__/*`, `@loaders/*``src/loaders/*`, `@i18n/*``src/i18n/*`, `@layouts/*``src/layouts/*`, `@visualizations/*``src/visualizations/*`
114+
115+
## Deployment
116+
117+
Netlify with SSR (server mode by default). Functions in `netlify/functions/tina.ts` handle TinaCMS backend and S3 media uploads. Static build available with `STATIC_BUILD=true`.
118+
119+
## Style
120+
121+
- 2-space indent for `.tsx` and `.astro` files
122+
- Astro components for server-rendered content, React (`.tsx`) with `client:only='react'` or `client:load` for interactive UI
123+
- Detail pages use `server:defer` for deferred SSR
124+
- Tailwind CSS v4 for styling
125+
126+
## Related Skills
127+
128+
Invoke these skills for domain-specific guidance:
129+
130+
- **performant-studio** — Performant Studio product suite (FairData, FairImage, FairCopy), pricing tiers, and product context
131+
- **astro** — Astro framework patterns, configuration, content collections, and conventions
132+
- **netlify** — Netlify site management, deployments, environment variables, and functions
133+
- **tinacms** — TinaCMS configuration, schemas, content collections, and self-hosted setup
134+
- **playwright-a11y** — Playwright E2E testing with embedded WCAG 2.2 AA accessibility checks via axe-core

docs/a11y-test-results.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Accessibility Test Results
2+
3+
**Date:** 2026-02-19
4+
**Engine:** axe-core 4.11 via `@axe-core/playwright`
5+
**Ruleset:** WCAG 2.0/2.1/2.2 Level A and AA only
6+
**Browser:** Chromium
7+
**Target:** USS content deployment (localhost:4321)
8+
9+
## Summary
10+
11+
| Status | Count |
12+
|--------|-------|
13+
| Passed | 6 |
14+
| Failed | 1 |
15+
| Skipped | 7 |
16+
17+
**1 unique WCAG violation** found. The test suite filters to WCAG 2.2 AA criteria and uses soft assertions to report all violations per page without stopping early.
18+
19+
## WCAG Violations
20+
21+
### Critical: `image-alt` — Images must have alternative text
22+
23+
- **WCAG:** 1.1.1 Non-text Content (Level A)
24+
- **Issue:** [#553](https://github.com/performant-software/core-data-places/issues/553)
25+
- **Pages:** `/en/pages/Institutions`, `/en/pages/About`, `/en/` (Home, intermittent due to `server:defer` timing)
26+
- **Element:** `<img>` in Banner component (`src/apps/pages/Banner.astro` line 52)
27+
- **Root cause:** `Banner.astro` uses `alt={imageAlt}`, but when TinaCMS content omits the `imageAlt` field, the `alt` attribute is undefined (missing entirely).
28+
- **Fix:** Default to `alt={imageAlt || ''}` for decorative images, or require alt text in the CMS schema.
29+
30+
## Best Practice Issues (Not WCAG Criteria)
31+
32+
These were found during initial testing with all axe-core rules enabled. They are not WCAG violations but are worth addressing:
33+
34+
- **`page-has-heading-one`** — 6 pages lack a visible `<h1>` at scan time because the heading is inside `Header.astro` with `server:defer`
35+
- **`region`** — Header and footer use `<div>` instead of semantic landmarks (`<header>`, `<nav>`, `<footer>`)
36+
37+
## Skipped Tests
38+
39+
These were skipped because the USS config doesn't include the relevant features:
40+
41+
- Paths page / Path detail (no paths collection)
42+
- Search table view / filters panel / detail panel (search type is `list`, not `map`)
43+
- Gallery / Gallery item (no gallery URL configured)
44+
45+
## Test Changes Made
46+
47+
1. **Bug fix:** `test/browser/a11y.test.ts:59` — changed `posts/` to `paths/` for path detail page URLs
48+
2. **WCAG filter:** `checkPage` now uses `.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'])` to scan only WCAG criteria
49+
3. **Soft assertions:** `expect.soft()` records all violations without stopping, so every page gets fully scanned
50+
51+
## CI Workflow
52+
53+
`.github/workflows/a11y.yml` — manual trigger (`workflow_dispatch`), chromium only, HTML report uploaded as artifact.
54+
55+
Two modes:
56+
- **With URL input:** tests any deployed URL (staging, production, PR preview)
57+
- **Without URL:** builds static site locally and serves with `astro preview`

test/browser/a11y.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ test.describe('Accessibility testing', () => {
5656
const paths = await fetchPaths();
5757

5858
for (const path of paths) {
59-
await page.goto(`posts/${path._sys.filename}`);
59+
await page.goto(`paths/${path._sys.filename}`);
6060
await checkPage(page);
6161
}
6262
});
@@ -198,6 +198,9 @@ test.describe('Accessibility testing', () => {
198198
* @param message
199199
*/
200200
const checkPage = async (page: Page, message: string = '') => {
201-
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
202-
expect(accessibilityScanResults.violations, message || page.url()).toEqual([]);
201+
const accessibilityScanResults = await new AxeBuilder({ page })
202+
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'])
203+
.analyze();
204+
205+
expect.soft(accessibilityScanResults.violations, message || page.url()).toEqual([]);
203206
};

0 commit comments

Comments
 (0)