diff --git a/.github/workflows/tests-axe.yml b/.github/workflows/tests-axe.yml
new file mode 100644
index 0000000000..15a62c5954
--- /dev/null
+++ b/.github/workflows/tests-axe.yml
@@ -0,0 +1,52 @@
+name: Accessibility tests
+on:
+ workflow_dispatch:
+ pull_request:
+ paths-ignore:
+ - ".github/workflows/**"
+ - "*.md"
+ - "integration/**"
+ - AUTHORS
+
+concurrency:
+ group: ci-${{ github.ref }}-a11y
+ cancel-in-progress: true
+
+env:
+ MIX_ENV: dev
+ MBTA_API_BASE_URL: ${{ secrets.MBTA_API_BASE_URL }}
+ MBTA_API_KEY: ${{ secrets.MBTA_API_KEY }}
+
+jobs:
+ axe_test:
+ name: Run accessibility tests against Docker container
+ timeout-minutes: 30
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .tool-versions
+ cache: npm
+ cache-dependency-path: package-lock.json
+ - name: Configure AWS Credentials
+ uses: aws-actions/configure-aws-credentials@v4
+ with:
+ role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
+ aws-region: us-east-1
+ mask-aws-account-id: true
+ - run: docker compose --file deploy/local.yml run a11y-test
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: axe-report-${{ github.sha }}
+ path: playwright-report
+ retention-days: 30
+ - name: show docker container logs
+ run: docker compose --file deploy/local.yml logs
+ if: ${{ failure() }}
diff --git a/deploy/dev.yml b/deploy/dev.yml
index 9703089432..6beab132aa 100644
--- a/deploy/dev.yml
+++ b/deploy/dev.yml
@@ -33,10 +33,13 @@ services:
- MIX_ENV=dev
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
+ - MBTA_API_BASE_URL=${MBTA_API_BASE_URL}
+ - MBTA_API_KEY=${MBTA_API_KEY}
- PORT=4002
- REDIS_HOST=10.0.0.11
- REDIS_PORT=6379
- WEBPACK_PORT=8092
+ - WARM_CACHES=false
expose:
- 4002
ports:
@@ -66,10 +69,13 @@ services:
- MIX_ENV=dev
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
+ - MBTA_API_BASE_URL=${MBTA_API_BASE_URL}
+ - MBTA_API_KEY=${MBTA_API_KEY}
- PORT=4003
- REDIS_HOST=10.0.0.11
- REDIS_PORT=6379
- WEBPACK_PORT=8093
+ - WARM_CACHES=false
expose:
- 4003
ports:
diff --git a/deploy/dotcom/dev/1/Dockerfile b/deploy/dotcom/dev/1/Dockerfile
index 187a1652c0..623f04a885 100644
--- a/deploy/dotcom/dev/1/Dockerfile
+++ b/deploy/dotcom/dev/1/Dockerfile
@@ -6,14 +6,15 @@ RUN apt-get install -y nodejs
WORKDIR /app
-COPY mix.exs .
-COPY mix.lock .
-COPY package.json .
-COPY assets/package.json ./assets/package.json
+COPY . .
RUN mix local.hex --force
RUN mix local.rebar --force
-RUN mix deps.get
-RUN npm --prefix assets install --package-lock-only --ignore-scripts --no-save --audit false --fund false --loglevel verbose
-CMD elixir --sname dotcom1 --cookie foobarbaz -S mix phx.server
+EXPOSE 4001
+
+CMD mix deps.get \
+ && npm ci \
+ && npm --prefix assets run webpack:build:react \
+ && mix phx.digest \
+ && elixir --sname dotcom1 --cookie foobarbaz -S mix phx.server
diff --git a/deploy/dotcom/dev/2/Dockerfile b/deploy/dotcom/dev/2/Dockerfile
index 89fd8bd119..1eb0fb02ed 100644
--- a/deploy/dotcom/dev/2/Dockerfile
+++ b/deploy/dotcom/dev/2/Dockerfile
@@ -8,5 +8,5 @@ WORKDIR /app
COPY mix.exs .
COPY mix.lock .
-RUN mix deps.get
-CMD elixir --sname dotcom2 --cookie foobarbaz -S mix phx.server
+CMD mix deps.get \
+ && elixir --sname dotcom2 --cookie foobarbaz -S mix phx.server
diff --git a/deploy/local.yml b/deploy/local.yml
new file mode 100644
index 0000000000..91afa493fc
--- /dev/null
+++ b/deploy/local.yml
@@ -0,0 +1,63 @@
+services:
+ redis:
+ image: grokzen/redis-cluster
+ network_mode: host
+ environment:
+ - INITIAL_PORT=30001
+ - REDIS_CLUSTER_IP=0.0.0.0
+ - IP=0.0.0.0
+ healthcheck:
+ test: ["CMD", "redis-cli", "-p", "30001", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ start_period: 5s
+ volumes:
+ - ../:/app
+ dotcom:
+ network_mode: host
+ depends_on:
+ redis:
+ condition: service_started
+ build:
+ context: ../
+ dockerfile: ./deploy/dotcom/dev/1/Dockerfile
+ environment:
+ - MIX_ENV=dev
+ - CMS_API_BASE_URL=https://live-mbta.pantheonsite.io
+ - OPEN_TRIP_PLANNER_URL=http://otp2-local.mbtace.com
+ - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
+ - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
+ - MBTA_API_BASE_URL=${MBTA_API_BASE_URL}
+ - MBTA_API_KEY=${MBTA_API_KEY}
+ - PORT=4001
+ - WEBPACK_PORT=8092
+ - WARM_CACHES=false
+ - REDIS_HOST=localhost
+ - REDIS_PORT=30001
+ expose:
+ - 4001
+ healthcheck:
+ test: curl --fail http://localhost:4001/_health || exit 1
+ interval: 60s
+ retries: 20
+ start_period: 240s
+ timeout: 60s
+ volumes:
+ - ../:/app
+ a11y-test:
+ image: mcr.microsoft.com/playwright:v1.42.1-jammy
+ healthcheck:
+ disable: true
+ depends_on:
+ dotcom:
+ condition: service_healthy
+ volumes:
+ - ../:/app
+ working_dir: /app
+ entrypoint: /bin/sh
+ command: -c "npx playwright test axe"
+ network_mode: host
+ environment:
+ - CI=true
+ - PLAYWRIGHT_HTML_OPEN=never
diff --git a/integration/e2e_tests/a11y-report-template.html b/integration/e2e_tests/a11y-report-template.html
new file mode 100644
index 0000000000..16ffa422c9
--- /dev/null
+++ b/integration/e2e_tests/a11y-report-template.html
@@ -0,0 +1,298 @@
+
+
+
+
+
+
+
+
+ axe-core test results
+
+
+
+
+
+
+ Tested at
+
+
+
+
+
+
+
+
+
+ Failed
+
+
+
+
+
+
+
+
+
+
+ Needs review
+
+
+
+
+
+ These results were aborted and require further testing. This can happen either
+ because of technical restrictions to what the rule can test, or because a javascript error occurred.
+
+
+
+
+
+
+
+ Passed
+
+
+
+
+
+
+
+
+
+ Other checks
+
+
+
+
+
+ These results indicate which rules did not run because no matching content was
+ found on the page. For example, with no video, those rules won't run.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Show nodes
+
+
+ − Hide nodes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Target:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/integration/e2e_tests/axe.spec.js b/integration/e2e_tests/axe.spec.js
new file mode 100644
index 0000000000..210118907d
--- /dev/null
+++ b/integration/e2e_tests/axe.spec.js
@@ -0,0 +1,128 @@
+const { test, expect } = require("@playwright/test");
+const AxeBuilder = require('@axe-core/playwright').default;
+const fs = require("fs");
+const path = require("path");
+
+async function writeHTMLReport(pagePath, axeResult) {
+ const reportName = `axe-report-${pagePath.replaceAll("/", "-")}-${axeResult.timestamp}.html`;
+ const filePath = path.join(__dirname, "a11y-report-template.html");
+ const html = await fs.promises.readFile(filePath, { encoding: 'utf8' });
+ const $ = require('cheerio').load(html);
+ $('body').append(``);
+ const rendered = $.html();
+ const reportPath = path.join(__dirname, reportName);
+ fs.writeFileSync(reportPath, rendered);
+ return { reportName, reportPath };
+}
+
+const axeTest = test.extend({
+ makeAxeBuilder: async ({ page }, use, testInfo) => {
+ const makeAxeBuilder = () => new AxeBuilder({ page })
+ .withTags(['wcag22aa']);
+
+ await use(makeAxeBuilder);
+ }
+});
+
+function runAxeTest(pagePath) {
+ axeTest(`${pagePath} landing page`, async ({ page, makeAxeBuilder }, testInfo) => {
+ // default localhost:4001, specify HOST environment variable to change baseURL
+ await page.goto(`${pagePath}`);
+
+ // Avoid repeatedly surfacing errors from the page template by only
+ // testing the main content area unless we're testing the homepage.
+ const axe = await makeAxeBuilder();
+ if (pagePath !== "/") axe.include('main');
+
+ // Analyze the page!
+ const axeResult = await axe.analyze();
+
+ // Attach screenshots of target HTML elements noted in the test failures
+ const screenshots = await Promise.all(axeResult.violations
+ .flatMap(({ id, nodes }) => {
+ return nodes.flatMap(async ({ target }, index) => {
+ const shot = await page.locator(target[0]).screenshot();
+ return { shot, shotName: `${id}-${index}` };
+ })
+ })
+ );
+ screenshots.forEach(async ({ shot, shotName }) => {
+ await testInfo.attach(shotName, { body: shot, contentType: 'image/png' });
+ });
+
+ // Create custom HTML report from the axe output JSON and attach to test
+ const { reportName, reportPath } = await writeHTMLReport(pagePath, axeResult)
+ await testInfo.attach(reportName, { path: reportPath });
+ fs.unlinkSync(reportPath);
+
+ expect(axeResult.violations.length).toEqual(0);
+ });
+}
+
+axeTest.describe('has no automatically detectable accessibility issues', () => {
+ /**
+ * Top-level pages from the main navigation.
+ */
+ [
+ "/",
+ "/schedules/subway",
+ "/schedules/bus",
+ "/schedules/commuter-rail",
+ "/schedules/ferry",
+ "/accessibility/the-ride",
+ "/trip-planner",
+ "/alerts",
+ "/parking",
+ "/bikes",
+ "/guides",
+ "/holidays",
+ "/accessibility",
+ "/transit-near-me",
+ "/stops",
+ "/destinations",
+ "/maps",
+ "/fares",
+ "/fares/subway-fares",
+ "/fares/bus-fares",
+ "/fares/commuter-rail-fares",
+ "/fares/ferry-fares",
+ "/fares/charliecard-store",
+ "/fares/charliecard",
+ "/fares/retail-sales-locations",
+ "/customer-support",
+ "/customer-support/lost-and-found",
+ "/language-services",
+ "/transit-police",
+ "/transit-police/see-something-say-something",
+ "/mbta-at-a-glance",
+ "/leadership",
+ "/history",
+ "/financials",
+ "/events",
+ "/news",
+ "/policies",
+ "/safety",
+ "/quality-compliance-oversight",
+ "/careers",
+ "/pass-program",
+ "/business",
+ "/innovation",
+ "/engineering/design-standards-and-guidelines",
+ "/sustainability",
+ "/projects"
+ ].forEach(runAxeTest);
+
+ /**
+ * Other specific pages and sub-pages to test. In the future, can write tests
+ * for different application states and interactions.
+ */
+ [
+ "/schedules/Red/line",
+ "/schedules/Orange/alerts",
+ "/schedules/111/line",
+ "/schedules/CR-Worcester/timetable",
+ "/schedules/Boat-F1",
+ "/stops/place-north",
+ "/stops/1"
+ ].forEach(runAxeTest);
+});
diff --git a/lib/dotcom/react/react.ex b/lib/dotcom/react/react.ex
index f43faa3bbd..ad71f8edc4 100644
--- a/lib/dotcom/react/react.ex
+++ b/lib/dotcom/react/react.ex
@@ -77,13 +77,18 @@ defmodule Dotcom.React do
def dev_build(nil, _), do: :ok
def dev_build(path, cmd_fn) do
- {_, 0} =
- cmd_fn.(
- "npm",
- ["run", "webpack:build:react"],
- cd: path
- )
-
- :ok
+ case cmd_fn.(
+ "npm",
+ ["run", "webpack:build:react"],
+ cd: path
+ ) do
+ {_, 0} ->
+ :ok
+ {output, _} ->
+ _ =
+ Logger.warning(fn -> "react_renderer build error #{inspect(output)}" end)
+
+ :ok
+ end
end
end
diff --git a/package-lock.json b/package-lock.json
index 729273248a..b4679ce5a3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"redis": "4.6.13"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.9.1",
"lint-staged": "^15.2.2",
"prettier": "3.2.5"
}
@@ -1198,6 +1199,18 @@
"tslib": "^2.3.1"
}
},
+ "node_modules/@axe-core/playwright": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.9.1.tgz",
+ "integrity": "sha512-8m4WZbZq7/aq7ZY5IG8GqV+ZdvtGn/iJdom+wBg+iv/3BAOBIfNQtIu697a41438DzEEyptXWmC3Xl5Kx/o9/g==",
+ "dev": true,
+ "dependencies": {
+ "axe-core": "~4.9.1"
+ },
+ "peerDependencies": {
+ "playwright-core": ">= 1.0.0"
+ }
+ },
"node_modules/@babel/parser": {
"version": "7.23.6",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz",
@@ -3918,6 +3931,15 @@
"uuid": "dist/bin/uuid"
}
},
+ "node_modules/axe-core": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.9.1.tgz",
+ "integrity": "sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/axios": {
"version": "1.6.8",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz",
diff --git a/package.json b/package.json
index 801e41e451..92d49f064c 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"redis": "4.6.13"
},
"devDependencies": {
+ "@axe-core/playwright": "^4.9.1",
"lint-staged": "^15.2.2",
"prettier": "3.2.5"
},
diff --git a/playwright.config.js b/playwright.config.js
index 6738c3b223..5df45e0d34 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -18,6 +18,7 @@ module.exports = defineConfig({
baseURL: process.env.HOST ? `https://${process.env.HOST}` : 'http://localhost:4001',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
+ screenshot: "on"
},
/* set the expect timeout to 30s */
expect: { timeout: 30000 },