diff --git a/.github/actions/test-types.json b/.github/actions/test-types.json index a71cbe2b9..bc8439978 100644 --- a/.github/actions/test-types.json +++ b/.github/actions/test-types.json @@ -2,6 +2,7 @@ "accessibility", "api", "event", + "ui-accessibility", "ui-component", "ui-routing-component", "ui-e2e", diff --git a/frontend/src/components/forms/ChooseMessageOrder/ChooseMessageOrder.tsx b/frontend/src/components/forms/ChooseMessageOrder/ChooseMessageOrder.tsx index eee6de6ba..8020af0a7 100644 --- a/frontend/src/components/forms/ChooseMessageOrder/ChooseMessageOrder.tsx +++ b/frontend/src/components/forms/ChooseMessageOrder/ChooseMessageOrder.tsx @@ -1,3 +1,5 @@ +/* eslint-disable jsx-a11y/alt-text */ +/* eslint-disable @next/next/no-img-element */ 'use client'; import { useActionState, useState } from 'react'; diff --git a/package-lock.json b/package-lock.json index c1ff3b91a..f6b2c6425 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16121,6 +16121,27 @@ "node": ">=18.0.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.0.tgz", + "integrity": "sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ==", + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@axe-core/playwright/node_modules/axe-core": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -37607,11 +37628,13 @@ "@aws-sdk/client-sqs": "3.911.0", "@aws-sdk/client-ssm": "3.911.0", "@aws-sdk/lib-dynamodb": "3.911.0", + "@axe-core/playwright": "^4.11.0", "@faker-js/faker": "^9.9.0", "@nhsdigital/nhs-notify-event-schemas-template-management": "*", "@playwright/test": "^1.51.1", "async-mutex": "^0.5.0", "aws-amplify": "^6.13.6", + "axe-core": "^4.11.0", "date-fns": "^4.1.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-base": "^15.0.0", @@ -39067,6 +39090,15 @@ "npm": ">=9.0.0" } }, + "tests/test-team/node_modules/axe-core": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", + "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "tests/test-team/node_modules/eslint-plugin-playwright": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.2.2.tgz", diff --git a/scripts/tests/test.mk b/scripts/tests/test.mk index 1c6a541dc..202b3c044 100644 --- a/scripts/tests/test.mk +++ b/scripts/tests/test.mk @@ -23,6 +23,9 @@ test-coverage: # Evaluate code coverage from scripts/test/coverage @Testing test-accessibility: # Run tests from scripts/tests/accessibility.sh @Testing make _test name="accessibility" +test-ui-accessibility: # Run tests from scripts/tests/ui-accessibility.sh @Testing + make _test name="ui-accessibility" + test-ui-routing-component: # Run tests from scripts/tests/ui-routing-component.sh @Testing make _test name="ui-routing-component" @@ -50,6 +53,7 @@ test: # Run all the test tasks @Testing test-lint \ test-typecheck \ test-coverage \ + test-ui-accessibility \ test-ui-component \ test-ui-e2e \ test-api \ @@ -73,6 +77,7 @@ ${VERBOSE}.SILENT: \ test-coverage \ test-lint \ test-typecheck \ + test-ui-accessibility \ test-ui-component \ test-api \ test-ui-e2e \ diff --git a/scripts/tests/ui-accessibility.sh b/scripts/tests/ui-accessibility.sh new file mode 100755 index 000000000..d139b5d5f --- /dev/null +++ b/scripts/tests/ui-accessibility.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +npx playwright install --with-deps > /dev/null +cd tests/test-team +TEST_EXIT_CODE=0 +npm run test:accessibility || TEST_EXIT_CODE=$? +echo "TEST_EXIT_CODE=$TEST_EXIT_CODE" + +mkdir -p ../acceptance-test-report +cp -r ./playwright-report ../acceptance-test-report +[[ -e test-results ]] && cp -r ./test-results ../acceptance-test-report + +exit $TEST_EXIT_CODE diff --git a/tests/test-team/config/accessibility/accessibility.config.ts b/tests/test-team/config/accessibility/accessibility.config.ts new file mode 100644 index 000000000..3f3509536 --- /dev/null +++ b/tests/test-team/config/accessibility/accessibility.config.ts @@ -0,0 +1,56 @@ +import path from 'node:path'; +import { defineConfig, devices } from '@playwright/test'; +import baseConfig from '../playwright.config'; + +const buildCommand = [ + 'INCLUDE_AUTH_PAGES=true', + 'npm run build && npm run start', +].join(' '); + +export default defineConfig({ + ...baseConfig, + fullyParallel: true, + timeout: 60_000, // 30 seconds in the playwright default + expect: { + timeout: 10_000, // default is 5 seconds. After creating and previewing sometimes the load is slow on a cold start + }, + projects: [ + { + name: 'accessibility:setup', + testMatch: 'ui.setup.ts', + use: { + baseURL: 'http://localhost:3000', + ...devices['Desktop Chrome'], + headless: true, + screenshot: 'only-on-failure', + }, + }, + { + name: 'accessibility', + testMatch: '*.accessibility.spec.ts', + use: { + screenshot: 'only-on-failure', + baseURL: 'http://localhost:3000', + ...devices['Desktop Chrome'], + headless: true, + storageState: path.resolve(__dirname, '../.auth/user.json'), + }, + dependencies: ['accessibility:setup'], + teardown: 'accessibility:teardown', + }, + { + name: 'accessibility:teardown', + testMatch: 'ui.teardown.ts', + }, + ], + /* Run your local dev server before starting the tests */ + webServer: { + timeout: 4 * 60 * 1000, // 4 minutes + command: buildCommand, + cwd: path.resolve(__dirname, '../../../..'), + url: 'http://localhost:3000/templates/create-and-submit-templates', + reuseExistingServer: !process.env.CI, + stderr: 'pipe', + stdout: 'pipe', + }, +}); diff --git a/tests/test-team/fixtures/accessibility-analyze.ts b/tests/test-team/fixtures/accessibility-analyze.ts new file mode 100644 index 000000000..cde5ae3f8 --- /dev/null +++ b/tests/test-team/fixtures/accessibility-analyze.ts @@ -0,0 +1,56 @@ +import { test as base, Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import { TemplateMgmtBasePage } from 'pages/template-mgmt-base-page'; +import { expect } from '@playwright/test'; + +type Analyze = ( + page: T, + opts?: { + id?: string; + beforeAnalyze?: (page: T) => Promise; + } +) => Promise; + +type AccessibilityFixture = { + analyze: Analyze; +}; + +const DISABLED_RULES = [ + /* We don't have control over NHS colours. + * Axe decides the page is 5.75 ratio and wcag2aaa expects 7:1 + */ + 'color-contrast-enhanced', +]; + +const makeAxeBuilder = (page: Page) => + new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag2aaa']) + .disableRules(DISABLED_RULES); + +export const test = base.extend({ + analyze: async ({ baseURL, page }, use) => { + const analyze: Analyze = async (pageUnderTest, opts) => { + const { id, beforeAnalyze } = opts ?? {}; + + await pageUnderTest.loadPage(id); + + if (beforeAnalyze) { + await beforeAnalyze(pageUnderTest); + } + + const pageUrlSegment = ( + pageUnderTest.constructor as typeof TemplateMgmtBasePage + ).pageUrlSegment; + + await expect(page).toHaveURL( + new RegExp(`${baseURL}/templates/${pageUrlSegment}(.*)`) // eslint-disable-line security/detect-non-literal-regexp + ); + + const results = await makeAxeBuilder(page).analyze(); + + expect(results.violations).toEqual([]); + }; + + await use(analyze); + }, +}); diff --git a/tests/test-team/package.json b/tests/test-team/package.json index 7faa46336..a8e1b872f 100644 --- a/tests/test-team/package.json +++ b/tests/test-team/package.json @@ -11,6 +11,8 @@ "@faker-js/faker": "^9.9.0", "@nhsdigital/nhs-notify-event-schemas-template-management": "*", "@playwright/test": "^1.51.1", + "@axe-core/playwright": "^4.11.0", + "axe-core": "^4.11.0", "async-mutex": "^0.5.0", "aws-amplify": "^6.13.6", "date-fns": "^4.1.0", @@ -30,6 +32,7 @@ "scripts": { "lint": "eslint .", "lint:fix": "npm run lint -- --fix", + "test:accessibility": "playwright test --project accessibility -c config/accessibility/accessibility.config.ts", "test:api": "playwright test --project api -c config/api/api.config.ts", "test:e2e": "playwright test --project e2e -c config/e2e/e2e.config.ts", "test:event": "playwright test -c config/event/event.config.ts", diff --git a/tests/test-team/pages/routing/index.ts b/tests/test-team/pages/routing/index.ts new file mode 100644 index 000000000..8052b1bfb --- /dev/null +++ b/tests/test-team/pages/routing/index.ts @@ -0,0 +1,6 @@ +export * from './campaign-id-required-page'; +export * from './choose-message-order-page'; +export * from './choose-templates-page'; +export * from './create-message-plan-page'; +export * from './message-plans-page'; +export * from './invalid-message-plan-page'; diff --git a/tests/test-team/template-mgmt-accessibility/routing.accessibility.spec.ts b/tests/test-team/template-mgmt-accessibility/routing.accessibility.spec.ts new file mode 100644 index 000000000..82283e05f --- /dev/null +++ b/tests/test-team/template-mgmt-accessibility/routing.accessibility.spec.ts @@ -0,0 +1,127 @@ +import { + createAuthHelper, + TestUser, + testUsers, +} from 'helpers/auth/cognito-auth-helper'; +import { RoutingConfigFactory } from 'helpers/factories/routing-config-factory'; +import { RoutingConfigStorageHelper } from 'helpers/db/routing-config-storage-helper'; +import { test } from 'fixtures/accessibility-analyze'; +import { + RoutingChooseMessageOrderPage, + RoutingCreateMessagePlanPage, + RoutingChooseTemplatesPage, + RoutingInvalidMessagePlanPage, + RoutingMessagePlanCampaignIdRequiredPage, + RoutingMessagePlansPage, +} from 'pages/routing'; +import { loginAsUser } from 'helpers/auth/login-as-user'; +import { randomUUID } from 'node:crypto'; +import { TemplateFactory } from 'helpers/factories/template-factory'; +import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; + +let userWithMultipleCampaigns: TestUser; +const routingStorageHelper = new RoutingConfigStorageHelper(); +const templateStorageHelper = new TemplateStorageHelper(); +const validRoutingConfigId = randomUUID(); +const messageOrder = 'NHSAPP,EMAIL,SMS,LETTER'; + +test.describe('Routing - Accessibility', () => { + test.beforeAll(async () => { + const authHelper = createAuthHelper(); + + const user = await authHelper.getTestUser(testUsers.User1.userId); + + userWithMultipleCampaigns = await authHelper.getTestUser( + testUsers.UserWithMultipleCampaigns.userId + ); + + const templateIds = { + NHSAPP: randomUUID(), + SMS: randomUUID(), + LETTER: randomUUID(), + }; + + const routingConfig = RoutingConfigFactory.createForMessageOrder( + user, + messageOrder, + { + id: validRoutingConfigId, + name: 'Test plan with some templates', + } + ) + .addTemplate('NHSAPP', templateIds.NHSAPP) + .addTemplate('SMS', templateIds.SMS) + .addTemplate('LETTER', templateIds.LETTER).dbEntry; + + const templates = [ + TemplateFactory.createNhsAppTemplate( + templateIds.NHSAPP, + user, + 'Test NHS App template' + ), + TemplateFactory.createSmsTemplate( + templateIds.SMS, + user, + 'Test SMS template' + ), + TemplateFactory.uploadLetterTemplate( + templateIds.LETTER, + user, + 'Test Letter template' + ), + ]; + + await routingStorageHelper.seed([routingConfig]); + await templateStorageHelper.seedTemplateData(templates); + }); + + test.afterAll(async () => { + await routingStorageHelper.deleteSeeded(); + await templateStorageHelper.deleteSeededTemplates(); + }); + + test('Message plans', async ({ page, analyze }) => + analyze(new RoutingMessagePlansPage(page))); + + test('Campaign required', async ({ page, analyze }) => + analyze(new RoutingMessagePlanCampaignIdRequiredPage(page))); + + test('Invalid message plans', async ({ page, analyze }) => + analyze(new RoutingInvalidMessagePlanPage(page))); + + test('Choose message order', async ({ page, analyze }) => + analyze(new RoutingChooseMessageOrderPage(page))); + + test('Choose template', async ({ page, analyze }) => + analyze(new RoutingChooseTemplatesPage(page), { + id: validRoutingConfigId, + })); + + test('Choose message order - error', async ({ page, analyze }) => + analyze(new RoutingChooseMessageOrderPage(page), { + beforeAnalyze: (p) => p.clickContinueButton(), + })); + + test.describe('client has multiple campaigns', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeEach(async ({ page }) => { + await loginAsUser(userWithMultipleCampaigns, page); + }); + + test('Create message plan', async ({ page, analyze }) => + analyze( + new RoutingCreateMessagePlanPage(page, { + messageOrder, + }) + )); + + test('Create message plan - error', async ({ page, analyze }) => + analyze( + new RoutingCreateMessagePlanPage(page, { + messageOrder, + }), + { beforeAnalyze: (p) => p.clickSubmit() } + )); + }); +});