diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index 1ab7e3aa..726976bd 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -17,16 +17,34 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 1 steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Verify Jira ticket in PR title and Jira" - uses: ./.github/actions/verify-jira-ticket - with: - pr_title: ${{ github.event.pull_request.title }} - pr_branch: ${{ github.head_ref }} - jira_token: ${{ secrets.NHS_HOMETEST_JIRA_PAT }} - actor: ${{ github.event.pull_request.user.login }} + - name: "Check PR title for HOTE- Jira ID" + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BRANCH: ${{ github.head_ref }} + run: | + JIRA_PATTERN='HOTE-[0-9]+' + + echo "PR Title: $PR_TITLE" + echo "PR Branch: $PR_BRANCH" + + if [[ "$PR_TITLE" =~ $JIRA_PATTERN ]]; then + TICKET="${BASH_REMATCH[0]}" + echo "✅ Found Jira ticket '$TICKET' in PR title" + exit 0 + fi + + if [[ "$PR_BRANCH" =~ $JIRA_PATTERN ]]; then + TICKET="${BASH_REMATCH[0]}" + echo "⚠️ Jira ticket '$TICKET' found in branch name but NOT in PR title" + echo "Please include the Jira ticket ID in the PR title, e.g.: '$TICKET: '" + exit 1 + fi + echo "❌ No Jira ticket ID found in PR title or branch name" + echo "" + echo "PR title must contain a Jira ticket ID matching pattern: HOTE-" + echo "Example: 'HOTE-123: Add user authentication'" + exit 1 metadata: name: "Set CI/CD metadata" runs-on: ubuntu-latest @@ -48,28 +66,30 @@ jobs: id: variables run: | datetime=$(date -u +'%Y-%m-%dT%H:%M:%S%z') - BUILD_DATETIME=$datetime make version-create-effective-file - echo "build_datetime_london=$(TZ=Europe/London date --date=$datetime +'%Y-%m-%dT%H:%M:%S%z')" >> $GITHUB_OUTPUT - echo "build_datetime=$datetime" >> $GITHUB_OUTPUT - echo "build_timestamp=$(date --date=$datetime -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT - echo "build_epoch=$(date --date=$datetime -u +'%s')" >> $GITHUB_OUTPUT - echo "nodejs_version=$(grep "^v" .nvmrc | cut -f2 -d'v')" >> $GITHUB_OUTPUT - echo "python_version=$(grep "^python\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "terraform_version=$(grep "^terraform\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "version=$(head -n 1 .version 2> /dev/null || echo unknown)" >> $GITHUB_OUTPUT + BUILD_DATETIME="$datetime" make version-create-effective-file + { + echo "build_datetime_london=$(TZ=Europe/London date --date="$datetime" +'%Y-%m-%dT%H:%M:%S%z')" + echo "build_datetime=$datetime" + echo "build_timestamp=$(date --date="$datetime" -u +'%Y%m%d%H%M%S')" + echo "build_epoch=$(date --date="$datetime" -u +'%s')" + echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" + echo "python_version=$(grep "^python\s" .tool-versions | cut -f2 -d' ')" + echo "terraform_version=$(grep "^terraform\s" .tool-versions | cut -f2 -d' ')" + echo "version=$(head -n 1 .version 2> /dev/null || echo unknown)" + } >> "$GITHUB_OUTPUT" - name: "Check if pull request exists for this branch" id: pr_exists env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - branch_name=${GITHUB_HEAD_REF:-$(echo $GITHUB_REF | sed 's#refs/heads/##')} + branch_name=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}} echo "Current branch is '$branch_name'" - if gh pr list --head $branch_name | grep -q .; then + if gh pr list --head "$branch_name" | grep -q .; then echo "Pull request exists" - echo "does_pull_request_exist=true" >> $GITHUB_OUTPUT + echo "does_pull_request_exist=true" >> "$GITHUB_OUTPUT" else echo "Pull request doesn't exist" - echo "does_pull_request_exist=false" >> $GITHUB_OUTPUT + echo "does_pull_request_exist=false" >> "$GITHUB_OUTPUT" fi - name: "List variables" run: | diff --git a/.github/workflows/cicd-2-publish.yaml b/.github/workflows/cicd-2-publish.yaml index 229a1e94..2fc60852 100644 --- a/.github/workflows/cicd-2-publish.yaml +++ b/.github/workflows/cicd-2-publish.yaml @@ -28,14 +28,16 @@ jobs: id: variables run: | datetime=$(date -u +'%Y-%m-%dT%H:%M:%S%z') - echo "build_datetime=$datetime" >> $GITHUB_OUTPUT - echo "build_timestamp=$(date --date=$datetime -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT - echo "build_epoch=$(date --date=$datetime -u +'%s')" >> $GITHUB_OUTPUT - echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "python_version=$(grep "^python\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "terraform_version=$(grep "^terraform\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - # TODO: Get the version, but it may not be the .version file as this should come from the CI/CD Pull Request Workflow - echo "version=$(head -n 1 .version 2> /dev/null || echo unknown)" >> $GITHUB_OUTPUT + { + echo "build_datetime=$datetime" + echo "build_timestamp=$(date --date="$datetime" -u +'%Y%m%d%H%M%S')" + echo "build_epoch=$(date --date="$datetime" -u +'%s')" + echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" + echo "python_version=$(grep "^python\s" .tool-versions | cut -f2 -d' ')" + echo "terraform_version=$(grep "^terraform\s" .tool-versions | cut -f2 -d' ')" + # TODO: Get the version, but it may not be the .version file as this should come from the CI/CD Pull Request Workflow + echo "version=$(head -n 1 .version 2> /dev/null || echo unknown)" + } >> "$GITHUB_OUTPUT" - name: "List variables" run: | export BUILD_DATETIME="${{ steps.variables.outputs.build_datetime }}" @@ -89,7 +91,7 @@ jobs: steps: - name: "Check prerequisites for notification" id: check - run: echo "secret_exist=${{ secrets.TEAMS_NOTIFICATION_WEBHOOK_URL != '' }}" >> $GITHUB_OUTPUT + run: echo "secret_exist=${{ secrets.TEAMS_NOTIFICATION_WEBHOOK_URL != '' }}" >> "$GITHUB_OUTPUT" - name: "Notify on publishing packages" if: steps.check.outputs.secret_exist == 'true' uses: nhs-england-tools/notify-msteams-action@v1.0.0 diff --git a/.github/workflows/cicd-3-deploy.yaml b/.github/workflows/cicd-3-deploy.yaml index 34892977..aa2466c8 100644 --- a/.github/workflows/cicd-3-deploy.yaml +++ b/.github/workflows/cicd-3-deploy.yaml @@ -3,11 +3,6 @@ name: "CI/CD deploy" on: workflow_dispatch: - inputs: - tag: - description: "This is the tag that is oging to be deployed" - required: true - default: "latest" jobs: metadata: @@ -22,23 +17,24 @@ jobs: python_version: ${{ steps.variables.outputs.python_version }} terraform_version: ${{ steps.variables.outputs.terraform_version }} version: ${{ steps.variables.outputs.version }} - tag: ${{ steps.variables.outputs.tag }} steps: - name: "Checkout code" uses: actions/checkout@v6 + - name: "Set CI/CD variables" id: variables run: | datetime=$(date -u +'%Y-%m-%dT%H:%M:%S%z') - echo "build_datetime=$datetime" >> $GITHUB_OUTPUT - echo "build_timestamp=$(date --date=$datetime -u +'%Y%m%d%H%M%S')" >> $GITHUB_OUTPUT - echo "build_epoch=$(date --date=$datetime -u +'%s')" >> $GITHUB_OUTPUT - echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "python_version=$(grep "^python\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - echo "terraform_version=$(grep "^terraform\s" .tool-versions | cut -f2 -d' ')" >> $GITHUB_OUTPUT - # TODO: Get the version, but it may not be the .version file as this should come from the CI/CD Pull Request Workflow - echo "version=$(head -n 1 .version 2> /dev/null || echo unknown)" >> $GITHUB_OUTPUT - echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + { + echo "build_datetime=$datetime" + echo "build_timestamp=$(date --date="$datetime" -u +'%Y%m%d%H%M%S')" + echo "build_epoch=$(date --date="$datetime" -u +'%s')" + echo "nodejs_version=$(grep "^nodejs\s" .tool-versions | cut -f2 -d' ')" + echo "python_version=$(grep "^python\s" .tool-versions | cut -f2 -d' ')" + echo "terraform_version=$(grep "^terraform\s" .tool-versions | cut -f2 -d' ')" + # TODO: Get the version, but it may not be the .version file as this should come from the CI/CD Pull Request Workflow + echo "version=$(head -n 1 .version 2> /dev/null || echo unknown)" + } >> "$GITHUB_OUTPUT" - name: "List variables" run: | export BUILD_DATETIME="${{ steps.variables.outputs.build_datetime }}" @@ -48,8 +44,8 @@ jobs: export PYTHON_VERSION="${{ steps.variables.outputs.python_version }}" export TERRAFORM_VERSION="${{ steps.variables.outputs.terraform_version }}" export VERSION="${{ steps.variables.outputs.version }}" - export TAG="${{ steps.variables.outputs.tag }}" make list-variables + deploy: name: "Deploy to an environment" runs-on: ubuntu-latest diff --git a/.github/workflows/playwright-e2e.yaml b/.github/workflows/playwright-e2e.yaml index 9ac84346..8efdae17 100644 --- a/.github/workflows/playwright-e2e.yaml +++ b/.github/workflows/playwright-e2e.yaml @@ -1,11 +1,20 @@ --- name: "Playwright E2E Tests" +run-name: "E2E Tests: env: ${{ inputs.environment || 'local' }}, browser: ${{ inputs.browser || 'chromium' }}, filter: ${{ inputs.test_filter && format(' - {0}', inputs.test_filter) || '' }}" on: schedule: - cron: '0 2 * * *' # Every day at 2am UTC workflow_dispatch: inputs: + environment: + description: "Target environment" + required: false + default: "local" + type: choice + options: + - local + - dev browser: description: "Browser to run tests on" required: false @@ -27,15 +36,14 @@ jobs: name: "Playwright E2E tests" runs-on: ubuntu-latest timeout-minutes: 30 + env: + TARGET_ENV: ${{ inputs.environment || 'local' }} steps: - name: "Checkout code" uses: actions/checkout@v6 - - name: "Install mise" - uses: jdx/mise-action@v3 - with: - install: true - cache: true + - name: Initialize mise + uses: ./.github/actions/init-mise - name: "Install Playwright browsers" working-directory: tests @@ -57,34 +65,50 @@ jobs: EOF - name: "Start the application" + if: env.TARGET_ENV == 'local' || env.TARGET_ENV == 'dev' run: | npm run start - name: "Show application status" + if: env.TARGET_ENV == 'local' || env.TARGET_ENV == 'dev' run: | docker compose -f local-environment/docker-compose.yml ps docker logs ui - name: "Get terraform outputs" + if: env.TARGET_ENV == 'local' || env.TARGET_ENV == 'dev' id: terraform run: | UI_URL=$(terraform -chdir=local-environment/infra output -raw ui_url) API_URL=$(terraform -chdir=local-environment/infra output -raw api_base_url) - echo "ui_url=$UI_URL" >> $GITHUB_OUTPUT - echo "api_base_url=$API_URL" >> $GITHUB_OUTPUT + echo "ui_url=$UI_URL" >> "$GITHUB_OUTPUT" + echo "api_base_url=$API_URL" >> "$GITHUB_OUTPUT" echo "UI URL: $UI_URL" echo "API URL: $API_URL" + - name: "Set environment URLs" + id: urls + run: | + if [ "$TARGET_ENV" == "dev" ]; then + echo "ui_url=https://dev.hometest.service.nhs.uk" >> "$GITHUB_OUTPUT" + echo "api_url=https://dev.hometest.service.nhs.uk/" >> "$GITHUB_OUTPUT" + else + echo "ui_url=${{ steps.terraform.outputs.ui_url }}" >> "$GITHUB_OUTPUT" + echo "api_url=${{ steps.terraform.outputs.api_base_url }}" >> "$GITHUB_OUTPUT" + fi + - name: "Wait for UI to be reachable" run: | - UI_URL="${{ steps.terraform.outputs.ui_url }}" + UI_URL="${{ steps.urls.outputs.ui_url }}" echo "Waiting for UI to be reachable at $UI_URL..." timeout=120 elapsed=0 until curl -sf "$UI_URL" > /dev/null 2>&1; do if [ $elapsed -ge $timeout ]; then echo "Timeout: UI not reachable after ${timeout}s" - docker logs ui + if [ "$TARGET_ENV" == "local" ] || [ "$TARGET_ENV" == "dev" ]; then + docker logs ui + fi exit 1 fi echo "Waiting... (${elapsed}s)" @@ -101,7 +125,7 @@ jobs: FILTER="${{ inputs.test_filter }}" # Build the command - CMD="npx playwright test" + CMD="HEADLESS=true ENV=dev npx playwright test" # Add browser project if [ "$BROWSER" != "all" ]; then @@ -114,19 +138,19 @@ jobs: fi echo "Running: $CMD" - eval $CMD + eval "$CMD" env: CI: true FORCE_COLOR: true - UI_BASE_URL: ${{ steps.terraform.outputs.ui_url }} - API_BASE_URL: ${{ steps.terraform.outputs.api_base_url }} + UI_BASE_URL: ${{ steps.urls.outputs.ui_url }} + API_BASE_URL: ${{ steps.urls.outputs.api_url }} - name: "Grab docker compose logs" + if: always() && (env.TARGET_ENV == 'local' || env.TARGET_ENV == 'dev') run: | for service in $(docker compose -f local-environment/docker-compose.yml ps --services); do docker compose -f local-environment/docker-compose.yml logs "$service" > "tests/testResults/docker-compose-${service}.log" 2>&1 done - if: always() - name: "Publish Test Results" uses: dorny/test-reporter@v2 @@ -140,24 +164,26 @@ jobs: - name: "Generate Job Summary" if: always() run: | - echo "## Playwright Test Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - if [ -f tests/testResults/junit-results.xml ]; then - TESTS=$(grep -oP 'tests="\K[0-9]+' tests/testResults/junit-results.xml | head -1) - FAILURES=$(grep -oP 'failures="\K[0-9]+' tests/testResults/junit-results.xml | head -1) - ERRORS=$(grep -oP 'errors="\K[0-9]+' tests/testResults/junit-results.xml | head -1) - TIME=$(grep -oP 'time="\K[0-9.]+' tests/testResults/junit-results.xml | head -1) - PASSED=$((TESTS - FAILURES - ERRORS)) - echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY - echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| Total Tests | $TESTS |" >> $GITHUB_STEP_SUMMARY - echo "| :white_check_mark: Passed | $PASSED |" >> $GITHUB_STEP_SUMMARY - echo "| :x: Failed | $FAILURES |" >> $GITHUB_STEP_SUMMARY - echo "| :warning: Errors | $ERRORS |" >> $GITHUB_STEP_SUMMARY - echo "| :stopwatch: Duration | ${TIME}s |" >> $GITHUB_STEP_SUMMARY - else - echo ":warning: No test results found" >> $GITHUB_STEP_SUMMARY - fi + { + echo "## Playwright Test Results" + echo "" + if [ -f tests/testResults/junit-results.xml ]; then + TESTS=$(grep -oP 'tests="\K[0-9]+' tests/testResults/junit-results.xml | head -1) + FAILURES=$(grep -oP 'failures="\K[0-9]+' tests/testResults/junit-results.xml | head -1) + ERRORS=$(grep -oP 'errors="\K[0-9]+' tests/testResults/junit-results.xml | head -1) + TIME=$(grep -oP 'time="\K[0-9.]+' tests/testResults/junit-results.xml | head -1) + PASSED=$((TESTS - FAILURES - ERRORS)) + echo "| Metric | Value |" + echo "|--------|-------|" + echo "| Total Tests | $TESTS |" + echo "| :white_check_mark: Passed | $PASSED |" + echo "| :x: Failed | $FAILURES |" + echo "| :warning: Errors | $ERRORS |" + echo "| :stopwatch: Duration | ${TIME}s |" + else + echo ":warning: No test results found" + fi + } >> "$GITHUB_STEP_SUMMARY" - name: "Upload test results" uses: actions/upload-artifact@v7 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51797845..8e6ef193 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,13 +8,20 @@ repos: args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - id: check-yaml + args: [--allow-multiple-documents] - id: check-json + - id: check-toml - id: check-added-large-files + args: ["--maxkb=500"] - id: check-case-conflict - id: check-merge-conflict + - id: check-symlinks - id: detect-private-key - id: check-executables-have-shebangs - - id: forbid-submodules + - id: mixed-line-ending + args: [--fix=lf] + - id: no-commit-to-branch + args: [--branch, main, --branch, master, --branch, develop] - repo: local hooks: diff --git a/local-environment/scripts/localstack/deploy.sh b/local-environment/scripts/localstack/deploy.sh index d0502a1e..077ffe7b 100755 --- a/local-environment/scripts/localstack/deploy.sh +++ b/local-environment/scripts/localstack/deploy.sh @@ -1,9 +1,7 @@ #!/bin/bash set -ex -SCRIPT_DIR=$(dirname "$0") ENDPOINT_URL="http://localstack:4566" -ROLE_ARN="arn:aws:iam::000000000000:role/lambda-exec" export AWS_ACCESS_KEY_ID="test" export AWS_SECRET_ACCESS_KEY="test" diff --git a/local-environment/scripts/localstack/get_supplier_id.sh b/local-environment/scripts/localstack/get_supplier_id.sh index 3a5f892e..44b9fd21 100644 --- a/local-environment/scripts/localstack/get_supplier_id.sh +++ b/local-environment/scripts/localstack/get_supplier_id.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -for i in {1..10}; do +for _ in {1..10}; do SUPPLIER_ID=$(docker exec postgres-db psql "postgresql://app_user:STRONG_APP_PASSWORD@localhost:5432/local_hometest_db" -A -t -c "SET search_path TO hometest; SELECT supplier_id FROM supplier LIMIT 1;" 2>/dev/null | grep -v '^$' | grep -v '^SET$' | head -n 1 || echo "") if [[ -n "$SUPPLIER_ID" ]]; then echo "{\"supplier_id\": \"$SUPPLIER_ID\"}" diff --git a/package.json b/package.json index e84cc5f6..1f22ee5b 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,12 @@ "scripts": { "postinstall": "npm --prefix ui install && npm --prefix lambdas install && npm --prefix tests install", "test": "npm --prefix ui run test && npm --prefix lambdas run test", - "test:playwright": "UI_BASE_URL=$(terraform -chdir=local-environment/infra output -raw ui_url) API_BASE_URL=$(terraform -chdir=local-environment/infra output -raw api_base_url) npm --prefix tests run test:chrome", + "test:playwright": "HEADLESS=false ENV=local UI_BASE_URL=$(terraform -chdir=local-environment/infra output -raw ui_url) API_BASE_URL=$(terraform -chdir=local-environment/infra output -raw api_base_url) npm --prefix tests run test:chrome", + "build:lambdas": "npm --prefix lambdas run build", "package:lambdas": "npm --prefix lambdas run package", + + "clean:all": "npm run stop && rm -rf ./node_modules */node_modules */dist */build && docker system prune -f --volumes", "start": "npm ci && npm run local:start", "stop": "npm run local:stop", "local:start": "npm run local:backend:start && npm run local:terraform:init && npm run local:deploy && npm run local:frontend:start", diff --git a/scripts/config/gitleaks.toml b/scripts/config/gitleaks.toml index 9a60db13..4193b120 100644 --- a/scripts/config/gitleaks.toml +++ b/scripts/config/gitleaks.toml @@ -28,5 +28,6 @@ paths = [ '''.session-cache/*''', '''ui/.next/*''', '''ui/build/*''', '''cdk.out/*''', - '''.idea/*''' + '''.idea/*''', + '''.pre-commit-config.yaml''' ] diff --git a/tests/configuration/.env.dev b/tests/configuration/.env.dev index 71e19849..2def9ad7 100644 --- a/tests/configuration/.env.dev +++ b/tests/configuration/.env.dev @@ -4,3 +4,8 @@ API_BASE_URL= HEADLESS=false TIMEOUT=30000 SLOW_MO=0 + +# External Links +EXTERNAL_LINK_SEXUAL_HEALTH_CLINIC=https://www.nhs.uk/service-search/sexual-health-services/find-a-sexual-health-clinic/ +EXTERNAL_LINK_NEAREST_AE=https://www.nhs.uk/service-search/find-an-accident-and-emergency-service/ +EXTERNAL_LINK_HIV_AIDS_INFO=https://www.nhs.uk/conditions/hiv-and-aids/ diff --git a/tests/configuration/EnvironmentVariables.ts b/tests/configuration/EnvironmentVariables.ts index e85ea5c8..c2078b2d 100644 --- a/tests/configuration/EnvironmentVariables.ts +++ b/tests/configuration/EnvironmentVariables.ts @@ -14,6 +14,9 @@ export enum EnvironmentVariables { DB_USER = 'DB_USER', DB_PASSWORD = 'DB_PASSWORD', DB_SCHEMA = 'DB_SCHEMA', + EXTERNAL_LINK_SEXUAL_HEALTH_CLINIC = 'EXTERNAL_LINK_SEXUAL_HEALTH_CLINIC', + EXTERNAL_LINK_NEAREST_AE = 'EXTERNAL_LINK_NEAREST_AE', + EXTERNAL_LINK_HIV_AIDS_INFO = 'EXTERNAL_LINK_HIV_AIDS_INFO', } export const availableEnvironments = ['local', 'dev'] as const; diff --git a/tests/configuration/configuration.ts b/tests/configuration/configuration.ts new file mode 100644 index 00000000..64a5f805 --- /dev/null +++ b/tests/configuration/configuration.ts @@ -0,0 +1,230 @@ +import * as dotenv from 'dotenv'; +import * as path from 'path'; +import * as fs from 'fs'; +import { EnvironmentVariables, availableEnvironments, Environment } from './EnvironmentVariables'; + +export { EnvironmentVariables }; + +export enum AuthType { + SANDBOX = 'sandbox' +} + +export interface ConfigInterface { + uiBaseUrl: string; + apiBaseUrl: string; + headless: boolean; + timeout: number; + slowMo: number; + authType: AuthType; + accessibilityStandards: string; + reportingOutputDirectory: string; + externalLinkSexualHealthClinic: string; + externalLinkNearestAE: string; + externalLinkHivAidsInfo: string; + enableTracingOnGlobalSetup: boolean; +} + +export class ConfigFactory { + private static cachedConfig: ConfigInterface | undefined; + private static envName: Environment; + + public static getConfig(): ConfigInterface { + this.envName = (process.env.ENV as Environment) || 'local'; + + // Validate environment + if (!availableEnvironments.includes(this.envName)) { + throw new Error( + `Invalid environment: ${this.envName}. Available environments: ${availableEnvironments.join(', ')}` + ); + } + + this.cachedConfig ??= this.loadConfiguration(); + + return this.cachedConfig; + } + + private static loadConfiguration(): ConfigInterface { + console.log('Loading configuration for the tests...'); + + const defaultConfig = this.loadDefaultConfiguration(); // start with default configuration + const envConfig = this.readConfigurationFromEnvFile(); // override with values from .env file + const localConfig = this.readConfigurationFromLocalFile(); // override with local JSON file + const cliEnvConfig = this.readConfigurationFromProcessEnv(); // highest priority: CLI env vars + + const cachedConfig = { + ...defaultConfig, + ...envConfig, + ...localConfig, + ...cliEnvConfig + }; + + return cachedConfig; + } + + /** + * Read configuration directly from process.env (CLI environment variables) + * This has the highest priority and overrides all other configuration sources + */ + private static readConfigurationFromProcessEnv(): Partial { + const config: Partial = {}; + + // Only include values that are explicitly set in environment + if (process.env[EnvironmentVariables.UI_BASE_URL]) { + config.uiBaseUrl = process.env[EnvironmentVariables.UI_BASE_URL]; + } + if (process.env[EnvironmentVariables.API_BASE_URL]) { + config.apiBaseUrl = process.env[EnvironmentVariables.API_BASE_URL]; + } + if (process.env[EnvironmentVariables.HEADLESS] !== undefined) { + config.headless = process.env[EnvironmentVariables.HEADLESS] === 'true'; + } + if (process.env[EnvironmentVariables.TIMEOUT]) { + config.timeout = parseInt(process.env[EnvironmentVariables.TIMEOUT]!, 10); + } + if (process.env[EnvironmentVariables.SLOW_MO]) { + config.slowMo = parseInt(process.env[EnvironmentVariables.SLOW_MO]!, 10); + } + if (process.env[EnvironmentVariables.ACCESSIBILITY_STANDARDS]) { + config.accessibilityStandards = process.env[EnvironmentVariables.ACCESSIBILITY_STANDARDS]; + } + + if (Object.keys(config).length > 0) { + console.log('✅ Applied CLI environment variable overrides:', Object.keys(config).join(', ')); + } + + return config; + } + + private static loadDefaultConfiguration(): ConfigInterface { + return { + uiBaseUrl: 'http://localhost:3000', + apiBaseUrl: 'http://localhost:4000/api', + headless: true, + timeout: 30000, + slowMo: 0, + authType: AuthType.SANDBOX, + accessibilityStandards: 'wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa', + reportingOutputDirectory: 'tests/testResults', + externalLinkSexualHealthClinic: 'https://www.nhs.uk/service-search/sexual-health-services/find-a-sexual-health-clinic/', + externalLinkNearestAE: 'https://www.nhs.uk/service-search/find-an-accident-and-emergency-service/', + externalLinkHivAidsInfo: 'https://www.nhs.uk/conditions/hiv-and-aids/', + enableTracingOnGlobalSetup: false, + }; + } + + private static readConfigurationFromEnvFile(): Partial { + const envFilePath = path.resolve(__dirname, `.env.${this.envName}`); + + const result = dotenv.config({ path: envFilePath }); + + if (result.error) { + console.log( + `No .env file found for environment: ${this.envName}. Using default configuration.` + ); + return {}; + } + + console.log(`✅ Loaded configuration from .env.${this.envName}`); + + // Map environment variables to config interface + return { + uiBaseUrl: process.env[EnvironmentVariables.UI_BASE_URL], + apiBaseUrl: process.env[EnvironmentVariables.API_BASE_URL], + headless: process.env[EnvironmentVariables.HEADLESS] === 'true', + timeout: process.env[EnvironmentVariables.TIMEOUT] + ? parseInt(process.env[EnvironmentVariables.TIMEOUT]!, 10) + : undefined, + slowMo: process.env[EnvironmentVariables.SLOW_MO] + ? parseInt(process.env[EnvironmentVariables.SLOW_MO]!, 10) + : undefined, + accessibilityStandards: process.env[EnvironmentVariables.ACCESSIBILITY_STANDARDS], + reportingOutputDirectory: process.env[EnvironmentVariables.REPORTING_OUTPUT_DIRECTORY], + externalLinkSexualHealthClinic: process.env[EnvironmentVariables.EXTERNAL_LINK_SEXUAL_HEALTH_CLINIC], + externalLinkNearestAE: process.env[EnvironmentVariables.EXTERNAL_LINK_NEAREST_AE], + externalLinkHivAidsInfo: process.env[EnvironmentVariables.EXTERNAL_LINK_HIV_AIDS_INFO], + }; + } + + private static readConfigurationFromLocalFile(): Partial { + const localFilePath = path.join(__dirname, `./local.json`); + + if (!fs.existsSync(localFilePath)) { + console.log('No local configuration file found'); + return {}; + } + + return this.readConfigurationFromFile(localFilePath); + } + + private static readConfigurationFromFile(filePath: string): Partial { + try { + const configurationFileContent = fs.readFileSync(filePath, 'utf8'); + const configuration = JSON.parse(configurationFileContent); + return configuration as Partial; + } catch (e) { + console.log(`Error reading configuration file: ${filePath}`, e); + throw e; + } + } + + public static get(key: keyof ConfigInterface): any { + const config = this.getConfig(); + return config[key]; + } + + public static getEnvironment(): Environment { + return this.envName; + } +} + +// Backward compatibility wrapper +class ConfigWrapper { + get(key: EnvironmentVariables): string { + const config = ConfigFactory.getConfig(); + + switch (key) { + case EnvironmentVariables.UI_BASE_URL: + return config.uiBaseUrl; + case EnvironmentVariables.API_BASE_URL: + return config.apiBaseUrl; + case EnvironmentVariables.HEADLESS: + return String(config.headless); + case EnvironmentVariables.TIMEOUT: + return String(config.timeout); + case EnvironmentVariables.SLOW_MO: + return String(config.slowMo); + case EnvironmentVariables.ACCESSIBILITY_STANDARDS: + return config.accessibilityStandards || 'wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa'; + case EnvironmentVariables.REPORTING_OUTPUT_DIRECTORY: + return config.reportingOutputDirectory; + case EnvironmentVariables.EXTERNAL_LINK_SEXUAL_HEALTH_CLINIC: + return config.externalLinkSexualHealthClinic; + case EnvironmentVariables.EXTERNAL_LINK_NEAREST_AE: + return config.externalLinkNearestAE; + case EnvironmentVariables.EXTERNAL_LINK_HIV_AIDS_INFO: + return config.externalLinkHivAidsInfo; + default: + throw new Error(`Unknown configuration key: ${key}`); + } + } + + getBoolean(key: EnvironmentVariables): boolean { + const value = this.get(key); + return value.toLowerCase() === 'true'; + } + + getNumber(key: EnvironmentVariables): number { + const value = this.get(key); + const numValue = parseInt(value, 10); + if (isNaN(numValue)) { + throw new Error(`Configuration error: '${key}' value '${value}' is not a valid number.`); + } + return numValue; + } + + getEnvironment(): Environment { + return ConfigFactory.getEnvironment(); + } +} + +export const config = new ConfigWrapper(); diff --git a/tests/fixtures/consoleErrorFixture.ts b/tests/fixtures/consoleErrorFixture.ts index 592f11bc..637e46c1 100644 --- a/tests/fixtures/consoleErrorFixture.ts +++ b/tests/fixtures/consoleErrorFixture.ts @@ -39,12 +39,17 @@ const defaultOptions: ErrorCaptureOptions = { // Network transient errors /net::ERR_NETWORK_CHANGED/, /net::ERR_CONNECTION_RESET/, + /net::ERR_CONNECTION_REFUSED/, /net::ERR_INTERNET_DISCONNECTED/, // External NHS resources not available in test environment /NHSCookieConsent is not defined/, /nhsapp is not defined/, /"undefined" is not valid JSON/, - // Next.js hydration warning - not an application issue + // CSP font violations from external NHS assets + /Content Security Policy directive.*font-src/, + /assets\.nhs\.uk.*font/i, + // React Router v7 warning: root route has a loader but no HydrateFallback defined + // This is a known issue to be fixed in the UI app (add HydrateFallback to root route) /No `HydrateFallback` element provided to render during initial hydration/, ], ignoreStatusCodes: [], diff --git a/tests/global-setup.ts b/tests/global-setup.ts index 00f21ca0..a13a8271 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -1,3 +1,6 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { ConfigFactory } from './configuration/configuration'; import { CredentialsHelper } from './utils/CredentialsHelper'; import { UserManagerFactory } from './utils/users/UserManagerFactory'; @@ -5,8 +8,19 @@ async function globalSetup() { console.log('🚀 Global setup started'); console.log(`Tests will run on environment: ${process.env.ENV ?? 'local'}`); - // Add user credentials to env variable + // Add user credentials to env variable await new CredentialsHelper().addCredentialsToEnvVariable(); + + // Check if we should skip login (for reusing existing sessions) + if (process.env.SKIP_LOGIN === 'true') { + const sessionDir = path.resolve(__dirname, '.session-cache'); + if (fs.existsSync(sessionDir) && fs.readdirSync(sessionDir).length > 0) { + console.log('⏭️ SKIP_LOGIN=true - Reusing existing session files'); + return; + } + console.log('⚠️ SKIP_LOGIN=true but no session files found, proceeding with login'); + } + await new UserManagerFactory().getUserManager().loginWorkerUsers(); } diff --git a/tests/page-objects/HomeTestStartPage.ts b/tests/page-objects/HomeTestStartPage.ts index 84cdb359..af660374 100644 --- a/tests/page-objects/HomeTestStartPage.ts +++ b/tests/page-objects/HomeTestStartPage.ts @@ -47,6 +47,11 @@ export class HomeTestStartPage extends BasePage { await this.page.goto(`${this.config.uiBaseUrl}/get-self-test-kit-for-HIV`); } + async waitUntilPageLoad(): Promise { + // Wait for the requireAuth loader to complete and the page to render + await this.headerText.waitFor({ timeout: 30000 }); + } + async clickFindClinicLink(expectedUrl: string): Promise { await this.findClinicLink.click(); await this.page.waitForURL(expectedUrl); diff --git a/tests/page-objects/NHSLogin/CodeSecurityPage.ts b/tests/page-objects/NHSLogin/CodeSecurityPage.ts index 35ebd36b..303e571d 100644 --- a/tests/page-objects/NHSLogin/CodeSecurityPage.ts +++ b/tests/page-objects/NHSLogin/CodeSecurityPage.ts @@ -25,7 +25,8 @@ export class CodeSecurityPage { async fillAuthOneTimePasswordAndClickContinue( oneTimePassword: string ): Promise { - await this.waitForOtpTrigger(); + // Wait for OTP input field to be visible (indicates OTP page loaded) + await this.securityCodeField.waitFor({ timeout: 30000 }); await this.fillAuthOneTimePassword(oneTimePassword); await this.continueBtn.click(); } @@ -34,6 +35,9 @@ export class CodeSecurityPage { await this.rememberDeviceCheckbox.click(); } + /** + * @deprecated Use waitFor on securityCodeField instead - network wait is unreliable + */ async waitForOtpTrigger(): Promise { await this.page.waitForResponse( (response) => diff --git a/tests/page-objects/NHSLogin/NhsLoginConsentPage.ts b/tests/page-objects/NHSLogin/NhsLoginConsentPage.ts new file mode 100644 index 00000000..cc907742 --- /dev/null +++ b/tests/page-objects/NHSLogin/NhsLoginConsentPage.ts @@ -0,0 +1,33 @@ +import { type Locator, type Page } from '@playwright/test'; + +export class NhsLoginConsentPage { + readonly page: Page; + // NHS Login consent page has an "agree" submit button — try common text variants + readonly agreeToShareBtn: Locator; + readonly continueBtn: Locator; + readonly doNotAgreeToShareInformationLink: Locator; + + constructor(page: Page) { + this.page = page; + this.agreeToShareBtn = page.getByRole('button', { + name: /I agree to share this information/i + }); + // Fallback: some NHS Login consent pages just have a "Continue" button to accept + this.continueBtn = page.locator('button[type="submit"]'); + this.doNotAgreeToShareInformationLink = page.getByRole('link', { + name: /I do not agree/i + }); + } + + async agreeAndContinue(): Promise { + // Wait for the page to fully load (do-not-agree link is the stable indicator) + await this.doNotAgreeToShareInformationLink.waitFor({ timeout: 15000 }); + + if (await this.agreeToShareBtn.isVisible()) { + await this.agreeToShareBtn.click(); + } else { + // Fall back to submit button (the main CTA that accepts consent) + await this.continueBtn.click(); + } + } +} diff --git a/tests/page-objects/NhsLoginHelper.ts b/tests/page-objects/NhsLoginHelper.ts index 43676e42..d16a617f 100644 --- a/tests/page-objects/NhsLoginHelper.ts +++ b/tests/page-objects/NhsLoginHelper.ts @@ -6,6 +6,8 @@ import { import type { NHSLoginUser } from '../utils/users/BaseUser'; import { NHSEmailAndPasswordPage } from './NHSLogin/NHSEmailAndPasswordPage'; import { CodeSecurityPage } from './NHSLogin/CodeSecurityPage'; +import { NhsLoginConsentPage } from './NHSLogin/NhsLoginConsentPage'; +import * as fs from 'fs'; export default class NhsLoginHelper { readonly config: ConfigInterface; @@ -19,16 +21,93 @@ export default class NhsLoginHelper { ): Promise { const loginPage = new NHSEmailAndPasswordPage(page); const codeSecurityPage = new CodeSecurityPage(page); + const consentPage = new NhsLoginConsentPage(page); + // Log file for debugging + const logFile = 'testResults/nhs-login-debug.log'; + fs.mkdirSync('testResults', { recursive: true }); + const log = (message: string) => { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] ${message}\n`; + console.log(message); // Also log to console + fs.appendFileSync(logFile, logMessage); + }; + + log('=== NHS Login Flow Started ==='); + + // Capture all browser console messages + page.on('console', (msg) => { + const type = msg.type(); + const text = msg.text(); + const location = msg.location(); + + log(`[Browser Console - ${type.toUpperCase()}] ${text}`); + if (location.url) { + log(` ↳ ${location.url}:${location.lineNumber}:${location.columnNumber}`); + } + }); + + // Capture all network requests and responses + page.on('request', (request) => { + log(`[Network Request] ${request.method()} ${request.url()}`); + const postData = request.postData(); + if (postData) { + log(` ↳ Body: ${postData.substring(0, 200)}${postData.length > 200 ? '...' : ''}`); + } + }); + + page.on('response', async (response) => { + const status = response.status(); + const statusText = response.statusText(); + const url = response.url(); + + log(`[Network Response] ${status} ${statusText} - ${response.request().method()} ${url}`); + + // Log response body for non-2xx responses or specific content types + if (status >= 400 || url.includes('/api/')) { + try { + const contentType = response.headers()['content-type'] || ''; + if (contentType.includes('application/json')) { + const body = await response.text(); + log(` ↳ Response Body: ${body.substring(0, 500)}${body.length > 500 ? '...' : ''}`); + } + } catch (error) { + log(` ↳ Could not read response body: ${error}`); + } + } + }); + + log(`Navigating to: ${this.config.uiBaseUrl}`); await page.goto(`${this.config.uiBaseUrl}`); await page.waitForURL(/signin\.nhs\.uk/, { timeout: 60000 }); - console.log(`Redirected to NHS Login: ${page.url()}`); + log(`Redirected to NHS Login: ${page.url()}`); + log('Filling login credentials...'); await loginPage.fillAuthFormWithCredentialsAndClickContinue(nhsLoginUser); + + log('Entering OTP code...'); await codeSecurityPage.fillAuthOneTimePasswordAndClickContinue( nhsLoginUser.otp ); - await page.waitForURL('**/get-self-test-kit-for-HIV'); + + // Handle NHS Login consent page if it appears (first login or expired consent) + // Race between consent page and direct app redirect — whichever arrives first + log('Waiting for consent page or redirect...'); + const consentAppeared = await Promise.race([ + page.waitForURL(/nhs-login-consent/, { timeout: 15000 }).then(() => true), + page.waitForURL('**/get-self-test-kit-for-HIV', { timeout: 15000 }).then(() => false) + ]).catch(() => false); + + if (consentAppeared) { + log('Consent page detected, agreeing to share information...'); + await consentPage.agreeAndContinue(); + } else { + log('No consent page - direct redirect to app'); + } + + log('Waiting for final redirect to app...'); + await page.waitForURL('**/get-self-test-kit-for-HIV', { timeout: 60000 }); + log('=== NHS Login Flow Completed Successfully ==='); } public async loginNhsUser(page: Page, user: NHSLoginUser): Promise { diff --git a/tests/utils/users/BaseUserManager.ts b/tests/utils/users/BaseUserManager.ts index 8ad71d53..c72b0aa9 100644 --- a/tests/utils/users/BaseUserManager.ts +++ b/tests/utils/users/BaseUserManager.ts @@ -14,8 +14,10 @@ interface NetworkError { statusText: string; method: string; timestamp: string; + responseText: Promise; } + export abstract class BaseUserManager { protected readonly workerUsers: TUser[]; private readonly numberOfWorkerUsers: number; @@ -86,6 +88,7 @@ export abstract class BaseUserManager { statusText: response.statusText(), method: response.request().method(), timestamp: new Date().toISOString(), + responseText: response.text().catch(() => "Unable to retrieve response body"), }); console.error( `❌ HTTP ${status} ${response.statusText()}: ${response.request().method()} ${response.url()}`, @@ -242,17 +245,19 @@ export abstract class BaseUserManager { console.error(`\n🌐 Network errors detected during setup:`); networkErrors.forEach((err, idx) => { console.error( - ` ${idx + 1}. ${err.method} ${err.url} => ${err.status} ${err.statusText}`, + ` ${idx + 1}. ${err.method}, ${err.url} => ${err.status} ${err.statusText}`, ); }); } await this.captureFailureArtifacts(page, context, user, error as Error, networkErrors); await browser.close(); + console.log(`✅ Successfully logged in worker user ${user.nhsNumber} and saved session state. CATCH`); throw error; } await browser.close(); + console.log(`✅ Successfully logged in worker user ${user.nhsNumber} and saved session state.`); } } getSpecialUser(key: SpecialUserKey): TUser {