diff --git a/.env.tpl b/.env.tpl index 9f2f28fa6..b86c67798 100644 --- a/.env.tpl +++ b/.env.tpl @@ -40,3 +40,11 @@ CYPRESS_ADMIN_USER_EMAIL=op://mdct_devs/mcr_secrets/CYPRESS_ADMIN_USER_EMAIL CYPRESS_ADMIN_USER_PASSWORD=op://mdct_devs/mcr_secrets/CYPRESS_ADMIN_USER_PASSWORD # pragma: allowlist secret CYPRESS_STATE_USER_EMAIL=op://mdct_devs/mcr_secrets/CYPRESS_STATE_USER_EMAIL CYPRESS_STATE_USER_PASSWORD=op://mdct_devs/mcr_secrets/CYPRESS_STATE_USER_PASSWORD # pragma: allowlist secret + +# needed for playwright e2e tests +TEST_ADMIN_USER_EMAIL=op://mdct_devs/mcr_secrets/CYPRESS_ADMIN_USER_EMAIL +TEST_ADMIN_USER_PASSWORD=op://mdct_devs/mcr_secrets/CYPRESS_ADMIN_USER_PASSWORD # pragma: allowlist secret +TEST_STATE_USER_EMAIL=op://mdct_devs/mcr_secrets/CYPRESS_STATE_USER_EMAIL +TEST_STATE_USER_PASSWORD=op://mdct_devs/mcr_secrets/CYPRESS_STATE_USER_PASSWORD # pragma: allowlist secret +TEST_STATE=MN +TEST_STATE=Minnesota diff --git a/.github/workflows/delete-pages.yml b/.github/workflows/delete-pages.yml new file mode 100755 index 000000000..30aa63d83 --- /dev/null +++ b/.github/workflows/delete-pages.yml @@ -0,0 +1,50 @@ +name: Delete old folders from GitHub Pages + +on: + push: + branches: + - "gh-pages" + schedule: + - cron: '0 0 * * *' # This will run the workflow daily at midnight UTC + +jobs: + delete_old_folders: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: gh-pages + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.9 + + - name: Get current directory + run: echo "CURRENT_DIR=$(pwd)" >> $GITHUB_ENV + + - name: Run the script + run: python rm_old_folders.py --n-days 30 --folder-name "${{ env.CURRENT_DIR }}" + + - name: Commit all changed files back to the repository + uses: stefanzweifel/git-auto-commit-action@v5 + with: + branch: gh-pages + commit_message: Delete folders older than 30 days + + notify_on_delete_pages_failure: + runs-on: ubuntu-latest + needs: + - delete_old_folders + if: failure() + steps: + - name: Slack Notification + uses: rtCamp/action-slack-notify@v2 + env: + SLACK_TITLE: ":boom: The nightly delete of expired Playwright reports job has failed in ${{ github.repository }}." + MSG_MINIMAL: true + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f7ba106c1..5cb552f76 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,7 +11,8 @@ concurrency: permissions: id-token: write - contents: read + contents: write + pages: write actions: read jobs: @@ -279,6 +280,101 @@ jobs: ${{github.workspace}}/tests/cypress/videos/ retention-days: 14 + test: + name: Playwright Tests + needs: + - deploy + - register-runner + - e2e-test + - a11y-tests + if: ${{ always() && !cancelled() && needs.deploy.result == 'success' && github.ref_name != 'production' }} + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Configure AWS credentials for GitHub Actions + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets[env.BRANCH_SPECIFIC_VARNAME_AWS_OIDC_ROLE_TO_ASSUME] || secrets.AWS_OIDC_ROLE_TO_ASSUME }} + aws-region: ${{ secrets[env.BRANCH_SPECIFIC_VARNAME_AWS_DEFAULT_REGION] || secrets.AWS_DEFAULT_REGION }} + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + - name: yarn install + run: yarn install + - name: yarn install tests + run: yarn install + working-directory: tests + - name: Install Playwright Browsers + run: yarn playwright install --with-deps + working-directory: tests + - name: Run Playwright tests + run: yarn playwright test + working-directory: tests + continue-on-error: true + env: + BASE_URL: ${{ needs.deploy.outputs.application_endpoint }} + TEST_STATE_USER_EMAIL: ${{ secrets.CYPRESS_STATE_USER_EMAIL }} + TEST_STATE_USER_PASSWORD: ${{ secrets.CYPRESS_STATE_USER_PASSWORD }} + TEST_ADMIN_USER_EMAIL: ${{ secrets.CYPRESS_ADMIN_USER_EMAIL }} + TEST_ADMIN_USER_PASSWORD: ${{ secrets.CYPRESS_ADMIN_USER_PASSWORD }} + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-html-report # upload artifact as this name + # path: playwright-report/index.html # path on runner + path: tests/playwright-report # path on runner + retention-days: 30 + + upload-reports: + name: Upload Reports + needs: + - test + if: ${{ always() && github.ref_name != 'production' }} + runs-on: ubuntu-latest + outputs: + timestamp: ${{ steps.timestampid.outputs.timestamp }} + steps: + # create a unique folder name to put playwright reports in + - name: Set a Timestamp + id: timestampid + run: echo "timestamp=$(date --utc +%Y%m%d_%H%M%SZ)" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + - name: Install dependencies + run: yarn install + # downloads artifact created from the test job + - name: Download reports from GitHub Actions Artifacts + uses: actions/download-artifact@v4 + with: + name: playwright-html-report # download from previous job + path: downloaded-html-report # save as this when downloaded + - name: Push files to github pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./downloaded-html-report # publish downloaded dir to github pages + destination_dir: ${{ steps.timestampid.outputs.timestamp }} + # need to extract just org name for reassembling the github pages URL + - name: Extract Organization Name + id: extract-org + run: | + echo "ORG_NAME=$(echo $GITHUB_REPOSITORY | cut -d'/' -f1)" >> $GITHUB_ENV + echo "org name: ${ORG_NAME}" + # need to extract just the repo name for reassembling the github pages URL + - name: Extract Repository Name + id: extract-repo + run: | + echo "REPO_NAME=$(echo $GITHUB_REPOSITORY | cut -d'/' -f2)" >> $GITHUB_ENV + echo "repo name: ${REPO_NAME}" + # assembles org name, repo name, and unique timestamp to link to github pages url that was published + - name: Write URL in Summary + run: | + echo "## Playwright Test Results" >> $GITHUB_STEP_SUMMARY + echo "https://${ORG_NAME}.github.io/${REPO_NAME}/${{ steps.timestampid.outputs.timestamp }}/" >> $GITHUB_STEP_SUMMARY + cleanup: name: Delist GHA Runner CIDR Blocks if: ${{ github.ref_name != 'main' && github.ref_name != 'val' && github.ref_name != 'production' }} @@ -287,6 +383,7 @@ jobs: - register-runner - a11y-tests - e2e-test + - test env: SLS_DEPRECATION_DISABLE: "*" # Turn off deprecation warnings in the pipeline steps: diff --git a/.gitignore b/.gitignore index 2c3dbe615..500a7666e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,7 @@ tests/cypress/downloads .vscode/ *._S3rver_cors.xml services/database/local_buckets +tests/test-results/ +tests/playwright-report/ +tests/playwright/.cache/ +tests/playwright/.auth diff --git a/package.json b/package.json index ad4408cfc..e1525f9da 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ }, "scripts": { "test": "cd tests && npm test && cd -", - "test:ci": "cd tests && yarn run test:ci" + "test:ci": "cd tests && yarn run test:ci", + "test:e2e": "cd tests && yarn run test:e2e", + "test:e2e-ui": "cd tests && yarn run test:e2e-ui" }, "repository": { "type": "git", diff --git a/rm_old_folders.py b/rm_old_folders.py new file mode 100755 index 000000000..75f78807c --- /dev/null +++ b/rm_old_folders.py @@ -0,0 +1,113 @@ +import argparse +import os +import re +from datetime import datetime, timedelta +import shutil + +def find_old_folders(n_days, directory): + """ + Find folders in the specified directory that are older than n_days. + + Args: + directory (str): The directory to search for folders. + n_days (int): The number of days to determine which folders to delete. + + Returns: + list: List of folder names older than n_days. + """ + current_time = datetime.utcnow() + folder_name_regex = re.compile(r'^\d{8}_\d{6}Z$') + + old_folders = [] + for entry in os.scandir(directory): + if entry.is_dir() and re.match(folder_name_regex, entry.name): + try: + folder_date = datetime.strptime(entry.name, "%Y%m%d_%H%M%SZ") + time_difference = current_time - folder_date + if time_difference > timedelta(days=n_days): + old_folders.append(entry.name) + else: + print( + f"SKIPPED --- Folder '{entry.name}' is not older than " + f"{n_days} days. It will not be deleted." + ) + except ValueError: + print( + f"SKIPPED --- Error parsing timestamp for folder '{entry.name}'. " + f"It will not be deleted." + ) + else: + print( + f"SKIPPED --- Found folder/file with name '{entry.name}' that does " + f"not match the expected timestamp format. It will not be deleted." + ) + + return old_folders + +def is_valid_directory(base_directory, folder_path): + """ + Check if the folder_path is a valid directory within the base_directory. + + Args: + base_directory (str): The base directory. + folder_path (str): The path of the folder to validate. + + Returns: + bool: True if the folder_path is valid, False otherwise. + """ + # Resolve absolute paths + base_directory = os.path.abspath(base_directory) + folder_path = os.path.abspath(folder_path) + + # Ensure that the folder_path starts with the base_directory + return folder_path.startswith(base_directory) + +def delete_folders(base_directory, folder_names): + """ + Delete specified folders and their contents in the given directory. + + Args: + base_directory (str): The base directory containing the folders to delete. + folder_names (list): List of folder names to delete. + """ + for folder_name in folder_names: + folder_path = os.path.join(base_directory, folder_name) + if is_valid_directory(base_directory, folder_path): + try: + shutil.rmtree(folder_path) + print( + f"DELETED --- Folder '{folder_name}' and its contents have " + f"been deleted." + ) + except FileNotFoundError: + print(f"Folder '{folder_name}' not found.") + except Exception as e: + print(f"Error deleting folder '{folder_name}': {e}") + else: + print(f"SKIPPED --- Invalid folder path: '{folder_path}'") + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Delete old folders in a specified directory." + ) + parser.add_argument( + "--n-days", + type=int, + required=True, + help="Number of days (days older than current date) to determine " + "which folders to delete." + ) + parser.add_argument( + "--folder-name", + type=str, + required=True, + help="Full path to the directory where reports are located." + ) + args = parser.parse_args() + + # Ensure the provided folder name is an absolute path + if not os.path.isabs(args.folder_name): + raise ValueError("The folder name must be an absolute path.") + + old_folders = find_old_folders(args.n_days, args.folder_name) + delete_folders(args.folder_name, old_folders) diff --git a/tests/package.json b/tests/package.json index e8c9ff6fc..232c8c4e9 100644 --- a/tests/package.json +++ b/tests/package.json @@ -7,12 +7,16 @@ "start": "cd ../ && ./run local && cd -", "cypress": "cypress open", "test": "concurrently --kill-others \"yarn start\" \"yarn cypress\"", - "test:ci": "cypress install && cypress run --browser chrome --headless" + "test:ci": "cypress install && cypress run --browser chrome --headless", + "test:e2e": "playwright test", + "test:e2e-ui": "playwright test --ui" }, "author": "", "license": "ISC", "devDependencies": { + "@axe-core/playwright": "^4.10.0", "@cypress-audit/pa11y": "^1.3.1", + "@playwright/test": "^1.48.0", "axe-core": "^4.6.3", "concurrently": "^8.2.2", "cypress": "^12.17.4", diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts new file mode 100644 index 000000000..8b169b1a8 --- /dev/null +++ b/tests/playwright.config.ts @@ -0,0 +1,59 @@ +import { defineConfig, devices } from "@playwright/test"; +import dotenv from "dotenv"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +dotenv.config({ path: "../.env" }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "playwright", + testMatch: ["**/*.spec.js", "**/*.spec.ts"], + /* Run tests in files in parallel */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.BASE_URL || "http://localhost:3000", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + + /* Video recording configuration */ + video: "retain-on-failure", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "setup", + use: { ...devices["Desktop Chrome"] }, + testMatch: /.*\.setup\.ts/, + }, + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + dependencies: ["setup"], + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: process.env.CI ? "" : "cd ../ && ./run local", + url: process.env.BASE_URL || "http://localhost:3000", + reuseExistingServer: !!process.env.CI, + stdout: "pipe", + }, +}); diff --git a/tests/playwright/pages/home.spec.ts b/tests/playwright/pages/home.spec.ts new file mode 100644 index 000000000..435dde011 --- /dev/null +++ b/tests/playwright/pages/home.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "../utils/fixtures/base"; +import BasePage from "../utils/pageObjects/base.page"; + +test.describe("state user home page", () => { + test("Should see the correct home page as a state user", async ({ + stateHomePage, + }) => { + await stateHomePage.goto(); + await stateHomePage.isReady(); + await expect(stateHomePage.wpButton).toBeVisible(); + await expect(stateHomePage.sarButton).toBeVisible(); + }); + + test("Is accessible on all device types for state user", async ({ + stateHomePage, + }) => { + await stateHomePage.goto(); + await stateHomePage.e2eA11y(); + }); +}); + +test.describe("admin user home page", () => { + test("Should see the correct home page as an admin user", async ({ + adminHomePage, + }) => { + await adminHomePage.goto(); + await adminHomePage.isReady(); + await expect(adminHomePage.dropdown).toBeVisible(); + }); + + test("Is accessible on all device types for admin user", async ({ + adminHomePage, + }) => { + await adminHomePage.goto(); + await adminHomePage.e2eA11y(); + }); +}); + +test.describe("not logged in home page", () => { + test("Is assessible when not logged in", async ({ browser }) => { + const userContext = await browser.newContext({ + storageState: { + cookies: [], + origins: [], + }, + }); + const homePage = new BasePage(await userContext.newPage()); + await homePage.goto(); + await homePage.e2eA11y(); + await userContext.close(); + }); +}); diff --git a/tests/playwright/utils/a11y.ts b/tests/playwright/utils/a11y.ts new file mode 100644 index 000000000..73201ca57 --- /dev/null +++ b/tests/playwright/utils/a11y.ts @@ -0,0 +1,22 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, Page } from "@playwright/test"; + +// Note that this helper function actually calls expect +export async function e2eA11y(page: Page, url: string) { + const breakpoints = { + mobile: [560, 800], + tablet: [880, 1000], + desktop: [1200, 1200], + }; + + await page.goto(url); + + for (const size of Object.values(breakpoints)) { + page.setViewportSize({ width: size[0], height: size[1] }); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa"]) + .disableRules(["duplicate-id"]) + .analyze(); + expect(results.violations).toEqual([]); + } +} diff --git a/tests/playwright/utils/auth.setup.ts b/tests/playwright/utils/auth.setup.ts new file mode 100644 index 000000000..9afb9c41b --- /dev/null +++ b/tests/playwright/utils/auth.setup.ts @@ -0,0 +1,43 @@ +import { test as setup } from "@playwright/test"; + +import { adminPassword, adminUser, statePassword, stateUser } from "./consts"; + +const adminFile = "playwright/.auth/admin.json"; + +setup("authenticate as admin", async ({ page }) => { + await page.goto("/"); + const emailInput = page.getByRole("textbox", { name: "email" }); + const passwordInput = page.getByRole("textbox", { name: "password" }); + const loginButton = page.getByRole("button", { name: "Log In with Cognito" }); + await emailInput.fill(adminUser); + await passwordInput.fill(adminPassword); + await loginButton.click(); + await page.waitForURL("/"); + await page + .getByRole("heading", { + name: "View State/Territory Reports", + }) + .isVisible(); + await page.waitForTimeout(1000); + await page.context().storageState({ path: adminFile }); +}); + +const userFile = "playwright/.auth/user.json"; + +setup("authenticate as user", async ({ page }) => { + await page.goto("/"); + const emailInput = page.getByRole("textbox", { name: "email" }); + const passwordInput = page.getByRole("textbox", { name: "password" }); + const loginButton = page.getByRole("button", { name: "Log In with Cognito" }); + await emailInput.fill(stateUser); + await passwordInput.fill(statePassword); + await loginButton.click(); + await page.waitForURL("/"); + await page + .getByRole("heading", { + name: "Managed Care Reporting Portal", + }) + .isVisible(); + await page.waitForTimeout(1000); + await page.context().storageState({ path: userFile }); +}); diff --git a/tests/playwright/utils/consts.ts b/tests/playwright/utils/consts.ts new file mode 100644 index 000000000..367ceeb8c --- /dev/null +++ b/tests/playwright/utils/consts.ts @@ -0,0 +1,9 @@ +export const adminUser = process.env.TEST_ADMIN_USER_EMAIL!; +export const adminPassword = process.env.TEST_ADMIN_USER_PASSWORD!; // pragma: allowlist secret +export const stateUser = process.env.TEST_STATE_USER_EMAIL!; +export const statePassword = process.env.TEST_STATE_USER_PASSWORD!; // pragma: allowlist secret + +export const stateAbbreviation = process.env.TEST_STATE || "MN"; +export const stateName = process.env.TEST_STATE_NAME || "Minnesota"; + +export const currentYear: number = new Date().getFullYear(); diff --git a/tests/playwright/utils/fixtures/base.ts b/tests/playwright/utils/fixtures/base.ts new file mode 100644 index 000000000..5ea5de358 --- /dev/null +++ b/tests/playwright/utils/fixtures/base.ts @@ -0,0 +1,31 @@ +import { mergeTests, test as base } from "@playwright/test"; +import StateHomePage from "../pageObjects/stateHome.page"; +import AdminHomePage from "../pageObjects/adminHome.page"; + +type CustomFixtures = { + stateHomePage: StateHomePage; + adminHomePage: AdminHomePage; +}; + +export const baseTest = base.extend({ + stateHomePage: async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: "playwright/.auth/user.json", + }); + const stateHomePage = new StateHomePage(await context.newPage()); + await use(stateHomePage); + await context.close(); + }, + adminHomePage: async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: "playwright/.auth/admin.json", + }); + const adminHomePage = new AdminHomePage(await context.newPage()); + await use(adminHomePage); + await context.close(); + }, +}); + +export const test = mergeTests(baseTest); + +export { expect } from "@playwright/test"; diff --git a/tests/playwright/utils/index.ts b/tests/playwright/utils/index.ts new file mode 100644 index 000000000..1b4506130 --- /dev/null +++ b/tests/playwright/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./consts"; +export * from "./login"; +export * from "./a11y"; diff --git a/tests/playwright/utils/login.ts b/tests/playwright/utils/login.ts new file mode 100644 index 000000000..64192d8f3 --- /dev/null +++ b/tests/playwright/utils/login.ts @@ -0,0 +1,36 @@ +import { Page } from "@playwright/test"; +import { adminPassword, adminUser, statePassword, stateUser } from "./consts"; + +export async function logInUser(page: Page, email: string, password: string) { + await page.goto("/"); + + const emailInput = page.getByRole("textbox", { name: "email" }); + const passwordInput = page.getByRole("textbox", { name: "password" }); + const loginButton = page.getByRole("button", { name: "Log In with Cognito" }); + + await emailInput.fill(email); + await passwordInput.fill(password); + await loginButton.click(); +} + +export async function logOutUser(page: Page) { + const menuButton = page.getByRole("button", { name: "My Account" }); + const menu = page.getByTestId("header-menu-options-list"); + const logoutButton = page.getByTestId("header-menu-option-log-out"); + + await menuButton.click(); + await menu.isVisible(); + await logoutButton.click(); + await page.evaluate(() => window.localStorage.clear()); + await page.goto("/"); +} + +export async function logInStateUser(page: Page) { + await logInUser(page, stateUser, statePassword); + await page.getByText("Managed Care Reporting Portal").isVisible(); +} + +export async function logInAdminUser(page: Page) { + await logInUser(page, adminUser, adminPassword); + await page.getByText("View State/Territory Reports").isVisible(); +} diff --git a/tests/playwright/utils/pageObjects/adminHome.page.ts b/tests/playwright/utils/pageObjects/adminHome.page.ts new file mode 100644 index 000000000..92678722e --- /dev/null +++ b/tests/playwright/utils/pageObjects/adminHome.page.ts @@ -0,0 +1,59 @@ +import { Locator, Page } from "@playwright/test"; +import BasePage from "./base.page"; + +export default class AdminHomePage extends BasePage { + public path = "/"; + + readonly page: Page; + readonly title: Locator; + readonly dropdown: Locator; + + constructor(page: Page) { + super(page); + this.page = page; + this.title = page.getByRole("heading", { + name: "View State/Territory Reports", + }); + this.dropdown = page.getByRole("combobox", { + name: "List of states, including District of Columbia and Puerto Rico", + }); + } + + public async selectMCPAR(state: string) { + await this.page + .getByRole("combobox", { + name: "List of states, including District of Columbia and Puerto Rico", + }) + .selectOption(state); + await this.page + .getByRole("radio", { + name: "Managed Care Program Annual Report (MCPAR)", + }) + .click(); + await this.goToDashboard(); + } + + public async selectMLR() { + await this.page + .getByRole("radio", { + name: "Medicaid Medical Loss Ratio (MLR)", + }) + .click(); + } + + public async selectNAAAR() { + await this.page + .getByRole("radio", { + name: "Network Adequacy and Access Assurances Report (NAAAR)", + }) + .click(); + } + + public async goToDashboard() { + await this.page + .getByRole("button", { + name: "Go to Report Dashboard", + }) + .click(); + } +} diff --git a/tests/playwright/utils/pageObjects/base.page.ts b/tests/playwright/utils/pageObjects/base.page.ts new file mode 100644 index 000000000..46ac386f6 --- /dev/null +++ b/tests/playwright/utils/pageObjects/base.page.ts @@ -0,0 +1,72 @@ +import { expect, Locator, Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +export default class BasePage { + public path = "/"; + + readonly page: Page; + readonly title: Locator; + readonly continueButton: Locator; + readonly previousButton: Locator; + readonly myAccountButton: Locator; + readonly accountMenu: Locator; + readonly manageAccountButton: Locator; + readonly logoutButton: Locator; + + constructor(page: Page) { + this.page = page; + this.title = page.getByRole("heading", { + name: "Managed Care Reporting", + }); + this.continueButton = page.getByRole("button", { name: "Continue" }); + this.previousButton = page.getByRole("button", { name: "Previous" }); + this.myAccountButton = page.getByRole("button", { name: "My Account" }); + this.accountMenu = page.getByRole("menu"); + this.manageAccountButton = page.getByRole("menuitem", { + name: "Manage Account", + }); + this.logoutButton = page.getByRole("menuitem", { name: "Log Out" }); + } + + public async goto(url?: string) { + if (url) { + await this.page.goto(url); + } else { + await this.page.goto(this.path); + } + } + + public async isReady() { + await this.title.isVisible(); + return expect(this.page).toHaveURL(this.path); + } + + public async manageAccount() { + await this.myAccountButton.click(); + await this.accountMenu.isVisible(); + await this.manageAccountButton.click(); + } + + public async logOut() { + await this.myAccountButton.click(); + await this.accountMenu.isVisible(); + await this.logoutButton.click(); + } + + public async e2eA11y() { + const breakpoints = { + mobile: [560, 800], + tablet: [880, 1000], + desktop: [1200, 1200], + }; + + for (const size of Object.values(breakpoints)) { + this.page.setViewportSize({ width: size[0], height: size[1] }); + const results = await new AxeBuilder({ page: this.page }) + .withTags(["wcag2a", "wcag2aa"]) + .disableRules(["duplicate-id"]) + .analyze(); + expect(results.violations).toEqual([]); + } + } +} diff --git a/tests/playwright/utils/pageObjects/stateHome.page.ts b/tests/playwright/utils/pageObjects/stateHome.page.ts new file mode 100644 index 000000000..f452846d0 --- /dev/null +++ b/tests/playwright/utils/pageObjects/stateHome.page.ts @@ -0,0 +1,24 @@ +import { Locator, Page } from "@playwright/test"; +import BasePage from "./base.page"; + +export default class StateHomePage extends BasePage { + public path = "/"; + + readonly page: Page; + readonly title: Locator; + readonly wpButton: Locator; + readonly sarButton: Locator; + + constructor(page: Page) { + super(page); + this.page = page; + this.title = page.getByRole("heading", { + name: "Managed Care Reporting Portal", + }); + this.wpButton = page.getByRole("button", { + name: "Enter MCPAR online", + }); + this.sarButton = page.getByRole("button", { name: "Enter MLR online" }); + this.sarButton = page.getByRole("button", { name: "Enter NAAAR online" }); + } +} diff --git a/tests/yarn.lock b/tests/yarn.lock index 79165836b..5abe59bd1 100644 --- a/tests/yarn.lock +++ b/tests/yarn.lock @@ -2,6 +2,13 @@ # yarn lockfile v1 +"@axe-core/playwright@^4.10.0": + version "4.10.1" + resolved "https://registry.yarnpkg.com/@axe-core/playwright/-/playwright-4.10.1.tgz#c811ba8bfa244833cce422c4131e0043828c42cc" + integrity sha512-EV5t39VV68kuAfMKqb/RL+YjYKhfuGim9rgIaQ6Vntb2HgaCaau0h98Y3WEUqW1+PbdzxDtDNjFAipbtZuBmEA== + dependencies: + axe-core "~4.10.2" + "@babel/runtime@^7.21.0": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.5.tgz#230946857c053a36ccc66e1dd03b17dd0c4ed02c" @@ -59,6 +66,13 @@ debug "^3.1.0" lodash.once "^4.1.1" +"@playwright/test@^1.48.0": + version "1.48.2" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.48.2.tgz#87dd40633f980872283404c8142a65744d3f13d6" + integrity sha512-54w1xCWfXuax7dz4W2M9uw0gDyh+ti/0K/MxcCUxChFh37kkdxPdfZDw5QBbuPUJHr1CiHJ1hXgSs+GgeQc5Zw== + dependencies: + playwright "1.48.2" + "@types/node@*": version "17.0.32" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.32.tgz#51d59d7a90ef2d0ae961791e0900cad2393a0149" @@ -177,6 +191,11 @@ axe-core@^4.6.3: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.6.3.tgz#fc0db6fdb65cc7a80ccf85286d91d64ababa3ece" integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg== +axe-core@~4.10.2: + version "4.10.2" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df" + integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w== + axe-core@~4.2.1: version "4.2.4" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.2.4.tgz#626cfbd1827985c5b20a9b9ae5bc3dbe8a3df490" @@ -659,6 +678,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -1140,6 +1164,20 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +playwright-core@1.48.2: + version "1.48.2" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.48.2.tgz#cd76ed8af61690edef5c05c64721c26a8db2f3d7" + integrity sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA== + +playwright@1.48.2: + version "1.48.2" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.48.2.tgz#fca45ae8abdc34835c715718072aaff7e305167e" + integrity sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ== + dependencies: + playwright-core "1.48.2" + optionalDependencies: + fsevents "2.3.2" + pretty-bytes@^5.6.0: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" diff --git a/tsconfig.json b/tsconfig.json index 9f28e1e5e..ffb5cbeb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,6 @@ "jsx": "preserve" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, "outDir": "build_dev" /* Redirect output structure to the directory. */, "rootDir": "src", - /* Strict Type-Checking Options */ "strict": true /* Enable all strict type-checking options. */, "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, @@ -21,20 +20,22 @@ "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, - /* Additional Checks */ "noUnusedLocals": true /* Report errors on unused locals. */, "noUnusedParameters": true /* Report errors on unused parameters. */, "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, - /* Module Resolution Options */ "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, - /* Advanced Options */ "skipLibCheck": true /* Skip type checking of declaration files. */, "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ }, - "exclude": ["./services/ui-src/*", "./services/app-api/*"] -} + "exclude": [ + "./services/ui-src/*", + "./services/app-api/*", + "./tests/**/**.ts", + "./tests/playwright.config.ts" + ] +} \ No newline at end of file