diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 2cb18429c..8c5e61265 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -34,6 +34,12 @@ resources: variables: - group: Digital-Infrastructure - group: FixMyGlass-BuildBranches + - name: dockerImageName + value: 'playwright-tests' + - name: imageTag + value: '$(Build.BuildId)' + - name: totalShards + value: 2 stages: # PR's @@ -57,6 +63,168 @@ stages: npmLocation: $(Build.SourcesDirectory) testResultsFile: junit.xml summaryFileLocation: $(Build.SourcesDirectory)/coverage/cobertura-coverage.xml + - job: playwright_tests + continueOnError: true + strategy: + matrix: + shard1: + shardNumber: 1 + shard2: + shardNumber: 2 + + steps: + - task: Docker@2 + displayName: 'Build Docker Image' + inputs: + command: build + dockerfile: Dockerfile.playwright + repository: $(dockerImageName) + tags: $(imageTag) + arguments: '--no-cache --pull' + + - script: | + branch_name=$(System.PullRequest.SourceBranch) + echo "Retrieved branch name: '$branch_name'" + JIRA_CARD_NUMBER="${branch_name##*/}" + echo "Extracted JIRA Card number: '$JIRA_CARD_NUMBER'" + # Create container and run tests + container_id=$(docker create \ + --ipc=host \ + -e CCIS_API_AUTH=$(CCIS_API_AUTH) \ + -e BASE_URL=$(BASE_URL) \ + -e CCIS_API_URL=$(CCIS_API_URL) \ + -e ADMIN_SERVICE_API_URL=$(ADMIN_SERVICE_API_URL) \ + -e SHARD=$(shardNumber) \ + -e CI=true \ + -e ENABLE_MOCK_TESTING=true \ + -e ENABLE_ACCESSIBILITY_TESTING=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 \"@$JIRA_CARD_NUMBER\"") + + # Start container and stream logs + echo "Starting tests for shard $(shardNumber)..." + docker start -a $container_id + + # Create directory for test results + echo "Creating test results directory..." + mkdir -p $(System.DefaultWorkingDirectory)/blob-reports/shard-$(shardNumber) + + # Copy test results from container + echo "Copying test results..." + docker cp $container_id:/app/blob-report/. $(System.DefaultWorkingDirectory)/blob-reports/shard-$(shardNumber)/ + + # Remove container + echo "Cleaning up container..." + docker rm $container_id + + # Check if tests failed + if [ $? -ne 0 ]; then + echo "Tests failed in shard $(shardNumber) or tests don't exist for this shard number!" + exit 0 # Suppress error. It will be visible in report. + fi + displayName: 'Run Playwright Tests - Shard $(shardNumber)' + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Test Reports - Shard $(shardNumber)' + condition: always() + inputs: + targetPath: '$(System.DefaultWorkingDirectory)/blob-reports/shard-$(shardNumber)' + artifact: 'playwright-report-shard-$(shardNumber)' + publishLocation: 'pipeline' + + - script: | + docker rmi $(dockerImageName):$(imageTag) -f + displayName: 'Cleanup Docker Image' + condition: always() + + # Add job to merge test reports and update Jira + - job: download_and_merge_reports + dependsOn: playwright_tests + timeoutInMinutes: 10 + cancelTimeoutInMinutes: 10 + steps: + - task: DownloadPipelineArtifact@2 + inputs: + targetPath: '$(System.DefaultWorkingDirectory)/playwright-reports' + - task: Docker@2 + displayName: 'Build Docker Image' + inputs: + command: build + dockerfile: Dockerfile.playwright + repository: $(dockerImageName) + tags: $(imageTag) + arguments: '--no-cache --pull' + - bash: | + branch_name=$(System.PullRequest.SourceBranch) + echo "Retrieved branch name: '$branch_name'" + JIRA_CARD_NUMBER="${branch_name##*/}" + echo "Extracted JIRA Card number: '$JIRA_CARD_NUMBER'" + # 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 JIRA_CARD_NUMBER="$JIRA_CARD_NUMBER" \ + $(dockerImageName):$(imageTag) \ + bash -c "echo \"Moving Playwright reports out of subfolders...\" && + find ./playwright-reports/ -mindepth 2 -type f -exec mv {} ./playwright-reports/ \; && + echo \"Merging reports...\" && + export NODE_OPTIONS=--max_old_space_size=4096 + PLAYWRIGHT_JUNIT_OUTPUT_DIR='/app/test-results' PLAYWRIGHT_JUNIT_OUTPUT_NAME='junit_results.xml' npx playwright merge-reports --reporter=ortoni-report,junit,\"/app/playwright-tests/impl/reporter/JiraWritebackReporter.ts\" ./playwright-reports + echo \"Contents of ortoni-report:\" && ls ./ortoni-report && + echo 'Current dir: ' && pwd + echo 'Contents of current dir: ' && ls + echo 'Contents of /app/test-results' && ls /app/test-results + echo \"Writing report to Jira card '$JIRA_CARD_NUMBER'...\" && + /app/devops/scripts/jira_writeback.sh add_comment \"$JIRA_CARD_NUMBER\" /app/ortoni-report/ortoni-report.html \"AUTOMATED TEST RUN: $(date)\" ") + + # Start container and stream logs + echo "Starting merge" + docker start -a $container_id + + # Create directory for test results + echo "Creating test results directory..." + mkdir -p $(System.DefaultWorkingDirectory)/ortoni-report + mkdir -p $(System.DefaultWorkingDirectory)/test-results + + # Copy test results from container + echo "Copying test results..." + docker cp $container_id:/app/ortoni-report/. $(System.DefaultWorkingDirectory)/ortoni-report + docker cp $container_id:/app/test-results/junit_results.xml $(System.DefaultWorkingDirectory)/test-results + + # Remove container + echo "Cleaning up container..." + docker rm $container_id + env: + JIRA_API_KEY: $(JIRA_API_KEY) + displayName: merge_and_publish_results_to_jira + + - task: PublishTestResults@2 + displayName: 'Publish test results' + inputs: + searchFolder: 'test-results' + testResultsFormat: 'JUnit' + testResultsFiles: 'junit_results.xml' + mergeTestResults: true + failTaskOnFailedTests: false + testRunTitle: 'Playwright Tests' + condition: succeededOrFailed() + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Merged Report' + condition: always() + inputs: + targetPath: '$(System.DefaultWorkingDirectory)/ortoni-report' + artifact: 'playwright-merged-report' + publishLocation: 'pipeline' + - script: | + docker rmi $(dockerImageName):$(imageTag) -f + displayName: 'Cleanup Docker Image' + condition: always() - ${{ else }}: # Dev Build/Deploy - stage: Dev diff --git a/package-lock.json b/package-lock.json index 00fc7673a..2b541b385 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.1.0", "dependencies": { "@iframe-resizer/child": "^5.3.3", - "axios": "^0.23.0", "bootstrap": "^5.3.3", "core-js": "^3.38.1", "http-status-codes": "^2.3.0", @@ -38,6 +37,9 @@ "@vue/eslint-config-prettier": "^9.0.0", "@vue/test-utils": "^2.4.6", "@vue/vue3-jest": "^27.0.0", + "axios": "^1.8.4", + "axios-mock-adapter": "^2.1.0", + "axios-retry": "^4.5.0", "dotenv-safe": "^9.1.0", "eslint": "8.57", "eslint-plugin-prettier": "^5.2.1", @@ -5412,12 +5414,82 @@ } }, "node_modules/axios": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", - "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", + "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.14.4" + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios-mock-adapter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, + "node_modules/axios-mock-adapter/node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=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/axios/node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" } }, "node_modules/babel-jest": { @@ -8005,6 +8077,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", @@ -9227,6 +9315,7 @@ "version": "1.15.6", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "dev": true, "funding": [ { "type": "individual", @@ -9694,6 +9783,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-unicode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", @@ -10509,6 +10614,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", @@ -16247,6 +16365,13 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", diff --git a/package.json b/package.json index c63d8b5e8..923fec124 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@iframe-resizer/child": "^5.3.3", - "axios": "^0.23.0", "bootstrap": "^5.3.3", "core-js": "^3.38.1", "http-status-codes": "^2.3.0", @@ -42,6 +41,9 @@ "@vue/eslint-config-prettier": "^9.0.0", "@vue/test-utils": "^2.4.6", "@vue/vue3-jest": "^27.0.0", + "axios": "^1.8.4", + "axios-mock-adapter": "^2.1.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/.env.dev b/playwright-tests/.env.dev index 47ec8336d..8c064ba62 100644 --- a/playwright-tests/.env.dev +++ b/playwright-tests/.env.dev @@ -4,9 +4,13 @@ # Environment type NODE_ENV="qa" +SKIP_CONTENT_SITE=false + # Base URLs by environment (uncomment the one you need) # qa BASE_URL="https://www-qa2.safelite.com/" +# qa with skipToInsurance Turned Off +# BASE_URL="https://fixmyglassqa.safelite.com/?cns=all&experiments=ConceptFunnel=ConceptFunnel_V1=ConceptFunnel_VinAndEmailOptional=true,NextGen_IGQSkipToInsurance=NextGen_IGQSkipToInsurance_V1=NextGen_IGQSkipToInsurance_CONTROL=true" # sys # BASE_URL="https://www-test2.safelite.com/fmg/?fmgPage=vehicle" # dev diff --git a/playwright-tests/business-logic/constants/DefaultTestData.ts b/playwright-tests/business-logic/constants/DefaultTestData.ts index 6efb2c1db..197297b4b 100644 --- a/playwright-tests/business-logic/constants/DefaultTestData.ts +++ b/playwright-tests/business-logic/constants/DefaultTestData.ts @@ -43,7 +43,7 @@ export function getCustomerDetails(): ICustomerDetails { street: faker.location.streetAddress(), city: 'Columbus', state: 'Ohio', - postalCode: '43215', + postalCode: '21237', country: 'United States' } }; diff --git a/playwright-tests/business-logic/types/JiraApi.ts b/playwright-tests/business-logic/types/JiraApi.ts new file mode 100644 index 000000000..d5b34b081 --- /dev/null +++ b/playwright-tests/business-logic/types/JiraApi.ts @@ -0,0 +1,111 @@ +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 +} + +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 {} \ 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..fdb970d1e --- /dev/null +++ b/playwright-tests/impl/API/JiraApiUtil.ts @@ -0,0 +1,143 @@ +import { PostBulkFetchIssuesRequestBody, PostBulkFetchIssuesResponse, GetIssueResponse, GetIssueTransitionsResponse, GetIssueTypesResponse, PostAddCommentResponse, PostBulkCreateIssueRequestBody, PostBulkCreateIssueResponse, PostCreateIssueRequestBody, PostCreateIssueResponse, PostTransitionIssueRequestBody, PutEditIssueRequestBody, PostBulkTransitionIssuesRequestBody } from "@business-logic/types/JiraApi"; +import axios, { request } from "axios"; +import axiosRetry from "axios-retry"; + +const jiraUrl = process.env.JIRA_SERVER!; +const jiraUsername = process.env.JIRA_USERNAME!; +const jiraApiKey = process.env.JIRA_API_KEY!; +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 axiosClient: axios.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.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' + } + }); + } + + 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' + }, + }); + } +} \ 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..c1db33244 --- /dev/null +++ b/playwright-tests/impl/reporter/JiraWritebackReporter.ts @@ -0,0 +1,339 @@ +import JiraApiUtil from "@impl/API/JiraApiUtil"; +import type { FullResult, Reporter, TestCase, TestResult } from "@playwright/test/reporter"; +import { formatDate } from "@impl/utils/DateUtils"; +import { GetIssueResponse, JiraIssue, JiraIssueFields } from "@business-logic/types/JiraApi"; + +const jiraCardNumber = process.env.JIRA_CARD_NUMBER!; +const jiraProjectKey = 'INSR'; +const passTransitionId = '111'; +const failTransitionId = '101'; + +export default class JiraWritebackReporter implements Reporter { + readonly jiraApiUtil: JiraApiUtil = new JiraApiUtil(); + 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. + 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; + 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 + }; + } + } + + /** + * Grabs parent card and existing subtasks. + */ + async loadReporterData() { + await this.loadJiraIssueTypes(); + + console.log('JiraWritebackReporter >> Searching for parent card...'); + const parentRes = await this.jiraApiUtil.getIssue(jiraCardNumber); + const parentCard = parentRes.data; + console.log(`JiraWritebackReporter >> Retrieved parent card: ${parentCard.key}: ${parentCard.fields.summary}`); + + 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.'); + // TODO: Handle multiple matches for bug/test case. Currently picks one in that case. + } + } + } + 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 { + // this.ortoniReport.onEnd(result); + return this.loadReporterData().then(async () => { + this.loadIssueCalls.map(fn => fn()); + await this.writeResults(); + console.log(`JiraWritebackReporter >> Posted necessary changes for '${jiraCardNumber}'`); + }); + } + + // These overloads will become necessary if we move the ortoni report upload into this reporter + // onBegin(config: FullConfig, suite: Suite): void { + // this.ortoniReport.onBegin(config, suite); + // } + + // onError(error: TestError): void { + // this.ortoniReport.onError(error); + // } + + // onExit(): Promise { + // return this.ortoniReport.onExit(); + // } + + // onStdOut(chunk: string | Buffer, test: void | TestCase, result: void | TestResult): void { + // this.ortoniReport.onStdOut(chunk, test, result); + // } +} \ No newline at end of file diff --git a/playwright-tests/pages/ServiceLocationPage.ts b/playwright-tests/pages/ServiceLocationPage.ts index b8c8649b6..c9cb6c98f 100644 --- a/playwright-tests/pages/ServiceLocationPage.ts +++ b/playwright-tests/pages/ServiceLocationPage.ts @@ -114,9 +114,6 @@ export class ServiceLocationPage extends BasePage { await this.mobileButton.click(); await this.enterServiceAddressButton.click(); await this.addressForm.populateAddress({ address: appointmentDetails.serviceAddress! }); - if (await this.repeatedClicksModalCloseButton.isVisible()) { - await this.repeatedClicksModalCloseButton.click(); - } if (faker.datatype.boolean()) { await this.vehicleProtectedYesButton.check(); } else { diff --git a/playwright-tests/pages/ServicePackagesPage.ts b/playwright-tests/pages/ServicePackagesPage.ts index 9a3b87ae3..8b1597598 100644 --- a/playwright-tests/pages/ServicePackagesPage.ts +++ b/playwright-tests/pages/ServicePackagesPage.ts @@ -41,7 +41,7 @@ export class ServicePackagesPage extends BasePage { this.closeButton = this.page. getByRole('dialog').locator('button').filter({ hasText: 'Close' }); this.promoCodeTextbox = this.page.getByLabel('Enter a promo code'); this.applyPromoButton = this.page.getByRole('button', { name: 'Apply promo code' }); - this.repeatedClicksModalCloseButton = this.page.getByRole('img').nth(1); + this.repeatedClicksModalCloseButton = this.page.locator('#app').getByRole('dialog').locator('button').filter({ hasText: 'Close' }); // this.validateURL(this.url); } @@ -76,6 +76,7 @@ export class ServicePackagesPage extends BasePage { if (await this.repeatedClicksModalCloseButton.isVisible()) { await this.repeatedClicksModalCloseButton.click(); } + await this.page.waitForTimeout(3000); await this.closeButton.click(); } else { diff --git a/playwright-tests/tests/0000__M.test.ts b/playwright-tests/tests/0000__M.test.ts index 6bef5a2f3..656e078c2 100644 --- a/playwright-tests/tests/0000__M.test.ts +++ b/playwright-tests/tests/0000__M.test.ts @@ -192,8 +192,9 @@ async function runWorkflow(page: Page, testCase: TestCase) { //============================= TEST WORKFLOW STEPS ============================= - // Execute home page for qa and dev environments (skip for sys) - if (process.env.NODE_ENV !== 'sys') { + // Use Environment Variable to decide whether or not we want to skip content site aka home page + + if (process.env.SKIP_CONTENT_SITE == "false") { await test.step('HomePage >> Lets Get Started', async () => { let homePage = testCase.pages.homePage; console.log(`Customer for this test: ${customerDetails?.firstName} ${customerDetails?.lastName}`);