diff --git a/.gitignore b/.gitignore index 5ea4793fd..ddc565745 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ pnpm-debug.log* /blob-report/ /playwright/.cache/ artifacts/ +ortoni-report/ # Editor directories and files .idea diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 838ffe7ac..2ee6c385a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -42,6 +42,8 @@ variables: value: '$(Build.BuildId)' - name: totalShards value: 2 + - name: IS_REGRESSION + value: 'false' stages: # PR's @@ -166,6 +168,7 @@ 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" \ -e JIRA_PROJECT_KEY="$JIRA_PROJECT_KEY" \ $(dockerImageName):$(imageTag) \ @@ -173,14 +176,11 @@ stages: 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'...\" && - 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)\" ") + echo 'Contents of /app/test-results' && ls /app/test-results ") # Start container and stream logs echo "Starting merge" diff --git a/package-lock.json b/package-lock.json index 24235112c..0abffb4d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@vue/eslint-config-prettier": "^9.0.0", "@vue/test-utils": "^2.4.6", "@vue/vue3-jest": "^27.0.0", + "axios-retry": "^4.5.0", "dotenv-safe": "^9.1.0", "eslint": "8.57", "eslint-plugin-prettier": "^5.2.1", @@ -5267,6 +5268,19 @@ "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": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", @@ -10202,6 +10216,19 @@ "dev": true, "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", diff --git a/package.json b/package.json index 96b866e15..13c6eb8e0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@vue/eslint-config-prettier": "^9.0.0", "@vue/test-utils": "^2.4.6", "@vue/vue3-jest": "^27.0.0", + "axios-retry": "^4.5.0", "dotenv-safe": "^9.1.0", "eslint": "8.57", "eslint-plugin-prettier": "^5.2.1", diff --git a/playwright-tests/azure-pipelines-automated-testing.yml b/playwright-tests/azure-pipelines-automated-testing.yml index ca1cb3ce4..cab4df9df 100644 --- a/playwright-tests/azure-pipelines-automated-testing.yml +++ b/playwright-tests/azure-pipelines-automated-testing.yml @@ -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' variables: @@ -9,6 +17,14 @@ variables: value: '$(Build.BuildId)' - name: totalShards 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: - stage: TestPr @@ -40,17 +56,19 @@ stages: - 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 SKIP_CONTENT_SITE=$(SKIP_CONTENT_SITE) \ - -e CI=true \ - -e NODE_ENV=$(NODE_ENV) \ - $(dockerImageName):$(imageTag) \ - npm run test:playwright -- --shard=$(shardNumber)/$(totalShards) --reporter=list,blob --grep '@(Alert|CASH)') - + --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 @@ -104,192 +122,25 @@ stages: tags: $(imageTag) arguments: '--no-cache --pull' - 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 container_id=$(docker create \ --ipc=host \ -e JIRA_SERVER=$(JIRA_SERVER) \ -e JIRA_USERNAME=$(JIRA_USERNAME) \ -e JIRA_API_KEY=$(JIRA_API_KEY) \ - -e REPORT_NAME="$report_name" \ - -e CURRENT_DAY="$current_day" \ - -e NUMERIC_DATE="$numeric_date" \ - -e NODE_ENV=$(NODE_ENV) \ - -e CARD_KEY="$card_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 "chmod +x devops/scripts/jira_writeback.sh - 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/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 + 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/playwright-tests/artifacts/test-results' && ls /app/playwright-tests/artifacts/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}\" ") + echo 'Contents of /app/test-results' && ls /app/test-results ") # Start container and stream logs echo "Starting merge" @@ -303,7 +154,7 @@ stages: # 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/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 echo "Cleaning up container..." diff --git a/playwright-tests/business-logic/types/JiraApi.ts b/playwright-tests/business-logic/types/JiraApi.ts new file mode 100644 index 000000000..af0ec7dfb --- /dev/null +++ b/playwright-tests/business-logic/types/JiraApi.ts @@ -0,0 +1,135 @@ +export interface PutEditIssueRequestBody { + fields: Partial +} + +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 +} \ No newline at end of file diff --git a/playwright-tests/impl/API/JiraApiUtil.ts b/playwright-tests/impl/API/JiraApiUtil.ts new file mode 100644 index 000000000..41299aa5e --- /dev/null +++ b/playwright-tests/impl/API/JiraApiUtil.ts @@ -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(url, { + headers: { + Authorization: `Basic ${encodedAuthKey}`, + Accept: 'application/json' + } + }); + } + + 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: { + Authorization: `Basic ${encodedAuthKey}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }); + } + + getIssueTransitions(issueKey: string) { + const url = `${this.issueUrl}/${issueKey}/transitions` + return this.axiosClient.get(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(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(this.issueUrl, requestBody, { + headers: { + Authorization: `Basic ${encodedAuthKey}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + }); + } + + postBulkCreateIssue(requestBody: PostBulkCreateIssueRequestBody) { + return this.axiosClient.post(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() + } + }); + } +} \ No newline at end of file diff --git a/playwright-tests/impl/reporter/JiraWritebackReporter.ts b/playwright-tests/impl/reporter/JiraWritebackReporter.ts new file mode 100644 index 000000000..6f228be00 --- /dev/null +++ b/playwright-tests/impl/reporter/JiraWritebackReporter.ts @@ -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 { + return this.ortoniReport.onBegin(config, suite); + } + + onError(error: TestError): void { + return this.ortoniReport.onError(error); + } + + onExit(): Promise { + return this.ortoniReport.onExit(); + } + + 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 7348ea601..d22047c11 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',