Set up daily regression run and extended Jira Writeback Reporter to cover all Jira API functionality (#938)

* Initial regression pipeline

* Progress toward Jira API Util changes

* Updated Regression run and Jira Writeback Reporter to work together

* Temporarily disabled cron

* Allow Jira Writeback Reporter to set UAT tester

* Updates to azure-pipelines to avoid using jira command line

* Deleted unused script

* Changed pool to default

* Added back closing quote

* Added missing paren

* Set up cron schedule
This commit is contained in:
chase-safelite 2025-05-14 09:22:06 -04:00 committed by GitHub
parent 6bef3840cc
commit 2791150daf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 555 additions and 228 deletions

1
.gitignore vendored
View file

@ -28,6 +28,7 @@ pnpm-debug.log*
/blob-report/
/playwright/.cache/
artifacts/
ortoni-report/
# Misc
coverage/*

View file

@ -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()

View file

@ -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"

View file

@ -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

169
package-lock.json generated
View file

@ -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",

View file

@ -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"
}
}

View file

@ -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 {}
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
}

View file

@ -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<GetSprintResponse>(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<GetJqlSearchIssueResponse>(this.getJqlSearchIssueUrl, {
params: params,
headers: {
Authorization: `Basic ${encodedAuthKey}`,
Accept: 'application/json'
}
})
}
postBulkFetchIssues(requestBody: PostBulkFetchIssuesRequestBody) {
return this.axiosClient.post<PostBulkFetchIssuesResponse>(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()
}
});
}
}

View file

@ -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<void> {
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<void> {
// return this.ortoniReport.onExit();
// }
onExit(): Promise<void> {
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);
}
}

View file

@ -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',