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. + + + +
+
+
+ + + + + + + + + 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 },