From c0acaae2ac1017fc8105af75f78de29101f3f91d Mon Sep 17 00:00:00 2001 From: Ryan Lewis <93001277+rylew1@users.noreply.github.com> Date: Tue, 18 Jun 2024 11:01:12 -0700 Subject: [PATCH] [Issue #73]: Dynamic sitemap for pa11y-ci (#83) ## Summary Fixes #73 ### Time to review: 5 min ## Changes proposed - Generate dynamic sitemap with `/app/sitemap.ts` (next convention) - Split pa11y config into `pa11y-desktop` and `pa11y-mobile` --- .github/workflows/ci-frontend-a11y.yml | 5 +- frontend/.pa11yci-desktop.json | 16 ++++ frontend/.pa11yci-mobile.json | 23 +++++ frontend/.pa11yci.json | 100 ---------------------- frontend/package.json | 3 +- frontend/src/app/[locale]/health/page.tsx | 9 +- frontend/src/app/sitemap.ts | 16 ++++ frontend/src/middleware.ts | 10 +-- frontend/src/utils/getRoutes.ts | 40 +++++++++ frontend/tests/utils/getRoutes.test.ts | 74 ++++++++++++++++ 10 files changed, 187 insertions(+), 109 deletions(-) create mode 100644 frontend/.pa11yci-desktop.json create mode 100644 frontend/.pa11yci-mobile.json delete mode 100644 frontend/.pa11yci.json create mode 100644 frontend/src/app/sitemap.ts create mode 100644 frontend/src/utils/getRoutes.ts create mode 100644 frontend/tests/utils/getRoutes.test.ts diff --git a/.github/workflows/ci-frontend-a11y.yml b/.github/workflows/ci-frontend-a11y.yml index 7bfe2d058..8930c324f 100644 --- a/.github/workflows/ci-frontend-a11y.yml +++ b/.github/workflows/ci-frontend-a11y.yml @@ -51,9 +51,10 @@ jobs: - name: Run pa11y-ci run: | set -e # Ensure the script fails if any command fails - npm run test:pa11y + npm run test:pa11y-desktop + npm run test:pa11y-mobile echo "pa11y-ci tests finished." - + - name: Upload screenshots to artifacts if: always() uses: actions/upload-artifact@v3 diff --git a/frontend/.pa11yci-desktop.json b/frontend/.pa11yci-desktop.json new file mode 100644 index 000000000..2f477b7bc --- /dev/null +++ b/frontend/.pa11yci-desktop.json @@ -0,0 +1,16 @@ +{ + "defaults": { + "timeout": 240000, + "runners": ["axe"], + "ignore": ["color-contrast"], + "concurrency": 1, + "chromeLaunchConfig": { + "ignoreHTTPSErrors": true, + "args": ["--disable-dev-shm-usage", "--no-sandbox"] + }, + "actions": [ + "wait for element #main-content to be visible", + "screen capture screenshots-output/desktop-main-view.png" + ] + } +} diff --git a/frontend/.pa11yci-mobile.json b/frontend/.pa11yci-mobile.json new file mode 100644 index 000000000..36e8032fa --- /dev/null +++ b/frontend/.pa11yci-mobile.json @@ -0,0 +1,23 @@ +{ + "defaults": { + "timeout": 240000, + "runners": ["axe"], + "ignore": ["color-contrast"], + "concurrency": 1, + "chromeLaunchConfig": { + "ignoreHTTPSErrors": true, + "args": ["--disable-dev-shm-usage", "--no-sandbox"] + }, + "viewport": { + "width": 390, + "height": 844, + "mobile": true + }, + "actions": [ + "wait for element #main-content to be visible", + "screen capture screenshots-output/mobile-main-view.png", + "click element .usa-navbar button", + "screen capture screenshots-output/mobile-expand-menu.png" + ] + } +} diff --git a/frontend/.pa11yci.json b/frontend/.pa11yci.json deleted file mode 100644 index 24a1e1769..000000000 --- a/frontend/.pa11yci.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "defaults": { - "timeout": 240000, - "runners": ["axe"], - "ignore": ["color-contrast"], - "concurrency": 1, - "chromeLaunchConfig": { - "ignoreHTTPSErrors": true, - "args": ["--disable-dev-shm-usage", "--no-sandbox"] - } - }, - "urls": [ - { - "url": "http://localhost:3000", - "viewport": { "width": 320, "height": 480 }, - "actions": [ - "wait for element #main-content to be visible", - "screen capture screenshots-output/mobile-main-view.png", - "click element .usa-navbar button", - "screen capture screenshots-output/mobile-expand-menu.png" - ] - }, - { - "url": "http://localhost:3000", - "actions": [ - "wait for element #main-content to be visible", - "screen capture screenshots-output/desktop-main-view-home.png" - ] - }, - { - "url": "http://localhost:3000/search?status=forecasted,posted", - "viewport": { "width": 320, "height": 480 }, - "actions": [ - "wait for element #main-content to be visible", - "screen capture screenshots-output/mobile-main-view-search.png", - "click element .usa-navbar button", - "screen capture screenshots-output/mobile-expand-menu-search.png" - ] - }, - { - "url": "http://localhost:3000/search?status=forecasted,posted", - "viewport": { "width": 320, "height": 480 }, - "actions": [ - "wait for element #main-content to be visible", - "screen capture screenshots-output/desktop-main-view-search.png" - ] - }, - { - "url": "http://localhost:3000/process", - "viewport": { "width": 320, "height": 480 }, - "actions": [ - "wait for element #main-content to be visible", - "screen capture screenshots-output/mobile-main-view-process.png", - "click element .usa-navbar button", - "screen capture screenshots-output/mobile-expand-menu-process.png" - ] - }, - { - "url": "http://localhost:3000/process", - "actions": [ - "wait for element #main-content to be visible", - "screen capture screenshots-output/desktop-main-view-process.png" - ] - }, - { - "url": "http://localhost:3000/research", - "viewport": { "width": 320, "height": 480 }, - "actions": [ - "wait for element #main-content to be visible", - "screen capture screenshots-output/mobile-main-view-research.png", - "click element .usa-navbar button", - "screen capture screenshots-output/mobile-expand-menu-research.png" - ] - }, - { - "url": "http://localhost:3000/research", - "actions": [ - "wait for element #main-content to be visible", - "screen capture screenshots-output/desktop-main-view-research.png" - ] - }, - { - "url": "http://localhost:3000/newsletter", - "viewport": { "width": 320, "height": 480 }, - "actions": [ - "wait for element #main-content to be visible", - "screen capture screenshots-output/mobile-main-view-newsletter.png", - "click element .usa-navbar button", - "screen capture screenshots-output/mobile-expand-menu-newsletter.png" - ] - }, - { - "url": "http://localhost:3000/newsletter", - "actions": [ - "wait for element #main-content to be visible", - "screen capture screenshots-output/desktop-main-view-newsletter.png" - ] - } - ] -} diff --git a/frontend/package.json b/frontend/package.json index c90957005..36040149d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,7 +17,8 @@ "storybook": "storybook dev -p 6006", "storybook-build": "storybook build", "test": "jest --ci --coverage", - "test:pa11y": "pa11y-ci --config .pa11yci.json", + "test:pa11y-desktop": "pa11y-ci --config .pa11yci-desktop.json --sitemap http://localhost:3000/sitemap.xml", + "test:pa11y-mobile": "pa11y-ci --config .pa11yci-mobile.json --sitemap http://localhost:3000/sitemap.xml", "test-update": "jest --update-snapshot", "test-watch": "jest --watch", "test:e2e": "npx playwright test --config ./tests/playwright.config.ts", diff --git a/frontend/src/app/[locale]/health/page.tsx b/frontend/src/app/[locale]/health/page.tsx index 1c4e40ea2..8b1e282e1 100644 --- a/frontend/src/app/[locale]/health/page.tsx +++ b/frontend/src/app/[locale]/health/page.tsx @@ -1,3 +1,10 @@ export default function Health() { - return <>healthy; + return ( + <> + + Health Check + +
healthy
+ + ); } diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts new file mode 100644 index 000000000..503238882 --- /dev/null +++ b/frontend/src/app/sitemap.ts @@ -0,0 +1,16 @@ +import { MetadataRoute } from "next"; +import { getNextRoutes } from "../utils/getRoutes"; + +export default function sitemap(): MetadataRoute.Sitemap { + const routes = getNextRoutes("./src/app"); + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; + const sitemap: MetadataRoute.Sitemap = routes.map((route) => ({ + url: `${baseUrl}${route || ""}`, + lastModified: new Date().toISOString(), + changeFrequency: "weekly", + priority: 0.5, + })); + + return sitemap; +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 996176d04..7f833f577 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -1,3 +1,7 @@ +import { NextRequest, NextResponse } from "next/server"; +import { defaultLocale, locales } from "./i18n/config"; + +import { FeatureFlagsManager } from "./services/FeatureFlagManager"; /** * @file Middleware allows you to run code before a request is completed. Then, based on * the incoming request, you can modify the response by rewriting, redirecting, @@ -5,10 +9,6 @@ * @see https://nextjs.org/docs/app/building-your-application/routing/middleware */ import createIntlMiddleware from "next-intl/middleware"; -import { NextRequest, NextResponse } from "next/server"; -import { defaultLocale, locales } from "./i18n/config"; - -import { FeatureFlagsManager } from "./services/FeatureFlagManager"; export const config = { matcher: [ @@ -19,7 +19,7 @@ export const config = { * - _next/image (image optimization files) * - images (static files in public/images/ directory) */ - "/((?!api|_next/static|_next/image|public|img|uswds|images|robots.txt|site.webmanifest).*)", + "/((?!api|_next/static|_next/image|sitemap|public|img|uswds|images|robots.txt|site.webmanifest).*)", /** * Fix issue where the pattern above was causing middleware * to not run on the homepage: diff --git a/frontend/src/utils/getRoutes.ts b/frontend/src/utils/getRoutes.ts new file mode 100644 index 000000000..4cc33a6f6 --- /dev/null +++ b/frontend/src/utils/getRoutes.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; + +// Helper function to list all paths recursively +export function listPaths(dir: string): string[] { + let fileList: string[] = []; + const files = fs.readdirSync(dir); + files.forEach((file) => { + const filePath = path.join(dir, file); + if (fs.statSync(filePath).isDirectory()) { + fileList = fileList.concat(listPaths(filePath)); + } else { + fileList.push(filePath); + } + }); + return fileList; +} + +// Function to get the Next.js routes +export function getNextRoutes(src: string): string[] { + // Get all paths from the `app` directory + const appPaths = listPaths(src).filter((file) => file.endsWith("page.tsx")); + + // Extract the route name for each `page.tsx` file + // Basically anything between [locale] and /page.tsx is extracted, + // which lets us get nested routes such as /newsletter/unsubscribe + const appRoutes = appPaths.map((filePath) => { + const relativePath = path.relative(src, filePath); + const route = relativePath + ? "/" + + relativePath + .replace("/page.tsx", "") + .replace(/\[locale\]/g, "") + .replace(/\\/g, "/") + : "/"; + return route.replace(/\/\//g, "/"); + }); + + return appRoutes; +} diff --git a/frontend/tests/utils/getRoutes.test.ts b/frontend/tests/utils/getRoutes.test.ts new file mode 100644 index 000000000..c6fed8a06 --- /dev/null +++ b/frontend/tests/utils/getRoutes.test.ts @@ -0,0 +1,74 @@ +import { getNextRoutes, listPaths } from "../../src/utils/getRoutes"; + +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +jest.mock("../../src/utils/getRoutes", () => { + const originalModule = jest.requireActual("../../src/utils/getRoutes"); + return { + ...originalModule, + listPaths: jest.fn(), + }; +}); + +const mockedListPaths = listPaths as jest.MockedFunction; + +describe("getNextRoutes", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should get Next.js routes from src directory", () => { + const mockedFiles: string[] = getPaths(); + + mockedListPaths.mockReturnValue(mockedFiles); + + const result = getNextRoutes("src/app"); + + expect(result).toEqual([ + "/dev/feature-flags", + "/health", + "/newsletter/confirmation", + "/newsletter", + "/newsletter/unsubscribe", + "/", + "/process", + "/research", + "/search", + ]); + }); +}); + +function getPaths() { + return [ + "src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx", + "src/app/[locale]/dev/feature-flags/page.tsx", + "src/app/[locale]/health/page.tsx", + "src/app/[locale]/newsletter/NewsletterForm.tsx", + "src/app/[locale]/newsletter/confirmation/page.tsx", + "src/app/[locale]/newsletter/page.tsx", + "src/app/[locale]/newsletter/unsubscribe/page.tsx", + "src/app/[locale]/page.tsx", + "src/app/[locale]/process/ProcessIntro.tsx", + "src/app/[locale]/process/ProcessInvolved.tsx", + "src/app/[locale]/process/ProcessMilestones.tsx", + "src/app/[locale]/process/page.tsx", + "src/app/[locale]/research/ResearchArchetypes.tsx", + "src/app/[locale]/research/ResearchImpact.tsx", + "src/app/[locale]/research/ResearchIntro.tsx", + "src/app/[locale]/research/ResearchMethodology.tsx", + "src/app/[locale]/research/ResearchThemes.tsx", + "src/app/[locale]/research/page.tsx", + "src/app/[locale]/search/SearchForm.tsx", + "src/app/[locale]/search/actions.ts", + "src/app/[locale]/search/error.tsx", + "src/app/[locale]/search/loading.tsx", + "src/app/[locale]/search/page.tsx", + "src/app/api/BaseApi.ts", + "src/app/api/SearchOpportunityAPI.ts", + "src/app/api/mock/APIMockResponse.json", + "src/app/layout.tsx", + "src/app/not-found.tsx", + "src/app/sitemap.ts", + "src/app/template.tsx", + ]; +}