Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/a11y.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Accessibility E2E Tests

on:
pull_request:
push:
branches:
- main
- staging

jobs:
cypress:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install
run: npm ci

- name: Run E2E (Cypress + Axe)
env:
NEXT_PUBLIC_DMS: ${{ secrets.NEXT_PUBLIC_DMS }}
run: npm run test:e2e
30 changes: 30 additions & 0 deletions a11y.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: CI

on:
pull_request:
push:
branches:
- main
- staging

jobs:
cypress:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install
run: npm ci

- name: Run E2E (Cypress + Axe)
env:
NEXT_PUBLIC_DMS: ${{ secrets.NEXT_PUBLIC_DMS }}
run: npm run test:e2e
25 changes: 25 additions & 0 deletions cypress.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineConfig } from "cypress";
import fs from "fs";
import path from "path";

type RouteFile = { routes: string[] };

function loadRoutes(): string[] {
const filePath = path.resolve(process.cwd(), "public/__routes.json");
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as RouteFile;
return parsed.routes ?? [];
}

export default defineConfig({
e2e: {
baseUrl: "http://localhost:3000",
supportFile: "cypress/support/e2e.ts",
specPattern: "cypress/e2e/**/*.cy.{ts,tsx,js,jsx}",
video: false,
setupNodeEvents(on, config) {
config.env.routes = loadRoutes();
return config;
},
},
});
74 changes: 74 additions & 0 deletions cypress/e2e/a11y-all-pages.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { logA11yViolations } from "../support/axe-logger";

const routes = (Cypress.env("routes") as string[]) ?? [];

describe("Accessibility (WCAG 2.2 AA) – per page", () => {
before(() => {
expect(routes.length, "routes discovered").to.be.greaterThan(0);
});

routes.forEach((route) => {
const url = String(route);

it(`has no a11y violations: ${url}`, () => {
const consoleErrors: string[] = [];

cy.on("window:before:load", (win) => {
cy.stub(win.console, "error").callsFake((...args: unknown[]) => {
consoleErrors.push(args.map(String).join(" "));
});
});

cy.request({
url,
failOnStatusCode: false,
followRedirect: true,
}).then((resp) => {
if (resp.status < 200 || resp.status >= 300) {
cy.log(`Skipping ${url} (status ${resp.status})`);
return;
}

cy.visit(url, { failOnStatusCode: false });
cy.get("main", { timeout: 10000 }).should("exist");
cy.contains("Not Found", { matchCase: false }).should("not.exist");

cy.injectAxe();
cy.checkA11y(
undefined,
{
runOnly: {
type: "tag",
values: ["wcag2aa", "wcag21aa", "wcag22aa"],
},
},
(violations) => {
if (!violations?.length) return;

logA11yViolations(violations);

const safe =
url.replaceAll("/", "_").replaceAll("@", "_at_") || "home";
cy.screenshot(`a11y-${safe}`, { capture: "viewport" });

throw new Error(
`A11y violations found on ${url}: ${violations.length}`
);
}
);

cy.then(() => {
const meaningful = consoleErrors.filter(
(m) => !m.includes("ResizeObserver loop limit exceeded")
);

if (meaningful.length) {
// eslint-disable-next-line no-console
console.table(meaningful);
throw new Error(`Console errors on ${url}`);
}
});
});
});
});
});
30 changes: 30 additions & 0 deletions cypress/support/axe-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Result } from "axe-core";


export function logA11yViolations(violations: Result[]) {
if (!violations?.length) return;


console.log(`\nA11y violations: ${violations.length}\n`);

violations.forEach((v) => {

console.log(`\n[${v.impact ?? "impact?"}] ${v.id}: ${v.help}`);
if (v.helpUrl) {

console.log(`Help: ${v.helpUrl}`);
}

v.nodes.forEach((node, idx) => {
const selector = node.target?.[0] || "(no selector)";

console.log(` Node ${idx + 1}: ${selector}`);
if (node.failureSummary) {

console.log(` Failure: ${node.failureSummary}`);
}

console.log(` HTML: ${node.html}\n`);
});
});
}
1 change: 1 addition & 0 deletions cypress/support/e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "cypress-axe";
173 changes: 173 additions & 0 deletions docs/accessibility/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Accessibility checks (developer guide)

This project includes **automated accessibility checks** using **Cypress + Axe**.

The goal is to:

* Catch common accessibility issues early
* Fail PRs when regressions are introduced
* Keep the setup simple and repeatable across projects

---

## What runs automatically

On every **pull request** and **push**:

* Routes are generated automatically
* Cypress opens each route
* Axe scans the page using **WCAG 2.2 AA rules**
* The build fails if any violation is found

---

## How routes are generated

Routes are generated by:

```
scripts/generate-routes.mjs
```

This script creates:

```
public/__routes.json
```

### Static routes

The script:

* Scans the **Next.js App Router** (`src/app` or `app`)
* Looks for `page.tsx / page.jsx / page.js`

Example output:

```
/
/groups
/search
/organizations
```

---

## CKAN dataset routes (dynamic pages)

To also scan dataset pages, the script fetches **one dataset from CKAN**.

Using:

* `NEXT_PUBLIC_DMS` (CKAN base URL)
* `package_search` API

It adds:

* `/@org-name/dataset-name`
* `/@org-name/dataset-name/resource-id` (first resource only)

This ensures:

* Dataset pages are covered
* Resource pages are covered
* Tests stay fast and deterministic

---

## How accessibility tests work

Tests live in:

```
cypress/e2e/a11y-all-pages.cy.ts
```

For each generated route:

* Cypress visits the page
* Axe is injected
* WCAG rules are executed:

* `wcag2aa`
* `wcag21aa`
* `wcag22aa`

If a violation is found:

* The test fails
* Detailed logs are printed:

* Rule ID (e.g. `color-contrast`)
* Impact level
* CSS selectors
* HTML snippets

---

## Example failure output

You may see errors like:

* `color-contrast`
* `aria-required-attr`
* `label`

This usually points to:

* Low contrast text
* Missing labels
* Invalid ARIA usage

The console output shows **exact selectors** so the issue can be fixed quickly.

---

## Running locally

### Run everything

```bash
npm run test:e2e
```

### Generate routes only

```bash
npm run routes:gen
```

---

## Environment variables

| Variable | Purpose |
| --------------------------------- | ---------------------------------- |
| `NEXT_PUBLIC_DMS` | CKAN base URL |

---

## CI behavior

In CI:

* Tests run automatically on PRs
* Test status is visible in GitHub
* PRs cannot be merged if accessibility checks fail

---

## Summary

* Routes are generated automatically
* Dataset + resource pages included
* WCAG 2.2 AA rules enforced
* Fails fast on PRs


## References

- Cypress documentation: https://docs.cypress.io/
- cypress-axe: https://github.com/component-driven/cypress-axe
- axe-core: https://github.com/dequelabs/axe-core
- WCAG 2.2 (W3C): https://www.w3.org/TR/WCAG22/
File renamed without changes.
File renamed without changes.
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"readMore": "Read more",
"readLess": "Read less",
"error" : "Something went wrong.",
"skipToContent": "Skip to main content",
"reset": "Reset"
},

Expand Down
1 change: 1 addition & 0 deletions messages/sv.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"readMore": "Läs mer",
"readLess": "Läs mindre",
"error": "Något gick fel.",
"skipToContent": "Hoppa till huvudinnehåll",
"reset": "Återställ"
},

Expand Down
Loading