diff --git a/.gitignore b/.gitignore index c859f2d5..e1824046 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ pnpm-debug.log* /blob-report/ /playwright/.cache/ artifacts/ +ortoni-report/ # Misc coverage/* diff --git a/azure-pipelines-automated-testing.yml b/azure-pipelines-automated-testing.yml new file mode 100644 index 00000000..cab8b401 --- /dev/null +++ b/azure-pipelines-automated-testing.yml @@ -0,0 +1,187 @@ +trigger: none +schedules: +- cron: 0 9 * * MON-FRI + always: true + displayName: Daily Test Automation Run for ISS-NextGen + branches: + include: + - develop +pool: 'Default' + +variables: + # - group: Digital-Infrastructure + # - group: ISS-BuildBranches + - name: dockerImageName + value: 'playwright-tests' + - name: imageTag + value: '$(Build.BuildId)' + - name: totalShards + value: 4 + - name: IS_REGRESSION + value: 'true' + - name: JIRA_BOARD_ID + value: '853' + - name: JIRA_EPIC_KEY + value: 'INSR-414' + - name: JIRA_PROJECT_KEY + value: 'INSR' + +stages: + - stage: TestPr + displayName: Run Playwright Test + jobs: + - job: playwright_tests + continueOnError: true + strategy: + matrix: + shard1: + shardNumber: 1 + shard2: + shardNumber: 2 + shard3: + shardNumber: 3 + shard4: + shardNumber: 4 + + steps: + - task: Docker@2 + displayName: 'Build Docker Image' + inputs: + command: build + dockerfile: Dockerfile.playwright + repository: $(dockerImageName) + tags: $(imageTag) + arguments: '--no-cache --pull' + + - script: | + # Create container and run tests + container_id=$(docker create \ + --ipc=host \ + -e CCIS_API_AUTH=$(CCIS_API_AUTH) \ + -e BASE_URL=$(BASE_URL) \ + -e CCIS_API_URL=$(CCIS_API_URL) \ + -e ADMIN_SERVICE_API_URL=$(ADMIN_SERVICE_API_URL) \ + -e SHARD=$(shardNumber) \ + -e CI=true \ + -e NODE_ENV=$(NODE_ENV) \ + $(dockerImageName):$(imageTag) \ + npx concurrently -k -n "server,playwright"\ + "sed -i \"s|^process\.env\.VUE_APP_CONSUMER_CF_DISTRO = .*|process\.env\.VUE_APP_CONSUMER_CF_DISTRO='https://digitalapi.test.safelite.io'|\" \"./vue.config.js\" && echo \"Updated config file to use TEST APIs\" && npm run serve -- --port=8080"\ + "npx wait-on http://localhost:8080 && npm run test:playwright -- --shard=$(shardNumber)/$(totalShards) --reporter=list,blob --grep \"@smoke|@Advanced\"") + + # Start container and stream logs + echo "Starting tests for shard $(shardNumber)..." + docker start -a $container_id + + # Create directory for test results + echo "Creating test results directory..." + mkdir -p $(System.DefaultWorkingDirectory)/blob-reports/shard-$(shardNumber) + + # Copy test results from container + echo "Copying test results..." + docker cp $container_id:/app/blob-report/. $(System.DefaultWorkingDirectory)/blob-reports/shard-$(shardNumber)/ + + # Remove container + echo "Cleaning up container..." + docker rm $container_id + + # Check if tests failed + if [ $? -ne 0 ]; then + echo "Tests failed in shard $(shardNumber) or tests don't exist for this shard number!" + exit 0 # Suppress error. It will be visible in report. + fi + displayName: 'Run Playwright Tests - Shard $(shardNumber)' + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Test Reports - Shard $(shardNumber)' + condition: always() + inputs: + targetPath: '$(System.DefaultWorkingDirectory)/blob-reports/shard-$(shardNumber)' + artifact: 'playwright-report-shard-$(shardNumber)' + publishLocation: 'pipeline' + + - script: | + docker rmi $(dockerImageName):$(imageTag) -f + displayName: 'Cleanup Docker Image' + condition: always() + + - job: download_and_merge_reports + dependsOn: playwright_tests + timeoutInMinutes: 8 + cancelTimeoutInMinutes: 10 + steps: + - task: DownloadPipelineArtifact@2 + inputs: + targetPath: '$(System.DefaultWorkingDirectory)/playwright-reports' + - task: Docker@2 + displayName: 'Build Docker Image' + inputs: + command: build + dockerfile: Dockerfile.playwright + repository: $(dockerImageName) + tags: $(imageTag) + arguments: '--no-cache --pull' + - bash: | + # Create container for jira writeback + container_id=$(docker create \ + --ipc=host \ + -e JIRA_SERVER=$(JIRA_SERVER) \ + -e JIRA_USERNAME=$(JIRA_USERNAME) \ + -e JIRA_API_KEY=$(JIRA_API_KEY) \ + -e IS_REGRESSION=$(IS_REGRESSION) \ + -e JIRA_BOARD_ID=$(JIRA_BOARD_ID) \ + -e JIRA_EPIC_KEY=$(JIRA_EPIC_KEY) \ + -e JIRA_PROJECT_KEY=$(JIRA_PROJECT_KEY) \ + $(dockerImageName):$(imageTag) \ + bash -c "echo \"Moving Playwright reports out of subfolders...\" && + find ./playwright-reports/ -mindepth 2 -type f -exec mv {} ./playwright-reports/ \; && + echo \"Merging reports...\" && + PLAYWRIGHT_JUNIT_OUTPUT_DIR='/app/test-results' PLAYWRIGHT_JUNIT_OUTPUT_NAME='junit_results.xml' npx playwright merge-reports --reporter='playwright-tests/impl/reporter/JiraWritebackReporter.ts',junit ./playwright-reports + echo \"Contents of ortoni-report:\" && ls ./ortoni-report && + echo 'Current dir: ' && pwd + echo 'Contents of current dir: ' && ls + echo 'Contents of /app/test-results' && ls /app/test-results ") + + # Start container and stream logs + echo "Starting merge" + docker start -a $container_id + + # Create directory for test results + echo "Creating test results directory..." + mkdir -p $(System.DefaultWorkingDirectory)/ortoni-report + mkdir -p $(System.DefaultWorkingDirectory)/test-results + + # Copy test results from container + echo "Copying test results..." + docker cp $container_id:/app/ortoni-report/. $(System.DefaultWorkingDirectory)/ortoni-report + docker cp $container_id:/app/test-results/junit_results.xml $(System.DefaultWorkingDirectory)/test-results + + # Remove container + echo "Cleaning up container..." + docker rm $container_id + env: + JIRA_API_KEY: $(JIRA_API_KEY) + displayName: merge_and_publish_results_to_jira + + - task: PublishTestResults@2 + displayName: 'Publish test results' + inputs: + searchFolder: 'test-results' + testResultsFormat: 'JUnit' + testResultsFiles: 'junit_results.xml' + mergeTestResults: true + failTaskOnFailedTests: false + testRunTitle: 'Playwright Tests' + condition: succeededOrFailed() + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Merged Report' + condition: always() + inputs: + targetPath: '$(System.DefaultWorkingDirectory)/ortoni-report' + artifact: 'playwright-merged-report' + publishLocation: 'pipeline' + - script: | + docker rmi $(dockerImageName):$(imageTag) -f + displayName: 'Cleanup Docker Image' + condition: always() \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 8c39d447..bcb4ec5f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -39,6 +39,8 @@ variables: value: '$(Build.BuildId)' - name: totalShards value: 2 + - name: IS_REGRESSION + value: 'false' stages: # PR's @@ -160,19 +162,18 @@ stages: -e JIRA_SERVER=$(JIRA_SERVER) \ -e JIRA_USERNAME=$(JIRA_USERNAME) \ -e JIRA_API_KEY=$(JIRA_API_KEY) \ + -e IS_REGRESSION=$(IS_REGRESSION) \ -e JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER" \ $(dockerImageName):$(imageTag) \ bash -c "echo \"Moving Playwright reports out of subfolders...\" && find ./playwright-reports/ -mindepth 2 -type f -exec mv {} ./playwright-reports/ \; && echo \"Merging reports...\" && export NODE_OPTIONS=--max_old_space_size=4096 - PLAYWRIGHT_JUNIT_OUTPUT_DIR='/app/test-results' PLAYWRIGHT_JUNIT_OUTPUT_NAME='junit_results.xml' npx playwright merge-reports --reporter=ortoni-report,junit,\"/app/playwright-tests/impl/reporter/JiraWritebackReporter.ts\" ./playwright-reports + PLAYWRIGHT_JUNIT_OUTPUT_DIR='/app/test-results' PLAYWRIGHT_JUNIT_OUTPUT_NAME='junit_results.xml' npx playwright merge-reports --reporter=junit,\"/app/playwright-tests/impl/reporter/JiraWritebackReporter.ts\" ./playwright-reports echo \"Contents of ortoni-report:\" && ls ./ortoni-report && echo 'Current dir: ' && pwd echo 'Contents of current dir: ' && ls - echo 'Contents of /app/test-results' && ls /app/test-results - echo \"Writing report to Jira card '$JIRA_CARD_NUMBER'...\" && - /app/devops/scripts/jira_writeback.sh add_comment \"$JIRA_CARD_NUMBER\" /app/ortoni-report/ortoni-report.html \"AUTOMATED TEST RUN: $(date)\" ") + echo 'Contents of /app/test-results' && ls /app/test-results ") # Start container and stream logs echo "Starting merge" diff --git a/devops/scripts/jira_writeback.sh b/devops/scripts/jira_writeback.sh deleted file mode 100755 index 1d605072..00000000 --- a/devops/scripts/jira_writeback.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/bin/bash -create_issue() { - local title="$1" - local project_key="$2" - local issue_type="$3" - local parent_issue_key="$4" - - AUTH=$(echo -ne "$JIRA_USERNAME:$JIRA_API_KEY" | base64 --wrap 0) - - local parent_issue=$(curl -s -H "Authorization: Basic $AUTH" \ - "$JIRA_SERVER/rest/api/3/issue/$parent_issue_key") - - local parent_fix_versions=$(echo $parent_issue | jq -r '.fields.fixVersions') - - local created_issue=$(curl -X POST -H "Content-Type: application/json" \ - -H "Authorization: Basic $AUTH" \ - -d '{ - "fields": { - "summary": "'"$title"'", - "project": { - "key": "'"$project_key"'" - }, - "issuetype": { - "name": "'"$issue_type"'" - }, - "parent": { - "key": "'"$parent_issue_key"'" - }, - "fixVersions": '"$parent_fix_versions"' - } - }' \ - "$JIRA_SERVER/rest/api/3/issue") - - local created_issue_id=$(echo $created_issue | jq -r '.id') - - curl -X PUT -H "Content-Type: application/json" \ - -H "Authorization: Basic $AUTH" \ - -d '{ - "fields": { - "fixVersions": '"$parent_fix_versions"' - } - }' \ - "$JIRA_SERVER/rest/api/3/issue/$created_issue_id" -} - -extract_uuid() { - local url="$1" - local uuid_regex='[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' - if [[ "$url" =~ $uuid_regex ]]; then - echo "${BASH_REMATCH}" - else - echo "No UUID found in the URL." - fi -} - -update_issue_status() { - local issue_key="$1" - local status_name="$2" - - AUTH=$(echo -ne "$JIRA_USERNAME:$JIRA_API_KEY" | base64 --wrap 0) - - local transitions=$(curl -s -H "Authorization: Basic $AUTH" \ - "$JIRA_SERVER/rest/api/3/issue/$issue_key/transitions") - - local transition_id=$(echo "$transitions" | jq -r --arg status_name "$status_name" ' - .transitions[] | select(.isAvailable == true and .to.name == $status_name) | .id - ') - - if [ -z "$transition_id" ]; then - echo "BadRequestError" - exit 1 - else - curl -X POST -H "Content-Type: application/json" \ - -H "Authorization: Basic $AUTH" \ - -d '{ - "transition": { - "id": "'"$transition_id"'" - } - }' \ - "$JIRA_SERVER/rest/api/3/issue/$issue_key/transitions" - fi -} - -add_attachments() { - AUTH=$(echo -ne "$JIRA_USERNAME:$JIRA_API_KEY" | base64 --wrap 0) - - local issue_key="$1" - shift - local attachments=("$@") - - echo $issue_key - echo $attachments - - local form_data="" - for attachment in "${attachments[@]}"; do - form_data+="--form file=@$attachment " - done - - echo $form_data - - echo $(curl -X POST $form_data \ - -H "X-Atlassian-Token: no-check" \ - -H "Authorization: Basic $AUTH" \ - "$JIRA_SERVER/rest/api/3/issue/$issue_key/attachments") -} - -add_comment() { - AUTH=$(echo -ne "$JIRA_USERNAME:$JIRA_API_KEY" | base64 --wrap 0) - local issue_key="$1" - shift - local comment_items_input=("$@") - local comment_json="[]" - - for item in "${comment_items_input[@]}"; do - if [ -e "$item" ]; then - # If it's a file path - local attachment=$(add_attachments "$issue_key" "$item") - local id=$(echo $attachment | grep -oP '"id":\s*"\K[^"]+') - - local attachment_content=$(curl -s -I -L -H "Authorization: Basic $AUTH" "$JIRA_SERVER/rest/api/3/attachment/content/$id" \ - | grep -i "Location:" | tail -1 | awk '{print $2}' | tr -d '\r') - echo "$JIRA_SERVER/rest/api/3/attachment/content/$id" - echo "$attachment_content" - - local uuid=$(extract_uuid "$attachment_content") - echo "$uuid" - - json_object=$(jq -n --arg uuid "$uuid" '{ type: "mediaSingle", attrs: { layout: "align-start" }, content: [{ type: "media", attrs: { type: "file", id: $uuid, width: 200, height: 200, collection: "", alt: "" } }]}') - - comment_json=$(echo "$comment_json" | jq --argjson obj "$json_object" '. += [$obj]') - else - # If it's a string - json_object=$(jq -n --arg text "$item" '{ type: "paragraph", content: [{ type: "text", text: $text }]}') - - comment_json=$(echo "$comment_json" | jq --argjson obj "$json_object" '. += [$obj]') - fi - done - - request=$(jq -n --argjson content "$comment_json" '{body: { type: "doc", version: 1, content: $content }}') - - curl -X POST -H "Content-Type: application/json" \ - -H "Authorization: Basic $AUTH" \ - -d "$request" \ - "$JIRA_SERVER/rest/api/3/issue/$issue_key/comment" -} - -if [[ $# -gt 0 ]]; then # IF function call passed in - "$@" # Call function -fi \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 763af73d..d6e03de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@testing-library/user-event": "14.4.3", "@testing-library/vue": "6.6.1", "@types/dotenv-safe": "^8.1.6", + "@types/form-data": "^2.2.1", "@types/node": "^22.7.5", "@vitejs/plugin-vue": "4.2.3", "@vitest/coverage-v8": "^0.34.1", @@ -52,6 +53,7 @@ "eslint-plugin-import": "2.26.0", "eslint-plugin-jsdoc": "^46.4.3", "eslint-plugin-vue": "^9.15.1", + "form-data": "^4.0.2", "jest": "^27.0.5", "jest-junit": "^13.0.0", "jest-serializer-vue": "^3.1.0", @@ -3852,6 +3854,16 @@ "@types/range-parser": "*" } }, + "node_modules/@types/form-data": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-2.2.1.tgz", + "integrity": "sha512-JAMFhOaHIciYVh8fb5/83nmuO/AHwmto+Hq7a9y8FzLDcC1KCU344XDOMEmahnrTFlHjgh4L0WJFczNIX2GxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -6737,6 +6749,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -8531,6 +8557,21 @@ "dotenv": ">= 8.2.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -8797,13 +8838,11 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -8848,6 +8887,35 @@ "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", "dev": true }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-shim-unscopables": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", @@ -10077,13 +10145,15 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" }, "engines": { @@ -10271,16 +10341,22 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -10298,6 +10374,20 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", @@ -10402,12 +10492,13 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10488,23 +10579,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -10513,12 +10593,13 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, + "license": "MIT", "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -15216,6 +15297,16 @@ "resolved": "https://registry.npmjs.org/maska/-/maska-2.1.11.tgz", "integrity": "sha512-IGqWjBnKxMYcVa06pb4mPfag9sJjnR2T15CdGfQ2llR3gajiSd4AxXCvNqHMEq9W3UBhjjTazgWumsP3sWrUSg==" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", diff --git a/package.json b/package.json index 6d1da384..02e989aa 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@testing-library/user-event": "14.4.3", "@testing-library/vue": "6.6.1", "@types/dotenv-safe": "^8.1.6", + "@types/form-data": "^2.2.1", "@types/node": "^22.7.5", "@vitejs/plugin-vue": "4.2.3", "@vitest/coverage-v8": "^0.34.1", @@ -48,6 +49,7 @@ "@vue/cli-service": "~5.0.0", "@vue/test-utils": "^2.4.1", "@vue/vue3-jest": "^27.0.0-alpha.1", + "axe-core": "^4.10.2", "axios": "^1.7.8", "axios-mock-adapter": "^1.21.5", "babel-jest": "^27.0.6", @@ -59,6 +61,7 @@ "eslint-plugin-import": "2.26.0", "eslint-plugin-jsdoc": "^46.4.3", "eslint-plugin-vue": "^9.15.1", + "form-data": "^4.0.2", "jest": "^27.0.5", "jest-junit": "^13.0.0", "jest-serializer-vue": "^3.1.0", @@ -73,7 +76,6 @@ "vite": "^4.5.9", "vitest": "^0.33.0", "volar-service-vetur": "latest", - "wait-on": "^8.0.2", - "axe-core": "^4.10.2" + "wait-on": "^8.0.2" } } diff --git a/playwright-tests/business-logic/types/JiraApi.ts b/playwright-tests/business-logic/types/JiraApi.ts index d5b34b08..af0ec7df 100644 --- a/playwright-tests/business-logic/types/JiraApi.ts +++ b/playwright-tests/business-logic/types/JiraApi.ts @@ -43,7 +43,10 @@ export interface JiraIssueFields { parent?: JiraParent, fixVersions?: JiraVersion[], subtasks?: JiraSubTask[], - customfield_14857?: JiraContent // Test Steps field + customfield_14857?: JiraContent, // Test Steps field + customfield_10007?: number // Sprint field, + customfield_13100?: { id: string } // UAT Tester ID field + } export interface JiraSubTask { @@ -108,4 +111,25 @@ export interface JiraIssue { fields: JiraIssueFields } -export interface GetIssueResponse extends JiraIssue {} \ No newline at end of file +export interface GetIssueResponse extends JiraIssue {} + +export interface GetBoardResponse { + id: number, + name: string +} + +export interface GetSprintResponse { + values: JiraSprint[] +} + +export interface GetJqlSearchIssueParams { + jql: string +} + +export interface GetJqlSearchIssueResponse extends PostBulkFetchIssuesResponse {} + +export interface JiraSprint { + id: number, + name: string, + originBoardId: number +} \ No newline at end of file diff --git a/playwright-tests/impl/api/JiraApiUtil.ts b/playwright-tests/impl/api/JiraApiUtil.ts index fdb970d1..777809ac 100644 --- a/playwright-tests/impl/api/JiraApiUtil.ts +++ b/playwright-tests/impl/api/JiraApiUtil.ts @@ -1,10 +1,14 @@ -import { PostBulkFetchIssuesRequestBody, PostBulkFetchIssuesResponse, GetIssueResponse, GetIssueTransitionsResponse, GetIssueTypesResponse, PostAddCommentResponse, PostBulkCreateIssueRequestBody, PostBulkCreateIssueResponse, PostCreateIssueRequestBody, PostCreateIssueResponse, PostTransitionIssueRequestBody, PutEditIssueRequestBody, PostBulkTransitionIssuesRequestBody } from "@business-logic/types/JiraApi"; -import axios, { request } from "axios"; +import { PostBulkFetchIssuesRequestBody, PostBulkFetchIssuesResponse, GetIssueResponse, GetIssueTransitionsResponse, GetIssueTypesResponse, PostAddCommentResponse, PostBulkCreateIssueRequestBody, PostBulkCreateIssueResponse, PostCreateIssueRequestBody, PostCreateIssueResponse, PostTransitionIssueRequestBody, PutEditIssueRequestBody, PostBulkTransitionIssuesRequestBody, GetSprintResponse, GetJqlSearchIssueParams, GetJqlSearchIssueResponse } from "@business-logic/types/JiraApi"; +import axios from "axios"; import axiosRetry from "axios-retry"; +import FormData from 'form-data'; +import path from "path"; +import fs from 'fs'; const jiraUrl = process.env.JIRA_SERVER!; const jiraUsername = process.env.JIRA_USERNAME!; const jiraApiKey = process.env.JIRA_API_KEY!; +const boardId = process.env.JIRA_BOARD_ID!; const encodedAuthKey = Buffer.from(`${jiraUsername}:${jiraApiKey}`).toString('base64'); export default class JiraApiUtil { @@ -13,6 +17,9 @@ export default class JiraApiUtil { readonly bulkIssueCreateUrl: string; readonly bulkIssueFetchUrl: string; readonly bulkTransitionIssuesUrl: string; + readonly getCurrentSprintUrl: string; + readonly getJqlSearchIssueUrl: string; + readonly axiosClient: axios.AxiosInstance; constructor() { @@ -20,7 +27,9 @@ export default class JiraApiUtil { this.issueUrl = `${this.baseUrl}/rest/api/3/issue`; this.bulkIssueCreateUrl = `${this.issueUrl}/bulk`; this.bulkIssueFetchUrl = `${this.issueUrl}/bulkfetch`; - this.bulkTransitionIssuesUrl = `${this.baseUrl}/rest/api/3/bulk/issues/transition` + this.bulkTransitionIssuesUrl = `${this.baseUrl}/rest/api/3/bulk/issues/transition`; + this.getCurrentSprintUrl = `${this.baseUrl}/rest/agile/1.0/board/${boardId}/sprint?state=active`; + this.getJqlSearchIssueUrl = `${this.baseUrl}/rest/api/3/search/jql`; this.axiosClient = axios.create(); // interceptor to log error message from api this.axiosClient.interceptors.response.use( @@ -48,6 +57,33 @@ export default class JiraApiUtil { }); } + async getCurrentSprint() { + const res = await this.axiosClient.get(this.getCurrentSprintUrl, { + headers: { + Authorization: `Basic ${encodedAuthKey}`, + Accept: 'application/json' + } + }); + const currentSprint = res.data.values.filter(sprint => { + return `${sprint.originBoardId}` === boardId; + }); + if (currentSprint.length === 1) { + return currentSprint[0]; + } else { + console.error(`JiraApiUtil >> Multiple active sprints found for board ${boardId}`); + } + } + + getJqlSearchIssue(params: GetJqlSearchIssueParams) { + return this.axiosClient.get(this.getJqlSearchIssueUrl, { + params: params, + headers: { + Authorization: `Basic ${encodedAuthKey}`, + Accept: 'application/json' + } + }) + } + postBulkFetchIssues(requestBody: PostBulkFetchIssuesRequestBody) { return this.axiosClient.post(this.bulkIssueFetchUrl, requestBody, { headers: { @@ -140,4 +176,19 @@ export default class JiraApiUtil { }, }); } + + postUploadAttachment(issueKey: string, filePath: string) { + const url = `${this.baseUrl}/rest/api/3/issue/${issueKey}/attachments` + const form = new FormData(); + const fileName = path.basename(filePath); + + form.append('file', fs.createReadStream(filePath), fileName); + return this.axiosClient.post(url, form, { + headers: { + Authorization: `Basic ${encodedAuthKey}`, + 'X-Atlassian-Token': 'no-check', + ...form.getHeaders() + } + }); + } } \ No newline at end of file diff --git a/playwright-tests/impl/reporter/JiraWritebackReporter.ts b/playwright-tests/impl/reporter/JiraWritebackReporter.ts index 521c1af2..1d2a40e3 100644 --- a/playwright-tests/impl/reporter/JiraWritebackReporter.ts +++ b/playwright-tests/impl/reporter/JiraWritebackReporter.ts @@ -1,15 +1,37 @@ import JiraApiUtil from "@impl/api/JiraApiUtil"; -import type { FullResult, Reporter, TestCase, TestResult } from "@playwright/test/reporter"; -import { formatDate } from "@impl/utils/DateUtils"; +import type { FullConfig, FullResult, Reporter, Suite, TestCase, TestError, TestResult } from "@playwright/test/reporter"; +import { formatDate, formatDateForFilename } from "@impl/utils/DateUtils"; import { GetIssueResponse, JiraIssue, JiraIssueFields } from "@business-logic/types/JiraApi"; +import OrtoniReport, { OrtoniReportConfig } from "ortoni-report"; -const jiraCardNumber = process.env.JIRA_CARD_NUMBER!; -const jiraProjectKey = 'INSR'; +let jiraCardNumber = process.env.JIRA_CARD_NUMBER || ''; +const isRegressionRun = process.env.IS_REGRESSION === 'true'? true: false; +const jiraProjectKey = process.env.JIRA_PROJECT_KEY!; +const jiraEpicKey = process.env.JIRA_EPIC_KEY!; const passTransitionId = '111'; const failTransitionId = '101'; +const inTestTransitionId = '271'; +const currentDate = formatDateForFilename(new Date()); + +// Ortoni config +const reportName = `ortoni_report_${currentDate}.html`; +const reportConfig: OrtoniReportConfig = { + port: 1994, + open: "never", + folderPath: "ortoni-report", + filename: reportName, + logo: 'playwright-tests/business-logic/data/logo.png', + title: "Test Report", + showProject: false, + projectName: "ISS-Nextgen-Playwright-Report", + testType: `E2E- Environment: ${process.env.NODE_ENV} `, + preferredTheme: "light", + base64Image: true, +}; export default class JiraWritebackReporter implements Reporter { readonly jiraApiUtil: JiraApiUtil = new JiraApiUtil(); + readonly ortoniReport: OrtoniReport = new OrtoniReport(reportConfig); readonly loadIssueCalls: (() => void)[] = []; // Calls to loadIssue must be deferred because they depend on information from the API. readonly issuesToPass: string[] = []; // Issues to transition to "Pass". readonly issuesToFail: string[] = []; // Issues to transition to "Fail". @@ -17,6 +39,7 @@ export default class JiraWritebackReporter implements Reporter { existingSubtasks: JiraIssue[] = []; // Array to hold existing subtasks of the dev card. testCaseTypeId: string|undefined = undefined; // ID of the test case subtask type in Jira. Will be filled by API call. bugTypeId:string|undefined = undefined; // ID of the bug subtask type. + userStoryTypeId:string|undefined = undefined; parentCard: GetIssueResponse | undefined = undefined; // Variable to hold the parent card. Will be filled by API call. // Will need this if we want to move ortoni report upload into this reporter. @@ -36,6 +59,9 @@ export default class JiraWritebackReporter implements Reporter { this.bugTypeId = issueTypes.issueTypes.find(issueType => { return issueType.subtask === true && issueType.name === 'Bug Sub-task' })?.id; + this.userStoryTypeId = issueTypes.issueTypes.find(issueType => { + return issueType.name === 'Story'; + })?.id; console.log('JiraWritebackReporter >> Loaded issue types.'); } @@ -167,16 +193,97 @@ export default class JiraWritebackReporter implements Reporter { } } + /** + * + * @param cardName + * @param sprintId + * @returns + */ + getCreateRegressionCardRequestBody(cardName: string, sprintId: number) { + const body: JiraIssueFields = { + summary: cardName, + description: { + content: [ + { + "content": [ + { + "text": 'Card to hold regression test results for this sprint.', + "type": "text" + } + ], + "type": "paragraph" + } + ], + "type": "doc", + "version": 1 + }, + project: { + key: jiraProjectKey + }, + issuetype: { id: this.userStoryTypeId!}, + parent: { key: jiraEpicKey }, + customfield_10007: sprintId, + customfield_13100: { id: '557058:a314fc5b-aed9-4472-90f8-00f106e06207' } // Mark UAT Tester as N/A + } + return body; + } + /** * Grabs parent card and existing subtasks. */ async loadReporterData() { + let parentCard: GetIssueResponse; await this.loadJiraIssueTypes(); - - console.log('JiraWritebackReporter >> Searching for parent card...'); - const parentRes = await this.jiraApiUtil.getIssue(jiraCardNumber); - const parentCard = parentRes.data; - console.log(`JiraWritebackReporter >> Retrieved parent card: ${parentCard.key}: ${parentCard.fields.summary}`); + if (isRegressionRun) { + console.log('JiraWritebackReporter >> Searching for current regression card...'); + const sprint = await this.jiraApiUtil.getCurrentSprint(); + if (!sprint) { + console.error('JiraWritebackReporter >> Could not find current sprint'); + } else { + console.log(`JiraWritebackReporter >> Found sprint with id '${sprint.id}' called '${sprint.name}'`); + } + const regressionCardName = `Playwright Automated Regression Tests ${sprint?.name}`; + const parentRes = await this.jiraApiUtil.getJqlSearchIssue({ jql: `sprint = ${sprint?.id} and summary ~ "${regressionCardName}"` }); + if (parentRes.data.issues.length > 1) { + console.log('JiraWritebackReporter >> WARNING: Multiple regression cards found. Using first one.'); + parentCard = (await this.jiraApiUtil.getIssue(parentRes.data.issues[0].id!)).data; + await this.jiraApiUtil.postTransitionIssue(parentCard.id!, { + transition: { + id: inTestTransitionId + } + }); + } else if (parentRes.data.issues.length < 1) { + // NO regression card found. Make one. + console.log('JiraWritebackReporter >> No regression card was found for this sprint. Creating one.'); + const fields = this.getCreateRegressionCardRequestBody(regressionCardName, sprint!.id); + const regressionCard: JiraIssue = { + fields: fields, + transition: { + id: inTestTransitionId + } + }; + const createRes = await this.jiraApiUtil.postCreateIssue(regressionCard); + regressionCard.id = createRes.data.id; + regressionCard.key = createRes.data.key; + parentCard = regressionCard; + } else { + // Found one + console.log('JiraWritebackReporter >> Found one regression card. Using it.'); + parentCard = (await this.jiraApiUtil.getIssue(parentRes.data.issues[0].id!)).data; + await this.jiraApiUtil.postTransitionIssue(parentCard.id!, { + transition: { + id: inTestTransitionId + } + }); + } + jiraCardNumber = parentCard.key!; + console.log(`JiraWritebackReporter >> Retrieved current regression card: ${parentCard.key}: ${parentCard.fields.summary}`); + } else { + console.log('JiraWritebackReporter >> Searching for parent card...'); + const parentRes = await this.jiraApiUtil.getIssue(jiraCardNumber); + parentCard = parentRes.data; + console.log(`JiraWritebackReporter >> Retrieved parent card: ${parentCard.key}: ${parentCard.fields.summary}`); + } console.log(`JiraWritebackReporter >> Searching for all subtasks of parent card '${jiraCardNumber}'...`); const subtaskIds = parentCard.fields.subtasks?.map(subtask => { @@ -258,7 +365,6 @@ export default class JiraWritebackReporter implements Reporter { if (new RegExp(`^TEST FAILED [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]: ${test.title}$`).test(card.fields.summary)) { existingBug = card; console.log('JiraWritebackReporter >> Found existing bug.'); - // TODO: Handle multiple matches for bug/test case. Currently picks one in that case. } } } @@ -302,7 +408,7 @@ export default class JiraWritebackReporter implements Reporter { * @param result */ onTestEnd(test: TestCase, result: TestResult) { - // this.ortoniReport.onTestEnd(test, result); + this.ortoniReport.onTestEnd(test, result); this.loadIssueCalls.push(() => { this.loadIssue(test, result); }); } @@ -312,28 +418,32 @@ export default class JiraWritebackReporter implements Reporter { * @returns Promise to write results to Jira. */ onEnd(result: FullResult): Promise<{ status?: FullResult["status"]; } | undefined | void>|void { - // this.ortoniReport.onEnd(result); - return this.loadReporterData().then(async () => { - this.loadIssueCalls.map(fn => fn()); - await this.writeResults(); - console.log(`JiraWritebackReporter >> Posted necessary changes for '${jiraCardNumber}'`); + return this.ortoniReport.onEnd(result).then(async () => { + await this.loadReporterData().then(async () => { + this.loadIssueCalls.map(fn => fn()); + await this.writeResults(); + }).then(async () => { + console.log('JiraWritebackReporter >> Uploading Ortoni HTML Report to Jira...'); + await this.jiraApiUtil.postUploadAttachment(jiraCardNumber, `${reportConfig.folderPath}/${reportConfig.filename}`); + console.log('JiraWritebackReporter >> Uploaded Ortoni HTML Report to Jira.'); + console.log(`JiraWritebackReporter >> Posted necessary changes for '${jiraCardNumber}'`); + }); }); } - // These overloads will become necessary if we move the ortoni report upload into this reporter - // onBegin(config: FullConfig, suite: Suite): void { - // this.ortoniReport.onBegin(config, suite); - // } + onBegin(config: FullConfig, suite: Suite): Promise { + return this.ortoniReport.onBegin(config, suite); + } - // onError(error: TestError): void { - // this.ortoniReport.onError(error); - // } + onError(error: TestError): void { + return this.ortoniReport.onError(error); + } - // onExit(): Promise { - // return this.ortoniReport.onExit(); - // } + onExit(): Promise { + return this.ortoniReport.onExit(); + } - // onStdOut(chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void { - // this.ortoniReport.onStdOut(chunk, test, result); - // } + onStdOut(chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void { + return this.ortoniReport.onStdOut(chunk, test, result); + } } \ No newline at end of file diff --git a/playwright-tests/impl/utils/DateUtils.ts b/playwright-tests/impl/utils/DateUtils.ts index 7348ea60..d22047c1 100644 --- a/playwright-tests/impl/utils/DateUtils.ts +++ b/playwright-tests/impl/utils/DateUtils.ts @@ -3,6 +3,15 @@ export function formatDate(date: Date) { return isoString.slice(0, 10); } +export function formatDateForFilename(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hour = String(date.getHours()).padStart(2, '0'); + const minute = String(date.getMinutes()).padStart(2, '0'); + return `${year}${month}${day}_${hour}${minute}`; +} + export function formatTime(date: Date) { return date.toLocaleTimeString('en-US', { hour: 'numeric',