Adds Automated Regression Pipeline&Jira writeback reporter to playwright
Implements a custom Playwright reporter to integrate with Jira. This reporter automatically creates/updates test case and bug subtasks in Jira based on Playwright test results. It also uploads the ortoni HTML report to the parent Jira card. Key features: - Creates a regression card if none are found - Transitions test subtasks based on test results. - Creates bug subtasks for failed tests. - Uploads the Ortoni HTML report as an attachment to the Jira card. - Configurable through environment variables (Jira server, username, API key, project key, etc.). Also adds axios-retry to handle rate limiting errors from Jira API
This commit is contained in:
parent
75e6a8a86f
commit
f29937700c
9 changed files with 861 additions and 194 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -19,6 +19,7 @@ pnpm-debug.log*
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
artifacts/
|
artifacts/
|
||||||
|
ortoni-report/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.idea
|
.idea
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ variables:
|
||||||
value: '$(Build.BuildId)'
|
value: '$(Build.BuildId)'
|
||||||
- name: totalShards
|
- name: totalShards
|
||||||
value: 2
|
value: 2
|
||||||
|
- name: IS_REGRESSION
|
||||||
|
value: 'false'
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
# PR's
|
# PR's
|
||||||
|
|
@ -166,6 +168,7 @@ stages:
|
||||||
-e JIRA_SERVER=$(JIRA_SERVER) \
|
-e JIRA_SERVER=$(JIRA_SERVER) \
|
||||||
-e JIRA_USERNAME=$(JIRA_USERNAME) \
|
-e JIRA_USERNAME=$(JIRA_USERNAME) \
|
||||||
-e JIRA_API_KEY=$(JIRA_API_KEY) \
|
-e JIRA_API_KEY=$(JIRA_API_KEY) \
|
||||||
|
-e IS_REGRESSION=$(IS_REGRESSION) \
|
||||||
-e JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER" \
|
-e JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER" \
|
||||||
-e JIRA_PROJECT_KEY="$JIRA_PROJECT_KEY" \
|
-e JIRA_PROJECT_KEY="$JIRA_PROJECT_KEY" \
|
||||||
$(dockerImageName):$(imageTag) \
|
$(dockerImageName):$(imageTag) \
|
||||||
|
|
@ -173,14 +176,11 @@ stages:
|
||||||
find ./playwright-reports/ -mindepth 2 -type f -exec mv {} ./playwright-reports/ \; &&
|
find ./playwright-reports/ -mindepth 2 -type f -exec mv {} ./playwright-reports/ \; &&
|
||||||
echo \"Merging reports...\" &&
|
echo \"Merging reports...\" &&
|
||||||
export NODE_OPTIONS=--max_old_space_size=4096
|
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 \"Contents of ortoni-report:\" && ls ./ortoni-report &&
|
||||||
echo 'Current dir: ' && pwd
|
echo 'Current dir: ' && pwd
|
||||||
echo 'Contents of current dir: ' && ls
|
echo 'Contents of current dir: ' && ls
|
||||||
echo 'Contents of /app/test-results' && ls /app/test-results
|
echo 'Contents of /app/test-results' && ls /app/test-results ")
|
||||||
echo \"Writing report to Jira card '$JIRA_CARD_NUMBER'...\" &&
|
|
||||||
chmod +x /app/devops/scripts/jira_writeback.sh &&
|
|
||||||
/app/devops/scripts/jira_writeback.sh add_comment \"$JIRA_CARD_NUMBER\" /app/ortoni-report/ortoni-report.html \"AUTOMATED TEST RUN: $(date)\" ")
|
|
||||||
|
|
||||||
# Start container and stream logs
|
# Start container and stream logs
|
||||||
echo "Starting merge"
|
echo "Starting merge"
|
||||||
|
|
|
||||||
27
package-lock.json
generated
27
package-lock.json
generated
|
|
@ -37,6 +37,7 @@
|
||||||
"@vue/eslint-config-prettier": "^9.0.0",
|
"@vue/eslint-config-prettier": "^9.0.0",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/vue3-jest": "^27.0.0",
|
"@vue/vue3-jest": "^27.0.0",
|
||||||
|
"axios-retry": "^4.5.0",
|
||||||
"dotenv-safe": "^9.1.0",
|
"dotenv-safe": "^9.1.0",
|
||||||
"eslint": "8.57",
|
"eslint": "8.57",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
|
|
@ -5267,6 +5268,19 @@
|
||||||
"follow-redirects": "^1.14.4"
|
"follow-redirects": "^1.14.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axios-retry": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"is-retry-allowed": "^2.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"axios": "0.x || 1.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-jest": {
|
"node_modules/babel-jest": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz",
|
||||||
|
|
@ -10202,6 +10216,19 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-retry-allowed": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-stream": {
|
"node_modules/is-stream": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
"@vue/eslint-config-prettier": "^9.0.0",
|
"@vue/eslint-config-prettier": "^9.0.0",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"@vue/vue3-jest": "^27.0.0",
|
"@vue/vue3-jest": "^27.0.0",
|
||||||
|
"axios-retry": "^4.5.0",
|
||||||
"dotenv-safe": "^9.1.0",
|
"dotenv-safe": "^9.1.0",
|
||||||
"eslint": "8.57",
|
"eslint": "8.57",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
trigger: none
|
||||||
|
schedules:
|
||||||
|
- cron: 0 9 * * MON-FRI
|
||||||
|
always: true
|
||||||
|
displayName: Daily Test Automation Run for FMG-NextGen
|
||||||
|
branches:
|
||||||
|
include:
|
||||||
|
- develop
|
||||||
pool: 'Default'
|
pool: 'Default'
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
|
|
@ -9,6 +17,14 @@ variables:
|
||||||
value: '$(Build.BuildId)'
|
value: '$(Build.BuildId)'
|
||||||
- name: totalShards
|
- name: totalShards
|
||||||
value: 4
|
value: 4
|
||||||
|
- name: IS_REGRESSION
|
||||||
|
value: 'true'
|
||||||
|
- name: JIRA_BOARD_ID
|
||||||
|
value: '845'
|
||||||
|
- name: JIRA_EPIC_KEY
|
||||||
|
value: 'CASH-208'
|
||||||
|
- name: JIRA_PROJECT_KEY
|
||||||
|
value: 'CASH'
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- stage: TestPr
|
- stage: TestPr
|
||||||
|
|
@ -40,17 +56,19 @@ stages:
|
||||||
- script: |
|
- script: |
|
||||||
# Create container and run tests
|
# Create container and run tests
|
||||||
container_id=$(docker create \
|
container_id=$(docker create \
|
||||||
--ipc=host \
|
--ipc=host \
|
||||||
-e CCIS_API_AUTH=$(CCIS_API_AUTH) \
|
-e CCIS_API_AUTH=$(CCIS_API_AUTH) \
|
||||||
-e BASE_URL="$(BASE_URL)" \
|
-e BASE_URL=$(BASE_URL) \
|
||||||
-e CCIS_API_URL=$(CCIS_API_URL) \
|
-e CCIS_API_URL=$(CCIS_API_URL) \
|
||||||
-e ADMIN_SERVICE_API_URL=$(ADMIN_SERVICE_API_URL) \
|
-e ADMIN_SERVICE_API_URL=$(ADMIN_SERVICE_API_URL) \
|
||||||
-e SKIP_CONTENT_SITE=$(SKIP_CONTENT_SITE) \
|
-e SHARD=$(shardNumber) \
|
||||||
-e CI=true \
|
-e CI=true \
|
||||||
-e NODE_ENV=$(NODE_ENV) \
|
-e NODE_ENV=$(NODE_ENV) \
|
||||||
$(dockerImageName):$(imageTag) \
|
$(dockerImageName):$(imageTag) \
|
||||||
npm run test:playwright -- --shard=$(shardNumber)/$(totalShards) --reporter=list,blob --grep '@(Alert|CASH)')
|
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
|
# Start container and stream logs
|
||||||
echo "Starting tests for shard $(shardNumber)..."
|
echo "Starting tests for shard $(shardNumber)..."
|
||||||
docker start -a $container_id
|
docker start -a $container_id
|
||||||
|
|
@ -104,192 +122,25 @@ stages:
|
||||||
tags: $(imageTag)
|
tags: $(imageTag)
|
||||||
arguments: '--no-cache --pull'
|
arguments: '--no-cache --pull'
|
||||||
- bash: |
|
- bash: |
|
||||||
# Jira credentials and server
|
|
||||||
jira_server=$(JIRA_SERVER)
|
|
||||||
jira_username=$(JIRA_USERNAME)
|
|
||||||
jira_api_key=$(JIRA_API_KEY)
|
|
||||||
|
|
||||||
echo "Using Jira server: $jira_server"
|
|
||||||
echo "Using Jira username: $jira_username"
|
|
||||||
|
|
||||||
# Jira card details
|
|
||||||
jira_key="CASH"
|
|
||||||
jira_card_summary="AUTOMATED REGRESSION RUN RESULTS"
|
|
||||||
jira_card_description="This card contains the results of automated regression runs."
|
|
||||||
jira_parent_key="CASH-208"
|
|
||||||
jira_uat_tester_id="557058:a314fc5b-aed9-4472-90f8-00f106e06207"
|
|
||||||
|
|
||||||
# Get active sprint with CASH in the name
|
|
||||||
echo "Getting active sprint with CASH in the name..."
|
|
||||||
sprint_response=$(curl -s -u "$jira_username:$jira_api_key" -X GET "$jira_server/rest/agile/1.0/board/845/sprint?state=active")
|
|
||||||
|
|
||||||
# Check if response contains an error
|
|
||||||
if echo "$sprint_response" | grep -q "\"errorMessages\""; then
|
|
||||||
echo "Error retrieving sprints: $(echo $sprint_response | jq -r '.errorMessages[0]')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
active_sprint=$(echo "$sprint_response" | jq -r '.values[] | select(.name | test("CASH"; "i")) | .id')
|
|
||||||
|
|
||||||
if [ -z "$active_sprint" ]; then
|
|
||||||
echo "No active sprint with CASH in the name found. Response was:"
|
|
||||||
echo "$sprint_response" | jq '.'
|
|
||||||
# Fallback to getting any active sprint
|
|
||||||
active_sprint=$(echo "$sprint_response" | jq -r '.values[0].id')
|
|
||||||
if [ -z "$active_sprint" ]; then
|
|
||||||
echo "No active sprints found at all."
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "Using fallback active sprint: $active_sprint"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "Found active CASH sprint with ID: $active_sprint"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if the card exists in the active sprint with detailed error handling
|
|
||||||
echo "Checking for existing card in sprint $active_sprint..."
|
|
||||||
|
|
||||||
sprint_issues_response=$(curl -s -u "$jira_username:$jira_api_key" -X GET "$jira_server/rest/agile/1.0/sprint/$active_sprint/issue")
|
|
||||||
|
|
||||||
if echo "$sprint_issues_response" | grep -q "\"errorMessages\""; then
|
|
||||||
echo "Error retrieving sprint issues: $(echo $sprint_issues_response | jq -r '.errorMessages[0]')"
|
|
||||||
# Fall back to searching for the issue directly
|
|
||||||
echo "Falling back to direct issue search..."
|
|
||||||
search_response=$(curl -s -u "$jira_username:$jira_api_key" -X GET "$jira_server/rest/api/2/search?jql=project=$jira_key%20AND%20summary~%22$jira_card_summary%22")
|
|
||||||
existing_card=$(echo "$search_response" | jq -r '.issues[0].key')
|
|
||||||
else
|
|
||||||
existing_card=$(echo "$sprint_issues_response" | jq -r ".issues[] | select(.fields.summary == \"$jira_card_summary\") | .key")
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Existing card on current sprint: $existing_card"
|
|
||||||
|
|
||||||
if [ -z "$existing_card" ]; then
|
|
||||||
echo "No existing card found. Creating new card..."
|
|
||||||
# Create JSON payload for issue creation
|
|
||||||
creation_payload='{
|
|
||||||
"fields": {
|
|
||||||
"project": {
|
|
||||||
"key": "'$jira_key'"
|
|
||||||
},
|
|
||||||
"summary": "'$jira_card_summary'",
|
|
||||||
"description": "'$jira_card_description'",
|
|
||||||
"issuetype": {
|
|
||||||
"name": "Task"
|
|
||||||
},
|
|
||||||
"parent": {
|
|
||||||
"key": "'$jira_parent_key'"
|
|
||||||
},
|
|
||||||
"customfield_13100": {
|
|
||||||
"id": "'$jira_uat_tester_id'"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
echo "Creation payload: $creation_payload"
|
|
||||||
|
|
||||||
response=$(curl -s -u "$jira_username:$jira_api_key" \
|
|
||||||
-X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$creation_payload" \
|
|
||||||
"$jira_server/rest/api/2/issue")
|
|
||||||
|
|
||||||
echo "Card creation response: $response"
|
|
||||||
|
|
||||||
# Check for errors in the response
|
|
||||||
if echo "$response" | grep -q "\"errorMessages\""; then
|
|
||||||
echo "Error creating card: $(echo $response | jq -r '.errorMessages[0]')"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
new_card=$(echo $response | jq -r '.key')
|
|
||||||
|
|
||||||
if [ -z "$new_card" ] || [ "$new_card" == "null" ]; then
|
|
||||||
echo "Failed to create new card. Response: $response"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
card_key=$new_card
|
|
||||||
echo "Created new card with key: $card_key"
|
|
||||||
|
|
||||||
# Move the new card to the active sprint with better error handling
|
|
||||||
echo "Moving card to sprint $active_sprint..."
|
|
||||||
sprint_move_payload="{\"fields\": {\"customfield_10007\": $active_sprint}}"
|
|
||||||
sprint_move_response=$(curl -s -u "$jira_username:$jira_api_key" \
|
|
||||||
-X PUT \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$sprint_move_payload" \
|
|
||||||
"$jira_server/rest/api/2/issue/$card_key")
|
|
||||||
|
|
||||||
# Check for errors in sprint move response (will be empty if successful)
|
|
||||||
if [ ! -z "$sprint_move_response" ] && echo "$sprint_move_response" | grep -q "\"errorMessages\""; then
|
|
||||||
echo "Warning: Error moving card to sprint: $(echo $sprint_move_response | jq -r '.errorMessages[0]')"
|
|
||||||
echo "Continuing anyway..."
|
|
||||||
else
|
|
||||||
echo "Card moved to sprint successfully"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Move the new card to In Test Status with better error handling
|
|
||||||
echo "Moving card to In Test status..."
|
|
||||||
transition_id="271"
|
|
||||||
transition_payload="{\"transition\": {\"id\": $transition_id}}"
|
|
||||||
transition_response=$(curl -s -u "$jira_username:$jira_api_key" \
|
|
||||||
-X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$transition_payload" \
|
|
||||||
"$jira_server/rest/api/2/issue/$card_key/transitions")
|
|
||||||
|
|
||||||
# Check for errors in transition response
|
|
||||||
if [ ! -z "$transition_response" ] && echo "$transition_response" | grep -q "\"errorMessages\""; then
|
|
||||||
echo "Warning: Error transitioning card: $(echo $transition_response | jq -r '.errorMessages[0]')"
|
|
||||||
# Try to get available transitions for debugging
|
|
||||||
available_transitions=$(curl -s -u "$jira_username:$jira_api_key" -X GET "$jira_server/rest/api/2/issue/$card_key/transitions")
|
|
||||||
echo "Available transitions: $available_transitions"
|
|
||||||
echo "Continuing anyway..."
|
|
||||||
else
|
|
||||||
echo "Card status changed successfully"
|
|
||||||
fi
|
|
||||||
|
|
||||||
else
|
|
||||||
card_key=$existing_card
|
|
||||||
echo "Using existing card with key: $card_key"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Get current day of week and numeric date for report name
|
|
||||||
current_day=$(date +%A)
|
|
||||||
numeric_date=$(date +%m-%d-%Y)
|
|
||||||
report_name="ortoni-report-${NODE_ENV}-${current_day}-${numeric_date}.html"
|
|
||||||
|
|
||||||
# Create container for jira writeback
|
# Create container for jira writeback
|
||||||
container_id=$(docker create \
|
container_id=$(docker create \
|
||||||
--ipc=host \
|
--ipc=host \
|
||||||
-e JIRA_SERVER=$(JIRA_SERVER) \
|
-e JIRA_SERVER=$(JIRA_SERVER) \
|
||||||
-e JIRA_USERNAME=$(JIRA_USERNAME) \
|
-e JIRA_USERNAME=$(JIRA_USERNAME) \
|
||||||
-e JIRA_API_KEY=$(JIRA_API_KEY) \
|
-e JIRA_API_KEY=$(JIRA_API_KEY) \
|
||||||
-e REPORT_NAME="$report_name" \
|
-e IS_REGRESSION=$(IS_REGRESSION) \
|
||||||
-e CURRENT_DAY="$current_day" \
|
-e JIRA_BOARD_ID=$(JIRA_BOARD_ID) \
|
||||||
-e NUMERIC_DATE="$numeric_date" \
|
-e JIRA_EPIC_KEY=$(JIRA_EPIC_KEY) \
|
||||||
-e NODE_ENV=$(NODE_ENV) \
|
-e JIRA_PROJECT_KEY=$(JIRA_PROJECT_KEY) \
|
||||||
-e CARD_KEY="$card_key" \
|
|
||||||
$(dockerImageName):$(imageTag) \
|
$(dockerImageName):$(imageTag) \
|
||||||
bash -c "chmod +x devops/scripts/jira_writeback.sh
|
bash -c "echo \"Moving Playwright reports out of subfolders...\" &&
|
||||||
echo \"Moving Playwright reports out of subfolders...\"
|
find ./playwright-reports/ -mindepth 2 -type f -exec mv {} ./playwright-reports/ \; &&
|
||||||
find ./playwright-reports/ -mindepth 2 -type f -exec mv {} ./playwright-reports/ \;
|
echo \"Merging 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
|
||||||
PLAYWRIGHT_JUNIT_OUTPUT_DIR='/app/playwright-tests/artifacts/test-results' PLAYWRIGHT_JUNIT_OUTPUT_NAME='junit_results.xml' npx playwright merge-reports --reporter=ortoni-report,junit ./playwright-reports
|
echo \"Contents of ortoni-report:\" && ls ./ortoni-report &&
|
||||||
echo \"Contents of ortoni-report:\" && ls ./ortoni-report
|
|
||||||
echo 'Current dir: ' && pwd
|
echo 'Current dir: ' && pwd
|
||||||
echo 'Contents of current dir: ' && ls
|
echo 'Contents of current dir: ' && ls
|
||||||
echo 'Contents of /app/playwright-tests/artifacts/test-results' && ls /app/playwright-tests/artifacts/test-results
|
echo 'Contents of /app/test-results' && ls /app/test-results ")
|
||||||
|
|
||||||
# Rename the report file to include the day and date
|
|
||||||
if [ -f /app/ortoni-report/ortoni-report.html ]; then
|
|
||||||
mv /app/ortoni-report/ortoni-report.html /app/ortoni-report/\${REPORT_NAME}
|
|
||||||
echo \"Renamed report to \${REPORT_NAME}\"
|
|
||||||
else
|
|
||||||
echo \"ortoni-report.html not found!\"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo \"Writing report to Jira card '\${CARD_KEY}'...\"
|
|
||||||
/app/devops/scripts/jira_writeback.sh add_comment \"\${CARD_KEY}\" /app/ortoni-report/\${REPORT_NAME} \"AUTOMATED TEST RUN: $(date) - \${CURRENT_DAY} (\${NUMERIC_DATE}) - Environment: \${NODE_ENV}\" ")
|
|
||||||
|
|
||||||
# Start container and stream logs
|
# Start container and stream logs
|
||||||
echo "Starting merge"
|
echo "Starting merge"
|
||||||
|
|
@ -303,7 +154,7 @@ stages:
|
||||||
# Copy test results from container
|
# Copy test results from container
|
||||||
echo "Copying test results..."
|
echo "Copying test results..."
|
||||||
docker cp $container_id:/app/ortoni-report/. $(System.DefaultWorkingDirectory)/ortoni-report
|
docker cp $container_id:/app/ortoni-report/. $(System.DefaultWorkingDirectory)/ortoni-report
|
||||||
docker cp $container_id:/app/playwright-tests/artifacts/test-results/junit_results.xml $(System.DefaultWorkingDirectory)/test-results
|
docker cp $container_id:/app/test-results/junit_results.xml $(System.DefaultWorkingDirectory)/test-results
|
||||||
|
|
||||||
# Remove container
|
# Remove container
|
||||||
echo "Cleaning up container..."
|
echo "Cleaning up container..."
|
||||||
|
|
|
||||||
135
playwright-tests/business-logic/types/JiraApi.ts
Normal file
135
playwright-tests/business-logic/types/JiraApi.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
export interface PutEditIssueRequestBody {
|
||||||
|
fields: Partial<JiraIssueFields>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostTransitionIssueRequestBody {
|
||||||
|
transition: { id: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostCreateIssueRequestBody extends JiraIssue {}
|
||||||
|
|
||||||
|
export interface PostCreateIssueResponse {
|
||||||
|
id: string,
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostBulkCreateIssueRequestBody {
|
||||||
|
issueUpdates: PostCreateIssueRequestBody[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkTransitionInput {
|
||||||
|
selectedIssueIdsOrKeys: string[],
|
||||||
|
transitionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostBulkTransitionIssuesRequestBody {
|
||||||
|
bulkTransitionInputs: BulkTransitionInput[],
|
||||||
|
sendBulkNotification: false
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostBulkCreateIssueResponse {
|
||||||
|
issues: PostCreateIssueResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostAddCommentResponse {
|
||||||
|
body: JiraContent
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraIssueFields {
|
||||||
|
summary: string,
|
||||||
|
description: JiraContent,
|
||||||
|
project?: JiraProject,
|
||||||
|
issuetype?: { id: string },
|
||||||
|
parent?: JiraParent,
|
||||||
|
fixVersions?: JiraVersion[],
|
||||||
|
subtasks?: JiraSubTask[],
|
||||||
|
customfield_14857?: JiraContent, // Test Steps field
|
||||||
|
customfield_10007?: number // Sprint field,
|
||||||
|
customfield_13100?: { id: string } // UAT Tester ID field
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraSubTask {
|
||||||
|
id: string,
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraProject {
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraParent {
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraContent {
|
||||||
|
type?: string,
|
||||||
|
text?: string,
|
||||||
|
version?: number
|
||||||
|
content?: JiraContent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraVersion {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraTransition {
|
||||||
|
id: string,
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraIssueType {
|
||||||
|
id: string,
|
||||||
|
self: string,
|
||||||
|
description: string,
|
||||||
|
iconUrl: string,
|
||||||
|
name: string,
|
||||||
|
untranslatedName: string,
|
||||||
|
subtask: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetIssueTransitionsResponse {
|
||||||
|
transitions: JiraTransition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetIssueTypesResponse {
|
||||||
|
issueTypes: JiraIssueType[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostBulkFetchIssuesRequestBody {
|
||||||
|
issueIdsOrKeys: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostBulkFetchIssuesResponse {
|
||||||
|
issues: GetIssueResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JiraIssue {
|
||||||
|
key?: string,
|
||||||
|
id?: string,
|
||||||
|
transition?: { id: string },
|
||||||
|
fields: JiraIssueFields
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
194
playwright-tests/impl/API/JiraApiUtil.ts
Normal file
194
playwright-tests/impl/API/JiraApiUtil.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import { PostBulkFetchIssuesRequestBody, PostBulkFetchIssuesResponse, GetIssueResponse, GetIssueTransitionsResponse, GetIssueTypesResponse, PostAddCommentResponse, PostBulkCreateIssueRequestBody, PostBulkCreateIssueResponse, PostCreateIssueRequestBody, PostCreateIssueResponse, PostTransitionIssueRequestBody, PutEditIssueRequestBody, PostBulkTransitionIssuesRequestBody, GetSprintResponse, GetJqlSearchIssueParams, GetJqlSearchIssueResponse } from "@business-logic/types/JiraApi";
|
||||||
|
import axios, { AxiosInstance } 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 {
|
||||||
|
readonly baseUrl: string;
|
||||||
|
readonly issueUrl: string;
|
||||||
|
readonly bulkIssueCreateUrl: string;
|
||||||
|
readonly bulkIssueFetchUrl: string;
|
||||||
|
readonly bulkTransitionIssuesUrl: string;
|
||||||
|
readonly getCurrentSprintUrl: string;
|
||||||
|
readonly getJqlSearchIssueUrl: string;
|
||||||
|
|
||||||
|
readonly axiosClient: AxiosInstance;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = jiraUrl;
|
||||||
|
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.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(
|
||||||
|
response => response,
|
||||||
|
error => {
|
||||||
|
console.error('Axios Error:', error?.response?.data || error.message);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Set up retries if requests are made too quickly
|
||||||
|
axiosRetry(this.axiosClient, {
|
||||||
|
retries: 4,
|
||||||
|
retryDelay: (retryCount) => { return Math.pow(2, retryCount) * 1000; }, // Exponential backoff
|
||||||
|
retryCondition: (error) => { return error.response?.status === 429 } // If rate-limit error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getIssue(issueKey: string) {
|
||||||
|
const url = `${this.issueUrl}/${issueKey}`;
|
||||||
|
return this.axiosClient.get<GetIssueResponse>(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${encodedAuthKey}`,
|
||||||
|
Accept: 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
||||||
|
Authorization: `Basic ${encodedAuthKey}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getIssueTransitions(issueKey: string) {
|
||||||
|
const url = `${this.issueUrl}/${issueKey}/transitions`
|
||||||
|
return this.axiosClient.get<GetIssueTransitionsResponse>(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${encodedAuthKey}`,
|
||||||
|
Accept: 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getIssueTypes(projectKey: string) {
|
||||||
|
const url = `${this.baseUrl}/rest/api/3/issue/createmeta/${projectKey}/issuetypes`;
|
||||||
|
return this.axiosClient.get<GetIssueTypesResponse>(url, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${encodedAuthKey}`,
|
||||||
|
Accept: 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
postAddComment(issueKey: string, commentBody: PostAddCommentResponse) {
|
||||||
|
const url = `${this.issueUrl}/${issueKey}/comment`;
|
||||||
|
return this.axiosClient.post(url, commentBody, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${encodedAuthKey}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
postTransitionIssue(issueKey: string, requestBody: PostTransitionIssueRequestBody) {
|
||||||
|
const transitionUrl = `${this.issueUrl}/${issueKey}/transitions`
|
||||||
|
return this.axiosClient.post(transitionUrl, requestBody, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${encodedAuthKey}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
putEditIssue(issueKey: string, requestBody: PutEditIssueRequestBody) {
|
||||||
|
const editIssueUrl = `${this.issueUrl}/${issueKey}`
|
||||||
|
return this.axiosClient.put(editIssueUrl, requestBody, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${encodedAuthKey}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
postBulkTransitionIssues(requestBody: PostBulkTransitionIssuesRequestBody) {
|
||||||
|
return this.axiosClient.post(this.bulkTransitionIssuesUrl, requestBody, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${encodedAuthKey}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
postCreateIssue(requestBody: PostCreateIssueRequestBody) {
|
||||||
|
return this.axiosClient.post<PostCreateIssueResponse>(this.issueUrl, requestBody, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${encodedAuthKey}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
postBulkCreateIssue(requestBody: PostBulkCreateIssueRequestBody) {
|
||||||
|
return this.axiosClient.post<PostBulkCreateIssueResponse>(this.bulkIssueCreateUrl, requestBody, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${encodedAuthKey}`,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
449
playwright-tests/impl/reporter/JiraWritebackReporter.ts
Normal file
449
playwright-tests/impl/reporter/JiraWritebackReporter.ts
Normal file
|
|
@ -0,0 +1,449 @@
|
||||||
|
import JiraApiUtil from "@impl/API/JiraApiUtil";
|
||||||
|
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";
|
||||||
|
|
||||||
|
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".
|
||||||
|
readonly issuesToCreate: JiraIssue[] = []; // Issues to be batch created.
|
||||||
|
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.
|
||||||
|
// readonly ortoniReport = new OrtoniReport(reportConfig);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function loads the IDs for the Jira Issue Types we use.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
async loadJiraIssueTypes() {
|
||||||
|
console.log(`JiraWritebackReporter >> Loading Jira Issue Types for project '${jiraProjectKey}'...`);
|
||||||
|
const issueTypesRes = await this.jiraApiUtil.getIssueTypes(jiraProjectKey);
|
||||||
|
const issueTypes = issueTypesRes.data;
|
||||||
|
this.testCaseTypeId = issueTypes.issueTypes.find(issueType => {
|
||||||
|
return issueType.subtask === true && issueType.name === 'Test Case Sub-task'
|
||||||
|
})?.id;
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function generates a test subtask based on the test results we pass in.
|
||||||
|
* @param test TestCase from onTestEnd()
|
||||||
|
* @param result TestResult from onTestEnd()
|
||||||
|
* @returns Jira Test Subtask based on test result.
|
||||||
|
*/
|
||||||
|
getCreateTestSubtask(test: TestCase, result: TestResult) {
|
||||||
|
if (result.status === 'skipped') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const testStepTitles = result.steps.map(step => {
|
||||||
|
return `-\t${step.title}`;
|
||||||
|
}).join('\n');
|
||||||
|
const allErrors = result.errors.map(value => {
|
||||||
|
return value.message
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const description = `Most recent test status: ${result.status}.\nDuration: ${result.duration/1000} seconds.\nErrors:\n${allErrors}`
|
||||||
|
const testSubtaskBody: JiraIssueFields = {
|
||||||
|
summary: test.title,
|
||||||
|
description: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": description,
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
key: jiraProjectKey
|
||||||
|
},
|
||||||
|
issuetype: {
|
||||||
|
id: this.testCaseTypeId!
|
||||||
|
},
|
||||||
|
parent: {
|
||||||
|
key: jiraCardNumber
|
||||||
|
},
|
||||||
|
customfield_14857: {
|
||||||
|
type: 'doc',
|
||||||
|
version: 1,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: testStepTitles
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return testSubtaskBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function generates a bug subtask based on the test results we pass in.
|
||||||
|
* @param test TestCase from onTestEnd()
|
||||||
|
* @param result TestResult from onTestEnd()
|
||||||
|
* @returns Jira Bug Subtask based on test result.
|
||||||
|
*/
|
||||||
|
getCreateBug(test: TestCase, result: TestResult) {
|
||||||
|
if (result.status === 'passed' || result.status === 'skipped') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const allErrors = result.errors.map(value => {
|
||||||
|
return value.message
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const bug: JiraIssueFields = {
|
||||||
|
summary: `TEST FAILED ${formatDate(new Date())}: ${test.title}`,
|
||||||
|
description: {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"text": allErrors,
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "paragraph"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "doc",
|
||||||
|
"version": 1
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
key: jiraProjectKey
|
||||||
|
},
|
||||||
|
issuetype: {
|
||||||
|
id: this.bugTypeId!
|
||||||
|
},
|
||||||
|
parent: {
|
||||||
|
key: jiraCardNumber
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return bug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function gives us the correct transition for a test subtask based on this test result.
|
||||||
|
* @param result TestResult from onTestEnd()
|
||||||
|
* @returns Correct transition to pass to the Jira API.
|
||||||
|
*/
|
||||||
|
getSubtaskTransition(result: TestResult): { id: string } | undefined {
|
||||||
|
if (result.status === 'skipped') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === 'passed') {
|
||||||
|
return {
|
||||||
|
id: passTransitionId
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
id: failTransitionId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @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();
|
||||||
|
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 => {
|
||||||
|
return subtask.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subtaskIds && subtaskIds.length > 0) {
|
||||||
|
for (let i = 0; i < subtaskIds.length; i+=50) {
|
||||||
|
const batch = subtaskIds.slice(i, i+50);
|
||||||
|
const subTasksRes = await this.jiraApiUtil.postBulkFetchIssues({ issueIdsOrKeys: batch });
|
||||||
|
this.existingSubtasks.push(...subTasksRes.data.issues);
|
||||||
|
}
|
||||||
|
console.log('JiraWritebackReporter >> Subtasks retrieved.');
|
||||||
|
} else {
|
||||||
|
console.log('JiraWritebackReporter >> No subtasks were found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk creates test cases and bugs in batches of up to 50. Bulk transitions existing test case subtasks.
|
||||||
|
*/
|
||||||
|
async writeResults() {
|
||||||
|
for (let i = 0; i < this.issuesToCreate.length; i+=50) {
|
||||||
|
const batch = this.issuesToCreate.slice(i, i + 50);
|
||||||
|
console.log('JiraWritebackReporter >> Creating issue batch...');
|
||||||
|
// fire request
|
||||||
|
await this.jiraApiUtil.postBulkCreateIssue({ issueUpdates: batch });
|
||||||
|
console.log('JiraWritebackReporter >> Issue batch created.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.issuesToPass.length > 0) {
|
||||||
|
console.log('JiraWritebackReporter >> Transitioning passed test cases to "Pass"...')
|
||||||
|
await this.jiraApiUtil.postBulkTransitionIssues( {
|
||||||
|
bulkTransitionInputs: [{
|
||||||
|
selectedIssueIdsOrKeys: this.issuesToPass,
|
||||||
|
transitionId: passTransitionId
|
||||||
|
}],
|
||||||
|
sendBulkNotification: false
|
||||||
|
});
|
||||||
|
console.log('JiraWritebackReporter >> Transition success.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.issuesToFail.length > 0) {
|
||||||
|
console.log('JiraWritebackReporter >> Transitioning failed test cases to "Fail"...')
|
||||||
|
await this.jiraApiUtil.postBulkTransitionIssues( {
|
||||||
|
bulkTransitionInputs: [{
|
||||||
|
selectedIssueIdsOrKeys: this.issuesToFail,
|
||||||
|
transitionId: failTransitionId
|
||||||
|
}],
|
||||||
|
sendBulkNotification: false
|
||||||
|
});
|
||||||
|
console.log('JiraWritebackReporter >> Transition success.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and loads appropriate response based on test result. This can be a test subtask/bug subtask or a call to transition a test subtask.
|
||||||
|
* @param test TestCase from onTestEnd()
|
||||||
|
* @param result TestResult from onTestEnd()
|
||||||
|
* @returns Void
|
||||||
|
*/
|
||||||
|
loadIssue(test: TestCase, result: TestResult) {
|
||||||
|
if (result.status === 'skipped') return;
|
||||||
|
if (result.status !== 'passed' && result.retry < test.retries) return; // Skip if test case failed and this isn't the last retry.
|
||||||
|
|
||||||
|
let existingTestSubtask: JiraIssue | undefined = undefined;
|
||||||
|
let existingBug: JiraIssue | undefined = undefined;
|
||||||
|
const subtaskTransition = this.getSubtaskTransition(result)!; // Get either a Pass or Fail transition depending on test results.
|
||||||
|
|
||||||
|
console.log('JiraWritebackReporter >> Checking for existing subtasks for this test...');
|
||||||
|
for (const card of this.existingSubtasks) {
|
||||||
|
if (card.fields.issuetype?.id === this.testCaseTypeId) {
|
||||||
|
if (card.fields.summary === test.title) {
|
||||||
|
existingTestSubtask = card;
|
||||||
|
console.log('JiraWritebackReporter >> Found existing test subtask.');
|
||||||
|
}
|
||||||
|
} else if (card.fields.issuetype?.id === this.bugTypeId) {
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(existingTestSubtask || existingBug)) {
|
||||||
|
console.log('JiraWritebackReporter >> No existing test subtask or bug was found.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const subTask = this.getCreateTestSubtask(test, result); // Will return undefined if test status is 'skipped'
|
||||||
|
const bug = this.getCreateBug(test, result); // Will return undefined if we don't need one
|
||||||
|
if (subTask) {
|
||||||
|
// Create or Edit Subtask
|
||||||
|
if (existingTestSubtask) {
|
||||||
|
if (result.status === 'passed') {
|
||||||
|
this.issuesToPass.push(existingTestSubtask.key!);
|
||||||
|
} else {
|
||||||
|
this.issuesToFail.push(existingTestSubtask.key!);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.issuesToCreate.push({
|
||||||
|
fields: subTask,
|
||||||
|
transition: subtaskTransition
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bug) {
|
||||||
|
// Create or Edit Bug
|
||||||
|
if (existingBug) {
|
||||||
|
// Leave it alone. This reporter should not modify existing bugs.
|
||||||
|
} else {
|
||||||
|
this.issuesToCreate.push({
|
||||||
|
fields: bug
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function loads calls to loadIssue() in an array to be executed when the necessary information from the Jira API is available.
|
||||||
|
* @param test
|
||||||
|
* @param result
|
||||||
|
*/
|
||||||
|
onTestEnd(test: TestCase, result: TestResult) {
|
||||||
|
this.ortoniReport.onTestEnd(test, result);
|
||||||
|
this.loadIssueCalls.push(() => { this.loadIssue(test, result); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes calls to load and execute API calls to Jira.
|
||||||
|
* @param result
|
||||||
|
* @returns Promise to write results to Jira.
|
||||||
|
*/
|
||||||
|
onEnd(result: FullResult): Promise<{ status?: FullResult["status"]; } | undefined | void>|void {
|
||||||
|
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}'`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onBegin(config: FullConfig, suite: Suite): Promise<void> {
|
||||||
|
return this.ortoniReport.onBegin(config, suite);
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(error: TestError): void {
|
||||||
|
return this.ortoniReport.onError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
onExit(): Promise<void> {
|
||||||
|
return this.ortoniReport.onExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onStdOut(chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void {
|
||||||
|
return this.ortoniReport.onStdOut(chunk, test, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,15 @@ export function formatDate(date: Date) {
|
||||||
return isoString.slice(0, 10);
|
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) {
|
export function formatTime(date: Date) {
|
||||||
return date.toLocaleTimeString('en-US', {
|
return date.toLocaleTimeString('en-US', {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue