diff --git a/.gitignore b/.gitignore index fc42c5243..5ea4793fd 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,13 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +artifacts/ + # Editor directories and files .idea .vscode diff --git a/Dockerfile.playwright b/Dockerfile.playwright new file mode 100644 index 000000000..89a856a23 --- /dev/null +++ b/Dockerfile.playwright @@ -0,0 +1,21 @@ +FROM node:20 + +FROM mcr.microsoft.com/playwright:v1.48.0-noble + +# Set the working directory in the container +WORKDIR /app + +# Copy package.json and package-lock.json +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Install Playwright browsers +RUN npx playwright install chromium --with-deps + +# Install jq +RUN apt-get install -y jq + +# Copy the rest of the application code +COPY . . \ No newline at end of file diff --git a/devops/scripts/jira_writeback.sh b/devops/scripts/jira_writeback.sh new file mode 100644 index 000000000..545c8ad3c --- /dev/null +++ b/devops/scripts/jira_writeback.sh @@ -0,0 +1,150 @@ +#!/bin/bash +create_issue() { + local title="$1" + local project_key="$2" + local issue_type="$3" + local parent_issue_key="$4" + + AUTH=$(echo -ne "$JIRA_USERNAME:$JIRA_API_KEY" | base64 --wrap 0) + + local parent_issue=$(curl -s -H "Authorization: Basic $AUTH" \ + "$JIRA_SERVER/rest/api/3/issue/$parent_issue_key") + + local parent_fix_versions=$(echo $parent_issue | jq -r '.fields.fixVersions') + + local created_issue=$(curl -X POST -H "Content-Type: application/json" \ + -H "Authorization: Basic $AUTH" \ + -d '{ + "fields": { + "summary": "'"$title"'", + "project": { + "key": "'"$project_key"'" + }, + "issuetype": { + "name": "'"$issue_type"'" + }, + "parent": { + "key": "'"$parent_issue_key"'" + }, + "fixVersions": '"$parent_fix_versions"' + } + }' \ + "$JIRA_SERVER/rest/api/3/issue") + + local created_issue_id=$(echo $created_issue | jq -r '.id') + + curl -X PUT -H "Content-Type: application/json" \ + -H "Authorization: Basic $AUTH" \ + -d '{ + "fields": { + "fixVersions": '"$parent_fix_versions"' + } + }' \ + "$JIRA_SERVER/rest/api/3/issue/$created_issue_id" +} + +extract_uuid() { + local url="$1" + local uuid_regex='[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' + if [[ "$url" =~ $uuid_regex ]]; then + echo "${BASH_REMATCH}" + else + echo "No UUID found in the URL." + fi +} + +update_issue_status() { + local issue_key="$1" + local status_name="$2" + + AUTH=$(echo -ne "$JIRA_USERNAME:$JIRA_API_KEY" | base64 --wrap 0) + + local transitions=$(curl -s -H "Authorization: Basic $AUTH" \ + "$JIRA_SERVER/rest/api/3/issue/$issue_key/transitions") + + local transition_id=$(echo "$transitions" | jq -r --arg status_name "$status_name" ' + .transitions[] | select(.isAvailable == true and .to.name == $status_name) | .id + ') + + if [ -z "$transition_id" ]; then + echo "BadRequestError" + exit 1 + else + curl -X POST -H "Content-Type: application/json" \ + -H "Authorization: Basic $AUTH" \ + -d '{ + "transition": { + "id": "'"$transition_id"'" + } + }' \ + "$JIRA_SERVER/rest/api/3/issue/$issue_key/transitions" + fi +} + +add_attachments() { + AUTH=$(echo -ne "$JIRA_USERNAME:$JIRA_API_KEY" | base64 --wrap 0) + + local issue_key="$1" + shift + local attachments=("$@") + + echo $issue_key + echo $attachments + + local form_data="" + for attachment in "${attachments[@]}"; do + form_data+="--form file=@$attachment " + done + + echo $form_data + + echo $(curl -X POST $form_data \ + -H "X-Atlassian-Token: no-check" \ + -H "Authorization: Basic $AUTH" \ + "$JIRA_SERVER/rest/api/3/issue/$issue_key/attachments") +} + +add_comment() { + set -x # Enable script tracing + AUTH=$(echo -ne "$JIRA_USERNAME:$JIRA_API_KEY" | base64 --wrap 0) + local issue_key="$1" + shift + local comment_items_input=("$@") + local comment_json="[]" + + for item in "${comment_items_input[@]}"; do + if [ -e "$item" ]; then + # If it's a file path + local attachment=$(add_attachments "$issue_key" "$item") + local id=$(echo $attachment | grep -oP '"id":\s*"\K[^"]+') + + local attachment_content=$(curl -s -I -L -H "Authorization: Basic $AUTH" "$JIRA_SERVER/rest/api/3/attachment/content/$id" \ + | grep -i "Location:" | tail -1 | awk '{print $2}' | tr -d '\r') + echo "$JIRA_SERVER/rest/api/3/attachment/content/$id" + echo "$attachment_content" + + local uuid=$(extract_uuid "$attachment_content") + echo "$uuid" + + json_object=$(jq -n --arg uuid "$uuid" '{ type: "mediaSingle", attrs: { layout: "align-start" }, content: [{ type: "media", attrs: { type: "file", id: $uuid, width: 200, height: 200, collection: "", alt: "" } }]}') + + comment_json=$(echo "$comment_json" | jq --argjson obj "$json_object" '. += [$obj]') + else + # If it's a string + json_object=$(jq -n --arg text "$item" '{ type: "paragraph", content: [{ type: "text", text: $text }]}') + + comment_json=$(echo "$comment_json" | jq --argjson obj "$json_object" '. += [$obj]') + fi + done + + request=$(jq -n --argjson content "$comment_json" '{body: { type: "doc", version: 1, content: $content }}') + + curl -X POST -H "Content-Type: application/json" \ + -H "Authorization: Basic $AUTH" \ + -d "$request" \ + "$JIRA_SERVER/rest/api/3/issue/$issue_key/comment" +} + +if [[ $# -gt 0 ]]; then # IF function call passed in + "$@" # Call function +fi \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4eabbf0c9..00fc7673a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,9 @@ }, "devDependencies": { "@babel/eslint-parser": "^7.25.1", + "@faker-js/faker": "^9.6.0", + "@playwright/test": "^1.51.1", + "@types/dotenv-safe": "^8.1.6", "@vue/cli-plugin-babel": "~5.0.8", "@vue/cli-plugin-eslint": "~5.0.8", "@vue/cli-plugin-unit-jest": "~5.0.8", @@ -35,9 +38,13 @@ "@vue/eslint-config-prettier": "^9.0.0", "@vue/test-utils": "^2.4.6", "@vue/vue3-jest": "^27.0.0", + "dotenv-safe": "^9.1.0", "eslint": "8.57", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-vue": "^9.27.0", + "luxon": "^3.6.1", + "ortoni-report": "^3.0.1", + "playwright": "^1.51.1", "prettier": "^3.3.3", "sass": "^1.77.8", "sass-loader": "^8.0.2", @@ -2108,6 +2115,32 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@faker-js/faker": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.6.0.tgz", + "integrity": "sha512-3vm4by+B5lvsFPSyep3ELWmZfE3kicDtmemVpuwl1yH7tqtnHdsA6hG8fbXedMVdkzgtvzWoRgjSB4Q+FHnZiw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -3095,6 +3128,51 @@ "node": ">= 8" } }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@one-ini/wasm": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", @@ -3126,6 +3204,22 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", + "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.25", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", @@ -3401,6 +3495,27 @@ "@types/node": "*" } }, + "node_modules/@types/dotenv-safe": { + "version": "8.1.6", + "resolved": "https://registry.npmjs.org/@types/dotenv-safe/-/dotenv-safe-8.1.6.tgz", + "integrity": "sha512-ftZXu3WGT6ALq+f98IX2gWriGMPds+0ku5h8kZewNpY47ua+Z+XNcin9apZ2kVd4B9LV1vMfUOyDf1/hhreR0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "dotenv": "^8.2.0" + } + }, + "node_modules/@types/dotenv-safe/node_modules/dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, "node_modules/@types/eslint": { "version": "8.56.11", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.11.tgz", @@ -4957,6 +5072,37 @@ "node": ">= 6.0.0" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5077,6 +5223,34 @@ "node": ">=4" } }, + "node_modules/ansi-to-html": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz", + "integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "entities": "^2.2.0" + }, + "bin": { + "ansi-to-html": "bin/ansi-to-html" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ansi-to-html/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -5098,6 +5272,15 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/arch": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", @@ -5119,6 +5302,23 @@ ], "license": "MIT" }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -5541,6 +5741,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -5561,9 +5772,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dev": true, "license": "MIT", "dependencies": { @@ -5575,7 +5786,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -5765,6 +5976,62 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -5785,6 +6052,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5926,6 +6224,17 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -5972,6 +6281,18 @@ "node": ">= 10.0" } }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -6193,6 +6514,18 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", @@ -6221,13 +6554,14 @@ } }, "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">= 12" + "node": ">=18" } }, "node_modules/commondir": { @@ -6336,6 +6670,15 @@ "node": ">=0.8" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/consolidate": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.15.1.tgz", @@ -6381,9 +6724,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "dev": true, "license": "MIT", "engines": { @@ -6913,6 +7256,23 @@ "dev": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -6920,6 +7280,17 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -7081,6 +7452,15 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7102,6 +7482,17 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -7299,6 +7690,31 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/dotenv-safe": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/dotenv-safe/-/dotenv-safe-9.1.0.tgz", + "integrity": "sha512-2qwVAnUN+EDpu41pIK1XiJpHXKHV9Dnti3cE1EnUXT1/BV5+B7xuSZtgZ/4LExkCpp5F6BGikraezQL+8hKCOA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "dotenv": ">= 8.2.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -7436,15 +7852,42 @@ } }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -7481,6 +7924,27 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -7502,14 +7966,11 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -7531,6 +7992,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", @@ -8400,6 +8874,17 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", @@ -8417,38 +8902,38 @@ } }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dev": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -8457,6 +8942,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/debug": { @@ -8613,6 +9102,14 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -8627,14 +9124,14 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -8825,6 +9322,14 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -8841,6 +9346,20 @@ "node": ">=10" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/fs-monkey": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", @@ -8880,6 +9399,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8901,17 +9443,22 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -8930,6 +9477,20 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", @@ -8943,6 +9504,14 @@ "node": ">=6" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -9017,13 +9586,13 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9066,6 +9635,29 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -9089,10 +9681,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -9102,18 +9694,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/hash-sum": { "version": "2.0.0", @@ -9267,6 +9855,16 @@ "node": ">=12" } }, + "node_modules/html-minifier-terser/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -9343,6 +9941,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true + }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -9459,6 +10066,18 @@ "node": ">=10.17.0" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -9594,6 +10213,27 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -9620,6 +10260,31 @@ "dev": true, "license": "ISC" }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "peer": true + }, "node_modules/ipaddr.js": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", @@ -9782,6 +10447,15 @@ "node": ">=8" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -12553,6 +13227,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/jsdom": { "version": "16.7.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", @@ -13149,6 +13832,16 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.11", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", @@ -13174,6 +13867,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -13190,6 +13937,16 @@ "integrity": "sha512-zDalYGEVjQvnmedj6Yaae532g1RQVKppX8w4+L4q5HPuTUCJew/YDtTsKto4ReYSk5+nfacGyyz067o7qo4xTQ==", "license": "MIT" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -13221,11 +13978,14 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge-source-map": { "version": "1.1.0", @@ -13324,6 +14084,20 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mini-css-extract-plugin": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.1.tgz", @@ -13445,6 +14219,86 @@ "node": ">=8" } }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/minipass/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -13452,6 +14306,29 @@ "dev": true, "license": "ISC" }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -13464,6 +14341,14 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/module-alias": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.2.3.tgz", @@ -13532,6 +14417,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -13574,6 +14467,42 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -13630,6 +14559,75 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/node-gyp/node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -13739,6 +14737,25 @@ "node": ">=4" } }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -13770,9 +14787,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -14013,6 +15030,24 @@ "node": ">=8" } }, + "node_modules/ortoni-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ortoni-report/-/ortoni-report-3.0.1.tgz", + "integrity": "sha512-JJJeVQA0NyvTSEtAM1vL7hpnGj0pcIyowhyxBE+OkURf6odLdEL3d2V7gH/6WPW3QtsG7UycZM3GCvtYKqLEiw==", + "dev": true, + "license": "GPL-3.0-only", + "bin": { + "ortoni-report": "dist/cli/cli.js" + }, + "peerDependencies": { + "ansi-to-html": "^0.7.2", + "commander": "^12.1.0", + "express": "^4.21.1", + "handlebars": "^4.7.8", + "sqlite": "^5.1.1", + "sqlite3": "^5.1.7" + } + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -14052,6 +15087,24 @@ "node": ">=8" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -14243,9 +15296,9 @@ } }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "dev": true, "license": "MIT" }, @@ -14301,6 +15354,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", + "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/portfinder": { "version": "1.0.32", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.32.tgz", @@ -14919,6 +16019,34 @@ "dev": true, "license": "MIT" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -15037,6 +16165,43 @@ "webpack": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" } }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promise-retry/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -15118,13 +16283,13 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -15207,6 +16372,34 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "peer": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -15699,9 +16892,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dev": true, "license": "MIT", "dependencies": { @@ -15740,6 +16933,16 @@ "dev": true, "license": "MIT" }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -15844,21 +17047,30 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dev": true, "license": "MIT", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -15948,16 +17160,73 @@ "license": "MIT" }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -15973,6 +17242,55 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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", + "peer": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "peer": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", @@ -16005,6 +17323,19 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -16017,6 +17348,40 @@ "websocket-driver": "^0.7.4" } }, + "node_modules/socks": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -16122,6 +17487,40 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sqlite": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-5.1.1.tgz", + "integrity": "sha512-oBkezXa2hnkfuJwUo44Hl9hS3er+YFtueifoajrgidvqsJRQFpc5fKoAkAor1O5ZnLoa28GBScfHXs8j0K358Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -16490,6 +17889,84 @@ "node": ">=6" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/terminal-link": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", @@ -16832,6 +18309,20 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -16906,6 +18397,21 @@ "node": ">=4.2.0" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", @@ -16957,6 +18463,30 @@ "node": ">=4" } }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -17982,6 +19512,18 @@ "node": ">= 8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", @@ -17999,6 +19541,14 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 65de4ba18..c63d8b5e8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vue-cli-service build", "test:unit": "vue-cli-service test:unit --coverage --ci", "test:unit:lite": "vue-cli-service test:unit --ci", - "lint": "vue-cli-service lint" + "lint": "vue-cli-service lint", + "test:playwright": "playwright test --config=playwright-tests/playwright.config.ts" }, "dependencies": { "@iframe-resizer/child": "^5.3.3", @@ -30,6 +31,9 @@ }, "devDependencies": { "@babel/eslint-parser": "^7.25.1", + "@faker-js/faker": "^9.6.0", + "@playwright/test": "^1.51.1", + "@types/dotenv-safe": "^8.1.6", "@vue/cli-plugin-babel": "~5.0.8", "@vue/cli-plugin-eslint": "~5.0.8", "@vue/cli-plugin-unit-jest": "~5.0.8", @@ -38,9 +42,13 @@ "@vue/eslint-config-prettier": "^9.0.0", "@vue/test-utils": "^2.4.6", "@vue/vue3-jest": "^27.0.0", + "dotenv-safe": "^9.1.0", "eslint": "8.57", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-vue": "^9.27.0", + "luxon": "^3.6.1", + "ortoni-report": "^3.0.1", + "playwright": "^1.51.1", "prettier": "^3.3.3", "sass": "^1.77.8", "sass-loader": "^8.0.2", diff --git a/playwright-tests/.dockerignore b/playwright-tests/.dockerignore new file mode 100644 index 000000000..3f9fe19df --- /dev/null +++ b/playwright-tests/.dockerignore @@ -0,0 +1,4 @@ +.git +*Dockerfile* +*docker-compose* +node_modules \ No newline at end of file diff --git a/playwright-tests/.env b/playwright-tests/.env new file mode 100644 index 000000000..166dea341 --- /dev/null +++ b/playwright-tests/.env @@ -0,0 +1,22 @@ +# .env +# Environment configuration + +# Environment type +NODE_ENV="qa" + +# Base URLs by environment (uncomment the one you need) +# qa +BASE_URL="https://www-qa2.safelite.com/" +# sys +# BASE_URL="https://www-test2.safelite.com/fmg/?fmgPage=vehicle" +# dev +# BASE_URL="https://www-dev2.safelite.com/" +# dev with leadgen +# BASE_URL="https://www-dev2.safelite.com/?&experiments=LeadGenHeritage=LeadGenHeritageExp_V3=LeadGenHeritageTestV3=true" + +# API endpoints +CCIS_API_URL="https://api.test.belronus.io" +ADMIN_SERVICE_API_URL="https://issadminapi.dev.sagaws.net/iss-admin/api/v1/" + +# API authentication (replace with actual value when running tests) +CCIS_API_AUTH="undefined" diff --git a/playwright-tests/.env.dev b/playwright-tests/.env.dev new file mode 100644 index 000000000..47ec8336d --- /dev/null +++ b/playwright-tests/.env.dev @@ -0,0 +1,22 @@ +# .env.dev +# Environment configuration + +# Environment type +NODE_ENV="qa" + +# Base URLs by environment (uncomment the one you need) +# qa +BASE_URL="https://www-qa2.safelite.com/" +# sys +# BASE_URL="https://www-test2.safelite.com/fmg/?fmgPage=vehicle" +# dev +# BASE_URL="https://www-dev2.safelite.com/" +# dev with leadgen +# BASE_URL="https://www-dev2.safelite.com/?&experiments=LeadGenHeritage=LeadGenHeritageExp_V3=LeadGenHeritageTestV3=true" + +# API endpoints +CCIS_API_URL="https://api.test.belronus.io" +ADMIN_SERVICE_API_URL="https://issadminapi.dev.sagaws.net/iss-admin/api/v1/" + +# API authentication (replace with actual value when running tests) +CCIS_API_AUTH="undefined" \ No newline at end of file diff --git a/playwright-tests/.env.example b/playwright-tests/.env.example new file mode 100644 index 000000000..f6eff5279 --- /dev/null +++ b/playwright-tests/.env.example @@ -0,0 +1,21 @@ +# .env.example +# Environment configuration + +# Environment type (uncomment one) +NODE_ENV="dev" +# NODE_ENV="qa" +# NODE_ENV="sys" + +# Base URLs by environment (uncomment the one you need) +BASE_URL="https://selfservice.dev.glassclaim.com" +# BASE_URL="https://selfservice.test.glassclaim.com" +# BASE_URL="https://www-test2.safelite.com/fmg/?fmgPage=vehicle" + +# API endpoints (uncomment to match your BASE_URL) +CCIS_API_URL="https://api.test.belronus.io" + +# Admin service API URL +ADMIN_SERVICE_API_URL="https://issadminapi.dev.sagaws.net/iss-admin/api/v1/" + +# API authentication (replace with actual value when running tests) +CCIS_API_AUTH="" \ No newline at end of file diff --git a/playwright-tests/.sauce/config.yml b/playwright-tests/.sauce/config.yml new file mode 100644 index 000000000..31f2a51e6 --- /dev/null +++ b/playwright-tests/.sauce/config.yml @@ -0,0 +1,74 @@ +apiVersion: v1alpha +kind: playwright +showConsoleLog: true +sauce: + region: us-west-1 + concurrency: 20 + sauceignore: .sauceignore +playwright: + version: 1.48.2 + configFile: playwright.config.ts + +suites: +- name: 'FMG-NextGen-chromium' + numShards: 20 + testMatch: + - tests/0000__M.test.ts + platformName: Windows 10 + env: + DEBUG: "pw:worker" + SAUCE_USERNAME: $SAUCE_USERNAME + SAUCE_ACCESS_KEY: $SAUCE_ACCESS_KEY + params: + browserName: chrome + project: "chromium" + artifacts: "**test-results\\index.html" +artifacts: + cleanup: true + download: + match: + - '*' + when: always + directory: ./artifacts + +# - name: 'Mobile Android Chrome Tests' +# shard: spec +# testMatch: +# - e2e/ +# platformName: Windows 11 +# params: +# browserName: chrome +# project: "Mobile Android Tests" + +# - name: 'Desktop Safari' +# testMatch: +# - .ts +# platformName: Windows 10 +# params: +# browserName: webkit +# project: "webkit" + + +# - name: 'Mobile iOS Tests' +# shard: spec +# testMatch: +# - e2e/ +# platformName: macOS 13 +# params: +# browserName: webkit +# project: "Mobile iOS Tests" +# env: +# DEBUG: "pw:worker" + +docker: + file: Dockerfile + image: fmgcqa-playwright-image + +rootDir: ./ +reporters: + spotlight: # Prints an overview of failed or otherwise interesting jobs. + enabled: true +npm: + dependencies: + - "package.json" + diff --git a/playwright-tests/.sauceignore b/playwright-tests/.sauceignore new file mode 100644 index 000000000..18a59d436 --- /dev/null +++ b/playwright-tests/.sauceignore @@ -0,0 +1,16 @@ +# This file instructs saucectl to not package any files mentioned here. +.git/ +.github/ +.DS_Store +.hg/ +.vscode/ +.idea/ +.gitignore +.hgignore +.gitlab-ci.yml +.npmrc +*.gif +screenshots +artifacts +# Remove this to have node_modules uploaded with code +# node_modules/ diff --git a/playwright-tests/README.md b/playwright-tests/README.md new file mode 100644 index 000000000..d9217787b --- /dev/null +++ b/playwright-tests/README.md @@ -0,0 +1,266 @@ +# QA Automation Framework +An automated testing framework for FMG using Playwright with TypeScript. + +## Technology Stack +• Playwright - Core testing framework
+• TypeScript - Programming language
+• Node.js - Runtime environment
+• SauceLabs/Dockers - Cross-browser testing platform
+ +## Key Features +• Page Object Model implementation
+• Data-driven test approach
+• Cross-browser testing support
+• Parallel test execution
+• HTML report generation
+• Environment-specific configurations
+• SauceLabs integration
+ +## Test Scenarios +The framework includes various test scenarios covering essential flows like: + +### Cash Payment - Repair Scenarios +1. **CashRepairMobileCreditCard**
+ - Repair or Replace: Repair
+ - Damage Type: Windshield Chip (1)
+ - Vehicle Lookup Type: ZIP
+ - CASH or Insurance: CASH
+ - Service Type: Glass Service Only
+ - Service Location Type: Mobile
+ - Schedule Appointment Type: Range of Hours
+ - Payment Type: PIA - Credit Card
+ +2. **CashRepairInShopAfterPay**
+ - Repair or Replace: Repair
+ - Damage Type: Windshield Chip (2)
+ - Vehicle Lookup Type: ZIP
+ - CASH or Insurance: CASH
+ - Service Type: Standard
+ - Service Location Type: In-Shop
+ - Schedule Appointment Type: Hourly
+ - Payment Type: PIA - AfterPay
+ +3. **CashRepairInShopPayPal**
+ - Repair or Replace: Repair
+ - Damage Type: Windshield Chip (3)
+ - Vehicle Lookup Type: ZIP
+ - CASH or Insurance: CASH
+ - Service Type: Premium
+ - Service Location Type: In-Shop
+ - Schedule Appointment Type: Hourly
+ - Payment Type: PIA - PayPal
+ +### Cash Payment - Single Glass Replacement Scenarios +4. **CashReplaceDynamicRecalMobile**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack
+ - Vehicle Lookup Type: VIN Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Glass Service Only
+ - Service Location Type: Mobile
+ - Schedule Appointment Type: Range of Hours
+ - Payment Type: Pay At Service
+ - Vehicle Info: Dynamic Recalibration Vehicle
+ +5. **CashReplaceGlassAddressLookupInshopAfterPay**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack
+ - Vehicle Lookup Type: Address Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Glass Service Only
+ - Service Location Type: In-Shop
+ - Schedule Appointment Type: Hourly
+ - Payment Type: PIA - Afterpay
+ +6. **CashReplaceGlassLicensePlateLookupInshopPaypal**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack
+ - Vehicle Lookup Type: License Plate Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Glass Service Only
+ - Service Location Type: In-Shop
+ - Schedule Appointment Type: Hourly
+ - Payment Type: PIA - PayPal
+ +7. **CashReplaceVinMobile**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack
+ - Vehicle Lookup Type: VIN Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Glass Service Only
+ - Service Location Type: Mobile
+ - Schedule Appointment Type: Range of Hours
+ - Payment Type: Pay At Service
+ +8. **CashReplaceSafeliteCanNotRecalMobile**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack
+ - Vehicle Lookup Type: ZIP Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Glass Service Only
+ - Service Location Type: Mobile
+ - Schedule Appointment Type: Range of Hours
+ - Payment Type: Pay At Service
+ - Additional Info: Safelite Cannot Recalibrate Vehicle
+ +### Cash Payment - Multiple Glass Replacement Scenarios +9. **CashReplaceMultiGlassPromoInshop**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack, Driver Front Door, Driver Rear Door, Driver Vent Glass, Passenger Vent Glass, Rear Window
+ - Vehicle Lookup Type: VIN Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Glass Service Only
+ - Service Location Type: In-Shop
+ - Schedule Appointment Type: Hourly
+ - Payment Type: Pay At Service
+ - Additional Info: Glass Promo
+ +10. **CashReplaceMultiSlidingGlassDropoff**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack, Passenger Front Door, Passenger Rear Door, Rear Glass With Slider
+ - Vehicle Lookup Type: ZIP Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Standard
+ - Service Location Type: Dropoff
+ - Schedule Appointment Type: Dropoff
+ - Payment Type: Pay At Service
+ - Vehicle Info: Sliding glass vehicle, capability questions
+ +11. **CashReplaceMultiGlassMobile**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack, Driver Quarter Panel, Passenger Quarter Panel
+ - Vehicle Lookup Type: ZIP Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Premium
+ - Service Location Type: Mobile
+ - Schedule Appointment Type: Range Of Hours
+ - Payment Type: Pay At Service
+ +### Cash Payment - Promo Scenarios +12. **CashReplaceGlassPromoInshop**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack
+ - Vehicle Lookup Type: VIN Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Glass Service Only
+ - Service Location Type: In-Shop
+ - Schedule Appointment Type: Hourly
+ - Payment Type: Pay At Service
+ - Additional Info: Glass Promo
+ +13. **CashReplaceRainDefensePromoInshop**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack
+ - Vehicle Lookup Type: VIN Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Premium
+ - Service Location Type: In-Shop
+ - Schedule Appointment Type: Hourly
+ - Payment Type: Pay At Service
+ - Additional Info: Rain Defense Promo
+ +14. **CashReplaceWiperPromoInshop**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack
+ - Vehicle Lookup Type: VIN Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Standard
+ - Service Location Type: In-Shop
+ - Schedule Appointment Type: Hourly
+ - Payment Type: Pay At Service
+ - Vehicle Info: Wiper Promo
+ +15. **CashReplaceWiperDropoff**
+ - Repair or Replace: Replace
+ - Damage Type: Windshield Crack
+ - Vehicle Lookup Type: ZIP Lookup
+ - CASH or Insurance: CASH
+ - Service Type: Standard
+ - Service Location Type: Drop Off
+ - Schedule Appointment Type: Drop Off
+ - Payment Type: Pay At Service
+ +## Installation +### Install dependencies +npm install + +npx playwright install +or +RUN npx playwright install chromium --with-deps + +### Run Tests with script +npm run test:playwright + +### Install SauceLabs CLI +npm install saucectl + +## Configuration +• Environment configurations in .env files
+• SauceLabs configuration in config.yml
+• Playwright configuration in playwright.config.ts
+ +## Project Structure + +├── tests/ # Test scenarios and cases
+├── pages/ # Page Object Models
+├── business-logic/ # Business logic and data models
+├── impl/ # Implementation utilities
+└── artifacts/ # Test artifacts and results
+ +## Running Tests +### Run all tests +npx playwright test + +### Run a specific test file +npx playwright test tests/0000__M.test.ts + +### Run tests with specific tag (cognitive approach) +npx playwright test --grep "@smoke" + +## Test Reports +Test results are available in: + +• ortoni report (summarized HTML)
+• HTML format (playwright-report)
+• JUnit format
+• SauceLabs dashboard
+ +## Reccomended Visual Studio Code Extensions +• JavaScript and TypeScript Nightly
+• Playwright Test for VSCode
+ +## Key Features +Page Object Model implementation
+Data-driven test approach
+Cross-browser testing support
+Integration with SauceLabs
+Parallel test execution
+HTML report generation
+Environment-specific configurations
+ +## Common Test Flows +• Vehicle lookup validation
+• Shop selection
+• Damage assessment
+• Appointment scheduling
+• Coverage verification
+• Payment processing
+ + +## Contributing +1. Follow the established page object pattern +2. Add proper test documentation +3. Include appropriate test tags +4. Ensure tests are isolated and repeatable + +## Environment Variables +BASE_URL=https://www-dev2.safelite.com
+CCIS_API_URL=
+ADMIN_SERVICE_API_URL=
+SAUCE_USERNAME=
+SAUCE_ACCESS_KEY=
+ + +## CI/CD Integration +The project uses Azure Pipelines for continuous integration with configurations defined in azure-pipelines.yml. + diff --git a/playwright-tests/azure-pipelines-automated-testing.yml b/playwright-tests/azure-pipelines-automated-testing.yml new file mode 100644 index 000000000..b4d7da831 --- /dev/null +++ b/playwright-tests/azure-pipelines-automated-testing.yml @@ -0,0 +1,198 @@ +schedules: +- cron: 0 9 * * MON-FRI + always: true + displayName: Daily Test Automation Run for FMG-NextGen + branches: + include: + - main + +pool: 'AmazonLinuxPool' + +variables: + # - group: Digital-Infrastructure + # - group: FMG-BuildBranches + - name: dockerImageName + value: 'playwright-tests' + - name: imageTag + value: '$(Build.BuildId)' + - name: totalShards + value: 4 + +stages: + - stage: TestPr + displayName: Run Playwright Test + jobs: + - job: playwright_tests + continueOnError: true + strategy: + matrix: + shard1: + shardNumber: 1 + shard2: + shardNumber: 2 + shard3: + shardNumber: 3 + shard4: + shardNumber: 4 + + steps: + - task: Docker@2 + displayName: 'Build Docker Image' + inputs: + command: build + dockerfile: Dockerfile.playwright + repository: $(dockerImageName) + tags: $(imageTag) + arguments: '--no-cache --pull' + + - script: | + # Create container and run tests + container_id=$(docker create \ + --ipc=host \ + -e CCIS_API_AUTH=$(CCIS_API_AUTH) \ + -e BASE_URL=$(BASE_URL) \ + -e CCIS_API_URL=$(CCIS_API_URL) \ + -e ADMIN_SERVICE_API_URL=$(ADMIN_SERVICE_API_URL) \ + -e SHARD=$(shardNumber) \ + -e CI=true \ + -e NODE_ENV=$(NODE_ENV) \ + $(dockerImageName):$(imageTag) \ + npm run test:playwright -- --shard=$(shardNumber)/$(totalShards) --reporter=list,blob --grep '@(Alert|CASH)') + + # Start container and stream logs + echo "Starting tests for shard $(shardNumber)..." + docker start -a $container_id + + # Create directory for test results + echo "Creating test results directory..." + mkdir -p $(System.DefaultWorkingDirectory)/blob-reports/shard-$(shardNumber) + + # Copy test results from container + echo "Copying test results..." + docker cp $container_id:/app/blob-report/. $(System.DefaultWorkingDirectory)/blob-reports/shard-$(shardNumber)/ + + # Remove container + echo "Cleaning up container..." + docker rm $container_id + + # Check if tests failed + if [ $? -ne 0 ]; then + echo "Tests failed in shard $(shardNumber) or tests don't exist for this shard number!" + exit 0 # Suppress error. It will be visible in report. + fi + displayName: 'Run Playwright Tests - Shard $(shardNumber)' + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Test Reports - Shard $(shardNumber)' + condition: always() + inputs: + targetPath: '$(System.DefaultWorkingDirectory)/blob-reports/shard-$(shardNumber)' + artifact: 'playwright-report-shard-$(shardNumber)' + publishLocation: 'pipeline' + + - script: | + docker rmi $(dockerImageName):$(imageTag) -f + displayName: 'Cleanup Docker Image' + condition: always() + + - job: download_and_merge_reports + dependsOn: playwright_tests + timeoutInMinutes: 8 + cancelTimeoutInMinutes: 10 + steps: + - task: DownloadPipelineArtifact@2 + inputs: + targetPath: '$(System.DefaultWorkingDirectory)/playwright-reports' + - task: Docker@2 + displayName: 'Build Docker Image' + inputs: + command: build + dockerfile: Dockerfile.playwright + repository: $(dockerImageName) + tags: $(imageTag) + arguments: '--no-cache --pull' + - bash: | + # branch_name=$(Build.SourceBranch) + # TODO: Replace hardcoded Jira card. Better to search the active sprint for regression card and create one if not found. + jira_card_number="CASH-425" + + # 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 jira_card_number="$jira_card_number" \ + -e REPORT_NAME="$report_name" \ + -e CURRENT_DAY="$current_day" \ + -e NUMERIC_DATE="$numeric_date" \ + $(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 + 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 '$jira_card_number'...\" + /app/devops/scripts/jira_writeback.sh add_comment \"$jira_card_number\" /app/ortoni-report/\${REPORT_NAME} \"AUTOMATED TEST RUN: $(date) - \${CURRENT_DAY} (\${NUMERIC_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/playwright-tests/artifacts/test-results/junit_results.xml $(System.DefaultWorkingDirectory)/test-results + + # Remove container + echo "Cleaning up container..." + docker rm $container_id + env: + JIRA_API_KEY: $(JIRA_API_KEY) + displayName: merge_and_publish_results_to_jira + + - task: PublishTestResults@2 + displayName: 'Publish test results' + inputs: + searchFolder: 'test-results' + testResultsFormat: 'JUnit' + testResultsFiles: 'junit_results.xml' + mergeTestResults: true + failTaskOnFailedTests: false + testRunTitle: 'Playwright Tests' + condition: succeededOrFailed() + + - task: PublishPipelineArtifact@1 + displayName: 'Publish Merged Report' + condition: always() + inputs: + targetPath: '$(System.DefaultWorkingDirectory)/ortoni-report' + artifact: 'playwright-merged-report' + publishLocation: 'pipeline' + - script: | + docker rmi $(dockerImageName):$(imageTag) -f + displayName: 'Cleanup Docker Image' + condition: always() \ No newline at end of file diff --git a/playwright-tests/business-logic/Data/PaymentData.ts b/playwright-tests/business-logic/Data/PaymentData.ts new file mode 100644 index 000000000..ae733d755 --- /dev/null +++ b/playwright-tests/business-logic/Data/PaymentData.ts @@ -0,0 +1,47 @@ +import { IPaymentDetails } from "@business-logic/types/CustomerDetails"; +import { PaymentType } from "@business-logic/types/Enums"; + +const defaultCreditCardDetails: IPaymentDetails = { + paymentType: PaymentType.Credit, + cardNumber: '4111111111111111', + expirationMonth: '12 - December', + expirationYear: '2029', + cvv: '555', + billingAddress: { + street: '2088 Tuller St', + city: 'Columbus', + state: 'OH', + postalCode: '43028', + country: 'US' + } +} + +const defaultAfterpayDetails: IPaymentDetails = { + paymentType: PaymentType.AfterPay, + username: 'itqatest@safelite.com', + password: 'Safelite1', + cardNumber: '4111 1111 1111 1111', + expirationMonth: '12', + expirationYear: '34', + cvv: '000' +} + +const defaultPaypalDetails: IPaymentDetails = { + paymentType: PaymentType.Paypal, + password: 'Safelite1' +} + +export default class PaymentData { + + static getDefaultCreditCardDetails() { + return defaultCreditCardDetails; + } + + static getDefaultAfterpayDetails() { + return defaultAfterpayDetails; + } + + static getDefaultPaypalDetails() { + return defaultPaypalDetails; + } +} \ No newline at end of file diff --git a/playwright-tests/business-logic/constants/DefaultTestData.ts b/playwright-tests/business-logic/constants/DefaultTestData.ts new file mode 100644 index 000000000..6efb2c1db --- /dev/null +++ b/playwright-tests/business-logic/constants/DefaultTestData.ts @@ -0,0 +1,114 @@ +// DefaultTestData.ts +import { faker } from "@faker-js/faker"; +import { PaymentMethod, ServicePackage, VehicleLookupType, AppointmentType, PaymentType, DamageType, VehicleDamage } from "@business-logic/types/Enums"; +import { ITestData } from "@business-logic/types/ITestData"; +import { getNextWeekday } from "@impl/utils/DateUtils"; +import { ICustomerDetails, IVehicleDetails, IAppointmentDetails, IPaymentDetails, IClaimDetails } from "@business-logic/types/CustomerDetails"; + +// Set a default seed for consistent data generation +const DEFAULT_SEED = 1234; +faker.seed(DEFAULT_SEED); + +// Function to reset faker to the default seed +export function resetFakerToDefaultSeed() { + faker.seed(DEFAULT_SEED); +} + +// Simple hash function to convert a string to a numeric value +function hashStringToNumber(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); +} + +// Function to set a custom seed based on test name +export function setFakerSeedFromTestName(testName: string) { + const seed = hashStringToNumber(testName); + faker.seed(seed); +} + +// Functions to generate data (rather than using pre-generated data) +export function getCustomerDetails(): ICustomerDetails { + return { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: "itqatest@safelite.com", + phoneNumber: '614-254-4109', + notes: 'Automated Test', + address: { + street: faker.location.streetAddress(), + city: 'Columbus', + state: 'Ohio', + postalCode: '43215', + country: 'United States' + } + }; +} + +export function getVehicleDetails(): IVehicleDetails { + return { + year: '2020', + make: 'Honda', + model: 'Accord', + style: '4 door sedan', + vehicleLookupType: VehicleLookupType.Zip + }; +} + +export function getAppointmentDetails(): IAppointmentDetails { + return { + serviceLocation: AppointmentType.InShop, + appointmentDate: getNextWeekday() + }; +} + +export function getPaymentDetails(): IPaymentDetails { + return { + paymentType: PaymentType.PayAtService + }; +} + +export function getClaimDetails(): IClaimDetails { + return { + client: 'ACUITY INSURANCE', + policyNumber: 'Mock' + faker.string.alphanumeric(6).toUpperCase(), + policyDeductible: 0, + damageDate: new Date(new Date().setDate(new Date().getDate() - 1)).toLocaleDateString('en-US', {month: '2-digit', day: '2-digit', year: 'numeric'}), + damageCause: DamageType.Hail + }; +} + +// Function to get the entire default test data set with current faker state +export function getDefaultTestData(): Partial { + return { + paymentMethod: PaymentMethod.SelfPay, + servicePackage: ServicePackage.GlassOnly, + customerDetails: getCustomerDetails(), + vehicleDetails: getVehicleDetails(), + appointmentDetails: getAppointmentDetails(), + paymentDetails: getPaymentDetails(), + claimDetails: getClaimDetails(), + vehicleDamage: [VehicleDamage.WindshieldCrack], + isDuplicateClaim: false, + isPolicyDriver: false, + isPolicyFound: false, + isUseVehicleOnPolicy: true, + isRecalNotification: false, + hasOemEndorsement: false, + skipEstimatePage: false, + isRecalVehicle: false, + enterFunnelWithZip: false + }; +} + +// Keep the pre-generated data for backward compatibility +export const defaultCustomerDetails = getCustomerDetails(); +export const defaultVehicleDetails = getVehicleDetails(); +export const defaultAppointmentDetails = getAppointmentDetails(); +export const defaultPaymentDetails = getPaymentDetails(); +export const defaultClaimDetails = getClaimDetails(); +export const defaultTestData = getDefaultTestData(); \ No newline at end of file diff --git a/playwright-tests/business-logic/constants/StateAbbreviation.ts b/playwright-tests/business-logic/constants/StateAbbreviation.ts new file mode 100644 index 000000000..f0fc95d79 --- /dev/null +++ b/playwright-tests/business-logic/constants/StateAbbreviation.ts @@ -0,0 +1,70 @@ +/** + * Mapping of state names to their two-letter abbreviations + */ +export const STATE_ABBREVIATIONS: Record = { + 'Alabama': 'AL', + 'Alaska': 'AK', + 'Arizona': 'AZ', + 'Arkansas': 'AR', + 'California': 'CA', + 'Colorado': 'CO', + 'Connecticut': 'CT', + 'Delaware': 'DE', + 'District of Columbia': 'DC', + 'Florida': 'FL', + 'Georgia': 'GA', + 'Hawaii': 'HI', + 'Idaho': 'ID', + 'Illinois': 'IL', + 'Indiana': 'IN', + 'Iowa': 'IA', + 'Kansas': 'KS', + 'Kentucky': 'KY', + 'Louisiana': 'LA', + 'Maine': 'ME', + 'Maryland': 'MD', + 'Massachusetts': 'MA', + 'Michigan': 'MI', + 'Minnesota': 'MN', + 'Mississippi': 'MS', + 'Missouri': 'MO', + 'Montana': 'MT', + 'Nebraska': 'NE', + 'Nevada': 'NV', + 'New Hampshire': 'NH', + 'New Jersey': 'NJ', + 'New Mexico': 'NM', + 'New York': 'NY', + 'North Carolina': 'NC', + 'North Dakota': 'ND', + 'Ohio': 'OH', + 'Oklahoma': 'OK', + 'Oregon': 'OR', + 'Pennsylvania': 'PA', + 'Rhode Island': 'RI', + 'South Carolina': 'SC', + 'South Dakota': 'SD', + 'Tennessee': 'TN', + 'Texas': 'TX', + 'Utah': 'UT', + 'Vermont': 'VT', + 'Virginia': 'VA', + 'Washington': 'WA', + 'West Virginia': 'WV', + 'Wisconsin': 'WI', + 'Wyoming': 'WY', + }; + + /** + * Gets the two-letter abbreviation for a state name + * @param stateName Full state name + * @returns Two-letter abbreviation + * @throws Error if state name is not found + */ + export function getStateAbbreviation(stateName: string): string { + const abbreviation = STATE_ABBREVIATIONS[stateName]; + if (!abbreviation) { + throw new Error(`State "${stateName}" not found in mapping`); + } + return abbreviation; + } \ No newline at end of file diff --git a/playwright-tests/business-logic/rules/RuleEngineBuiltins.ts b/playwright-tests/business-logic/rules/RuleEngineBuiltins.ts new file mode 100644 index 000000000..f9f1adf01 --- /dev/null +++ b/playwright-tests/business-logic/rules/RuleEngineBuiltins.ts @@ -0,0 +1,44 @@ +import TestCase from "@business-logic/types/TestCase"; +import { Rule } from "../types/RuleEngine"; +import EnumUtils from "../../impl/utils/EnumUtils"; + +// File containing Rule Engine Builtin Rules for JSON Data Validation + +export enum BuiltInRules { + //Custom Rules +} + +// Built-Ins shouldn't depend on other rules, custom rules however are supposed to depend on them +export const builtInRules: Rule[] = +[ +//{ +// id: BuiltInRules.TransactionExists, +// name: "Transaction Exists Rule", +// check: (testCase: TestCase) => { +// return testCase.transaction != null; +// } +// }, { +// id: BuiltInRules.PricingExists, +// name: "Pricing must exist on Transaction Rule", +// check: (testCase: TestCase) => { +// return testCase.transaction != null && testCase.transaction.pricing != null; +// } +// }, { +// id: BuiltInRules.TerminationIsMod, +// name: "OpportunityType Termination requires TransactionSubType Mod", +// check: (testCase: TestCase) => { + +// // Check old transactions +// for (var transaction of testCase.oldTransactions) { +// if (transaction.opportunityType == OpportunityTypes.Termination) +// return transaction.subType == TransactionSubTypes.Mod; +// } + +// // Check transaction +// if (testCase.transaction.opportunityType == OpportunityTypes.Termination) +// return testCase.transaction.subType == TransactionSubTypes.Mod; +// return true; +// }, +// dependsOn: [BuiltInRules.TransactionExists] +// }, { +]; \ No newline at end of file diff --git a/playwright-tests/business-logic/types/Authentication.ts b/playwright-tests/business-logic/types/Authentication.ts new file mode 100644 index 000000000..0b3400737 --- /dev/null +++ b/playwright-tests/business-logic/types/Authentication.ts @@ -0,0 +1 @@ +// File for interfaces related to authentication \ No newline at end of file diff --git a/playwright-tests/business-logic/types/CustomerDetails.ts b/playwright-tests/business-logic/types/CustomerDetails.ts new file mode 100644 index 000000000..bb1f08b96 --- /dev/null +++ b/playwright-tests/business-logic/types/CustomerDetails.ts @@ -0,0 +1,66 @@ +import { DamageType as DamageCause, WindshieldDamage, ServiceLocation, EndorsementType, VehicleLookupType, PartQuestionType, PaymentType, AppointmentType } from "./Enums"; +import { IAddress } from "./IAddress"; + +export interface ICustomerDetails { + firstName: string, + lastName: string, + email: string, + phoneNumber: string, + notes: string, + address: IAddress, + apptDate?: string, + packagePrice?: string +} + +export interface IVehicleDetails { + year: string, + make: string, + model: string, + style?: string, + vin?: string, + licensePlateNumber?: string, + licensePlateState?: string, + vehicleLookupType: VehicleLookupType, +} + +export interface IAppointmentDetails { + serviceLocation: AppointmentType, + appointmentDate?: Date, + shopAddress?: string, // Used for in-shop + serviceAddress?: IAddress, // Used for mobile + isVehicleProtected?: boolean // Used for mobile +} + +export interface IEndorsementDetails { + endorsementType: EndorsementType, + isOnPolicy: boolean, // Should we expect this endorsement to appear? + isClickYes: boolean // Should we click Yes or No? +} + +export interface IPartQuestion { + isOnPage: boolean, + optionToSelect: string, + partQuestionType: PartQuestionType, + secondaryQuestionOptionToSelect?: string +} + +export interface IPaymentDetails { + paymentType?: PaymentType, + username?: string, + password?: string, + cardNumber?: string, + expirationMonth?: string, + expirationYear?: string, + cvv?: string, + billingAddress?: IAddress, +} + +export interface IClaimDetails { + client: string, + policyNumber: string, + policyDeductible: number, + policyZip?: string + damageDate: string, + damageCause: DamageCause +} + diff --git a/playwright-tests/business-logic/types/DigitalAPI.ts b/playwright-tests/business-logic/types/DigitalAPI.ts new file mode 100644 index 000000000..7317639df --- /dev/null +++ b/playwright-tests/business-logic/types/DigitalAPI.ts @@ -0,0 +1,38 @@ +export interface IPartsOrQuestionsResponse { + partsOrQuestions: IPartOrQuestion[] +} + +export interface IPartOrQuestion { + glassPiece: IGlassPiece, + parts: IPart[], + partQuestions: IPartQuestion[] | null; +} + +export interface IGlassPiece { + name: string, + location: string +} + +export interface IPart { + childPartQuestions: IPartQuestion[], + basePartNumber: string, + safelitePartNumber: string, + color: string, + requiresRecalibration: boolean, + recalibrationType: null, // TODO: Add types + canSafeliteRecalibrate: boolean, + requiresCapabilityQuestions: boolean, + childParts: IChildPart[], + partNumber: string, + description: string, + partType: string +} + +export interface IPartQuestion { + // TODO: Define +} + +export interface IChildPart { + partNumber: string, + safelitePartNumber: string +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/Enums.ts b/playwright-tests/business-logic/types/Enums.ts new file mode 100644 index 000000000..782d21d45 --- /dev/null +++ b/playwright-tests/business-logic/types/Enums.ts @@ -0,0 +1,128 @@ +// Enums File + +export enum ConsoleColor { + Default = "", + Red = "\x1b[31m", + Green = "\x1b[32m", + Yellow = "\x1b[33m", + Orange = "\x1b[202m", + Blue = "\x1b[34m", + Magenta = "\x1b[35m", + Cyan = "\x1b[36m", + Reset = "\x1b[0m" +} + +export enum ResultTypes { + None = 0, + Skipped = 1, + Success = 2, + Failure = 3 +} + +export enum VehicleLookupType { + Vin, + Address, + LicensePlateNumber, + Zip +} + +export enum VehicleDamage { + WindshieldOneChip = "WINDSHIELD ONE CHIP", + WindshieldTwoChips = "WINDSHIELD TWO CHIPS", + WindshieldThreeChips = "WINDSHIELD THREE CHIPS", + WindshieldCrack = "WINDSHIELD", + RearWindow = "BACK GLASS", + RearSliding = "SLIDER", + DriverFrontDoor = "DRIVER FRONT DOOR GLASS", + DriverRearDoor = "DRIVER REAR DOOR GLASS", + DriverVentGlass = "DRIVER VENT GLASS", + DriverQuarterPanel = "DRIVER REAR QUARTER GLASS", + DriverSlidingDoor = "DRIVER SLIDING DOOR GLASS", + PassengerFrontDoor = "PASSENGER FRONT DOOR GLASS", + PassengerRearDoor = "PASSENGER REAR DOOR GLASS", + PassengerVentGlass = "PASSENGER VENT GLASS", + PassengerQuarterPanel = "PASSENGER REAR QUARTER GLASS" +} + +export enum WindshieldDamage{ + Crack, + OneChip, + TwoChips, + ThreeChips +} + +export enum SideDoorDamage{ + Passenger = "Passenger", + Driver = "Driver" +} + +export enum DamageType { + Rock = 'Rock from road', + Vandalism = 'Vandalism', + Theft = 'Attempted theft or theft', + Hail = 'Hail storm', + HurricaneStorm = 'Hurricane/Storm', + OtherWeather = 'Other weather', + Collision = 'Collision', + Object = 'Object hit glass', + Other = 'Other/unknown' +} + +export enum ServiceLocation { + Mobile, + InShop, + DropOff +} + +export enum ServicePackage { + GlassOnly = 'Glass service only', + Standard = 'Standard', + Premium = 'Premium' +} + +export enum EndorsementType { + Educator = '01', + EmployeeParking = '03' +} + +export enum PartQuestionType { + WindshieldColor = 'Windshield-Single', + DriverFrontColor = 'Driver-Front', + DriverQuarterColor = 'Driver-Quarter', + DriverRearColor = 'Driver-Back', + DriverVentColor = 'Driver-Vent', + PassengerFrontColor = 'Passenger-Front', + PassengerQuarterColor = 'Passenger-Quarter', + PassengerRearColor = 'Passenger-Back', + PassengerVentColor = 'Passenger-Vent', + RearWindowColor = 'Rear-Stationary', + RearSlidingWindowColor = 'Rear-Slider', + DriverSideColor = 'Driver-SideDoor', + // use generalized tag for other part questions + GeneralQuestion1 = 'question-0-1', + GeneralQuestion2 = 'question-0-2', + GeneralQuestion3 = 'question-0-3', + GeneralQuestion4 = 'question-0-4' +} + +export enum PaymentType{ + Credit = "Credit", + AfterPay = "AfterPay", + Paypal = "Paypal", + PayAtService = "Pay at Service", + PayWithInsurance = "Pay with Insurance" +} + +export enum PaymentMethod { + Insurance = 'Insurance', + SelfPay = 'SelfPay' +} + +export enum AppointmentType{ + InShop = "InShop", + Mobile = "Mobile", + DropOff = "Drop-off" +} + + + diff --git a/playwright-tests/business-logic/types/FrameworkConfig.ts b/playwright-tests/business-logic/types/FrameworkConfig.ts new file mode 100644 index 000000000..63a744b93 --- /dev/null +++ b/playwright-tests/business-logic/types/FrameworkConfig.ts @@ -0,0 +1,9 @@ +// File for Framework Config + +type FrameworkConfig = { + createResources: boolean; + destroyResources: boolean; + maxAllotmentHours: number; +}; + +export default FrameworkConfig; \ No newline at end of file diff --git a/playwright-tests/business-logic/types/IAddress.ts b/playwright-tests/business-logic/types/IAddress.ts new file mode 100644 index 000000000..44b6577ef --- /dev/null +++ b/playwright-tests/business-logic/types/IAddress.ts @@ -0,0 +1,9 @@ +// File with address interface + +export interface IAddress { + street: string, + city: string, + state: string, + postalCode: string, + country: string +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/IAlertFlags.ts b/playwright-tests/business-logic/types/IAlertFlags.ts new file mode 100644 index 000000000..b13f30bbc --- /dev/null +++ b/playwright-tests/business-logic/types/IAlertFlags.ts @@ -0,0 +1,10 @@ +export default interface IAlertFlags { + isHeavyTruckVehicle?: boolean, + isRepairReplace?: boolean, + isSplitWindshield?: boolean, + isRepairOnly?: boolean, + isUnserviceableZip?: boolean, + isInvalidZip?: boolean, + isVinNotFound?: boolean + +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/IDisposable.ts b/playwright-tests/business-logic/types/IDisposable.ts new file mode 100644 index 000000000..46e1a1ac9 --- /dev/null +++ b/playwright-tests/business-logic/types/IDisposable.ts @@ -0,0 +1,31 @@ +import LoggingUtils from "@impl/utils/LoggingUtils"; +import { ConsoleColor } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; + +// File with interfaces/classes related to Disposable + +export interface IDisposable { + disposeAll(): void; + setupAll(): void; +} + +export abstract class DisposableBase implements IDisposable { + protected abstract setup(): Promise; + protected abstract dispose(): Promise; + + public async setupAll(): Promise { + if (!TestCase.FrameworkConfig.destroyResources) { + LoggingUtils.log(TestCase.Constants.CREATION_HALTED, ConsoleColor.Yellow); + return; + } + await this.setup(); + } + + public async disposeAll(): Promise { + if (!TestCase.FrameworkConfig.destroyResources || !TestCase.FrameworkConfig.createResources) { + LoggingUtils.log(TestCase.Constants.DISPOSE_HALTED, ConsoleColor.Yellow); + return; + } + await this.dispose(); + } +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/ITestCase.ts b/playwright-tests/business-logic/types/ITestCase.ts new file mode 100644 index 000000000..3afbfd0db --- /dev/null +++ b/playwright-tests/business-logic/types/ITestCase.ts @@ -0,0 +1,18 @@ +import ITestPages from "@business-logic/types/ITestPages"; +import Validations from "./Validations"; +import { ITestData } from "./ITestData"; + +// File containing Test Case Interface + +export default interface ITestCase { + readonly testID?: string; + readonly name: string; + readonly tags: string[]; + + readonly validations?: Validations; + readonly tempData?: any[] + + readonly testData: Partial; + + pages?: ITestPages; +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/ITestData.ts b/playwright-tests/business-logic/types/ITestData.ts new file mode 100644 index 000000000..76d0e4534 --- /dev/null +++ b/playwright-tests/business-logic/types/ITestData.ts @@ -0,0 +1,36 @@ +// File for the Test Data Structure + +import { IAppointmentDetails, IClaimDetails, ICustomerDetails, IPaymentDetails, IVehicleDetails, IPartQuestion, IEndorsementDetails } from "./CustomerDetails"; +import { PaymentMethod, ServicePackage, VehicleDamage } from "./Enums"; +import IAlertFlags from "./IAlertFlags"; + +export interface ITestData { + + paymentMethod: PaymentMethod + servicePackage: ServicePackage, + customerDetails: ICustomerDetails, + vehicleDetails: IVehicleDetails, + vehicleDamage: VehicleDamage[], + appointmentDetails: IAppointmentDetails, + paymentDetails: IPaymentDetails, + claimDetails: IClaimDetails, + alertFlags: IAlertFlags, + partQuestions: IPartQuestion[], // part-questions page has unrelated part questions like leather seats + vehiclePartQuestions: IPartQuestion[], // vehicle-parts page usually has glass question + capabilityQuestions: IPartQuestion[], // Capability questions page usually has questions about autonomous driving features + moldingQuestions: IPartQuestion[], + enterFunnelWithZip: boolean, + isDuplicateClaim: boolean, + isPolicyDriver: boolean, // Does the Policy Driver Page show up for the insurance claim? + isPolicyFound: boolean, + otherVehiclesOnPolicy: IVehicleDetails[], // IF defined, we validate that the vehicles are present. + isUseVehicleOnPolicy: boolean, // Should we use the vehicle on the policy? + isRecalNotification: boolean, // Does Recalibration Information page show up? + endorsements: IEndorsementDetails[], + hasOemEndorsement: boolean, // OEM Endorsement does not appear on endorsements page, so it has a separate flag.s + skipEstimatePage: boolean, + isRecalVehicle: boolean, + canNotRecal: boolean, + dynamicRecal: boolean, + promoCode: string +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/ITestPages.ts b/playwright-tests/business-logic/types/ITestPages.ts new file mode 100644 index 000000000..77b9689e7 --- /dev/null +++ b/playwright-tests/business-logic/types/ITestPages.ts @@ -0,0 +1,62 @@ +import { HomePage } from "../../pages/HomePage" +import { LeadgenHomePage } from "../../pages/LeadgenHomePage" +import { ServicePackagesPage } from "../../pages/ServicePackagesPage" +import { VehicleDamagePage } from "../../pages/VehicleDamagePage" +import { VehicleLookupAddressPage } from "../../pages/VehicleLookupAddressPage" +import { VehicleLookupLicensePage } from "../../pages/VehicleLookupLicensePage" +import { EstimatePage } from "../../pages/EstimatePage" +import { VehicleSelectionPage } from "../../pages/VehicleSelectionPage" +import { VinLookupPage } from "../../pages/VinLookupPage" +import { ServiceLocationPage } from "../../pages/ServiceLocationPage" +import { SchedulePage } from "../../pages/SchedulePage" +import { ContactDetailsPage } from "../../pages/ContactDetailsPage" +import { PaymentMethodPage } from "../../pages/PaymentMethodPage" +import { ZipLookupPage } from "../../pages/ZipLookupPage" +import { OrderConfirmationPage } from "../../pages/OrderConfirmationPage" +import { PartQuestionsPage } from "../../pages/PartQuestionPage" +import VehiclePartQuestionsPage from "../../pages/VehiclePartsPage" +import CapabilityQuestionsPage from "../../pages/CapabilityQuestionsPage" +import MoldingQuestionsPage from "../../pages/MoldingQuestionsPage" +import { InsuranceCompanyPage } from "../../pages/InsuranceCompanyPage" +import { CCPolicyInfoPage } from "../../pages/CCPolicyInfoPage" +import { DuplicateCheckPage } from "../../pages/DuplicateCheckPage" +import { PolicyVehiclesPage } from "../../pages/PolicyVehiclesPage" +import { PolicyInfoSubmittedPage } from "../../pages/PolicyInfoSubmittedPage" +import RecalibrationInfoPage from "../../pages/RecalibrationInfoPage" +import { CoverageStatementPage } from "../../pages/CoverageStatementPage" +import { VerifyDetailsPage } from "../../pages/VerifyDetailsPage" +import { EndorsementsPage } from "../../pages/EndorsementsPage" +import { PolicyDriverPage } from "../../pages/PolicyDriverPage" +// File containing interface for all Page Object Models + +export default interface ITestPages { + capabilityQuestionsPage: CapabilityQuestionsPage, + ccPolicyInfoPage: CCPolicyInfoPage, + contactDetailsPage: ContactDetailsPage, + coverageStatementPage: CoverageStatementPage, + duplicateCheckPage: DuplicateCheckPage, + estimatePage: EstimatePage, + homePage: HomePage, + insuranceCompanyPage: InsuranceCompanyPage, + leadgenHomePage: LeadgenHomePage, + moldingQuestionsPage: MoldingQuestionsPage, + orderConfirmationPage: OrderConfirmationPage, + partQuestionsPage: PartQuestionsPage, + paymentMethodPage: PaymentMethodPage, + policyInfoSubmittedPage: PolicyInfoSubmittedPage, + policyVehiclesPage: PolicyVehiclesPage, + recalibrationInfoPage: RecalibrationInfoPage, + schedulePage: SchedulePage, + serviceLocationPage: ServiceLocationPage, + servicePackagePage: ServicePackagesPage, + vehicleDamagePage: VehicleDamagePage, + vehicleLookupAddressPage: VehicleLookupAddressPage, + vehicleLookupLicensePage: VehicleLookupLicensePage, + vehiclePartsPage: VehiclePartQuestionsPage, + vehicleSelectionPage: VehicleSelectionPage, + verifyDetailsPage: VerifyDetailsPage, + vinLookupPage: VinLookupPage, + zipLookupPage: ZipLookupPage, + endorsementsPage: EndorsementsPage, + policyDriverPage: PolicyDriverPage, +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/IValidations.ts b/playwright-tests/business-logic/types/IValidations.ts new file mode 100644 index 000000000..4faa6f7a3 --- /dev/null +++ b/playwright-tests/business-logic/types/IValidations.ts @@ -0,0 +1,5 @@ +// File containing interface for validations + +export default interface IValidations { + +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/RuleEngine.ts b/playwright-tests/business-logic/types/RuleEngine.ts new file mode 100644 index 000000000..9c324204c --- /dev/null +++ b/playwright-tests/business-logic/types/RuleEngine.ts @@ -0,0 +1,243 @@ +import { BuiltInRules, builtInRules } from "@business-logic/rules/RuleEngineBuiltins"; +import LoggingUtils from "@impl/utils/LoggingUtils"; +import { ConsoleColor, ResultTypes } from "@business-logic/types/Enums"; + +//File Containing Rule Engine Implementation + +export type Rule = { + id: number; + name: string; + check: (obj: T) => boolean; + dependsOn?: number[]; +}; + +export class ValidationOptions { + public skipBuiltIns: boolean; + public exclude: number[]; + public throwOnError: boolean = true; + public errorsOnly: boolean = false; + + constructor(options: { + throwOnError?: boolean; + errorsOnly?: boolean; + skipBuiltIns?: boolean; + exclude?: number[]; + } = {}) { + this.throwOnError = options.throwOnError ?? true, this.errorsOnly = options.errorsOnly ?? true, this.skipBuiltIns = options.skipBuiltIns ?? false; + this.exclude = options.exclude ?? []; + } +} + +export class RuleEngine { + private rules: Rule[] = []; + private nextCustomRuleId = 1; + + static readonly BuiltInRuleIds: Record = {} as Record; + + constructor() { + this.addBuiltInRules(builtInRules as { id: BuiltInRules; name: string; check: (obj: T) => boolean; dependsOn?: BuiltInRules[]; }[]); + } + + private addBuiltInRules(builtInRules: { id: BuiltInRules; name: string; check: (obj: T) => boolean; dependsOn?: BuiltInRules[]; }[]): void { + builtInRules.forEach(rule => this.addRuleInternal(rule.name, rule.check, rule.id, rule.dependsOn)); + } + + private addRuleInternal(name: string, check: (obj: T) => boolean, builtInId: BuiltInRules, dependsOn?: BuiltInRules[]): number { + const id = builtInId; + const dependsOnIds = dependsOn?.map(dep => dep as number); + this.rules.push({ id, name, check, dependsOn: dependsOnIds }); + RuleEngine.BuiltInRuleIds[builtInId] = id; + return id; + } + + addRule(name: string, check: (obj: T) => boolean, options?: { dependsOn?: (number | BuiltInRules)[]; }): Rule { + if (this.nextCustomRuleId >= 1000) + throw new Error("Maximum number of custom rules (999) has been reached."); + + const id = this.nextCustomRuleId++; + const dependsOn = options?.dependsOn?.map(dep => typeof dep === "number" ? dep : RuleEngine.BuiltInRuleIds[dep]); + const rule: Rule = { id, name, check, dependsOn }; + this.rules.push(rule); + return rule; + } + + when(condition: (obj: T) => boolean): WhenClause { + return new WhenClause(this, condition); + } + + checkRuleById(id: number | BuiltInRules, obj: T): boolean { + const ruleId = typeof id === "number" ? id : RuleEngine.BuiltInRuleIds[id]; + const rule = this.rules.find(r => r.id === ruleId); + return rule ? rule.check(obj) : true; + } + + validate(obj: T): ValidationResult[] { + const sortedRules = this.sortRules(); + const results = new Map(); + const resultSummary: ValidationResult[] = []; + + sortedRules.forEach(rule => { + const dependenciesValid = rule.dependsOn ? rule.dependsOn.every(dep => results.get(dep)) : true; + const result = dependenciesValid && rule.check(obj); + results.set(rule.id, result); + + const resultType: ResultTypes = dependenciesValid ? result ? ResultTypes.Success : ResultTypes.Failure : ResultTypes.Skipped; + + resultSummary.push(ValidationResult.FromRule(rule.name, rule.id, resultType, `Rule: "${rule.name}". Result: ${ResultTypes[resultType]}`)); + }); + return resultSummary; + } + + private sortRules(): Rule[] { + const sortedRules: Rule[] = []; + const rulesMap = new Map(this.rules.map(rule => [rule.id, rule])); + + const visit = (rule: Rule, visited: Set, stack: Set) => { + if (stack.has(rule.id)) + throw new Error(`Circular dependency detected in rule: ${rule.name}!`); + + if (!visited.has(rule.id)) { + stack.add(rule.id); + if (rule.dependsOn) { + for (const dependency of rule.dependsOn) { + const dependencyRule = rulesMap.get(dependency); + if (dependencyRule) + visit(dependencyRule, visited, stack); + } + } + stack.delete(rule.id); + visited.add(rule.id); + sortedRules.push(rule); + } + }; + + const visited = new Set(); + for (const rule of this.rules) + visit(rule, visited, new Set()); + + return sortedRules; + } + + getRulesByName(name: string): Rule[] { + return this.rules.filter(rule => rule.name === name); + } + + getRulesByID(id: number): Rule[] { + return this.rules.filter(rule => rule.id === id); + } + + static PrintValidationResults(results: ValidationResult[], options: ValidationOptions = new ValidationOptions()) { + if (options.skipBuiltIns) + results = results.filter(x => x.ruleID < 1000); + + if (options.exclude) + results = results.filter(x => !options.exclude!.includes(x.ruleID)); + + if (options.errorsOnly) + results = results.filter(x => x.result == ResultTypes.Failure); + + if (results.length > 0) { + LoggingUtils.log("Rule Validation:", ConsoleColor.Blue); + results.forEach(x => LoggingUtils.log(` ${LoggingUtils.icon(x.result!)} Rule: [${x.ruleID}] "${x.ruleName}". Result: ${ResultTypes[x.result!]}`, x.result == ResultTypes.Success ? ConsoleColor.Green : ConsoleColor.Red)); + console.log(); + } + + if (options.throwOnError && results.filter(x => x.result != ResultTypes.Success).length > 0) + throw new Error("Rule Validation Errors"); + } +} + +export class WhenClause { + constructor(private ruleEngine: RuleEngine, private condition: (obj: T) => boolean) { } + + then(consequent: (obj: T) => boolean): DescriptionClause { + return new DescriptionClause(this.ruleEngine, (obj: T) => { + return !this.condition(obj) || consequent(obj); + }, []); + } +} + +export class DescriptionClause { + private dependencies: (number | BuiltInRules)[] = []; + + constructor(private ruleEngine: RuleEngine, private check: (obj: T) => boolean, dependencies: (number | BuiltInRules)[] = []) { + this.dependencies = dependencies; + } + + because(description: string): Rule { + return this.ruleEngine.addRule(description, this.check, { dependsOn: this.dependencies }); + } + + dependsOn(...dependencies: (number | BuiltInRules | (number | BuiltInRules)[])[]): DescriptionClause { + const flatDependencies = dependencies.flat(); + const tmp = (obj: T) => { + const dependenciesMet = flatDependencies.every(depId => this.ruleEngine.checkRuleById(depId, obj)); + return dependenciesMet && this.check(obj); + }; + return new DescriptionClause(this.ruleEngine, tmp, [...this.dependencies, ...flatDependencies]); + } +} +export class ValidationResult { + value: any; + ruleName: string; + ruleID: number; + error: string | null; + message: string; + result: ResultTypes | null; + + public static FromRule(ruleName: string, ruleID: number, result: ResultTypes, message: string) { + const retval = new ValidationResult(); + retval.ruleName = ruleName; + retval.ruleID = ruleID; + retval.result = result; + retval.message = message; + return retval; + } + + public static FromSuccess(value: any, message: string): ValidationResult { + const retval = new ValidationResult(); + retval.value = value; + retval.message = message; + retval.result = ResultTypes.Success; + return retval; + } + + public static FromFailure(error: string): ValidationResult { + const retval = new ValidationResult(); + retval.error = error; + retval.result = ResultTypes.Failure; + return retval; + } + + public static PrintValidationResults(results: ValidationResult[], options: ValidationOptions) { + const errors = results.filter(x => x.result == ResultTypes.Failure); + if (errors.length > 0) { + LoggingUtils.log("Type Validation Errors:", ConsoleColor.Red); + errors.forEach((message) => LoggingUtils.log(` ${LoggingUtils.icon(false)} ${message.error}`)); + console.log(); + } + + const successes = results.filter(x => x.result != ResultTypes.Failure); + + const suffix = "is valid."; + successes.sort((a, b) => { + const aa = a.message.endsWith(suffix); + const bb = b.message.endsWith(suffix); + + return Number(bb) - Number(aa); + }); + + if (!options.errorsOnly && successes.length > 0) { + LoggingUtils.log("Type Validation Messages:", ConsoleColor.Green); + successes.forEach((message) => LoggingUtils.log(` ${LoggingUtils.icon(true)} ${message.message}`)); + console.log(); + } + + if (options.throwOnError && errors.length > 0) + throw new Error("Validation Errors"); + } + + public static HasError(results: ValidationResult[]): boolean { + return results.filter(x => x.result == ResultTypes.Failure).length > 0; + } +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/Test.ts b/playwright-tests/business-logic/types/Test.ts new file mode 100644 index 000000000..80ebb6a5a --- /dev/null +++ b/playwright-tests/business-logic/types/Test.ts @@ -0,0 +1,46 @@ +import { test as base } from "@playwright/test"; +import type { Page, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestInfo as PlaywrightTestInfo } from "@playwright/test"; +import ITestCase from "@business-logic/types/ITestCase"; +import { RuleEngine, ValidationOptions } from "@business-logic/types/RuleEngine"; +import TestCase from "@business-logic/types/TestCase"; +import Soft from "@business-logic/validations/Soft"; +import FakerUtils from "@impl/utils/FakerUtils"; +import LoggingUtils from "@impl/utils/LoggingUtils"; + +// File containing test function implementations + +export type TestFunction = (args: PlaywrightTestArgs & PlaywrightTestOptions & PlaywrightWorkerArgs & PlaywrightWorkerOptions, testInfo: TestInfo) => void | Promise; +export type TestRunnerFunction = (page: Page, testInfo: TestInfo, /*testCase: TestCase*/) => void | Promise; + +export interface TestInfo extends PlaywrightTestInfo { + testCase: TestCase; +} + +export const test = base.extend<{ testInfo: TestInfo; }>({ + // Do not use the fixture because it is not extended, and will cause a circular reference error + // Use ({}, use, testInfo) not ({ testInfo }, use) + testInfo: async ({}, use, testInfo) => { + await use(testInfo as TestInfo); + } +}); + +export function addSmokeTagToRandomTest(testCases: ITestCase[]) { + const index = Math.floor(Math.random() * testCases.length); + testCases.at(index)?.tags.push("@smoke"); +} + +export function prepareTest(testData: ITestCase, testRunner: TestRunnerFunction, validationOptions: ValidationOptions, ruleEngine: RuleEngine): [string, object, TestFunction] { + const name = testData.name; + const attributes = { tag: TestCase.getTags(testData) }; + const testFunction: TestFunction = ({ page }, testInfo) => { + // Do not instantiate TestCase outside of this function, + // otherwise it will be instantiated several times for each test case + Soft.initialize(testInfo, page); + testInfo.testCase = new TestCase(testData, validationOptions, FakerUtils.getRandomTestID()); + const results = ruleEngine.validate(testInfo.testCase); + RuleEngine.PrintValidationResults(results, validationOptions); + console.log(LoggingUtils.logValidate(`TestID: ${testInfo.testCase.testID}`, true)); + return testRunner(page, testInfo, /*testInfo.testCase*/); + }; + return [name, attributes, testFunction]; +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/TestCase.ts b/playwright-tests/business-logic/types/TestCase.ts new file mode 100644 index 000000000..5fe3ae857 --- /dev/null +++ b/playwright-tests/business-logic/types/TestCase.ts @@ -0,0 +1,184 @@ +import PropertyUtils from "@impl/utils/PropertyUtils"; +import { formatTag } from "@impl/utils/TaggingUtils"; +import { Page } from "@playwright/test"; +import { TestInfo } from "@business-logic/types/Test"; +import { ConsoleColor } from "@business-logic/types/Enums"; +import FrameworkConfig from "@business-logic/types/FrameworkConfig"; +import { DisposableBase } from "@business-logic/types/IDisposable"; +import ITestCase from "@business-logic/types/ITestCase"; +import ITestPages from "./ITestPages"; +import { ValidationOptions, ValidationResult } from "@business-logic/types/RuleEngine"; +import Soft, { SoftError } from "@business-logic/validations/Soft"; +import Validations from "./Validations"; +import FakerUtils from "@impl/utils/FakerUtils"; +import { ITestData } from "./ITestData"; +import { HomePage } from "../../pages/HomePage"; +import { VehicleSelectionPage } from "../../pages/VehicleSelectionPage"; +import { VehicleDamagePage } from "../../pages/VehicleDamagePage"; +import { EstimatePage } from "../../pages/EstimatePage"; +import { VehicleLookupAddressPage } from "../../pages/VehicleLookupAddressPage"; +import { VehicleLookupLicensePage } from "../../pages/VehicleLookupLicensePage"; +import { VinLookupPage } from "../../pages/VinLookupPage"; +import { ServicePackagesPage } from "../../pages/ServicePackagesPage"; +import { LeadgenHomePage } from "../../pages/LeadgenHomePage"; +import { ServiceLocationPage } from "../../pages/ServiceLocationPage"; +import { SchedulePage } from "../../pages/SchedulePage"; +import { ContactDetailsPage } from "../../pages/ContactDetailsPage"; +import { PaymentMethodPage } from "../../pages/PaymentMethodPage"; +import { ZipLookupPage } from "../../pages/ZipLookupPage"; +import { OrderConfirmationPage } from "../../pages/OrderConfirmationPage"; +import { PartQuestionsPage } from "../../pages/PartQuestionPage"; +import VehiclePartQuestionsPage from "../../pages/VehiclePartsPage"; +import CapabilityQuestionsPage from "../../pages/CapabilityQuestionsPage"; +import MoldingQuestionsPage from "../../pages/MoldingQuestionsPage"; +import { InsuranceCompanyPage } from "../../pages/InsuranceCompanyPage"; +import { CCPolicyInfoPage } from "../../pages/CCPolicyInfoPage"; +import { DuplicateCheckPage } from "../../pages/DuplicateCheckPage"; +import { PolicyVehiclesPage } from "../../pages/PolicyVehiclesPage"; +import { PolicyInfoSubmittedPage } from "../../pages/PolicyInfoSubmittedPage"; +import RecalibrationInfoPage from "../../pages/RecalibrationInfoPage"; +import { CoverageStatementPage } from "../../pages/CoverageStatementPage"; +import { VerifyDetailsPage } from "../../pages/VerifyDetailsPage"; +import { EndorsementsPage } from "../../pages/EndorsementsPage"; +import { PolicyDriverPage } from "../../pages/PolicyDriverPage"; + +// File for Test Case Class + +export default class TestCase extends DisposableBase implements ITestCase { + public static FrameworkConfig: FrameworkConfig = { + createResources: true, //process.env.FW_CREATE_RESOURCES! === "true", + destroyResources: true, // process.env.FW_DESTROY_RESOURCES! === "true", + maxAllotmentHours: Number(process.env.FW_MAX_ALLOTMENT_HOURS) + }; + + public static readonly Constants = class { + static readonly DISPOSE_HALTED: string = "FrameworkConfig is set to NOT destroy resources. Teardown halted!"; + static readonly CREATION_HALTED: string = "FrameworkConfig is set to NOT create resources. Preparation halted!"; + }; + + public readonly testID?: string; + public readonly name: string; + public readonly tags: string[]; + + public readonly testData: Partial; + + public readonly validations?: Validations; + public readonly tempData?: any[] = []; + + + public location: Location; + public alternateLocation?: Location; + + public pages: ITestPages; + + public constructor(data: ITestCase, validationOptions: ValidationOptions = new ValidationOptions(), testID: string) { + super(); + Object.assign(this, data); + this.testID = FakerUtils.getRandomTestID(); + + const validationResults: ValidationResult[] = []; + + this.name = PropertyUtils.getValue(data, TestCase.name, validationResults, { isRequired: true }, x => x.name); + this.tags = PropertyUtils.getValue(data, TestCase.name, validationResults, { isRequired: true }, x => x.tags); + this.validations = PropertyUtils.getValue(data, TestCase.name, validationResults, { isRequired: false }, x => x.validations); + + // if (PropertyUtils.hasProperty(data, TestCase.name, validationResults, { isRequired: false }, x => x.oldTransactions)) + // this.oldTransactions = data.oldTransactions.map((item: any) => new TransactionData(item, validationResults, this.testID)); + + // if (PropertyUtils.hasProperty(data, TestCase.name, validationResults, { isRequired: true }, x => x.transaction)) + // this.transaction = new TransactionData(data.transaction, validationResults, this.testID); + + ValidationResult.PrintValidationResults(validationResults, validationOptions); + } + + public static getTags(testCase: ITestCase): string[] { + const retval = [ + ...testCase.tags, + formatTag(testCase.name), + ]; + + return retval; + } + + public static async afterEachMethod(page: Page, testInfo: TestInfo) { + const originalStatus = testInfo.status; + + if (Soft.hasFailedAssertions()) + testInfo.status = "failed"; + + for (const a of Soft.getFailedAssertions()) + testInfo.errors.push(new SoftError(a)); + + // NOTE: This try catch is here because the tests when locally, frequently + // fail tests on screenshot, which we do not want, is it make it + // harder to parse any other real errors we do care about. T.S. 9.5.2024 + try { + await testInfo.attach("End of Test Screenshot", { + body: await page.screenshot({ fullPage: true }), + contentType: 'image/png' + }); + } catch(error) { + console.error(error); + } + + + if (testInfo.testCase) + await testInfo.testCase.disposeAll(); + + const seconds: string = String(testInfo.duration / 1000); + const minutes: string = (testInfo.duration / 1000 / 60).toFixed(2); + const validationErrors: string = Soft.hasFailedAssertions() ? ` with ${Soft.getFailureCount()} validation errors` : ""; + + if (originalStatus == "failed") + console.log(`${ConsoleColor.Red}Test failed after ${seconds} seconds, or roughly ${minutes} minutes${validationErrors}.${ConsoleColor.Reset}`); + else { + if (testInfo.status == "passed") + console.log(`${ConsoleColor.Green}Test finished successfully in ${seconds} seconds, or roughly ${minutes} minutes.${ConsoleColor.Reset}`); + else + console.log(`${ConsoleColor.Orange}Test finished in ${seconds} seconds, or roughly ${minutes} minutes${validationErrors}.${ConsoleColor.Reset}`); + } + console.log(page.url()); + } + + public async setup(): Promise { + //Setup for Test case + } + + public setupPages(page: Page): void { + this.pages = { + capabilityQuestionsPage: new CapabilityQuestionsPage(page), + ccPolicyInfoPage: new CCPolicyInfoPage(page), + contactDetailsPage: new ContactDetailsPage(page), + coverageStatementPage: new CoverageStatementPage(page), + duplicateCheckPage: new DuplicateCheckPage(page), + estimatePage: new EstimatePage(page), + homePage: new HomePage(page), + insuranceCompanyPage: new InsuranceCompanyPage(page), + leadgenHomePage: new LeadgenHomePage(page), + moldingQuestionsPage: new MoldingQuestionsPage(page), + orderConfirmationPage: new OrderConfirmationPage(page), + partQuestionsPage: new PartQuestionsPage(page), + paymentMethodPage: new PaymentMethodPage(page), + policyInfoSubmittedPage: new PolicyInfoSubmittedPage(page), + policyVehiclesPage: new PolicyVehiclesPage(page), + recalibrationInfoPage: new RecalibrationInfoPage(page), + schedulePage: new SchedulePage(page), + serviceLocationPage: new ServiceLocationPage(page), + servicePackagePage: new ServicePackagesPage(page), + vehicleDamagePage: new VehicleDamagePage(page), + vehicleLookupAddressPage: new VehicleLookupAddressPage(page), + vehicleLookupLicensePage: new VehicleLookupLicensePage(page), + vehiclePartsPage: new VehiclePartQuestionsPage(page), + vehicleSelectionPage: new VehicleSelectionPage(page), + verifyDetailsPage: new VerifyDetailsPage(page), + vinLookupPage: new VinLookupPage(page), + zipLookupPage: new ZipLookupPage(page), + endorsementsPage: new EndorsementsPage(page), + policyDriverPage: new PolicyDriverPage(page) + }; + } + + protected async dispose(): Promise { + // Tear down for test case + } +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/TestSuccessAlert.ts b/playwright-tests/business-logic/types/TestSuccessAlert.ts new file mode 100644 index 000000000..fe3d6b941 --- /dev/null +++ b/playwright-tests/business-logic/types/TestSuccessAlert.ts @@ -0,0 +1,6 @@ +export default class TestSuccessAlert extends Error { + constructor(message: string) { + super(message); + this.name = "TestSuccessAlert"; + } +} \ No newline at end of file diff --git a/playwright-tests/business-logic/types/Validations.ts b/playwright-tests/business-logic/types/Validations.ts new file mode 100644 index 000000000..f82c7fbaf --- /dev/null +++ b/playwright-tests/business-logic/types/Validations.ts @@ -0,0 +1,13 @@ +import PropertyUtils from "@impl/utils/PropertyUtils"; +import IValidations from "./IValidations"; +import { ValidationResult } from "./RuleEngine"; + +// File containing validation class + +export default class Validations implements IValidations { + public readonly exampleValue: boolean; + + public constructor(json: any, validationResults: ValidationResult[]) { + this.exampleValue = PropertyUtils.getValue(json, Validations.name, validationResults, { isRequired: true }, x => x.exampleValue); + } +} diff --git a/playwright-tests/business-logic/validations/Soft.ts b/playwright-tests/business-logic/validations/Soft.ts new file mode 100644 index 000000000..69fbfb734 --- /dev/null +++ b/playwright-tests/business-logic/validations/Soft.ts @@ -0,0 +1,179 @@ +import LoggingUtils from '@impl/utils/LoggingUtils'; +import { Page } from '@playwright/test'; +import { TestInfo } from '@business-logic/types/Test'; +import { TestInfoError } from "@playwright/test"; +import { DateTime } from 'luxon'; +import { expect as pw_expect } from '@playwright/test'; + +export default class Soft { + private static _instance: Soft | null = null; + private testInfo: TestInfo; + private page: Page; + private failedAssertions: string[] = []; + private errorCounter: number = 0; + private errorsOnly: boolean = false; + + private constructor(testInfo: TestInfo, page: Page) { + this.testInfo = testInfo; + this.page = page; + } + + public static initialize(testInfo: TestInfo, page: Page): void { + Soft._instance = new Soft(testInfo, page); + } + + public static setOptions(options: { errorsOnly: boolean }): void { + Soft.getInstance().errorsOnly = options.errorsOnly; + } + + public static getOptions(): { errorsOnly: boolean } { + return { errorsOnly: Soft.getInstance().errorsOnly }; + } + + private static getInstance(): Soft { + if (!Soft._instance) + throw new Error("Soft is not initialized. Call Soft.initialize(testInfo, page) first!"); + return Soft._instance; + } + + public async handleAssertion( + matcherFull: string, + matcherDisplay: string, + matcherFunction: () => Promise, + reason?: string): Promise { + reason = reason ? reason : '' + const reasonText = reason ? `'${reason}' ` : ''; + try { + await matcherFunction(); + if (!this.errorsOnly) + console.log(LoggingUtils.logValidate(`Validation ${reasonText}passed: ${matcherDisplay}!`, true)); + } catch (error) { + console.log(LoggingUtils.logValidate(`Validation ${reasonText}failed: ${matcherDisplay}!`, false)); + //const errorMessage = error instanceof Error ? error.message : String(error); + this.failedAssertions.push(`\n${++this.errorCounter}_Validation ${reasonText}failed:\n${LoggingUtils.replaceEmptyLinesWithMiddleDot(matcherFull)}!\n${error.stack}\n`); + // NOTE: I find it more helpful for the stack trace to be included here, so we know which line the validation is failing + try { + const screenshot: Buffer = await this.page.screenshot({ fullPage: true }); + const name: string = LoggingUtils.sanitizeFileName(`${this.errorCounter}_Validation_${reason}${DateTime.now().valueOf()}`); + await this.testInfo.attach(name, { + body: screenshot, + contentType: 'image/png' + }); + } catch(err){ + // ohwell + console.error(error) + } + + } + } + + public static expect(value: any, reason?: string): ExpectationChain { + return new ExpectationChain(value, Soft.getInstance(), reason); + } + + public static getFailedAssertions(): string[] { + return Soft.getInstance().failedAssertions; + } + + public static hasFailedAssertions(): boolean { + return Soft.getInstance().failedAssertions.length > 0; + } + + public static getFailureCount(): number { + return Soft.getInstance().failedAssertions.length; + } + + public static clearFailedAssertions(): void { + Soft.getInstance().failedAssertions = []; + } +} + +export class SoftError implements TestInfoError { + public readonly message?: string | undefined; + constructor(msg: string) { + this.message = msg; + } +} + +export class ExpectationChain { + constructor(private value: any, private soft: Soft, private reason?: string) { } + + private formatValue(value: any): string { + return LoggingUtils.truncateString(value); + } + + public async toBe(expected: any): Promise { + await this.soft.handleAssertion( + `expect(${this.value}).toBe(${expected})`, + `expect(${this.formatValue(this.value)}).toBe(${this.formatValue(expected)})`, + async () => await pw_expect(this.value).toBe(expected), + this.reason + ); + return this; + } + + public async toEqual(expected: any): Promise { + await this.soft.handleAssertion( + `expect(${JSON.stringify(this.value)}).toEqual(${expected})`, + `expect(${this.formatValue(JSON.stringify(this.value))}).toEqual(${this.formatValue(expected)})`, + async () => await pw_expect(this.value).toEqual(expected), + this.reason + ); + return this; + } + + public async toContain(expected: any): Promise { + await this.soft.handleAssertion( + `expect(${this.value}).toContain(${expected})`, + `expect(${this.formatValue(this.value)}).toContain(${this.formatValue(expected)})`, + async () => await pw_expect(this.value).toContain(expected), + this.reason + ); + return this; + } + + public async toHaveText(expected: string): Promise { + await this.soft.handleAssertion( + `expect(${this.value}).toHaveText(${expected})`, + `expect(${this.formatValue(this.value)}).toHaveText(${this.formatValue(expected)})`, + async () => { + if (typeof this.value.textContent !== 'function') { + throw new Error('value does not have a textContent method'); + } + const text = await this.value.textContent(); + await pw_expect(text).toHaveText(expected); + }, this.reason + ); + return this; + } + + public async toBeGreaterThan(expected: number): Promise { + await this.soft.handleAssertion( + `expect(${this.value}).toBeGreaterThan(${expected})`, + `expect(${this.formatValue(this.value)}).toBeGreaterThan(${this.formatValue(expected)})`, + async () => await pw_expect(this.value).toBeGreaterThan(expected), + this.reason + ); + return this; + } + + public async toBeTruthy(): Promise { + await this.soft.handleAssertion( + `expect(${this.value}).toBeTruthy`, + `expect(${this.formatValue(this.value)}).toBeTruthy`, + async () => await pw_expect(this.value).toBeTruthy(), + this.reason + ); + return this; + } + + public async toBeFalsy(): Promise { + await this.soft.handleAssertion( + `expect(${this.value}).toBeFalsy`, + `expect(${this.formatValue(this.value)}).toBeFalsy`, + async () => await pw_expect(this.value).toBeFalsy(), + this.reason + ); + return this; + } +} \ No newline at end of file diff --git a/playwright-tests/eslint.config.js b/playwright-tests/eslint.config.js new file mode 100644 index 000000000..56dbdf53f --- /dev/null +++ b/playwright-tests/eslint.config.js @@ -0,0 +1,4 @@ +// import tsEslint from "typescript-eslint" +const tsEslint = require('typescript-eslint'); +module.exports = + tsEslint.configs.strict \ No newline at end of file diff --git a/playwright-tests/impl/API/ApiResponseInterceptUtil.ts b/playwright-tests/impl/API/ApiResponseInterceptUtil.ts new file mode 100644 index 000000000..77810a781 --- /dev/null +++ b/playwright-tests/impl/API/ApiResponseInterceptUtil.ts @@ -0,0 +1,48 @@ +import { IPartsOrQuestionsResponse } from "@business-logic/types/DigitalAPI"; +import { ITestData } from "@business-logic/types/ITestData"; +import { expect, Response } from "@playwright/test"; + +export default class ApiResponseInterceptUtil { + readonly testData: Partial; + + constructor(testData: Partial) { + this.testData = testData; + + // Bind callback functions to the class instance so 'this' is usable within a callback + this.handleInterceptResponse = this.handleInterceptResponse.bind(this); + this.handlePartsOrQuestionsResponse = this.handlePartsOrQuestionsResponse.bind(this); + } + + async handleInterceptResponse(response: Response) { + if (!response.url().includes('safelite.io')) { + return; + } + + const urlPath = response.url().split('safelite.io')[1]; // get api path + + switch(urlPath) { + case '/parts/api/v1/parts/parts-or-questions': + await this.handlePartsOrQuestionsResponse(response); + break; + //TODO: add validation for other urls in the API + default: + break; + } + if (response.url().endsWith('/parts/parts-or-questions') && response.status() === 200) { + + } + } + + async handlePartsOrQuestionsResponse(response: Response) { + if (this.testData.hasOemEndorsement) { + const partsRes = (await response.json()) as IPartsOrQuestionsResponse; + for ( const partOrQuestion of partsRes.partsOrQuestions) { + for (const part of partOrQuestion.parts) { + expect.soft(part.partNumber.endsWith('OEM'), + `handlePartsOrQuestionsResponse>> OEM endorsement was expected, but "${part.partType}" with part number "${part.partNumber}" is not OEM.` + ).toEqual(true); + } + } + } + } +} \ No newline at end of file diff --git a/playwright-tests/impl/utils/DateUtils.ts b/playwright-tests/impl/utils/DateUtils.ts new file mode 100644 index 000000000..7348ea601 --- /dev/null +++ b/playwright-tests/impl/utils/DateUtils.ts @@ -0,0 +1,26 @@ +export function formatDate(date: Date) { + const isoString = date.toISOString(); + return isoString.slice(0, 10); +} + +export function formatTime(date: Date) { + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); +} + +export function getNextWeekday(date?: Date) { + if (!date) { + date = new Date(); + date.setHours(8,0,0,0); + } + const dayOfWeek = date.getDay(); + const daysToAdd = dayOfWeek === 5? 3: 1; // Add 3 days if today is friday. Otherwise add 1. + + const nextDay = new Date(date); + nextDay.setDate(date.getDate() + daysToAdd); + + return nextDay; +} \ No newline at end of file diff --git a/playwright-tests/impl/utils/EnumUtils.ts b/playwright-tests/impl/utils/EnumUtils.ts new file mode 100644 index 000000000..3635989c0 --- /dev/null +++ b/playwright-tests/impl/utils/EnumUtils.ts @@ -0,0 +1,76 @@ +function isFlags(enumObj: object): boolean { + const values = Object.values(enumObj).filter(v => typeof v === "number"); + return values.some(v => v !== 0 && (v & (v - 1)) === 0); +} + +function isValidEnumValue(value: any, enumType: object): boolean { + if (isFlags(enumType)) { + const allFlags = Object.values(enumType).reduce((acc, val) => typeof val === "number" ? acc | val : acc, 0); + return typeof value === "number" && (value & allFlags) === value; + } else { + return Object.values(enumType).includes(value); + } +} + +function getEnumValues(enumObj: object): string[] | number[] { + if (!isFlags(enumObj)) + return Object.values(enumObj); + return Object.values(enumObj).filter(value => typeof value === "number") as number[]; +} + +function getEnumString(enumObj: T, flags: number): string { + if (flags === 0) + return Object.keys(enumObj).find(key => enumObj[key] === 0) || 'None'; + + const attributes = Object.entries(enumObj).filter(([key, value]) => + typeof value === 'number' && value !== 0 && (flags & value) === value) + .map(([key]) => key); + + return attributes.length === 1 ? attributes[0] : attributes.join(', '); +} + +function validateEnumProperty(json: any, property: string, enumType: object): number | string | null { + if (json.hasOwnProperty(property) && isValidEnumValue(json[property], enumType)) + return json[property]; + return null; +} + +function hasFlag(value: number | string, flag: number | string, enumType: object): boolean { + if (isFlags(enumType)) + return typeof value === "number" && typeof flag === "number" && (value & flag) === flag; + else + return value === flag; +} + +function addFlag(value: number, flag: number, enumType: object): number { + if (isFlags(enumType)) + return value | flag; + else + throw new Error("Attempted to add flag on non-flags enum"); +} + +function removeFlag(value: number, flag: number, enumType: object): number { + if (isFlags(enumType)) + return value & ~flag; + else + throw new Error("Attempted to remove flag on non-flags enum"); +} + +function toggleFlag(value: number, flag: number, enumType: object): number { + if (isFlags(enumType)) + return value ^ flag; + else + throw new Error("Attempted to toggle flag on non-flags enum"); +} + +const EnumUtils = { + hasFlag, + addFlag, + removeFlag, + toggleFlag, + validateEnumProperty, + getEnumValues, + getEnumString +}; + +export default EnumUtils; \ No newline at end of file diff --git a/playwright-tests/impl/utils/FakerUtils.ts b/playwright-tests/impl/utils/FakerUtils.ts new file mode 100644 index 000000000..d406b13a5 --- /dev/null +++ b/playwright-tests/impl/utils/FakerUtils.ts @@ -0,0 +1,56 @@ +export default class FakerUtils { + private static NUMBERS = '0123456789'; + private static UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + private static LOWERCASE = 'abcdefghijklmnopqrstuvwxyz'; + private static ALPHABET = FakerUtils.UPPERCASE + FakerUtils.LOWERCASE; + private static ALPHANUMERIC = FakerUtils.NUMBERS + FakerUtils.ALPHABET; + + private static generateRandomString(length: number, characters: string): string { + return Array.from(crypto.getRandomValues(new Uint8Array(length))) + .map(byte => characters[byte % characters.length]) + .join(''); + } + + public static generateRandomNumber(min: number, max: number): number { + const range = max - min + 1; + const bytesNeeded = Math.ceil(Math.log2(range) / 8); + const randomBytes = new Uint8Array(bytesNeeded); + crypto.getRandomValues(randomBytes); + const randomValue = randomBytes.reduce((acc, byte) => (acc << 8) + byte, 0); + return min + (randomValue % range); + } + + public static getRandomTestID(): string { + return FakerUtils.generateRandomString(8, FakerUtils.ALPHANUMERIC); + } + + public static getRandomProperty(obj: Record): string { + const keys = Object.keys(obj); + const randomIndex = FakerUtils.generateRandomNumber(0, keys.length - 1); + return keys[randomIndex]; + } + + public static getRandomTail(registrationPrefix: string = "XX", testID: string = ""): string { + return FakerUtils.formatString(FakerUtils.generateRandomString(8, FakerUtils.ALPHANUMERIC)); + } + + public static getRandomEmail(domainSuffix: string = "@test.com", testID: string = ""): string { + const retval = FakerUtils.formatString("{0}{1}", FakerUtils.generateRandomString(8, FakerUtils.ALPHANUMERIC), domainSuffix); + return retval; + } + + public static getRandomLastName(testID: string = " - "): string { + return FakerUtils.formatString(" - {0}", FakerUtils.generateRandomString(21, FakerUtils.ALPHABET)); + } + + public static getObjectName(prefix: string, testID: string = ""): string { + return FakerUtils.formatString("{0} - {1}", prefix, FakerUtils.generateRandomString(21, FakerUtils.ALPHANUMERIC)); + } + + private static formatString(template: string, ...args: (string | (() => string))[]): string { + return template.replace(/\{(\d+)\}/g, (match, index) => { + const arg = args[parseInt(index)]; + return typeof arg === 'function' ? arg() : arg || ''; + }); + } +} \ No newline at end of file diff --git a/playwright-tests/impl/utils/FileUtils.ts b/playwright-tests/impl/utils/FileUtils.ts new file mode 100644 index 000000000..3f81f0c0a --- /dev/null +++ b/playwright-tests/impl/utils/FileUtils.ts @@ -0,0 +1,22 @@ +import fs from 'fs'; +import path from 'path'; + +export function writeFileToLocalCache(fileNameWithExtension: string, fileContents: string) { + const folderPath = path.join(process.cwd(), '.debug', '.cache'); + const filePath = path.join(folderPath, fileNameWithExtension); + + + try { + // Create the folder if it doesn't exist + if (!fs.existsSync(folderPath)) { + fs.mkdirSync(folderPath, { recursive: true }); + } + + // Write the data to the file + fs.writeFileSync(filePath, fileContents); + + console.log(`File "${filePath}" created successfully.`); + } catch (error) { + console.error('Error creating the file:', error); + } +} diff --git a/playwright-tests/impl/utils/HttpUtils.ts b/playwright-tests/impl/utils/HttpUtils.ts new file mode 100644 index 000000000..ec39d8036 --- /dev/null +++ b/playwright-tests/impl/utils/HttpUtils.ts @@ -0,0 +1,92 @@ +import { Page } from '@playwright/test'; +import { type AxiosInstance, type AxiosResponse } from 'axios'; +import * as fs from 'fs'; +import * as path from 'path'; + +export async function httpGet(client: AxiosInstance, url: string): Promise { + const [isSuccess, response] = await handleHttp(client.get(url)); + if(isSuccess) { + return response; + } + + console.error(`An error occurred calling GET ${url}\nError:${response}`); + throw response; +} + +export async function httpPost(client: AxiosInstance, url: string, data: D): Promise { + const [isSuccess, response] = await handleHttp(client.post(url, data)); + if(isSuccess) { + return response; + } + + console.error(`An error occurred calling POST ${url}\nError:${response}`); + throw response; +} + +export function handleHttp(request: Promise>): Promise<[isSuccess: true, data: T] | [isSuccess: false, error: Error]> { + return request.then(data => { + return [true, data.data] as [true, T] + }).catch((error: Error) => { + return [false, error] as [false, Error] + }) +} + +export function buildQueryString(data: T): URLSearchParams { + const params: Record = {}; + for (const key in data) { + const value = data[key]; + params[key] = `${value}`; + } + return new URLSearchParams(params); +} + +export function forceAPIError(page: Page, endpoint: string) { + page.route('**/*', (route) => { + return route.request().url().includes(endpoint) + ? route.abort() + : route.continue() + }); +} + +// Utility method to return mock response based on endpoint and scenario +export function getMockedApiResponse(endpoint: string, scenario: string): object | null { + const mockResponsesDir = path.resolve(__dirname, '../../tests/mockResponses'); + const configFilePath = path.join(mockResponsesDir, 'mockResponsesConfig.json'); + + if (fs.existsSync(configFilePath)) { + const config = JSON.parse(fs.readFileSync(configFilePath, 'utf-8')); + const scenarioConfig = config[scenario]; + const commonConfig = config['common']; + + let mockFilePath = scenarioConfig ? scenarioConfig[endpoint] : null; + if (!mockFilePath && commonConfig) { + mockFilePath = commonConfig[endpoint]; + } + + if (mockFilePath) { + const filePath = path.join(mockResponsesDir, mockFilePath); + if (fs.existsSync(filePath)) { + const mockResponse = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(mockResponse); + } + } + } + + return null; +} + +// Utility method for mocking API responses +export function mockApiResponse(page: Page, endpoint: string, scenario: string, mockTestingFlag: boolean) { + const mockResponse = getMockedApiResponse(endpoint, scenario); + page.route(`**/${endpoint}`, route => { + if (mockResponse && mockTestingFlag) { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mockResponse) + }); + } else { + route.continue(); + } + }); +} \ No newline at end of file diff --git a/playwright-tests/impl/utils/LoggingUtils.ts b/playwright-tests/impl/utils/LoggingUtils.ts new file mode 100644 index 000000000..1d0b10c07 --- /dev/null +++ b/playwright-tests/impl/utils/LoggingUtils.ts @@ -0,0 +1,128 @@ +import { ConsoleColor, ResultTypes } from "@business-logic/types/Enums"; + +export default class LoggingUtils { + + public static CONSOLE_WIDTH: number = 100; + //public static ICON_OK: string = "✅"; + public static ICON_OK: string = "\u2705"; + //public static ICON_SKIP: string = "⏩"; + public static ICON_SKIP: string = "\u23ED"; + //public static ICON_WARNING: string = "⚠️"; + public static ICON_WARNING: string = "\u26A0\uFE0F"; + //public static ICON_FAIL: string = "❗"; + public static ICON_FAIL: string = "\u2757"; + + public static log(message: string | null, color: ConsoleColor = ConsoleColor.Default): void { + if (message == null) + return; + console.log(`${color}%s${ConsoleColor.Reset}`, message); + } + + public static icon(value: boolean): string; + public static icon(value: ResultTypes): string; + public static icon(value: any): string { + if (typeof value === "boolean") + return value ? this.ICON_OK : this.ICON_FAIL; + + switch (value) { + case ResultTypes.Failure: + return this.ICON_FAIL; + case ResultTypes.Success: + return this.ICON_OK; + case ResultTypes.Skipped: + return this.ICON_SKIP; + } + return ""; + } + + public static logFunc(name: string, value: string | null = null, result: boolean | null = null): string { + let icon = this.ICON_SKIP; + if (result != null) + icon = result ? this.ICON_OK : this.ICON_FAIL; + + if (value != null) + return `${this.getShortDateTime()} [${this.centerPadString(`${name}: ${this.truncateString(value)}`)}] -> ${icon}`; + + return `${this.getShortDateTime()} [${this.centerPadString(name)}] -> ${icon}`; + } + + public static logValidate(text: string, success: boolean) { + return `${this.getShortDateTime()} [${this.centerPadString(text)}] -> ${success ? this.ICON_OK : this.ICON_FAIL}`; + } + + public static truncateString(value: any, maxLength: number = this.CONSOLE_WIDTH): string { + let str = typeof value === 'string' ? value : String(value); + str = str.replace(/\s+/g, ' ').trim(); + if (str.length <= maxLength) { + return str; + } + return str.slice(0, maxLength - 2) + '..'; + } + + public static sanitizeFileName(input: string): string { + // Remove characters that are invalid in both Windows and Linux file systems + let sanitized = input.replace(/[<>:"/\\|?*\x00-\x1F]/g, ''); + + // Remove leading and trailing spaces and dots + sanitized = sanitized.trim().replace(/^\.+|\.+$/g, ''); + + // Replace remaining dots and spaces with underscores + sanitized = sanitized.replace(/[\s.]+/g, '_'); + + // Ensure the name isn't empty after sanitization + if (sanitized.length === 0) { + sanitized = 'unnamed'; + } + + // Truncate to a reasonable maximum length (e.g., 255 characters) + sanitized = sanitized.slice(0, 255); + + return sanitized; + } + + public static replaceEmptyLinesWithMiddleDot(input: string): string { + const emptyLinesRegex: RegExp = /(.+?)(\n\s*\n)+/g; + + return input.replace(emptyLinesRegex, (_match, line) => { + return line + '·\n'; + }).replace(/\n$/, ''); + } + + private static centerPadString(str: string, length: number = this.CONSOLE_WIDTH): string { + if (str.length >= length) { + return this.truncateString(str); + } + + str = this.truncateString(str); + + const totalPadding = length - str.length; + const leftPadding = Math.ceil(totalPadding / 2); + const rightPadding = Math.floor(totalPadding / 2); + + return ' '.repeat(leftPadding) + str + ' '.repeat(rightPadding); + } + + private static getShortDateTime() { + return new Date().toLocaleString('en-US', { + year: '2-digit', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + } + + public static normalizeSalesForceType(input: string, prefix: string = "Apttus_Config2__", suffix: string = "__c") { + let result = input; + + if (result.startsWith(prefix)) + result = result.slice(prefix.length); + + if (result.endsWith(suffix)) + result = result.slice(0, -suffix.length); + + return result; + } +} \ No newline at end of file diff --git a/playwright-tests/impl/utils/ParsingUtils.ts b/playwright-tests/impl/utils/ParsingUtils.ts new file mode 100644 index 000000000..b72e1bbf8 --- /dev/null +++ b/playwright-tests/impl/utils/ParsingUtils.ts @@ -0,0 +1,34 @@ + +/** + * Parses a string containing a representation of currency, and returns a typed number. Can handle + * different types of currency represenations. + * + * USD 12,345.65 -> 12345.65 + * (USD 12345.00) -> -12345 + * + * @param text String containing currency representation + * @param currencyCode Optionally define different currency code + * @returns Parsed currency with type number + */ +export function parseCurrency(text: string, currencyCode: string = "USD"): number { + // TODO: Add ability to handle null fields/not treat null as 0 - KK 9/12/24 + // Base case, if text can be cast as a number then work is done + if (!isNaN(+text)) return +text; + // Remove parentheses and continue parsing, multiply return by -1 to preserve negative value + if (text.charAt(0) === '(') return -1 * parseCurrency(text.substring(1, text.length - 1)); + // Remove currency code prefix and continue parsing + if (text.split(' ')[0] === currencyCode) return parseCurrency(text.split(' ')[1]); + // Remove commas and cast to number + return Number(text.split(',').join('')); +} + +/** + * @param text + * @returns + */ +export function parseNumberOrCurrency(text: string): number | string { + if (!isNaN(+text)) return +text; + else if (text.charAt(0) === '(') return -1 * parseCurrency(text.substring(1, text.length - 1)); + else if (text.split(' ')[0] === "USD") return parseCurrency(text); + else return text; +} diff --git a/playwright-tests/impl/utils/PropertyUtils.ts b/playwright-tests/impl/utils/PropertyUtils.ts new file mode 100644 index 000000000..ac95d8a71 --- /dev/null +++ b/playwright-tests/impl/utils/PropertyUtils.ts @@ -0,0 +1,111 @@ +import { ValidationResult } from "@business-logic/types/RuleEngine"; +import EnumUtils from "./EnumUtils"; + +export type ExtractName = { [K in keyof T]: () => K; }; + +type GetValueOptions = { + isRequired: boolean; +}; + +function isNullOrWhiteSpace(input: unknown): boolean { + if (typeof input !== "string") + return input == null; + return input.trim().length === 0; +} + +// Matches: '() => _Enums.*****', e.g.: '() => _Enums.ModificationTypes' +// Matches: '() => *****', e.g.: '() => ModificationTypes' +// Returns: EnumName, e.g.: 'ModificationTypes' +function getEnumName(propertySelector: () => object): string { + const functionString = propertySelector.toString(); + const match = functionString.match(/\(\s*\)\s*=>\s*(?:_?[A-Z]\w*\.)?(\w+)(?::)?/); + return match ? match[1] : "Unknown"; +} + +// Matches: 'x => x.*****', e.g.: 'x => x.name', 'x => x.tags' +// Returns: PropertyName, e.g.: 'name', 'tags' +function getNameof(propertySelector: (obj: T) => any): string { + const propertyString = propertySelector.toString(); + const match = propertyString.match(/(?:=>|return)\s*([\w\s.]+)/); + + if (match && match[1]) { + const parts = match[1].split('.'); + return parts[parts.length - 1].trim(); + } + + throw new Error(`Invalid property selector: ${propertyString}`); +} + +function getValueOrNull(json: any, propertySelector: (obj: T) => any): any | null { + if (json == null) + return null; + + const property = getNameof(propertySelector); + + if (json.hasOwnProperty(property)) + return json[property]; + + return null; +} + +function getValue(json: any, typeName: string, validationResults: ValidationResult[], options: GetValueOptions, propertySelector: (obj: T) => any): any | null { + const retval = getValueOrNull(json, propertySelector); + + if (retval == null) + validationResults.push(options.isRequired ? ValidationResult.FromFailure(`Value for ${typeName}.${getNameof(propertySelector)} is null but is required!`) : ValidationResult.FromSuccess(retval, `Value for ${typeName}.${getNameof(propertySelector)} is null but is NOT required.`)); + else { + if (isNullOrWhiteSpace(retval)) + validationResults.push(ValidationResult.FromFailure(`Value for ${typeName}.${getNameof(propertySelector)} is empty!`)); + else + validationResults.push(ValidationResult.FromSuccess(retval, `Value for ${typeName}.${getNameof(propertySelector)} is valid.`)); + } + + return retval; +} + +function hasProperty(json: any, typeName: string, validationResults: ValidationResult[], options: GetValueOptions, propertySelector: (obj: ExtractName) => () => keyof T): boolean { + const retval = json.hasOwnProperty(getNameof(propertySelector)); + + if (retval) + validationResults.push(ValidationResult.FromSuccess(retval, `Value for ${typeName}.${getNameof(propertySelector)} is valid.`)); + else + validationResults.push(options.isRequired ? ValidationResult.FromSuccess(retval, `Value for ${typeName}.${getNameof(propertySelector)} is not defined but is required!`) : ValidationResult.FromFailure(`Value for ${typeName}.${getNameof(propertySelector)} is not defined but is NOT required!`)); + + return retval; +} + +function getEnumValueOrNull(json: any, enumType: object, propertySelector: (obj: T) => any): any | null { + if (json == null) + return null; + + const property = getNameof(propertySelector); + const retval = EnumUtils.validateEnumProperty(json, property, enumType); + + if (retval != null) + return retval; + else if (Object.values(enumType).includes(json[property])) + return json[property]; + + return null; +} + +function getEnumValue(json: any, typeName: string, validationResults: ValidationResult[], enumType: object, enumTypeInstance: () => object, options: GetValueOptions, propertySelector: (obj: T) => any): any | null { + const retval = getEnumValueOrNull(json, enumType, propertySelector); + + if (retval == null) + validationResults.push(options.isRequired ? ValidationResult.FromFailure(`Value for ${typeName}.${getNameof(propertySelector)} [${getEnumName(enumTypeInstance)}] is null but is required!`) : ValidationResult.FromSuccess(retval, `Value for ${typeName}.${getNameof(propertySelector)} [${getEnumName(enumTypeInstance)}] is null but is NOT required.`)); + else + validationResults.push(ValidationResult.FromSuccess(retval, `Value for ${typeName}.${getNameof(propertySelector)} is valid.`)); + + return retval; +} + +export { getEnumName, getEnumValueOrNull, getNameof, getValueOrNull }; + +const PropertyUtils = { + hasProperty, + getValue, + getEnumValue +}; + +export default PropertyUtils; \ No newline at end of file diff --git a/playwright-tests/impl/utils/TaggingUtils.ts b/playwright-tests/impl/utils/TaggingUtils.ts new file mode 100644 index 000000000..00a99fae5 --- /dev/null +++ b/playwright-tests/impl/utils/TaggingUtils.ts @@ -0,0 +1,32 @@ +export function formatTag(text: string): string { + return "@" + text.replace(/(?:^\w|[A-Z]|\b\w|\s+)/g, (match, index) => { + if (+match === 0) return ""; // Remove non-alphanumeric characters + return index === 0 ? match.toLowerCase() : match.toUpperCase(); + }); +} +/** + * @description - Use this to add calculated/standardized/randomized tags to scenarioData prior to running. Randomly adds a smoke tag to 1 of the testCases. + * @param scenarioData - the data from your test case + * @returns scenarioData, but with added tags. + */ + +export function getRandomTestName(scenarioData: any) : string { + const testCaseNames: string[] = Object.keys(scenarioData); + const totalKeys = testCaseNames.length + const randomIndex = Math.floor(Math.random() * totalKeys) - 1; + const randomKey = testCaseNames[randomIndex]; + + return randomKey; +} + +export function metaTags(currentTestName: string, randomTestName: string) : string [] { + return currentTestName == randomTestName ? ["@smoke", "@standardRegression"] : ["@standardRegression"] +} + +export function matchAndReplaceContactDataTag(data: string[], replacement: string): string[] { + return data.map((value) => value.replace(/[{]{2}contact[}]{2}/, replacement)); +} + +export function matchAndReplaceAccountDataTag(data: string[], replacement: string): string[] { + return data.map((value) => value.replace(/[{]{2}account[}]{2}/, replacement)); +} diff --git a/playwright-tests/impl/utils/ThrowUtils.ts b/playwright-tests/impl/utils/ThrowUtils.ts new file mode 100644 index 000000000..2debfca36 --- /dev/null +++ b/playwright-tests/impl/utils/ThrowUtils.ts @@ -0,0 +1,12 @@ +import { error } from "console"; + + +export function throwIf(conditionFunction: () => boolean, errorMessage: string): void { + if (conditionFunction()) + throw new Error(errorMessage); +} + + +export function throwNotYetImplemented(nameOfThingNotImplemented: string) { + throw new Error(`${nameOfThingNotImplemented} has not yet been implemented.`) +} \ No newline at end of file diff --git a/playwright-tests/impl/utils/TimingUtils.ts b/playwright-tests/impl/utils/TimingUtils.ts new file mode 100644 index 000000000..e88efc0ca --- /dev/null +++ b/playwright-tests/impl/utils/TimingUtils.ts @@ -0,0 +1,273 @@ +import { expect, Locator, Page } from "@playwright/test"; +import LoggingUtils from "./LoggingUtils"; +import { DateTime } from "luxon"; + + +export type TimeoutOpts = { + /** + * @description timeout Commonly referred to for an entire method; exists to allow developers to specify their own timeout whout overriding defaults + */ + + timeout: number + /** + * @description timeout_tiny use for exceedingly small waits, as in, waiting for label to contain the test you just typed into it. + */ + timeoutTiny: number + + /** + * @description timeout_short for relatively quick opperations, such as waiting for a dropdown to render + */ + timeoutShort: number + + /** + * @description timeout_medium for moderately slow operations, such as a new modal rendering, or a calculated field value being updated, or an API Call + */ + timeoutMedium: number + + /** + * @description timeout_long for high-risk, slow operations. Waiting for the minicart to load, waiting for login, or waiting for screen-to-screen navigation. + */ + timeoutLong: number + + /** + * @description for when things are really, really bad. + */ + timeoutConga: number +} + +export const timeoutOptDefaults: TimeoutOpts = { + timeout: 60_000, + timeoutTiny: +(process.env.TIMEOUT_TINY ?? 500), + timeoutShort: +(process.env.TIMEOUT_SHORT ?? 5000), + timeoutMedium: +(process.env.TIMEOUT_MEDIUM ?? 30_000), + timeoutLong: +(process.env.TIMEOUT_LONG ?? 180_000), + timeoutConga: +(process.env.TIMEOUT_CONGA ?? 500_000), +} + +export type WaitUntilOpts = { + delayBetweenChecks: number, + continueOnTimeoutError: boolean, + anticipatedConditionResult: boolean, + conditionName: string, + beginWaitingMessage: string, + delayBetweenChecksMessage: string, + timeoutErrorMessage: string, + successMessage: string, + ignoreErrorsFromConditionFunction: boolean, + +} +export const waitUntilOptDefaults: WaitUntilOpts = { + delayBetweenChecks: 3000, + continueOnTimeoutError: false, + anticipatedConditionResult: true, + conditionName: "", + beginWaitingMessage: "", + delayBetweenChecksMessage: "", + timeoutErrorMessage: "Timed Out", + successMessage: "", + ignoreErrorsFromConditionFunction: true, +} + +/** + * @description repeatedly execute an asynchronous conditional lambda until a given outcome occurs, or the method times-out. Useful for hedgning against GUI race conditions. + * @param conditionFunction the condition lambda. Example: ()=>{await return myPage.someButton.isVisible()} + * @param options standard Timeout and WaitUntil Options. + * @returns true or false - the outcome of the waituntil. + */ + +export async function waitUntil(conditionFunction: (...args: any[]) => Promise, options: Partial = {}): Promise { + const opts = { ...waitUntilOptDefaults, ...timeoutOptDefaults, ...options } + + const timeoutAt = Date.now() + opts.timeout; + let waitUntilHasTimedOut = false; + let conditionHasBeenMet = false; + do { + try { + const conditionResult = await conditionFunction() + conditionHasBeenMet = conditionResult == opts.anticipatedConditionResult + } + catch (e) { + if (!opts.ignoreErrorsFromConditionFunction) { + throw e + } + } + waitUntilHasTimedOut = Date.now() > timeoutAt + if (!waitUntilHasTimedOut && !conditionHasBeenMet) { + await delay(opts.delayBetweenChecks) + } + else if (waitUntilHasTimedOut && !opts.continueOnTimeoutError) { + throw new Error(opts.timeoutErrorMessage) + } + } + while (!conditionHasBeenMet || waitUntilHasTimedOut) + return conditionHasBeenMet; +} + +/** + * @description Order Matters; WaitUntil each condition passes before moving to the next. All conditions must pass in the expected order. + * @param sequentialConditionFunctions an array of async, boolean lambdas to be executed in sqeuance until all have passed. + * @param options standard Timeout and WaitUntil options + */ +export async function waitUntilValueStopsChanging(mercurialValueFunction: (...args: any[]) => Promise, options: Partial = {}): Promise { + const opts = { ...waitUntilOptDefaults, ...timeoutOptDefaults, ...options } + + let lastFoundValue: any = undefined; + const valueHasStoppedChanging = async () => { + const newFoundValue = await mercurialValueFunction(); + if (opts.delayBetweenChecksMessage.length > 0) console.log(`${opts.delayBetweenChecksMessage} - Last Value: ${lastFoundValue}`) + const valueIsStable = (newFoundValue != undefined) && (newFoundValue == lastFoundValue); + lastFoundValue = newFoundValue; + return valueIsStable; + } + await waitUntil(valueHasStoppedChanging, opts) + return lastFoundValue; +} + +/** + * @todo EXPIRIMENTAL! NO UNIT TESTS YET! TODO, add - DF, 5/23 + * @description Order Matters; WaitUntil each condition passes before moving to the next. All conditions must pass in the expected order. + * @param sequentialConditionFunctions an array of async, boolean lambdas to be executed in sqeuance until all have passed. + * @param options standard Timeout and WaitUntil options + */ +export async function waitUntilEach(sequentialConditionFunctions: ((...args: any[]) => Promise)[], options: Partial = {}): Promise { + for (const conditionFunction of sequentialConditionFunctions) { + await waitUntil(conditionFunction, options); + } +} + +/** + * @todo EXPIRIMENTAL! NO UNIT TESTS YET! TODO, add - DF, 5/23 + * @description Order Matters; WaitUntil each condition passes before moving to the next. All conditions must pass in the expected order. + * @param sequentialConditionFunctions an array of async, boolean lambdas to be executed in sequence until all have passed. + * @param options standard Timeout and WaitUntil options + */ +export async function waitUntilAll(sequentialConditionFunctions: ((...args: any[]) => Promise)[], options: Partial = {}): Promise { + for (const conditionFunction of sequentialConditionFunctions) { + await waitUntil(conditionFunction, options); + } +} + +/** + * @todo EXPIRIMENTAL! NO UNIT TESTS YET! TODO, add - DF, 5/23 + * @description Order DOES NOT Matter; WaitUntil ANY condition passes before completing. Use when multiple conditions can give confidence that sufficient waiting has occured. + * @param sequentialConditionFunctions an array of async, boolean lambdas to be executed in psudo-parallel until at least one has passed. + * @param options standard Timeout and WaitUntil options + */ +export async function waitUntilAny(multipleRequiredConditionFunctions: ((...args: any[]) => Promise)[], options: Partial = {}): Promise { + const anyConditionMet = async (): Promise => { + return multipleRequiredConditionFunctions.filter(async (fun: (...args: any[]) => Promise): Promise => await Function.call(fun)).length > 0; + }; + waitUntil(anyConditionMet, options) +} + +/** + * @param milliseconds delay duration + * @param logDelay defaults to false; if true, logs a waiting message. + */ +export async function delay(milliseconds: number, logDelay = false): Promise { + if (logDelay) { console.log(`delaying ${milliseconds} milliseconds before continuing...`) } + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +/** + * Waits for the page url + * @param partialUrl The string we are looking for in the url, to know we have transitioned to the correct page. + * @param timeout the amount of milliseconds to wait before giving up. + */ +export async function waitForUrlPartialMatch(page: Page, firstPartialUrl: string, timeout = 120_000) { + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + if (page.url().includes(firstPartialUrl)) { + return; // URL matches the partial string, exit the function + } + await page.waitForTimeout(100); // Wait for 100 milliseconds before checking again + } + throw new Error(`Timed out waiting for URL to match '${firstPartialUrl}'`); +} + +/** + * Run a function repeatedly until it returns true or the timeout is reached. + * + * This function executes the provided asynchronous block function in a loop until it returns true or the specified + * timeout duration has elapsed. Between each attempt, it waits for a specified delay. + * + * @param block - An asynchronous function that returns a boolean value. This function will be executed repeatedly until it returns true. + * @param timeout - The maximum duration to keep attempting to run the block function, in milliseconds. Default is 150000 (150 seconds). + * @param delayMs - The delay duration between each attempt, in milliseconds. Default is 3000 (3 seconds). + * @returns A promise that resolves to a boolean value indicating whether the block function eventually returned true. + * + * @example + * // Example usage: + * const blockFunction = async () => { + * // Some asynchronous condition check + * return await someConditionCheck(); + * }; + * const result = await runUntilTrue(blockFunction, 10000, 1000); + * console.log(result); // Outputs true if blockFunction returned true within the timeout, otherwise false. + */ +export async function runUntilTrue(block: () => Promise, timeout: number = 150000, delayMs: number = 3000){ + const startTime = DateTime.now(); + let attempts = 0; + let evaluatesToTrue = false; + + do { + // If timeout duration has elapsed, stop making attempts + if (DateTime.now().diff(startTime).as('milliseconds') > timeout) { + break; + } + + attempts++; + // If retrying, wait delay duration + if (attempts > 1) await delay(delayMs); + + evaluatesToTrue = await block(); + + } while (!evaluatesToTrue); + + return evaluatesToTrue; +} + +// TODO move this to impl/utils/WaitingUtils when that related pr is available in devleop branch - T.S. 5/20/24 +export async function waitForEither(block1: () => Promise, block2: () => Promise, timeOut: number = 180_000): Promise { + const startTime = Date.now(); + + while (true) { + try { + const result1 = await block1(); + const result2 = await block2(); + + // Check if either result is truthy (i.e., not falsy or undefined) + if (result1 || result2) { + // At least one block returned a truthy value, resolve the promise + return; + } + } catch (error) { + // Handle errors thrown by either block + console.error("An error occurred:", error); + } + + // Check if the timeout has been reached + if (Date.now() - startTime >= timeOut) { + throw new Error(`Timeout of ${timeOut} ms exceeded`); + } + + // Add some delay before checking again + await new Promise(resolve => setTimeout(resolve, 1000)); // Adjust delay as needed + } +} + +/** + * Waits for a specific locator to show up on screen, then disappear. Typically used for things like progress bars. + * @param locator The locator we want to become visible and then become hidden + */ +export async function waitToAppearAndDisappear(locator: Locator): Promise { + try { + await expect(locator).toBeVisible({ timeout: 60000 }); + await expect(locator).toBeHidden({ timeout: 60000 }); + } catch (err) { + if (err instanceof Error) + console.log(LoggingUtils.logFunc(waitToAppearAndDisappear.name, err.message, false)); + else + console.log(LoggingUtils.logFunc(waitToAppearAndDisappear.name, null, false)); + } +} \ No newline at end of file diff --git a/playwright-tests/impl/utils/TryUtils.ts b/playwright-tests/impl/utils/TryUtils.ts new file mode 100644 index 000000000..de30a0695 --- /dev/null +++ b/playwright-tests/impl/utils/TryUtils.ts @@ -0,0 +1,132 @@ +import LoggingUtils from "./LoggingUtils"; +import { delay, timeoutOptDefaults } from "./TimingUtils"; + +/** + * @description For situations where a Test Framework Exception (i.e., a locator timeout) could be incorrectly thrown based on the state of the Target-Application (i.e., a missing tail number). Use this to throw clearer 'Application Exception' errors under such circumstances. + * @param actionVerb What are you trying to do? Could be 'gotoCatalog', 'addAnEnhancement', etc. Logged as `Attempting to ${actionVerb}` + * @param failureExplenation Be descriptive. What is the context of the failure? If someone unfamiliar with the code base were to see this, how would they know if the error was caused by their code, or by an underlying problem with the Target Application? + * @param actionToAttempt a lambda for the flaky action. + * @returns + */ +export async function tryBusinessAction(actionVerb: string, failureExplenation: string, actionToAttempt: (...args: any[]) => Promise): Promise { + let actionResult: any; + console.log(`Attempting to ${actionVerb}...`) + try { + actionResult = await actionToAttempt(); + } catch (e) { + if (e instanceof Error) { + e.message = e.message + ">>Failure Explenation>> " + failureExplenation; + throw (e); + } else { + throw (new Error("Unknown 'AttemptBusinessAction' State...")) + } + } + console.log(`Successfully executed ${actionVerb}`) + return actionResult; +} + +/** + * @description Brute-Force Flaky GUI activities by reseting and retrying. + * @param actionToTry lambda for whatever flaky action you're trying to take + * @param resetAction lambda for backing out of the problem and returning to a known state. Often, refreshing a browser, or closing a popup. + * @param maxRetries number of times to retry the action + * @param delayBetweenRetries milliseconds between retries + */ +export async function tryResetAndRetry( + actionVerb: string, + actionToTry: (...args: any[]) => Promise, + resetAction: (...args: any[]) => Promise, + maxRetries = 2, + delayBetweenRetries = timeoutOptDefaults.timeoutMedium): Promise { + + for (let i = 1; i <= maxRetries; i++) { + try { + await actionToTry() + } catch { + await delay(delayBetweenRetries); + resetAction(); + } + } +} + +/** + * @description For situations where a Test Framework Exception (i.e., a locator timeout) could be incorrectly thrown based on the state of the Target-Application (i.e., a missing tail number). Use this to throw clearer 'Application Exception' errors under such circumstances. + * @param actionVerb What are you trying to do? Could be 'gotoCatalog', 'addAnEnhancement', etc. Logged as `Attempting to ${actionVerb}` + * @param failureExplenation Be descriptive. What is the context of the failure? If someone unfamiliar with the code base were to see this, how would they know if the error was caused by their code, or by an underlying problem with the Target Application? + * @param actionToAttempt a lambda for the flaky action. + * @returns + */ +export async function tryBusinessActionWithRetries(actionVerb: string, failureExplenation: string, actionToAttempt: (...args: any[]) => Promise, attempts = 3): Promise { + let actionResult: any; + let isSuccessful: boolean = false; + //actionVerb is already a logFunc string + console.log(actionVerb); + + for (let i = 0; i < attempts; i++) { + try { + actionResult = await actionToAttempt(); + isSuccessful = true; + break; // Break loop if actionToAttempt is successful + } catch (e) { + if (e instanceof Error) { + e.message = e.message + ">>Failure Explanation>> " + failureExplenation; + } else { + throw (new Error("Unknown 'AttemptBusinessAction' State")) + } + } + } + + if (!isSuccessful) { + throw new Error(`Failed to ${actionVerb}`); + } + + //actionVerb is already a logFunc string + console.log(actionVerb); + return actionResult; +} + +/** + * Tries to execute a block of code with chances to retry. + * @param {Function} block The block of code to be executed. + * @param {string} [blockDescription=''] A text description of the block (optional). + * @param {number} [maxRetries=3] The maximum number of retry attempts (optional). + * @param {number} [delayMs=1000] The delay between retry attempts in milliseconds (optional). + */ +export async function retry(block: () => Promise, blockDescription: string = '', maxRetries: number = 3, delayMs: number = 1000): Promise { + let retries: number = 0; + if (!blockDescription.length) { + blockDescription = block.toString(); + } + + while (retries < maxRetries) { + console.log(LoggingUtils.logFunc(retry.name, blockDescription)); + try { + return await block(); + } catch (error) { + if (retries === maxRetries - 1) { + throw new Error(`Max retries (${maxRetries}) exceeded. Last error: ${error}`); + } + // wait between retries + await new Promise(resolve => setTimeout(resolve, delayMs)); + retries++; + } + } + // This should not be reached, but just in case + throw new Error(`Unexpected code execution. Max retries (${maxRetries}) exceeded.`); +} + +export async function tryWithRetries(actionBlock: Function, attempts = 3, waitInterval = 1000) { + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + await actionBlock(); + if (attempt > 1) + console.warn(`Had to retry but attempt ${attempt} succeeded!`); + break; // Exit the loop if the action is successful + } catch (error) { + console.error(`Attempt ${attempt} failed!`); + if (attempt < attempts) { + await new Promise(resolve => setTimeout(resolve, waitInterval)); + } + } + } +} \ No newline at end of file diff --git a/playwright-tests/pages/AfterpayPage.ts b/playwright-tests/pages/AfterpayPage.ts new file mode 100644 index 000000000..aed3fe339 --- /dev/null +++ b/playwright-tests/pages/AfterpayPage.ts @@ -0,0 +1,56 @@ +import { Locator, Page } from "@playwright/test"; +import { BasePage } from "./BasePage"; +import { IPaymentDetails } from "@business-logic/types/CustomerDetails"; + +export class AfterpayPage extends BasePage { + readonly page: Page; + readonly submitButton: Locator; + + // Login + readonly passwordTextBox: Locator; + + // Card details + readonly cardholderNameTextBox: Locator; + readonly cardNumberTextBox: Locator; + readonly expirationDateTextBox: Locator; + readonly cvvTextBox: Locator; + + readonly confirmButton: Locator; + + constructor(page: Page) { + super(page); + this.page = page; + this.passwordTextBox = page.getByTestId('login-password-input'); + this.submitButton = page.getByRole('button', { name: 'Continue' }); + + this.cardholderNameTextBox = page.getByTestId('payment-method-cardHolderName-input'); + this.cardNumberTextBox = page.getByTestId('payment-method-cardNumber-input'); + this.expirationDateTextBox = page.getByTestId('payment-method-cardExpiry-input'); + this.cvvTextBox = page.getByTestId('payment-method-cardCvv-input'); + + this.confirmButton = page.getByRole('button', { name: 'Confirm' }); + } + + async login(password: string) { + await this.passwordTextBox.fill(password); + await this.submitButton.click(); + } + + async populateCardDetails(paymentDetails: IPaymentDetails) { + await this.cardholderNameTextBox.fill('Roberts'); // TODO: Add cardholder name field + await this.cardNumberTextBox.fill(paymentDetails.cardNumber!); + await this.expirationDateTextBox.fill(`${paymentDetails.expirationMonth}/${paymentDetails.expirationYear}`); + await this.cvvTextBox.fill(paymentDetails.cvv!); + await this.submitButton.click(); + } + + async executeAfterpayPayment(paymentDetails: IPaymentDetails) { + await this.login(paymentDetails.password!); + + await this.populateCardDetails(paymentDetails); + + await this.confirmButton.click(); + } + + +} \ No newline at end of file diff --git a/playwright-tests/pages/BaseHomepage.ts b/playwright-tests/pages/BaseHomepage.ts new file mode 100644 index 000000000..981756f90 --- /dev/null +++ b/playwright-tests/pages/BaseHomepage.ts @@ -0,0 +1,22 @@ +import { Page, Locator } from '@playwright/test'; +import { BasePage } from './BasePage'; + +export abstract class BaseHomePage extends BasePage { + readonly page: Page; + readonly url: string; + + constructor(page: Page) { + super(page); + this.page = page; + this.url = process.env['BASE_URL']!; + } + + abstract letsGetStarted(zip?: string): Promise; + + // Method to check if this is the correct homepage variant + abstract isCurrentVariant(): Promise; + + async goto() { + await this.page.goto(process.env['BASE_URL']!); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/BasePage.ts b/playwright-tests/pages/BasePage.ts new file mode 100644 index 000000000..3c6722bd8 --- /dev/null +++ b/playwright-tests/pages/BasePage.ts @@ -0,0 +1,99 @@ +import test, { expect, type Locator, type Page } from '@playwright/test'; +import { error } from 'console'; + +/** + * Base class for all page objects. + * Provides common functionality for interacting with pages. + */ +export class BasePage { + // Common locators + readonly page: Page; + readonly continueButton: Locator; + readonly backButton: Locator; + readonly pageSpinner: Locator; + readonly buttonLoadSpin: Locator; + readonly hamburgerMenu: Locator; + + constructor(page: Page){ + this.page = page; + this.continueButton = page.locator('[id="infoBox"]').getByRole('button'); + this.backButton = page.locator('[id="infoBox"]').getByRole('link'); + this.pageSpinner = page.getByRole('status'); + this.buttonLoadSpin = page.getByRole('alert'); + this.hamburgerMenu = this.page.getByRole('button', { name: 'Hamburger Menu (modal window)' }); + } + + async nextPage() { + const startingUrl = this.page.url(); + await expect(async () => { + const currentUrl = this.page.url(); + if (currentUrl === startingUrl) { + await this.continueButton.click({ timeout: 1000 }); + } + expect(currentUrl).not.toEqual(startingUrl); + }).toPass({ timeout: 240_000 }); + } + + async previousPage() { + const startingUrl = this.page.url(); + await expect(async () => { + const currentUrl = this.page.url(); + if (currentUrl === startingUrl) { + await this.backButton.click({ timeout: 1000 }); + } + expect(currentUrl).not.toEqual(startingUrl); + }).toPass({ timeout: 240_000 }); + } + + async validateURL(url:string){ + await expect(this.pageSpinner).toHaveCount(0, {timeout: 60000}); + await this.page.waitForURL(url); + } + + async fillAndValidate(element: Locator, value: string){ + await expect(async () => { + await element.clear(); + await element.fill(value); + await expect(element).toHaveValue(value); + }).toPass(); + } + + async clickWithRetry(element, page) { + const timeout = 5000; // milli seconds + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + try { + if (await element.isEnabled()) { + await element.click(); + await element.keyboard.press('Tab'); + return; // Exit loop if click succeeds + } + } catch (error) { + // Ignore error and retry + } + await page.waitForTimeout(100); // Small delay before retrying + } + console.log(`Failed to click the the element within ${timeout/1000} seconds` + error); + } + + async logReferralNumber() { + let mainLocalStorage = JSON.parse(await this.page.evaluate('localStorage.getItem(\'main\')')); + let referralNumber = mainLocalStorage.order.referralNumber as number; + let referralSequenceNumber = mainLocalStorage.order.referralSequenceNumber as number; + if (referralNumber == null) { + for (let i = 1; i <= 20; i++) { + if (!referralNumber == null) break; + await this.page.waitForTimeout(500); + mainLocalStorage = JSON.parse(await this.page.evaluate('localStorage.getItem(\'main\')')); + referralNumber = mainLocalStorage.order.referralNumber as number; + referralSequenceNumber = mainLocalStorage.order.referralSequenceNumber as number; + } + } + + await test.step(`Referral Number:${referralNumber} Referral Sequence Number:${referralSequenceNumber}`, async () => { + console.log(`Referral Number:${referralNumber}`); + console.log(`Referral Sequence Number:${referralSequenceNumber}`); + }); + + } +} \ No newline at end of file diff --git a/playwright-tests/pages/CCPolicyInfoPage.ts b/playwright-tests/pages/CCPolicyInfoPage.ts new file mode 100644 index 000000000..42ee3632b --- /dev/null +++ b/playwright-tests/pages/CCPolicyInfoPage.ts @@ -0,0 +1,73 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { IClaimDetails, ICustomerDetails } from '@business-logic/types/CustomerDetails'; +import { InsuranceBasePage } from './InsuranceBasePage'; +import { STATE_ABBREVIATIONS } from '@business-logic/constants/StateAbbreviation'; + +export class CCPolicyInfoPage extends InsuranceBasePage { + readonly policyNumber: Locator; + readonly policyZip: Locator; + readonly damageDate: Locator; + readonly damageCause: Locator; + readonly phoneNumber: Locator; + readonly city: Locator; + readonly state: Locator; + readonly hasAdditionalDamage: Locator; + readonly isRentalVehicle: Locator; + readonly isOtherPartyResponsibleForCoverage: Locator; + + url = `${process.env['BASE_URL']!}/FixMyGlass/CCPolicyInfo.aspx*`; + + constructor(page: Page) { + super(page); + + this.policyNumber = page.locator('#PolicyNumber'); + this.policyZip = page.locator('#PolicyZip'); + this.damageDate = page.locator('#LossDate'); + this.damageCause = page.locator('#LossCause'); + this.phoneNumber = page.locator('#PrimaryPhone'); + this.city = page.locator('#LossLocCity'); + this.state = page.locator('#LossLocState'); + this.hasAdditionalDamage = page.locator('#HasAdditionalDamage'); + this.isRentalVehicle = page.locator('#IsRentalVehicle'); + this.isOtherPartyResponsibleForCoverage = page.locator('#IsOtherPartyResponsibleForCoverage'); + } + + async hasCityInfo(): Promise { + await expect.soft(this.damageCause).toBeVisible(); + return this.city.isVisible(); + } + + private getStateAbbreviation(stateName: string): string { + const abbreviation = STATE_ABBREVIATIONS[stateName]; + if (!abbreviation) { + throw new Error(`State "${stateName}" not found in mapping`); + } + return abbreviation; + } + + async populatePage(customerDetails: ICustomerDetails, claimDetails: IClaimDetails, isFillCityInfo: boolean): Promise { + try { + await this.policyNumber.fill(claimDetails.policyNumber); + + await this.policyZip.fill(claimDetails.policyZip || customerDetails.address.postalCode); + + await this.damageDate.click(); + await this.damageDate.fill(claimDetails.damageDate); + await this.damageCause.selectOption(claimDetails.damageCause); + await this.damageCause.press('Tab'); + + await this.phoneNumber.fill(customerDetails.phoneNumber); + + if (isFillCityInfo) { + await this.city.fill(customerDetails.address.city); + } + if (await this.state.isVisible()) { + const stateAbbreviation = this.getStateAbbreviation(customerDetails.address.state); + await this.state.selectOption({ value: stateAbbreviation }); + } + } catch (error) { + console.error('Error populating policy info page:', error); + throw error; + } + } +} \ No newline at end of file diff --git a/playwright-tests/pages/CapabilityQuestionsPage.ts b/playwright-tests/pages/CapabilityQuestionsPage.ts new file mode 100644 index 000000000..e2b6260a3 --- /dev/null +++ b/playwright-tests/pages/CapabilityQuestionsPage.ts @@ -0,0 +1,10 @@ +import { Page } from "@playwright/test"; +import { PartQuestionsPage } from "./PartQuestionPage"; + +export default class CapabilityQuestionsPage extends PartQuestionsPage { + url = process.env['BASE_URL']! + '/fmg/?fmgPage=capability-questions'; + + constructor(page: Page) { + super(page); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/ContactDetailsPage.ts b/playwright-tests/pages/ContactDetailsPage.ts new file mode 100644 index 000000000..0b10be42a --- /dev/null +++ b/playwright-tests/pages/ContactDetailsPage.ts @@ -0,0 +1,42 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { ICustomerDetails } from '@business-logic/types/CustomerDetails'; + +export class ContactDetailsPage extends BasePage { + readonly page: Page; + url = process.env['BASE_URL']! + '/fmg/?fmgPage=customer-details'; + + // Contact details form + // TODO: Check if we can consolidate + readonly firstNameTextBox: Locator; + readonly lastNameTextBox: Locator; + readonly emailAddressTextBox: Locator; + readonly phoneNumberTextBox: Locator; + readonly notesTextBox: Locator; + + constructor(page: Page) { + super(page); + this.page = page; + + // Contact details form + this.firstNameTextBox = this.page.getByRole('textbox', { name: 'First name' }); + this.lastNameTextBox = this.page.getByRole('textbox', { name: 'Last name' }); + this.emailAddressTextBox = this.page.getByRole('textbox', { name: 'Email address' }); + this.phoneNumberTextBox = this.page.getByRole('textbox', { name: 'Phone number' }); + this.notesTextBox = this.page.getByRole('textbox', { name: 'Notes' }); + } + + + async enterContactDetails(customerDetails: ICustomerDetails) { + await this.firstNameTextBox.fill(customerDetails.firstName); + await this.lastNameTextBox.fill(customerDetails.lastName); + await this.emailAddressTextBox.fill(customerDetails.email); + await this.phoneNumberTextBox.fill(customerDetails.phoneNumber); + } + + async fillNotes(notes?: string) { + if (notes) { + await this.notesTextBox.fill(notes); + } + } +} \ No newline at end of file diff --git a/playwright-tests/pages/CoverageStatementPage.ts b/playwright-tests/pages/CoverageStatementPage.ts new file mode 100644 index 000000000..a140604f7 --- /dev/null +++ b/playwright-tests/pages/CoverageStatementPage.ts @@ -0,0 +1,58 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { InsuranceBasePage } from './InsuranceBasePage'; +import { IClaimDetails } from '@business-logic/types/CustomerDetails'; + +export class CoverageStatementPage extends InsuranceBasePage { + readonly page: Page; + readonly scheduleOnlineButton: Locator; + readonly cancelMyClaimButton: Locator; + readonly deductibleAmount: Locator; + readonly verfiyingCoverageText: Locator; + readonly continueButton: Locator; // For ITAC/NoComp + url = process.env['BASE_URL']! + '/FixMyGlass/CoverageStatement.aspx'; + + constructor(page: Page) { + super(page); + this.page = page; + this.scheduleOnlineButton = this.page.getByText('Continue to schedule online'); + this.cancelMyClaimButton = this.page.getByText('Cancel my claim'); + this.deductibleAmount = this.page.getByRole('heading', { name: '$' }).locator('span'); + this.verfiyingCoverageText = this.page.getByRole('heading', { name: 'We’re verifying your coverage' }); + this.continueButton = page.getByRole('button', { name: 'Continue' }); + // this.validateURL(this.url); + } + + async scheduleOnline(){ + await this.scheduleOnlineButton.click(); + } + + async cancelMyClaim(){ + await this.cancelMyClaimButton.click(); + } + + async validateDeductibleAmount(claimDetails: IClaimDetails) { + // Format the deductible number to have 2 decimal places + const formattedDeductible = typeof claimDetails.policyDeductible === 'number' + ? claimDetails.policyDeductible.toFixed(2) + : Number(claimDetails.policyDeductible).toFixed(2); + + // Add a regex to match the formatted value with commas + const expectedDeductibleRegex = formattedDeductible.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + + // Narrow down the selector to target the exact element + const deductibleElement = this.page.locator( + "span.deductible-text-black[data-bind='text: deductibleFormatted']" + ); + + // Check if the locator is visible before running the expectation + if (await deductibleElement.isVisible()) { + // Check if the page contains the properly formatted deductible amount + await expect(deductibleElement).toContainText(`$${expectedDeductibleRegex}`); + } + } + + async validateUnverifiedText(){ + await expect(this.verfiyingCoverageText).toBeEnabled(); + } + +} \ No newline at end of file diff --git a/playwright-tests/pages/DuplicateCheckPage.ts b/playwright-tests/pages/DuplicateCheckPage.ts new file mode 100644 index 000000000..58d5463dd --- /dev/null +++ b/playwright-tests/pages/DuplicateCheckPage.ts @@ -0,0 +1,21 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { InsuranceBasePage } from './InsuranceBasePage'; + +export class DuplicateCheckPage extends InsuranceBasePage { + readonly page: Page; + readonly newClaimButton: Locator; + url = process.env['BASE_URL']! + '/FixMyGlass/DuplicateCheck.aspx'; + + constructor(page: Page) { + super(page); + this.page = page; + this.newClaimButton = page.locator('a').filter({ hasText: 'Start a new claim Start a new' }); + // this.validateURL(this.url); + } + + async startNewClaim(){ + await this.newClaimButton.click(); + await this.page.waitForLoadState(); + } + +} \ No newline at end of file diff --git a/playwright-tests/pages/EndorsementsPage.ts b/playwright-tests/pages/EndorsementsPage.ts new file mode 100644 index 000000000..08d69e4e3 --- /dev/null +++ b/playwright-tests/pages/EndorsementsPage.ts @@ -0,0 +1,55 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { IEndorsementDetails } from '@business-logic/types/CustomerDetails'; +import { EndorsementType } from '@business-logic/types/Enums'; +import { InsuranceBasePage } from './InsuranceBasePage'; + +export class EndorsementsPage extends InsuranceBasePage { + readonly page: Page; + readonly educatorYesButton: Locator; + readonly educatorNoButton: Locator; + url = process.env['BASE_URL']! + '/FixMyGlass/PolicyEndorsements.aspx'; + + constructor(page: Page) { + super(page); + this.page = page; + this.educatorYesButton = this.page.getByRole('link', { name: 'Yes' }); + this.educatorNoButton = this.page.getByRole('link', { name: 'Yes' }); + } + + async verifyEndorsements(endorsements: IEndorsementDetails[]) { + for (const endorsement of endorsements) { + switch(endorsement.endorsementType) { + case EndorsementType.Educator: + if (endorsement.isOnPolicy) { + await expect.soft(this.educatorYesButton).toBeAttached(); + } else { + await expect.soft(this.educatorYesButton).not.toBeAttached(); + } + break; + case EndorsementType.EmployeeParking: + // TODO: Implement + break; + } + } + } + + async selectEndorsements(endorsements: IEndorsementDetails[]) { + for (const endorsement of endorsements) { + if (endorsement.isOnPolicy) { + switch (endorsement.endorsementType) { + case EndorsementType.Educator: + if (endorsement.isClickYes) { + await this.educatorYesButton.click(); + } else { + await this.educatorNoButton.click(); + } + break; + case EndorsementType.EmployeeParking: + // TODO: Implement + break; + } + } + } + } +} \ No newline at end of file diff --git a/playwright-tests/pages/EstimatePage.ts b/playwright-tests/pages/EstimatePage.ts new file mode 100644 index 000000000..141fa73a6 --- /dev/null +++ b/playwright-tests/pages/EstimatePage.ts @@ -0,0 +1,71 @@ +import { type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { VehicleLookupType } from '@business-logic/types/Enums'; +import { IVehicleDetails } from '@business-logic/types/CustomerDetails'; +import { VinLookupPage } from './VinLookupPage'; +import { VehicleLookupAddressPage } from './VehicleLookupAddressPage'; +import { VehicleLookupLicensePage } from './VehicleLookupLicensePage'; + +export class EstimatePage extends BasePage { + readonly page: Page; + readonly vinLookupButton: Locator; + readonly addressLookupButton: Locator; + readonly licenseLookupButton: Locator; + readonly vinLookupPage: VinLookupPage; + readonly zipLookupButton: Locator; + readonly vehicleLookupAddressPage: VehicleLookupAddressPage; + readonly vehicleLookupLicensePage: VehicleLookupLicensePage; + url = process.env['BASE_URL']! + '/fmg/?fmgPage=estimate'; + + constructor(page: Page) { + super(page); + this.page = page; + this.vinLookupButton = page.getByLabel('Provide my VIN', { exact: true }); + this.zipLookupButton = page.locator('label').filter({ hasText: 'I\'d rather not share my VIN' }).locator('div'); + this.addressLookupButton = page.getByLabel('Provide my home address', { exact: true }); + this.licenseLookupButton = page.getByLabel('Provide my license plate #', { exact: true }); + this.vinLookupPage = new VinLookupPage(page); + + // this.validateURL(this.url); + } + + async vehicleLookup(vehicleDetails: IVehicleDetails) { + switch (vehicleDetails.vehicleLookupType) { + case VehicleLookupType.Address: + await this.selectAddressLookup(); + await this.nextPage(); + break; + case VehicleLookupType.LicensePlateNumber: + await this.selectLicenseLookup(); + await this.nextPage(); + break; + case VehicleLookupType.Vin: + await this.selectVinLookup(); + await this.nextPage(); + break; + case VehicleLookupType.Zip: + await this.selectZipLookup(); + await this.nextPage(); + break; + default: + console.error('EstimatePage >> DATA ISSUE: VehicleLookupType not provided'); + break; + } + } + + async selectVinLookup(){ + await this.vinLookupButton.click(); + } + + async selectAddressLookup(){ + await this.addressLookupButton.click(); + } + + async selectLicenseLookup(){ + await this.licenseLookupButton.click(); + } + + async selectZipLookup(){ + await this.zipLookupButton.click(); + } +} diff --git a/playwright-tests/pages/HomePage.ts b/playwright-tests/pages/HomePage.ts new file mode 100644 index 000000000..9afb0a2e1 --- /dev/null +++ b/playwright-tests/pages/HomePage.ts @@ -0,0 +1,80 @@ +import test, { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; + +export class HomePage extends BasePage { + + readonly page: Page; + readonly letsGetStartedButton: Locator; + readonly cusmodalPopup: Locator; + readonly closePopupButton: Locator; + + //Quick Quote (LeadGen) Form + readonly yearDropdown: Locator; + readonly makeDropdown: Locator; + readonly modelDropdown: Locator; + readonly styleDropdown: Locator; + readonly damageTypeDropdown: Locator; + readonly zipCodeTextBox: Locator; + readonly phoneNumberTextBox: Locator; + readonly paymentOptionDropdown: Locator; + readonly viewQuoteButton: Locator; + + //Zip Entry + readonly enterServiceZipTextBox: Locator; + readonly zipEntryLetsGetStartedButton: Locator; + readonly getQuoteAndScheduleButton: Locator; + + url = process.env['BASE_URL']!; + + constructor(page: Page) { + super(page); + + this.page = page; + this.letsGetStartedButton = this.page.locator('a.btn.btn-primary.ghost'); + this.cusmodalPopup = this.page.locator('#Cusmodalpopup'); + this.closePopupButton = this.page.getByRole('button', { name: '×' }); + + //Quick Quote (LeadGen) Form + this.yearDropdown = this.page.locator('#year'); + this.makeDropdown = this.page.locator('#make'); + this.modelDropdown = this.page.locator('#model'); + this.styleDropdown = this.page.locator('#style'); + this.damageTypeDropdown = this.page.locator('#damage'); + this.zipCodeTextBox = this.page.locator('#zipcodeinput'); + this.phoneNumberTextBox = this.page.locator('#phonenumberinput'); + this.paymentOptionDropdown = this.page.locator('#insuranceCheckbox'); + this.viewQuoteButton = this.page.locator('#ctaSubmit'); + + //Zip Entry + this.enterServiceZipTextBox = this.page.locator('#zipCodeTextbox'); + this.zipEntryLetsGetStartedButton = this.page.locator('#zipCodeTextboxButton'); + this.getQuoteAndScheduleButton = this.page.getByLabel('main').getByRole('link', { name: 'Get quote + schedule' }); + } + + async goto() { + await this.page.goto(process.env['BASE_URL']!); + // await this.validateURL(this.url); + } + + async isCurrentVariant(): Promise { + return !(await this.page.getByPlaceholder('Enter service ZIP code').isVisible()); + } + + async letsGetStarted(zip: string, enterFunnelWithZip: boolean) { + if (enterFunnelWithZip) { + await this.letsGetStartedButton.evaluate((element, zip) => { + const currentHref = element.getAttribute('href') || ''; + element.setAttribute('href', `${currentHref}&zipCode=${zip}`); + }, zip); + } + + // Wait until modal pop up opens and close pop up + await this.page.waitForTimeout(5000); + if (await this.cusmodalPopup.isVisible()) { + await this.closePopupButton.click(); + } + await this.letsGetStartedButton.click(); + } + + +} \ No newline at end of file diff --git a/playwright-tests/pages/InsuranceBasePage.ts b/playwright-tests/pages/InsuranceBasePage.ts new file mode 100644 index 000000000..b055bad0f --- /dev/null +++ b/playwright-tests/pages/InsuranceBasePage.ts @@ -0,0 +1,142 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; + +export class InsuranceBasePage extends BasePage { + readonly cookieContinueButton: Locator; + readonly continueWithSafeliteButton: Locator; + + constructor(page: Page) { + super(page); + + // Targeting continue buttons with multiple selectors + this.cookieContinueButton = page.locator([ + 'button:has-text("Continue")', + 'button#navNext.btn-success', + 'button[name="navNext"][type="submit"]', + '.btn-success:has-text("Continue")' + ].join(', ')); + + // Targeting "Continue with Safelite" buttons + this.continueWithSafeliteButton = page.locator([ + 'button:has-text("Continue with Safelite")', + 'button#navNext:has-text("Safelite")', + '.btn-success:has-text("Continue with Safelite")' + ].join(', ')); + } + + /** + * Navigates to the next page in the insurance flow + * Overrides the BasePage nextPage() method to handle insurance modal buttons + */ + async nextPage() { + const startingUrl = this.page.url(); + let attemptCount = 0; + const maxAttempts = 3; + + while (attemptCount < maxAttempts) { + try { + // Try navNext ID button first + const navNextButton = this.page.locator('#navNext:visible'); + if (await navNextButton.count() > 0) { + await navNextButton.click(); + await this.page.waitForTimeout(500); + } + // Try button with Continue text + else if (await this.cookieContinueButton.isVisible({ timeout: 2000 })) { + await this.cookieContinueButton.click(); + await this.page.waitForTimeout(500); + } + // Try button with Continue with Safelite text + else if (await this.continueWithSafeliteButton.isVisible({ timeout: 2000 })) { + await this.continueWithSafeliteButton.click(); + await this.page.waitForTimeout(500); + } + // Try any button that might work + else { + const possibleButtons = [ + 'button.btn-success', + 'button[type="submit"]', + 'button[name="navNext"]', + '.button-content', + 'button:has-text("Continue")', + 'button:has-text("Next")' + ]; + + let buttonFound = false; + for (const selector of possibleButtons) { + const button = this.page.locator(selector); + if (await button.count() > 0 && await button.first().isVisible()) { + await button.first().click(); + buttonFound = true; + await this.page.waitForTimeout(500); + break; + } + } + + if (!buttonFound) { + await super.nextPage(); + return; + } + } + + // Check if URL changed + const currentUrl = this.page.url(); + if (currentUrl !== startingUrl) { + break; + } + + // Try Enter key on second attempt + if (attemptCount === 1) { + await this.page.keyboard.press('Enter'); + await this.page.waitForTimeout(1000); + + if (this.page.url() !== startingUrl) { + break; + } + } + + attemptCount++; + } catch (error) { + attemptCount++; + + if (attemptCount >= maxAttempts) { + await super.nextPage(); + return; + } + + await this.page.waitForTimeout(1000); + } + } + + // Wait for spinner to disappear + try { + await expect(this.pageSpinner).toHaveCount(0, { timeout: 60000 }); + } catch (error) { + // Continue if spinner check fails + } + + // Final navigation stability check + await this.page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { + // Continue if networkidle times out + }); + } + + /** + * Direct method to click Continue with Safelite button + */ + async clickContinueWithSafelite() { + try { + // Try direct ID selector first + if (await this.page.$('#navNext:has-text("Safelite")') !== null) { + await this.page.click('#navNext'); + } else if (await this.continueWithSafeliteButton.isVisible({ timeout: 2000 })) { + await this.continueWithSafeliteButton.click(); + } else { + await this.page.click('button:has-text("Continue with Safelite")'); + } + return true; + } catch (error) { + return false; + } + } +} \ No newline at end of file diff --git a/playwright-tests/pages/InsuranceCompanyPage.ts b/playwright-tests/pages/InsuranceCompanyPage.ts new file mode 100644 index 000000000..6bc125225 --- /dev/null +++ b/playwright-tests/pages/InsuranceCompanyPage.ts @@ -0,0 +1,47 @@ +import { type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { IClaimDetails } from '@business-logic/types/CustomerDetails'; + + +export class InsuranceCompanyPage extends BasePage { + readonly page: Page; + readonly insuranceInputBox: Locator; + readonly payOnMyOwnLink: Locator; + + url = process.env['BASE_URL']! + '/fmg/?fmgPage=insurance-company'; + + constructor(page: Page) { + super(page); + this.page = page; + this.insuranceInputBox = this.page.getByRole('textbox', { name: 'Enter your insurance company' }); + this.payOnMyOwnLink = this.page.getByRole('link', { name: 'Pay on my own' }); + + // this.validateURL(this.url); + } + + async enterInsuranceCompany(company: string): Promise { + + await this.insuranceInputBox.fill(company); + try { + // First attempt: Try to find and click an exact match + await this.page.getByText(company, { exact: true }) + .locator('xpath=ancestor::div[contains(@class, "ui-menu-item-wrapper")]') + .click({ timeout: 2000 }); // Short timeout for quick fallback + } catch (error) { + // Fallback: If exact match fails, select the first option containing the text + const options = this.page.locator('div.ui-menu-item-wrapper', { hasText: company }); + const count = await options.count(); + + if (count === 0) { + throw new Error(`No insurance company options found containing "${company}"`); + } else if (count === 1) { + // If only one option, click it + await options.click(); + } else { + // If multiple options, click the first one + await options.first().click(); + console.log(`Selected first match for "${company}" from ${count} options`); + } + } + } +} \ No newline at end of file diff --git a/playwright-tests/pages/LeadgenHomePage.ts b/playwright-tests/pages/LeadgenHomePage.ts new file mode 100644 index 000000000..747ed1794 --- /dev/null +++ b/playwright-tests/pages/LeadgenHomePage.ts @@ -0,0 +1,36 @@ +import test, { Locator, Page } from '@playwright/test'; + +import { BasePage } from './BasePage'; + +// This file is an example of a Page Object Model + +export class LeadgenHomePage extends BasePage { + + readonly page: Page; + readonly letsGetStartedButton: Locator; + readonly zipTextbox: Locator; + url = process.env['BASE_URL']!; + + constructor(page: Page) { + super(page); + + this.page = page; + this.letsGetStartedButton = this.page.locator('#zipCodeTextboxButton'); + this.zipTextbox = this.page.getByPlaceholder('Enter service ZIP code'); + } + + async goto() { + await this.page.goto(process.env['BASE_URL']!); + // await this.validateURL(this.url); + } + + async letsGetStarted(zip: string) { + await this.zipTextbox.fill(zip); + await this.letsGetStartedButton.click(); + } + + async isCurrentVariant(): Promise { + return await this.zipTextbox.isVisible(); + } + +} \ No newline at end of file diff --git a/playwright-tests/pages/LookupPage.ts b/playwright-tests/pages/LookupPage.ts new file mode 100644 index 000000000..06832cdb7 --- /dev/null +++ b/playwright-tests/pages/LookupPage.ts @@ -0,0 +1,131 @@ +import { type Locator, type Page, expect } from '@playwright/test'; +import { BasePage } from './BasePage'; +import TestSuccessAlert from '@business-logic/types/TestSuccessAlert'; +import IAlertFlags from '@business-logic/types/IAlertFlags'; +import { VehicleLookupType } from '@business-logic/types/Enums'; + +export class LookupPage extends BasePage { + protected serviceZipTextBox: Locator; + protected emailTextBox: Locator; + protected unserviceableZipAlertMessage: Locator; + protected invalidZipAlertMessage: Locator; + protected vinNotFoundAlertMessage: Locator; + + constructor(page: Page) { + super(page); + this.initializeLocators(page); + } + + protected initializeLocators(page: Page) { + this.serviceZipTextBox = page.getByRole('textbox', { name: 'Service ZIP' }); + this.emailTextBox = page.getByRole('textbox', { name: 'Mobile phone number or email' }); + this.unserviceableZipAlertMessage = this.page.locator('div.alert-danger.widget-name-widgetUndefined', { hasText: "We don't currently provide service within " }); + this.invalidZipAlertMessage = this.page.getByText('ZIP code is not valid.Please'); + this.vinNotFoundAlertMessage = this.page.locator('div.alert-danger.widget-name-AlertVinNotFoundWidget'); + } + + async enterZip(zip: string) { + await this.serviceZipTextBox.fill(zip); + } + + async enterEmail(email: string | undefined) { + if (email != undefined) { + await this.emailTextBox.fill(email); + } + } + + async checkForInvalidZipAlertMessage() { + await this.continueButton.click(); + await expect(this.invalidZipAlertMessage).toBeVisible(); + const alertMessage = await this.invalidZipAlertMessage.textContent(); + console.log(`Alert encountered: ${alertMessage}`); + + const expectedAlertMessage = `ZIP code is not valid.Please re-enter your correct service ZIP.`; + expect(alertMessage).toContain(expectedAlertMessage); + } + + async checkForUnserviceableZipAlertMessage(zipCode: string) { + await this.continueButton.click(); + await expect(this.unserviceableZipAlertMessage).toBeVisible(); + const alertMessage = await this.unserviceableZipAlertMessage.textContent(); + console.log(`Alert encountered: ${alertMessage}`); + + const expectedAlertMessage = `We don't currently provide service within ${zipCode}.Please enter a different ZIP code where you'd like service so we can get you scheduled.`; + expect(alertMessage).toContain(expectedAlertMessage); + } + + async checkForVinNotFoundAlertMessage(lookupType: VehicleLookupType) { + await this.continueButton.click(); + await expect(this.vinNotFoundAlertMessage).toBeVisible(); + const alertMessage = await this.vinNotFoundAlertMessage.textContent() || ''; + console.log(`Alert encountered: ${alertMessage}`); + + // Map lookup type enum to string + let lookupTypeString: string; + switch(lookupType) { + case VehicleLookupType.Address: + lookupTypeString = 'address'; + break; + case VehicleLookupType.LicensePlateNumber: + lookupTypeString = 'license plate'; + break; + case VehicleLookupType.Vin: + lookupTypeString = 'VIN'; + break; + case VehicleLookupType.Zip: + lookupTypeString = 'ZIP code'; + break; + default: + lookupTypeString = ''; + } + + // Map of expected messages by lookup type + const expectedMessages = { + 'address': "Your address didn't return a VIN match.Please re-enter the information below or provide your VIN in a different way.", + 'license plate': "Your license plate didn’t return a VIN match.Please re-enter the information above or provide your VIN in a different way.", + 'VIN': "Your VIN didn’t return a vehicle matchPlease re-enter your VIN or we can look up your VIN for you.", + 'ZIP code': "Your ZIP code didn't return a match.Please re-enter the information above or provide your VIN in a different way." + }; + + if (lookupTypeString && expectedMessages[lookupTypeString]) { + // If lookup type is provided and has a defined message, check for exact match + console.log(`Checking for message: "${expectedMessages[lookupTypeString]}"`); + expect(alertMessage).toContain(expectedMessages[lookupTypeString]); + console.log(`VIN not found alert validation passed for ${lookupTypeString}`); + } else { + // Fall back to checking for key phrases if no specific lookup type match + const containsVinMatch = alertMessage.includes("didn't return a VIN match") || + alertMessage.includes("didn't return a match"); + const containsReenterInfo = alertMessage.includes("re-enter the information"); + const containsProvideVin = alertMessage.includes("provide your VIN"); + + expect(containsVinMatch).toBeTruthy(); + expect(containsReenterInfo).toBeTruthy(); + expect(containsProvideVin).toBeTruthy(); + console.log("VIN not found alert validation passed with generic check"); + } + } + + async handleZipValidation(zip: string, lookupType: VehicleLookupType, alertFlags?: IAlertFlags): Promise { + + // Only proceed with validation if alertFlags is provided + if (alertFlags) { + if (alertFlags.isUnserviceableZip) { + await this.checkForUnserviceableZipAlertMessage(zip); + throw new TestSuccessAlert('Unserviceable ZIP validation successful.'); + } else if (alertFlags.isInvalidZip) { + await this.checkForInvalidZipAlertMessage(); + throw new TestSuccessAlert('Invalid ZIP validation successful.'); + } else if (alertFlags.isVinNotFound) { + await this.checkForVinNotFoundAlertMessage(lookupType); + throw new TestSuccessAlert('Vin not found validation successful.'); + } else { + + } + } + + // If no alert flags or no matching condition, just continue + await this.nextPage(); + return true; + } +} \ No newline at end of file diff --git a/playwright-tests/pages/MoldingQuestionsPage.ts b/playwright-tests/pages/MoldingQuestionsPage.ts new file mode 100644 index 000000000..3221a35e2 --- /dev/null +++ b/playwright-tests/pages/MoldingQuestionsPage.ts @@ -0,0 +1,10 @@ +import { Page } from "@playwright/test"; +import { PartQuestionsPage } from "./PartQuestionPage"; + +export default class MoldingQuestionsPage extends PartQuestionsPage { + url = process.env['BASE_URL']! + '/fmg/?fmgPage=molding-questions'; + + constructor(page: Page) { + super(page); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/OrderConfirmationPage.ts b/playwright-tests/pages/OrderConfirmationPage.ts new file mode 100644 index 000000000..c8b5f63f6 --- /dev/null +++ b/playwright-tests/pages/OrderConfirmationPage.ts @@ -0,0 +1,131 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { ICustomerDetails, IVehicleDetails } from '@business-logic/types/CustomerDetails'; +import { ServicePackage, PaymentType, PaymentMethod } from '@business-logic/types/Enums'; +import { ITestData } from '@business-logic/types/ITestData'; + +export class OrderConfirmationPage extends BasePage { + readonly page: Page; + readonly serviceText: Locator; + readonly emailText: Locator; + readonly apptDateText: Locator; + readonly amountDueText: Locator; + readonly viewCartButton: Locator; + readonly deductibleText: Locator; + readonly subtotalText: Locator; + readonly finalAmountDue: Locator; + readonly cartServicePackageText: Locator; + readonly winshieldWiper: Locator; + readonly rainDefense: Locator; + + url = process.env['BASE_URL']! + '/fmg/?fmgPage=confirmation'; + + constructor(page: Page) { + super(page); + this.page = page; + this.serviceText = this.page.locator('p', { + hasText: /to service your/ + }); + this.emailText = this.emailText = this.page.getByText('A confirmation email was sent'); + this.apptDateText = this.page.locator('[class="scheduleText"]'); + this.amountDueText = this.page.getByLabel('expand cart'); + this.viewCartButton = this.page.locator('#cart-dropdown-head'); + this.deductibleText = this.page.locator('#deductible-value'); + this.subtotalText = this.page.locator('.sub-total'); + this.finalAmountDue = this.page.locator('div.amount-due'); + this.cartServicePackageText = this.cartServicePackageText = this.page.locator('.cart-panel'); + // this.validateURL(this.url); + } + + async validateOrderConfirmationPage(testData: Partial) { + // Destructure data we use + const { vehicleDetails, customerDetails, servicePackage, promoCode, + isPolicyFound, claimDetails, paymentDetails, isUseVehicleOnPolicy, paymentMethod } = testData; + await this.serviceText.waitFor({ state: "visible" }); + + // Grab text + const serviceTextValue = await this.serviceText.textContent(); + const apptDateValue = await this.apptDateText.textContent(); + const emailTextValue = await this.emailText.textContent(); + const servicePackageValue = await this.cartServicePackageText.textContent(); + const amountDueValue = await this.amountDueText.textContent(); + // const deductibleTextValue = await this.deductibleText.textContent(); + const subtotalTextValue = await this.subtotalText.textContent(); + const finalAmountDueValue = await this.finalAmountDue.textContent(); + + // Extract service package price + const servicePackageAmt = Number.parseFloat(servicePackageValue!.split('$')[1].replaceAll(',', '')); + + // General Validations + expect.soft(serviceTextValue).toContain(`${vehicleDetails!.year} ${vehicleDetails!.make} ${vehicleDetails!.model}`); + expect.soft(apptDateValue).toContain(customerDetails!.apptDate); + expect.soft(emailTextValue).toContain(customerDetails!.email); + + // Service package validations + await expect.soft(this.cartServicePackageText).toContainText(`${servicePackage}`) + if (servicePackage === ServicePackage.Premium || servicePackage === ServicePackage.Standard) { + expect.soft(servicePackageValue).toContain('New wiper blades'); + } + if (servicePackage === ServicePackage.Premium) { + expect.soft(servicePackageValue).toContain('Rain Defense™'); + } + + // Promo Code Validation + if (promoCode) { + expect.soft(servicePackageValue).toContain(`Promo code ${promoCode} applied`); + }; + + if (paymentMethod === PaymentMethod.SelfPay || (paymentDetails?.paymentType && paymentDetails?.paymentType !== PaymentType.PayWithInsurance)) { + // Cart validation for non insurance users + // Extract numbers + const amountDueAmt = Number.parseFloat(amountDueValue!.split('$')[1].replaceAll(',', '')); + // const deductibleAmt = deductibleTextValue? Number.parseFloat(deductibleTextValue.split('$')[1].replaceAll(',', '')): 0; + const subtotalAmt = Number.parseFloat(subtotalTextValue!.split('$')[1].replaceAll(',', '')); + subtotalTextValue?.replaceAll(',', '') + const finalAmountDueAmt = Number.parseFloat(finalAmountDueValue!.split('$')[1].replaceAll(',', '')); + + + expect.soft(subtotalAmt).toBeGreaterThan(0); + // expect.soft(deductibleAmt).toEqual(0); + + if (paymentDetails!.paymentType === PaymentType.PayAtService && (servicePackageAmt > 0)) { + // Verify amount due > 0 + expect.soft(amountDueAmt).toBeGreaterThan(0); + expect.soft(finalAmountDueAmt).toBeGreaterThan(0); + } else { + // Verify amount due 0 + expect.soft(amountDueAmt).toEqual(0); + expect.soft(finalAmountDueAmt).toEqual(0); + } + } else { + // Price validations for insurance users + if (servicePackage === ServicePackage.GlassOnly) { + expect.soft(servicePackageAmt).toEqual(0); + } else { + expect.soft(servicePackageAmt).toBeGreaterThan(0); + } + + // Check for either "Verifying coverage" or "0.00" in price fields + expect.soft( + amountDueValue?.includes('Verifying coverage') || + amountDueValue?.includes('0.00') + ).toBeTruthy(); + + expect.soft( + subtotalTextValue?.includes('Verifying coverage') || + subtotalTextValue?.includes('0.00') + ).toBeTruthy(); + + expect.soft( + finalAmountDueValue?.includes('Verifying coverage') || + finalAmountDueValue?.includes('0.00') + ).toBeTruthy(); + } + } + + async logOrderNumber() { + const sessionStorage = JSON.parse(await this.page.evaluate('sessionStorage.getItem(\'submittedState\')')); + return sessionStorage.workOrderNumber; + } + +} \ No newline at end of file diff --git a/playwright-tests/pages/PartQuestionPage.ts b/playwright-tests/pages/PartQuestionPage.ts new file mode 100644 index 000000000..02c26490f --- /dev/null +++ b/playwright-tests/pages/PartQuestionPage.ts @@ -0,0 +1,59 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { IPartQuestion } from '@business-logic/types/CustomerDetails'; + +export class PartQuestionsPage extends BasePage { + readonly page: Page; + url = process.env['BASE_URL']! + '/fmg/?fmgPage=part-questions'; + + constructor(page: Page) { + super(page); + this.page = page; + } + + async getLocalStorage() { + // Retrieve local storage entries + const localStorageData = await this.page.evaluate(() => { + const data: Record = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key) { + data[key] = localStorage.getItem(key) || ''; + } + } + return data; + }); + + console.log('Local Storage Data:', localStorageData); + return localStorageData; + } + + async saveStorageState(filePath: string) { + // Save the current browser context's storage state + await this.page.context().storageState({ path: filePath }); + console.log(`Storage state saved to ${filePath}`); + } + + async validatePartQuestions(partQuestions: IPartQuestion[]) { + for (const pq of partQuestions) { + const partQuestionOptions = this.page.locator(`fieldset[aria-labelledby="${pq.partQuestionType}"]`); + if (pq.isOnPage) { + await expect(partQuestionOptions).toBeAttached(); + } else { + await expect(partQuestionOptions).not.toBeAttached(); + } + } + } + + async selectPartQuestionResponses(partQuestions: IPartQuestion[]) { + for (const pq of partQuestions) { + const parentobject=this.page.locator(`fieldset[aria-labelledby="${pq.partQuestionType}"]`); + const partQuestionOptionButton = parentobject.locator(`[buttonlabel="${pq.optionToSelect}"]`); + await partQuestionOptionButton.click(); + if (pq.secondaryQuestionOptionToSelect != null) { + const secondaryQuestionButton = parentobject.getByText(`${pq.secondaryQuestionOptionToSelect}`); + await secondaryQuestionButton.click(); + } + } + } +} diff --git a/playwright-tests/pages/PaymentMethodPage.ts b/playwright-tests/pages/PaymentMethodPage.ts new file mode 100644 index 000000000..c859aa916 --- /dev/null +++ b/playwright-tests/pages/PaymentMethodPage.ts @@ -0,0 +1,342 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { IPaymentDetails } from '@business-logic/types/CustomerDetails'; +import { PaymentMethod, PaymentType, ServicePackage, VehicleDamage } from '@business-logic/types/Enums'; +import { PaymentPage } from './PaymentPage'; +import { AfterpayPage } from './AfterpayPage'; +import { PaypalPage } from './PaypalPage'; +import { ITestData } from '@business-logic/types/ITestData'; + +export class PaymentMethodPage extends BasePage { + readonly page: Page; + readonly payAtServiceButton: Locator; + // readonly paypalButton: Locator; + // readonly creditCardButton: Locator; + readonly payNowButton: Locator; + readonly payWithInsuranceButton: Locator; + readonly payInFourButton: Locator; + readonly amountDueTextField: Locator; + readonly amountDueDropDown: Locator; + readonly appointmentDetailsDropdown: Locator; + readonly subtotalAmountTextField: Locator; + readonly submitButton: Locator; + readonly recalibrationCheckbox: Locator; + readonly paymentPage: PaymentPage; + readonly paypalPage: PaypalPage; + + // Payment detail page validation locators + readonly reviewTable: Locator; + readonly vehicleSection: Locator; + readonly damageSection: Locator; + readonly serviceDetailsSection: Locator; + readonly serviceLocationSection: Locator; + readonly appointmentDateSection: Locator; + readonly contactDetailsSection: Locator; + readonly cartPanelDetails: Locator; + readonly subtotalText: Locator; + readonly finalAmountDueText: Locator; + readonly glassServiceText: Locator; + readonly wiperBladesText: Locator; + readonly rainDefenseText: Locator; + readonly deductibleText: Locator; + + url = process.env['BASE_URL']! + '/fmg/?fmgPage=payment-method'; + + constructor(page: Page) { + super(page); + this.page = page; + this.payAtServiceButton = this.page.locator('[buttonlabel="Pay at my appointment"]'); //this.page.getByText('Pay at time of service'); + this.amountDueTextField = this.page.getByLabel('expand cart').locator('.amount-due'); + this.amountDueDropDown = this.page.getByLabel('expand cart'); + this.appointmentDetailsDropdown = this.page.getByLabel('expand appointment details'); + this.payWithInsuranceButton = this.page.locator('div').filter({ hasText: /^Pay with insurance$/ }).nth(1); + this.subtotalAmountTextField = this.page.locator('.sub-total span').nth(1); + this.payNowButton = this.page.locator('[buttonlabel="Pay now"]'); + this.payInFourButton = this.page.locator('[buttonlabel="Pay in 4 installments"]'); + this.submitButton = this.page.locator('[data-test-id="nav-bar-main-button"]'); + this.recalibrationCheckbox = this.page.getByLabel('I understand after windshield'); + // this.creditCardButton = page.locator('div').filter({ hasText: /^Credit or Debit$/ }).nth(1); + this.paymentPage = new PaymentPage(page); + this.paypalPage = new PaypalPage(page); + + // Payment details validation locators + this.reviewTable = this.page.locator('div.review-table'); + + // Section locators - find by heading text + this.vehicleSection = this.page.locator('.review-table').locator('div', { hasText: 'Vehicle' }).first(); + this.damageSection = this.page.locator('.review-table').locator('div', { hasText: 'Damage' }).first(); + this.serviceDetailsSection = this.page.locator('.review-table').locator('div', { hasText: 'Glass service only' }).first(); + this.serviceLocationSection = this.page.locator('.review-table').locator('div', { hasText: "We're coming to you" }).first(); + this.appointmentDateSection = this.page.locator('.review-table').locator('div', { hasText: 'Appointment date + time' }).first(); + this.contactDetailsSection = this.page.locator('.review-table').locator('div', { hasText: 'Contact details' }).first(); + + // Cart panel elements + this.cartPanelDetails = this.page.locator('.cart-panel'); + this.subtotalText = this.page.locator('.sub-total'); + this.finalAmountDueText = this.page.locator('div.amount-due'); + this.glassServiceText = this.page.locator('div', { hasText: /^Glass service only$/ }); + this.wiperBladesText = this.page.locator('div', { hasText: /^New wiper blades$/ }); + this.rainDefenseText = this.page.locator('div', { hasText: /^Rain Defense™$/ }); + this.deductibleText = this.page.locator('#deductible-value'); + } + + async validatePaymentDetailsPage(testData: Partial) { + // Destructure data we use + const { vehicleDetails, customerDetails, servicePackage, promoCode, + isPolicyFound, claimDetails, paymentDetails, appointmentDetails, + isUseVehicleOnPolicy, paymentMethod, vehicleDamage } = testData; + + // Wait for review table to be visible to ensure page is loaded + await this.appointmentDetailsDropdown.waitFor({state: "visible"}); + await this.appointmentDetailsDropdown.click(); // Expand cart to see all details + await this.amountDueDropDown.click(); + await this.reviewTable.waitFor({ state: "visible" }); + + // Helper method to get content lines from a section + const getSectionContent = async (section: Locator) => { + // First make sure section exists + if (await section.count() === 0) return []; + + // Find all content lines within this section + const contentLines = await section.locator('div.small.review-block-content').allInnerTexts(); + return contentLines; + }; + + // Validate vehicle information + const vehicleContent = await getSectionContent(this.vehicleSection); + if (vehicleContent.length > 0) { + const vehicleText = vehicleContent[0]; + expect.soft(vehicleText).toContain(`${vehicleDetails?.year} ${vehicleDetails?.make} ${vehicleDetails?.model}`); + } + + // Validate damage type + + // TODO: Work out logic on how to verify vehicle Damage in payment details with vehicleDamage + + // const damageContent = await getSectionContent(this.damageSection); + // if (vehicleDamage && damageContent.length > 0) { + // const damageText = damageContent[0]; + + // // For each damage type in the array, check if its display text is in the damage content + // for (const damage of vehicleDamage) { + // const expectedDamageText = this.getDamageDisplayText(damage); + + // // If this is a single damage item, it should match exactly + // if (vehicleDamage.length === 1) { + // expect.soft(damageText).toContain(expectedDamageText); + // } else { + // // For multiple damages, check if any of the damage content lines contain this damage type + // const damageFound = damageContent.some(content => + // content.includes(expectedDamageText) + // ); + // expect.soft(damageFound).toBeTruthy(); + // } + // } + // } + + // Validate service details based on package + const serviceContent = await getSectionContent(this.serviceDetailsSection); + if (servicePackage && serviceContent.length > 0) { + //TODO: Add service package validation + // move over logic from Kishan's code + + // Additional validations for Standard and Premium packages + if (servicePackage === ServicePackage.Premium || servicePackage === ServicePackage.Standard) { + await expect.soft(this.wiperBladesText).toBeVisible(); + } + + if (servicePackage === ServicePackage.Premium) { + await expect.soft(this.rainDefenseText).toBeVisible(); + } + } + + // Validate service location + const locationContent = await getSectionContent(this.serviceLocationSection); + if (appointmentDetails?.serviceLocation && locationContent.length > 0) { + // Find the title element of the service location section + const serviceLocationTitle = this.serviceLocationSection.locator('span').first(); + const serviceLocationValue = await serviceLocationTitle.innerText(); + + // Check for mobile/inshop service wording + if (appointmentDetails.serviceLocation.toString().includes('Mobile')) { + expect.soft(serviceLocationValue).toContain("We're coming to you"); + } else if (appointmentDetails.serviceLocation.toString().includes('InShop')) { + expect.soft(serviceLocationValue).toContain("Bring to shop"); + } + + // Validate address if available + if (appointmentDetails.serviceAddress && locationContent.length > 0) { + const addressText = locationContent[0].toLowerCase(); + expect.soft(addressText).toContain(appointmentDetails.serviceAddress.street.toLowerCase()); + } + } + + // Validate appointment date/time + const appointmentContent = await getSectionContent(this.appointmentDateSection); + if (customerDetails?.apptDate && appointmentContent.length > 0) { + const appointmentText = appointmentContent[0]; + expect.soft(appointmentText).toContain(customerDetails.apptDate); + } + + // Validate contact details + const contactContent = await getSectionContent(this.contactDetailsSection); + if (customerDetails && contactContent.length > 0) { + const fullName = `${customerDetails.firstName} ${customerDetails.lastName}`; + const email = customerDetails.email; + const phone = customerDetails.phoneNumber; + + // Check if contact details are present + const contactTextJoined = contactContent.join(' '); + expect.soft(contactTextJoined).toContain(fullName); + expect.soft(contactTextJoined).toContain(email); + expect.soft(contactTextJoined).toContain(phone); + } + + // Cart Validation + // Get pricing information from cart panel + if (await this.subtotalText.isVisible()) { + const subtotalValue = await this.subtotalText.innerText(); + const finalAmountDueValue = await this.finalAmountDueText.innerText(); + + // Pricing validations differ by payment method + if (paymentMethod === PaymentMethod.SelfPay && paymentDetails?.paymentType !== PaymentType.PayWithInsurance) { + // Extract amounts for self-pay customers + const subtotalAmount = this.extractAmount(subtotalValue); + const finalAmountDueAmount = this.extractAmount(finalAmountDueValue); + + // Subtotal should be greater than 0 + expect.soft(subtotalAmount).toBeGreaterThan(0); + + // Final amount differs based on payment type + if (paymentDetails?.paymentType === PaymentType.PayAtService) { + expect.soft(finalAmountDueAmount).toBeGreaterThan(0); + } else if (paymentDetails?.paymentType === PaymentType.Credit || + paymentDetails?.paymentType === PaymentType.Paypal || + paymentDetails?.paymentType === PaymentType.AfterPay) { + // For payment types that charge immediately, amount due could be 0 + // This logic might need adjusting based on actual business rules + } + } else { + // For insurance payments, check for proper indicators + // TODO: Apply logic for deductible and insurance payment logic + } + + // Service package validations + const servicePackageValue = await this.cartPanelDetails.textContent(); + await expect.soft(this.cartPanelDetails).toContainText(`${servicePackage}`) + if (servicePackage === ServicePackage.Premium || servicePackage === ServicePackage.Standard) { + expect.soft(servicePackageValue).toContain('New wiper blades'); + } + if (servicePackage === ServicePackage.Premium) { + expect.soft(servicePackageValue).toContain('Rain Defense™'); + } + + // Promo Code Validation + if (promoCode) { + expect.soft(servicePackageValue).toContain(`Promo code ${promoCode} applied`); + }; + } + } + + /** + * Helper method to extract numeric amount from price strings like "$123.45" + */ + private extractAmount(valueString: string): number { + const matches = valueString.match(/\$([0-9,]+(\.[0-9]{2})?)/); + if (matches && matches[1]) { + return parseFloat(matches[1].replace(/,/g, '')); + } + return 0; + } + + async getAmountDue(): Promise { + await expect(this.amountDueTextField).toBeVisible(); + const text = await this.amountDueTextField.textContent(); + if (!text) throw new Error("Amount due text is empty"); + return text; + } + + async getSubtotal(): Promise { + await this.amountDueDropDown.click(); + await expect(this.subtotalAmountTextField).toBeVisible(); + const text = await this.subtotalAmountTextField.textContent(); + if (!text) throw new Error("Subtotal text is empty"); + return text; + } + + async executePayment(paymentDetails: IPaymentDetails, isRecalVehicle: boolean) { + const browserContext = this.page.context(); + + switch(paymentDetails.paymentType) { + case PaymentType.Credit: + await this.selectCreditCard(); + await this.nextPage(); + await this.paymentPage.populateCreditCardDetails(paymentDetails); + await this.nextPage(); + break; + case PaymentType.AfterPay: + await this.payInFourButton.click(); + await this.nextPage(); + + // Capture popup + const afterpayPopup = await browserContext.waitForEvent('page'); + const afterpayPage = new AfterpayPage(afterpayPopup); + + // Execute payment + await afterpayPage.executeAfterpayPayment(paymentDetails); + break; + case PaymentType.Paypal: + await this.selectPaypal(); + // TODO: Click paypal button + await this.nextPage(); + await this.paymentPage.navigateToPaypalCheckout(); + await this.paypalPage.completePaypalPurchase(paymentDetails); + break; + case PaymentType.PayAtService: + await this.selectPayAtService(isRecalVehicle); + await this.nextPage(); + break; + case PaymentType.PayWithInsurance: + await this.payWithInsuranceButton.click(); + await this.nextPage(); + break; + default: + console.error('PaymentMethodPage >> Logic for this payment method unimplemented'); + break; + } + } + + async selectPaypal(){ + await this.payNowButton.click(); + } +l + async selectCreditCard(){ + await this.payNowButton.click(); + } + + async selectPayAtService(isRecalVehicle: boolean){ + if (await this.payAtServiceButton.isVisible()) { + await this.payAtServiceButton.click(); + } else if (isRecalVehicle) { + await this.recalibrationCheckbox.click(); + } + + } + + async verifyVAPS(): Promise { + // Get Vuex state from localStorage + const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + + // Validate the count of FRONT WIPER parts + if (vuexState.order?.lineItems?.vaps?.length > 0) { + const frontWiperPartsCount = vuexState.order.lineItems.vaps.filter(vap => + vap.partType === "FRONT WIPER" + ).length; + + await expect(frontWiperPartsCount).toBe(2); + } else { + throw new Error("No VAPS found in the order"); + } + } + +} \ No newline at end of file diff --git a/playwright-tests/pages/PaymentPage.ts b/playwright-tests/pages/PaymentPage.ts new file mode 100644 index 000000000..e18311b08 --- /dev/null +++ b/playwright-tests/pages/PaymentPage.ts @@ -0,0 +1,51 @@ +import { type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { IPaymentDetails } from '@business-logic/types/CustomerDetails'; + +export class PaymentPage extends BasePage { + readonly page: Page; + readonly cardNumberTextField: Locator; + readonly expirationMonthDropDown: Locator; + readonly expirationYearDropDown: Locator; + readonly cvvTextField: Locator; + readonly billingAddressTextField: Locator; + readonly cityTextField: Locator; + readonly stateDropDown: Locator; + readonly billingZipTextField: Locator; + readonly submitPaymentButton: Locator; + readonly payPalButton: Locator; + url = process.env['BASE_URL']! + '/fmg/?fmgPage=payment'; + + constructor(page: Page) { + super(page); + this.page = page; + this.cardNumberTextField = page.frameLocator('iframe[name="card-frame"]').getByLabel('Card number*'); + this.expirationMonthDropDown = page.frameLocator('iframe[name="card-frame"]').getByRole('combobox', { name: 'Expiration month' }); + this.expirationYearDropDown = page.frameLocator('iframe[name="card-frame"]').getByRole('combobox', { name: 'Expiration year' }); + this.cvvTextField = page.frameLocator('iframe[name="card-frame"]').getByRole('textbox', { name: 'CVV' }); + this.billingAddressTextField = page.frameLocator('iframe[name="card-frame"]').getByRole('textbox', { name: 'Billing address' }); + this.cityTextField = page.frameLocator('iframe[name="card-frame"]').getByRole('textbox', { name: 'City' }); + this.stateDropDown = page.frameLocator('iframe[name="card-frame"]').getByRole('combobox', { name: 'State' }); + this.billingZipTextField = page.frameLocator('iframe[name="card-frame"]').getByRole('textbox', { name: 'Billing ZIP code' });; + this.submitPaymentButton = page.frameLocator('iframe[name="card-frame"]').locator('#buttonContainer'); + this.payPalButton = page.frameLocator('iframe[name="card-frame"]').locator('#paypalParentLink'); + // this.validateURL(this.url); + } + + async populateCreditCardDetails(paymentDetails: IPaymentDetails){ + await this.cardNumberTextField.fill(paymentDetails.cardNumber || ''); + await this.expirationMonthDropDown.selectOption(paymentDetails.expirationMonth!); + await this.expirationYearDropDown.selectOption(paymentDetails.expirationYear!); + await this.cvvTextField.fill(paymentDetails.cvv!); + await this.billingAddressTextField.fill(paymentDetails.billingAddress!.street); + await this.cityTextField.fill(paymentDetails.billingAddress!.city); + await this.stateDropDown.selectOption(paymentDetails.billingAddress!.state); + await this.billingZipTextField.fill(paymentDetails.billingAddress!.postalCode); + await this.submitPaymentButton.click(); + } + + async navigateToPaypalCheckout() { + await this.payPalButton.click(); + } + +} \ No newline at end of file diff --git a/playwright-tests/pages/PaypalPage.ts b/playwright-tests/pages/PaypalPage.ts new file mode 100644 index 000000000..862a97edf --- /dev/null +++ b/playwright-tests/pages/PaypalPage.ts @@ -0,0 +1,27 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { IPaymentDetails } from '@business-logic/types/CustomerDetails'; + +export class PaypalPage extends BasePage { + readonly page: Page; + readonly loginWithPasswordButton: Locator; + readonly passwordTextBox: Locator; + readonly paypalLoginButton: Locator; + readonly completePurchaseButton: Locator; + + constructor(page: Page) { + super(page); + this.page = page; + this.loginWithPasswordButton = page.getByRole('link', { name: 'Log in with a password instead' }); + this.passwordTextBox = page.getByPlaceholder('Password'); + this.paypalLoginButton = page.getByRole('button', { name: 'Log In', exact: true }); + this.completePurchaseButton = page.getByTestId('submit-button-initial'); + } + + async completePaypalPurchase(paymentDetails: IPaymentDetails){ + await this.loginWithPasswordButton.click(); + await this.passwordTextBox.fill(paymentDetails.password!); + await this.paypalLoginButton.click(); + await this.completePurchaseButton.click(); + } +} diff --git a/playwright-tests/pages/PolicyDriverPage.ts b/playwright-tests/pages/PolicyDriverPage.ts new file mode 100644 index 000000000..f6e56ca5f --- /dev/null +++ b/playwright-tests/pages/PolicyDriverPage.ts @@ -0,0 +1,51 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { InsuranceBasePage } from './InsuranceBasePage'; +import { ICustomerDetails } from '@business-logic/types/CustomerDetails'; + +export class PolicyDriverPage extends InsuranceBasePage { + readonly page: Page; + readonly driverListItems: Locator; + readonly driverNotListedOption: Locator; + readonly vehicleParkedOption: Locator; + url = process.env['BASE_URL']! + '/FixMyGlass/PolicyDriver.aspx'; + + constructor(page: Page) { + super(page); + this.page = page; + + // Locators for driver list items (the clickable areas) + this.driverListItems = this.page.locator('.list-group-item'); + // Locators for driver options + this.driverNotListedOption = this.page.getByRole('link', { name: 'Driver not listed' }); + this.vehicleParkedOption = this.page.getByRole('link', { name: 'Vehicle was parked' }); + + // this.validateURL(this.url); + } + + async selectPolicyDriver(customerDetails: ICustomerDetails): Promise { + // Format the customer name for matching + const customerFullName = `${customerDetails.firstName} ${customerDetails.lastName}`.toUpperCase(); + + // Get all driver list items + const driverItems = await this.driverListItems.all(); + let driverFound = false; + + // Check each driver item for a match with our customer + for (const item of driverItems) { + const nameText = await item.locator('.third-span').textContent(); + + if (nameText && nameText.toUpperCase().includes(customerFullName)) { + // If we found a match, click the list-group-item (the whole row) + await item.click(); + driverFound = true; + break; + } + } + + // If no match was found, select "Driver not listed" + if (!driverFound) { + await this.driverNotListedOption.click(); + } + } + +} \ No newline at end of file diff --git a/playwright-tests/pages/PolicyInfoSubmittedPage.ts b/playwright-tests/pages/PolicyInfoSubmittedPage.ts new file mode 100644 index 000000000..6d5c96c5c --- /dev/null +++ b/playwright-tests/pages/PolicyInfoSubmittedPage.ts @@ -0,0 +1,24 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { InsuranceBasePage } from './InsuranceBasePage'; + +export class PolicyInfoSubmittedPage extends InsuranceBasePage { + readonly page: Page; + readonly policyInfoMessage: Locator; + + url = process.env['BASE_URL']! + '/FixMyGlass/PolicyInfoSubmitted.aspx'; + + constructor(page: Page) { + super(page); + this.page = page; + this.policyInfoMessage = this.page.getByRole('heading', { name: 'Your policy and vehicle' }); + + // this.validateURL(this.url); + } + + async verifyPolicyInfoSubmitted(): Promise { + await expect(this.policyInfoMessage).toBeVisible(); + const policyInfoMessage = await this.policyInfoMessage.textContent(); + expect(policyInfoMessage).toBe("Your policy and vehicle information has been submitted for coverage verification") + } + +} \ No newline at end of file diff --git a/playwright-tests/pages/PolicyVehiclesPage.ts b/playwright-tests/pages/PolicyVehiclesPage.ts new file mode 100644 index 000000000..90882062d --- /dev/null +++ b/playwright-tests/pages/PolicyVehiclesPage.ts @@ -0,0 +1,29 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { IVehicleDetails } from '@business-logic/types/CustomerDetails'; +import { InsuranceBasePage } from './InsuranceBasePage'; + +export class PolicyVehiclesPage extends InsuranceBasePage { + readonly page: Page; + url = process.env['BASE_URL']! + '/FixMyGlass/PolicyVehicle.aspx'; + + constructor(page: Page) { + super(page); + this.page = page; + // this.validateURL(this.url); + } + + async validateVehicleIsOnPolicy(vehicleDetails: IVehicleDetails) { + const vehicleRegExp = new RegExp(`${vehicleDetails.year} .+ ${vehicleDetails.model}`, 'i'); + await expect.soft(this.page.getByRole('radio', {name: vehicleRegExp})).toBeAttached(); + } + + async selectVehicle(vehicleDetails: IVehicleDetails){ + const baseModel = vehicleDetails.model.split(' ').pop() || vehicleDetails.model; + const vehicleRegExp = new RegExp(`${vehicleDetails.year}.*?${vehicleDetails.make}.*?${baseModel}`, 'i'); + await this.page.getByRole('radio', {name: vehicleRegExp}).click(); + } + + async selectVehicleNotListed(){ + await this.page.getByText('Vehicle not listed').click(); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/RecalibrationInfoPage.ts b/playwright-tests/pages/RecalibrationInfoPage.ts new file mode 100644 index 000000000..0af4b0a57 --- /dev/null +++ b/playwright-tests/pages/RecalibrationInfoPage.ts @@ -0,0 +1,14 @@ +import { Locator, Page } from "@playwright/test"; +import { InsuranceBasePage } from "./InsuranceBasePage"; + +export default class RecalibrationInfoPage extends InsuranceBasePage { + url = process.env['BASE_URL']! + '/FixMyGlass/RecalibrationInfo.aspx'; + + readonly continueButton: Locator; + + constructor(page: Page) { + super(page); + + this.continueButton = page.getByRole('button', { name: 'Continue' }); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/SchedulePage.ts b/playwright-tests/pages/SchedulePage.ts new file mode 100644 index 000000000..bea9e82bd --- /dev/null +++ b/playwright-tests/pages/SchedulePage.ts @@ -0,0 +1,59 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { IAppointmentDetails } from '@business-logic/types/CustomerDetails'; +import { formatDate, formatTime } from '@impl/utils/DateUtils'; +import { AppointmentType, ServiceLocation } from '@business-logic/types/Enums'; + +export class SchedulePage extends BasePage { + readonly page: Page; + url = process.env['BASE_URL']! + '/fmg/?fmgPage=schedule'; + readonly firstAvailableDate: Locator; + readonly firstAvailableTime: Locator; + readonly modalContinueButton: Locator; + readonly dropOffButton: Locator; + readonly dateText: Locator; + readonly viewMoreDatesLink: Locator; + + constructor(page: Page) { + super(page); + this.page = page; + this.firstAvailableDate = this.page.locator('.selectable-day').locator('nth=0'); + this.firstAvailableTime = this.page.locator('label').filter({ hasText: /AM|PM/ }).locator('div').locator('nth=0'); + this.modalContinueButton = this.page.getByRole('dialog').getByRole('button', { name: 'Continue' }); + this.dropOffButton = this.page.getByText('Drop off your vehicle', { exact: true }); + this.dateText = this.page.locator('label.modal-title'); + this.viewMoreDatesLink = this.page.getByText(/View more dates/).first(); + } + + async scheduleAppointment(appointmentDetails: IAppointmentDetails) { + const formattedDate = formatDate(appointmentDetails.appointmentDate!); + const formattedTime = formatTime(appointmentDetails.appointmentDate!); + const dateInput = this.page.locator(`div[id="${formattedDate}"]`); + const timeButton = this.page.locator(`div[aria-label="${formattedTime}"]`); + if (await dateInput.isVisible()) { + await dateInput.click(); + } else { + await this.viewMoreDatesLink.click(); + await dateInput.click(); + } + await timeButton.click(); + await this.modalContinueButton.click(); + } + + async scheduleFirstAppointment(serviceLocation: AppointmentType) { + if (await this.firstAvailableDate.isVisible()) { + await this.firstAvailableDate.click(); + } else { + await this.viewMoreDatesLink.click(); + while(await this.firstAvailableDate.isHidden()){ + await this.viewMoreDatesLink.click(); + } + await this.firstAvailableDate.click(); + } + + serviceLocation === AppointmentType.DropOff ? await this.dropOffButton.click() : await this.firstAvailableTime.click(); + const apptDate = `${await this.dateText.allInnerTexts()}` + await this.modalContinueButton.click(); + return (apptDate); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/ServiceLocationPage.ts b/playwright-tests/pages/ServiceLocationPage.ts new file mode 100644 index 000000000..b8c8649b6 --- /dev/null +++ b/playwright-tests/pages/ServiceLocationPage.ts @@ -0,0 +1,146 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { IAppointmentDetails } from '@business-logic/types/CustomerDetails'; +import { AppointmentType } from '@business-logic/types/Enums'; +import { AddressForm } from './forms/AddressForm'; +import { faker } from '@faker-js/faker'; + +export class ServiceLocationPage extends BasePage { + readonly page: Page; + + readonly addressForm: AddressForm; + + // Initial selection + readonly inShopButton: Locator; + readonly mobileButton: Locator; + readonly dropOffButton: Locator; + readonly RecalWarningMessage1: Locator; + readonly RecalWarningMessage2: Locator; + readonly militaryWarningMessage: Locator; + + // For in-shop and drop off + readonly selectAShopOptions: Locator; + readonly firstAppointmentButton: Locator; + readonly changeZipButton: Locator; + readonly updateZipTextBox: Locator; + readonly saveZipButton: Locator; + + // For mobile + readonly enterServiceAddressButton: Locator; + readonly serviceAddressTextBox: Locator; + readonly aptNumberTextBox: Locator; + readonly cityTextBox: Locator; + readonly stateDropDown: Locator; + readonly zipCodeTextBox: Locator; + readonly vehicleProtectedYesButton: Locator; + readonly vehicleProtectedNoButton: Locator; + readonly saveAddressButton: Locator; + readonly repeatedClicksModalCloseButton: Locator; + + url = process.env['BASE_URL']! + '/fmg/?fmgPage=service-location'; + + constructor(page: Page) { + super(page); + this.page = page; + + this.addressForm = new AddressForm(page); + + // Initial selection + this.inShopButton = this.page.getByText(/In-shop/); + this.mobileButton = this.page.getByText(/Mobile/); + this.dropOffButton = this.page.getByText(/Drop-off/); + this.RecalWarningMessage1 = this.page.getByText(/We're not able to provide mobile service/); + this.RecalWarningMessage2 = this.page.getByText(/advanced safety system recalibration needs to be done in our shop./); + this.militaryWarningMessage = this.page.locator('[class*="widget-name-AlertMilitaryBaseZipWidget"]'); + + // For in-shop and drop off + this.selectAShopOptions = this.page.locator('[class="shop-question"]'); + this.firstAppointmentButton = this.page.locator('div').filter({ hasText: /Appts/}).first(); + this.changeZipButton = this.page.locator('a:has(span.sr-only:has-text("edit zip code"))'); + this.updateZipTextBox = this.page.locator('#serviceZipCode'); + this.saveZipButton = this.page.getByText('Save ZIP code', { exact: true }); + + + // For mobile + this.enterServiceAddressButton = this.page.getByRole('link', { name: 'Enter your service address' }); + this.serviceAddressTextBox = this.page.getByRole('textbox', { name: 'Street Address' }); + this.aptNumberTextBox = this.page.getByRole('textbox', { name: 'Apt. number'}); + this.cityTextBox = this.page.getByRole('textbox', { name: 'City' }); + this.stateDropDown = this.page.getByRole('combobox', { name: 'State' }); + this.zipCodeTextBox = this.page.getByRole('textbox', { name: 'Zip code' }); + this.vehicleProtectedYesButton = this.page.locator('label').filter({ hasText: 'Yes' }).locator('div'); + this.vehicleProtectedNoButton = this.page.locator('label').filter({ hasText: 'No' }).locator('div'); + this.saveAddressButton = this.page.getByRole('button', { name: 'Continue' }) + this.repeatedClicksModalCloseButton = this.page.getByRole('img').nth(1); + } + + async selectLocation(appointmentDetails: IAppointmentDetails){ + + switch(appointmentDetails.serviceLocation) { + case AppointmentType.Mobile: + await this.scheduleMobile(appointmentDetails); + break; + case AppointmentType.InShop: + await this.scheduleInShop(appointmentDetails); + break; + case AppointmentType.DropOff: + await this.scheduleDropOff(appointmentDetails); + break; + } + } + + + async scheduleInShop(appointmentDetails?: IAppointmentDetails) { + await this.inShopButton.click(); + if (appointmentDetails && appointmentDetails.shopAddress) { + const zipCodeMatch = appointmentDetails.shopAddress.match(/\b\d{5}$/); + if (zipCodeMatch) { + const zipCode = zipCodeMatch[0]; + // Enter the ZIP code into the updateZipTextBox + await this.changeZipButton.click(); + await this.page.waitForTimeout(500); + await this.updateZipTextBox.fill(zipCode); + await this.saveZipButton.click(); + } + await this.inShopButton.click(); + await this.selectAShopOptions.locator(`[buttonbodycopy="${appointmentDetails.shopAddress}"]`).check(); + } else { + await this.firstAppointmentButton.click(); + } + } + + async scheduleMobile(appointmentDetails: IAppointmentDetails){ + if (appointmentDetails.serviceAddress) { + 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 { + await this.vehicleProtectedNoButton.check(); + } + await this.saveAddressButton.click(); + } else { + console.error('ServiceLocationPage >> Please supply an address') + } + } + + async scheduleDropOff(appointmentDetails?: IAppointmentDetails){ + await this.dropOffButton.click(); + if (appointmentDetails && appointmentDetails.shopAddress) { + await this.selectAShopOptions.locator(`[buttonbodycopy="${appointmentDetails.shopAddress}"]`).check(); + } else { + // await this.firstAppointmentButton.click(); + await this.clickWithRetry(this.firstAppointmentButton, this.page); + } + } + + async validateRecalWarning(){ + await expect(this.RecalWarningMessage1).toBeVisible(); + await expect(this.RecalWarningMessage2).toBeVisible(); + + } +} \ No newline at end of file diff --git a/playwright-tests/pages/ServicePackagesPage.ts b/playwright-tests/pages/ServicePackagesPage.ts new file mode 100644 index 000000000..9a3b87ae3 --- /dev/null +++ b/playwright-tests/pages/ServicePackagesPage.ts @@ -0,0 +1,181 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { ServicePackage, VehicleDamage } from '@business-logic/types/Enums'; +import { PaymentMethod } from '@business-logic/types/Enums'; + +export class ServicePackagesPage extends BasePage { + readonly page: Page; + readonly standardPackageButton: Locator; + readonly premiumPackageButton: Locator; + readonly glassOnlyButton: Locator; + readonly payOnMyOwnButton: Locator; + readonly paywithInsuranceButton: Locator; + readonly iHavePromoCodeButton: Locator; + + //Your quote is almost ready modal + readonly skipQuoteEmailButton: Locator; + readonly emailInput: Locator; + readonly getMyQuoteButton: Locator; + readonly closeButton: Locator; + url = process.env['BASE_URL']! + '/fmg/?fmgPage=quote'; + + //Enter a promo code modal + readonly promoCodeTextbox: Locator; + readonly applyPromoButton: Locator; + + readonly repeatedClicksModalCloseButton: Locator; + + + constructor(page: Page) { + super(page); + this.page = page; + this.standardPackageButton = this.page.getByText('Standard'); + this.premiumPackageButton = this.page.getByText('Premium'); + this.glassOnlyButton = this.page.getByText('Glass service only', { exact: true }); + this.payOnMyOwnButton = this.page.locator('label').filter({ hasText: 'Pay on my own' }).locator('div'); + this.paywithInsuranceButton = this.page.locator('label').filter({ hasText: 'Pay with insurance' }).locator('div'); + this.iHavePromoCodeButton = this.page.getByRole('link', { name: 'I have a promo code' }); + this.skipQuoteEmailButton = this.page.getByRole('button', { name: 'Skip' }); + this.emailInput = this.page.getByRole('textbox', { name: 'Enter your email address' }); + this.getMyQuoteButton = this.page.getByRole('button', { name: 'Get my quote' }); + 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.validateURL(this.url); + } + + async selectPaymentMethod(method: PaymentMethod): Promise { + const locators = { + [PaymentMethod.Insurance]: this.paywithInsuranceButton, + [PaymentMethod.SelfPay]: this.payOnMyOwnButton + } + + await locators[method].click(); + } + + async selectServicePackage(servicePackage: ServicePackage): Promise { + const locators = { + [ServicePackage.GlassOnly]: this.glassOnlyButton, + [ServicePackage.Premium]: this.premiumPackageButton, + [ServicePackage.Standard]: this.standardPackageButton, + } + + if (servicePackage != null) { + await locators[servicePackage].click(); + } + } + + async handleQuotePopup(email?: string): Promise { + await this.emailInput.waitFor({ state: 'visible' }); + if (await this.emailInput.isVisible()) { + if (email) { + await this.emailInput.fill(email); + await this.getMyQuoteButton.click(); + if (await this.repeatedClicksModalCloseButton.isVisible()) { + await this.repeatedClicksModalCloseButton.click(); + } + await this.closeButton.click(); + + } else { + await this.skipQuoteEmailButton.click(); + } + } + } + + async enterPromo(promoCode: string): Promise { + await this.iHavePromoCodeButton.click(); + await this.promoCodeTextbox.fill(promoCode); + await this.applyPromoButton.click(); + } + + async verifyCanNotRecal(): Promise { + // Get Vuex state from localStorage + const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + + // Validate in the backend to make sure the can safelite recalibrate data is correct + if (vuexState.order?.lineItems?.glassParts?.length > 0) { + for (const glassPart of vuexState.order.lineItems.glassParts) { + await expect(glassPart.canSafeliteRecalibrate).toBe(false); + await expect(glassPart.requiresRecalibration).toBe(true); + } + return true; + } + return false; + } + + async verifyDynamicRecal(): Promise { + // Get Vuex state from localStorage + const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + + // Validate in the backend to make sure the dynamic recalibration part is present + if (vuexState.order?.lineItems?.glassParts?.length > 0) { + const hasDynamicRecalPart = vuexState.order.lineItems.glassParts.some(glassPart => + glassPart.childParts.some(childPart => + childPart.partNumber.includes("RECAL DYNAMIC") + ) + ); + + await expect(hasDynamicRecalPart).toBe(true); + console.log("Recal part line item is verified"); + } else { + throw new Error("No glass parts found in the order"); + } + } + + async verifyIsRepair(isRepair: boolean, vehicleDamage: VehicleDamage[]): Promise { + // Get Vuex state from localStorage + const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + + // Validate in the backend to make sure isRepair is aligned with the data + if (isRepair) { + await expect(vuexState.order.damage.isRepair).toBe(true); + + // Check the number of chips based on vehicleDamage array + if (vehicleDamage.includes(VehicleDamage.WindshieldOneChip)) { + await expect(vuexState.order.damage.numberOfChips).toBe(1); + } + if (vehicleDamage.includes(VehicleDamage.WindshieldTwoChips)) { + await expect(vuexState.order.damage.numberOfChips).toBe(2); + } + if (vehicleDamage.includes(VehicleDamage.WindshieldThreeChips)) { + await expect(vuexState.order.damage.numberOfChips).toBe(3); + } + } else { + await expect(vuexState.order.damage.isRepair).toBe(false); + } + } + + + async verifyVehicleParts(vehicleDamage: VehicleDamage[]): Promise { + // Get Vuex state from localStorage + const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + + // Validate the presence of specific parts in the glassParts array + const glassParts = vuexState.order?.lineItems?.glassParts; + + if (glassParts?.length > 0) { + for (const partType of vehicleDamage) { + const hasPartType = glassParts.some(glassPart => glassPart.partType === partType); + await expect(hasPartType, `Expected part type: ${partType}`).toBe(true); + } + } else { + throw new Error("No glass parts found in the order"); + } + } + + async verifyOEMPart(): Promise { + // Get Vuex state from localStorage + const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + + // Validate the presence of an OEM part + if (vuexState.order?.lineItems?.glassParts?.length > 0) { + const firstGlassPartNumber = vuexState.order.lineItems.glassParts[0].partNumber; + + await expect(firstGlassPartNumber.includes("OEM")).toBe(true); + } else { + throw new Error("No glass parts found in the order"); + } + } +} \ No newline at end of file diff --git a/playwright-tests/pages/VehicleDamagePage.ts b/playwright-tests/pages/VehicleDamagePage.ts new file mode 100644 index 000000000..33d778a79 --- /dev/null +++ b/playwright-tests/pages/VehicleDamagePage.ts @@ -0,0 +1,164 @@ +import { type Locator, type Page, expect, test } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { SideDoorDamage, VehicleDamage, WindshieldDamage } from '@business-logic/types/Enums'; +import TestSuccessAlert from '@business-logic/types/TestSuccessAlert'; + +export class VehicleDamagePage extends BasePage { + readonly page: Page; + readonly windshieldChkBox: Locator; + readonly crackButton: Locator; + readonly chipButton: Locator; + readonly sideDoorButton: Locator; + readonly driverSideButton: Locator; + readonly passengerSideButton: Locator; + readonly driverQuarterPanelChkBox: Locator; + readonly driverFrontDoorChkBox: Locator; + readonly driverBackDoorChkBox: Locator; + readonly driverSlidingDoorChkBox: Locator; + readonly driverVentGlassChkBox: Locator; + readonly passengerQuarterPanelChkBox: Locator; + readonly passengerFrontDoorChkBox: Locator; + readonly passengerBackDoorChkBox: Locator; + readonly passengerVentGlassChkBox: Locator; + readonly rearWindowChkBox: Locator; + readonly rearStationaryBttn: Locator; + readonly rearSlidingGlassBttn: Locator; + readonly editVehicleLink: Locator; + + readonly noReplacementAvailableAlert: Locator; + readonly bothReplaceRepairAlert: Locator; + url = process.env['BASE_URL']! + '/fmg/?fmgPage=vehicle-damage'; + + constructor(page: Page) { + super(page); + this.page = page; + this.windshieldChkBox = this.page.locator('[buttonlabel="Windshield"]'); + this.crackButton = this.page.locator('[buttonlabel="Crack"]'); + this.chipButton = this.page.locator('[buttonlabel="Chip(s)"]'); + this.sideDoorButton = this.page.locator('[buttonlabel="Side door"]'); + this.driverSideButton = this.page.locator('[buttonlabel="Driver side"]'); + this.passengerSideButton = this.page.locator('[buttonlabel="Passenger side"]'); + this.driverQuarterPanelChkBox = this.page.locator('[aria-labelledby="driverSideOptions"]').locator('[buttonlabel="Quarter panel"]'); + this.driverFrontDoorChkBox = this.page.locator('[aria-labelledby="driverSideOptions"]').locator('[buttonlabel="Front door"]'); + this.driverBackDoorChkBox = this.page.locator('[aria-labelledby="driverSideOptions"]').locator('[buttonlabel="Back door"]'); + this.driverVentGlassChkBox = this.page.locator('[aria-labelledby="driverSideOptions"]').locator('[buttonlabel="Vent glass"]'); + this.driverSlidingDoorChkBox = this.page.locator('[aria-labelledby="driverSideOptions"]').locator('[buttonlabel="Sliding door"]'); + this.passengerQuarterPanelChkBox = this.page.locator('[aria-labelledby="passengerSideOptions"]').locator('[buttonlabel="Quarter panel"]'); + this.passengerFrontDoorChkBox = this.page.locator('[aria-labelledby="passengerSideOptions"]').locator('[buttonlabel="Front door"]'); + this.passengerVentGlassChkBox = this.page.locator('[aria-labelledby="passengerSideOptions"]').locator('[buttonlabel="Vent glass"]'); + this.passengerBackDoorChkBox = this.page.locator('[aria-labelledby="passengerSideOptions"]').locator('[buttonlabel="Back door"]'); + this.rearWindowChkBox = this.page.locator('[buttonlabel="Rear window"]'); + this.noReplacementAvailableAlert = this.page.locator('.alert-danger.widget-name-NoReplacementAvailableError'); + this.bothReplaceRepairAlert = this.page.locator('div.alert-danger.widget-name-HasReplacementConflict'); + this.rearStationaryBttn = this.page.locator('label').filter({ hasText: 'Stationary' }); + this.rearSlidingGlassBttn = this.page.locator('label').filter({ hasText: 'Glass with slider' }); + this.editVehicleLink = this.page.getByRole('link', { name: 'Edit vehicle' }); + } + + async selectDamage(vehicleDamage: VehicleDamage[]) { + for (const damage of vehicleDamage) { + switch(damage) { + case VehicleDamage.WindshieldOneChip: + await this.windshieldChkBox.check(); + await this.selectChips('1'); + break; + case VehicleDamage.WindshieldTwoChips: + await this.windshieldChkBox.check(); + await this.selectChips('2'); + break; + case VehicleDamage.WindshieldThreeChips: + await this.windshieldChkBox.check(); + await this.selectChips('3'); + break; + case VehicleDamage.WindshieldCrack: + await this.windshieldChkBox.check(); + await this.selectCrack(); + break; + case VehicleDamage.DriverFrontDoor: + await this.sideDoorButton.check(); + await this.driverSideButton.check(); + await this.driverFrontDoorChkBox.check(); + break; + case VehicleDamage.DriverRearDoor: + await this.sideDoorButton.check(); + await this.driverSideButton.check(); + await this.driverBackDoorChkBox.check(); + break; + case VehicleDamage.DriverQuarterPanel: + await this.sideDoorButton.check(); + await this.driverSideButton.check(); + await this.driverQuarterPanelChkBox.check(); + break; + case VehicleDamage.DriverVentGlass: + await this.sideDoorButton.check(); + await this.driverSideButton.check(); + await this.driverVentGlassChkBox.check(); + break; + case VehicleDamage.DriverSlidingDoor: + await this.sideDoorButton.check(); + await this.driverSideButton.check(); + await this.driverSlidingDoorChkBox.check(); + break; + case VehicleDamage.PassengerFrontDoor: + await this.sideDoorButton.check(); + await this.passengerSideButton.check(); + await this.passengerFrontDoorChkBox.check(); + break; + case VehicleDamage.PassengerRearDoor: + await this.sideDoorButton.check(); + await this.passengerSideButton.check(); + await this.passengerBackDoorChkBox.check(); + break; + case VehicleDamage.PassengerQuarterPanel: + await this.sideDoorButton.check(); + await this.passengerSideButton.check(); + await this.passengerQuarterPanelChkBox.click(); + break; + case VehicleDamage.PassengerVentGlass: + await this.sideDoorButton.check(); + await this.passengerSideButton.check(); + await this.passengerVentGlassChkBox.check(); + break; + case VehicleDamage.RearWindow: + await this.selectRearWindowDamage(); + if (await this.rearStationaryBttn.isVisible()) { + await this.rearStationaryBttn.click(); + } + break; + case VehicleDamage.RearSliding: + await this.selectRearWindowDamage(); + if (await this.rearSlidingGlassBttn.isVisible()) { + await this.rearSlidingGlassBttn.click(); + } + break; + } + } + } + + async selectCrack(){ + await this.crackButton.check(); + } + + async selectChips(numChips: string){ + await this.chipButton.check(); + await this.page.getByText(numChips, {exact: true}).click(); + } + + async selectRearWindowDamage(){ + await this.rearWindowChkBox.check(); + } + + async checkForBothRepairReplaceAlertMessage(): Promise { + await expect(this.bothReplaceRepairAlert).toBeVisible(); + const alertMessage = await this.bothReplaceRepairAlert.textContent(); + console.log(`Alert encountered: ${alertMessage}`); + expect(alertMessage).toContain("You'll need to schedule separate appointmentsVehicle service requiring both glass repair and replacement must be scheduled separately, as they're performed by different technicians. Continue scheduling your first service now, and then come back to schedule the second service.") + } + + async checkForRepairOnlyAlertMessage(): Promise { + await expect(this.noReplacementAvailableAlert).toBeVisible(); + const alertMessage = await this.noReplacementAvailableAlert.textContent(); + console.log(`Alert encountered: ${alertMessage}`); + expect(alertMessage).toContain("Service not availableWe're sorry, but we currently offer only repair service for your vehicle type. Need help with next steps? Call us at800-394-0288.") + } +} \ No newline at end of file diff --git a/playwright-tests/pages/VehicleLookupAddressPage.ts b/playwright-tests/pages/VehicleLookupAddressPage.ts new file mode 100644 index 000000000..23ba8521a --- /dev/null +++ b/playwright-tests/pages/VehicleLookupAddressPage.ts @@ -0,0 +1,28 @@ +import { type Page, Locator } from '@playwright/test'; +import { LookupPage } from './LookupPage'; +import { AddressForm } from './forms/AddressForm'; +import { VehicleSelectionForm } from './forms/VehicleSelectionForm'; +import { ICustomerDetails, IVehicleDetails } from '@business-logic/types/CustomerDetails'; + +export class VehicleLookupAddressPage extends LookupPage { + readonly addressForm: AddressForm; + readonly vehicleSelectionForm: VehicleSelectionForm; + + url = process.env['BASE_URL']! + '/fmg/?fmgPage=service-zip'; + + constructor(page: Page) { + super(page); + this.addressForm = new AddressForm(page); + this.vehicleSelectionForm = new VehicleSelectionForm(page); + + // Override the unserviceableZipAlertMessage for address-specific message + this.unserviceableZipAlertMessage = this.page.locator('div.alert-danger.widget-name-AlertVinNotFoundWidget', + { hasText: "Your address didn't return a VIN match." }); + } + + async lookupVehicleByAddress(customerDetails: ICustomerDetails, vehicleDetails: IVehicleDetails) { + await this.addressForm.populateAddress(customerDetails); + //await this.nextPage(); + //await this.vehicleSelectionForm.selectVehicle(vehicleDetails); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/VehicleLookupLicensePage.ts b/playwright-tests/pages/VehicleLookupLicensePage.ts new file mode 100644 index 000000000..492dfeffe --- /dev/null +++ b/playwright-tests/pages/VehicleLookupLicensePage.ts @@ -0,0 +1,27 @@ +import { type Locator, type Page } from '@playwright/test'; +import { LookupPage } from './LookupPage'; +import { IVehicleDetails } from '@business-logic/types/CustomerDetails'; + +export class VehicleLookupLicensePage extends LookupPage { + readonly licensePlateNumTextBox: Locator; + readonly licensePlateStateDrpDwn: Locator; + + url = process.env['BASE_URL']! + '/fmg/?fmgPage=license-plate-lookup'; + + constructor(page: Page) { + super(page); + this.licensePlateNumTextBox = page.getByRole('textbox', { name: 'License plate number'}); + this.licensePlateStateDrpDwn = page.getByRole('combobox', { name: 'License plate state'}); + + // Override the service zip textbox locator for license-specific label + this.serviceZipTextBox = page.getByRole('textbox', { name: 'ZIP code on vehicle' }); + + // Override the unserviceableZipAlertMessage for license-specific message + this.unserviceableZipAlertMessage = this.page.locator('div.alert-danger.widget-name-AlertVinNotFoundWidget', + { hasText: "Your license plate didn't return a VIN match." }); + } + + async enterPlateDetails(vehicleDetails: IVehicleDetails) { + await this.licensePlateNumTextBox.fill(vehicleDetails.licensePlateNumber || ''); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/VehiclePartsPage.ts b/playwright-tests/pages/VehiclePartsPage.ts new file mode 100644 index 000000000..1dc6b84f5 --- /dev/null +++ b/playwright-tests/pages/VehiclePartsPage.ts @@ -0,0 +1,10 @@ +import { Page } from "@playwright/test"; +import { PartQuestionsPage } from "./PartQuestionPage"; + +export default class VehiclePartQuestionsPage extends PartQuestionsPage{ + url = process.env['BASE_URL']! + '/fmg/?fmgPage=vehicle-parts'; + + constructor(page: Page) { + super(page); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/VehicleSelectionPage.ts b/playwright-tests/pages/VehicleSelectionPage.ts new file mode 100644 index 000000000..b2d370ef8 --- /dev/null +++ b/playwright-tests/pages/VehicleSelectionPage.ts @@ -0,0 +1,45 @@ +import { type Locator, type Page, expect, test } from '@playwright/test'; +import { BasePage } from './BasePage'; +import { IVehicleDetails } from '@business-logic/types/CustomerDetails'; +import TestSuccessAlert from '@business-logic/types/TestSuccessAlert'; + +export class VehicleSelectionPage extends BasePage { + readonly page: Page; + readonly yearDropdown: Locator; + readonly makeDropdown: Locator; + readonly modelDropdown: Locator; + readonly styleDropdown: Locator; + readonly discontinuedServiceAlert: Locator; + + url = process.env['BASE_URL']! + '/fmg/?fmgPage=vehicle'; + + constructor(page: Page) { + super(page); + this.page = page; + this.yearDropdown = this.page.locator('#yearQuestionField'); + this.makeDropdown = this.page.locator('#makeQuestionField'); + this.modelDropdown = this.page.locator('#modelQuestionField'); + this.styleDropdown = this.page.locator('#styleQuestionField'); + this.discontinuedServiceAlert = this.page.locator('.alert-danger.widget-name-AlertNoServiceWidget'); + // this.validateURL(this.url); + } + + async selectVehicle(vehicleDetails: IVehicleDetails) { + await this.yearDropdown.selectOption(vehicleDetails.year); + await this.yearDropdown.press('Tab'); + await this.makeDropdown.selectOption(vehicleDetails.make); + await this.yearDropdown.press('Tab'); + await this.modelDropdown.selectOption(vehicleDetails.model); + await this.yearDropdown.press('Tab'); + if (vehicleDetails.style != undefined) { + await this.styleDropdown.selectOption(vehicleDetails.style); + } + } + + async checkForAlertMessages() { + await expect(this.discontinuedServiceAlert).toBeVisible(); + const alertMessage = await this.discontinuedServiceAlert.textContent(); + await console.log(`Alert encountered: ${alertMessage}`); + await expect(alertMessage).toContain('Service not available in your areaWe do not offer glass service for your vehicle in your ZIP code. We apologize for the inconvenience.'); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/VerifyDetailsPage.ts b/playwright-tests/pages/VerifyDetailsPage.ts new file mode 100644 index 000000000..b74eb251e --- /dev/null +++ b/playwright-tests/pages/VerifyDetailsPage.ts @@ -0,0 +1,77 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { IClaimDetails, ICustomerDetails } from '@business-logic/types/CustomerDetails'; +import { InsuranceBasePage } from './InsuranceBasePage'; + +export class VerifyDetailsPage extends InsuranceBasePage { + readonly page: Page; + readonly policyNumberTextBox: Locator; + readonly policyZipTextBox: Locator; + readonly damageDate: Locator; + + url = process.env['BASE_URL']! + '/FixMyGlass/VerifyDetails.aspx'; + + constructor(page: Page) { + super(page); + this.page = page; + this.policyNumberTextBox = page.locator('#PolicyNumber'); + this.policyZipTextBox = page.locator('#PolicyZip'); + this.damageDate = page.locator('#LossDate'); + // this.validateURL(this.url); + } + + async verifyPolicyDetails(customerDetails: ICustomerDetails, claimDetails: IClaimDetails): Promise { + // Format the date from YYYY-MM-DD to MM/DD/YYYY if needed + const formattedDate = this.formatDate(claimDetails.damageDate); + + // Check and fill policy number + await this.verifyAndFillField( + this.policyNumberTextBox, + claimDetails.policyNumber, + 'Policy Number' + ); + + // Check and fill zip code + await this.verifyAndFillField( + this.policyZipTextBox, + customerDetails.address.postalCode, + 'Policy ZIP' + ); + + // Check and fill damage date + await this.verifyAndFillField( + this.damageDate, + formattedDate, + 'Damage Date' + ); + } + + private async verifyAndFillField(element: Locator, expectedValue: string, fieldName: string): Promise { + await element.waitFor({ state: 'visible' }); + const currentValue = await element.inputValue(); + + if (currentValue !== expectedValue) { + console.log(`${fieldName} requires correction: Current value '${currentValue}' doesn't match expected '${expectedValue}'`); + await element.clear(); + await element.fill(expectedValue); + console.log(`${fieldName} updated to: ${expectedValue}`); + } else { + console.log(`${fieldName} verified: ${currentValue}`); + } + } + + private formatDate(date: string): string { + // If the date is already in MM/DD/YYYY format, return it as is + if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(date)) { + return date; + } + + // If the date is in YYYY-MM-DD format, convert it + if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(date)) { + const [year, month, day] = date.split('-'); + return `${month}/${day}/${year}`; + } + + // Return the original string if format is unknown + return date; + } +} \ No newline at end of file diff --git a/playwright-tests/pages/VinLookupPage.ts b/playwright-tests/pages/VinLookupPage.ts new file mode 100644 index 000000000..4ba2de260 --- /dev/null +++ b/playwright-tests/pages/VinLookupPage.ts @@ -0,0 +1,23 @@ +import { type Locator, type Page } from '@playwright/test'; +import { LookupPage } from './LookupPage'; + +export class VinLookupPage extends LookupPage { + readonly vinLookupTextBox: Locator; + readonly invalidVinInlineError: Locator; + readonly cameraButton: Locator; + readonly whereCanIFindMyVinLink: Locator; + + url = process.env['BASE_URL']! + '/fmg/?fmgPage=vin-lookup'; + + constructor(page: Page) { + super(page); + this.vinLookupTextBox = page.getByRole('textbox', { name: 'Enter your VIN' }); + this.invalidVinInlineError = this.page.locator('span.d-inline-flex.small.mt-1:has-text("Invalid VIN")'); + this.cameraButton = this.page.getByRole('textbox', { name: 'Camera icon/button' }); + this.whereCanIFindMyVinLink = this.page.getByRole('link', { name: 'Where can I find my VIN?', exact: true }); + } + + async enterVin(vin: string) { + await this.vinLookupTextBox.fill(vin); + } +} \ No newline at end of file diff --git a/playwright-tests/pages/ZipLookupPage.ts b/playwright-tests/pages/ZipLookupPage.ts new file mode 100644 index 000000000..7785fc3c2 --- /dev/null +++ b/playwright-tests/pages/ZipLookupPage.ts @@ -0,0 +1,11 @@ +import { type Page } from '@playwright/test'; +import { LookupPage } from './LookupPage'; + +export class ZipLookupPage extends LookupPage { + url = process.env['BASE_URL']! + '/fmg/?fmgPage=service-zip'; + + constructor(page: Page) { + super(page); + } + // No additional methods needed as all functionality is inherited from LookupPage +} \ No newline at end of file diff --git a/playwright-tests/pages/forms/AddressForm.ts b/playwright-tests/pages/forms/AddressForm.ts new file mode 100644 index 000000000..acbdadec5 --- /dev/null +++ b/playwright-tests/pages/forms/AddressForm.ts @@ -0,0 +1,58 @@ +import { expect, type Locator, type Page } from '@playwright/test'; +import { BasePage } from '../BasePage'; +import { ICustomerDetails } from '@business-logic/types/CustomerDetails'; +import { faker } from '@faker-js/faker/locale/en'; + +export class AddressForm extends BasePage { + readonly page: Page; + readonly streetAddressTextBox: Locator; + readonly cityTextBox: Locator; + readonly stateDrpDwn: Locator; + readonly zipCodeTextBox: Locator; + readonly firstNameTextBox: Locator; + readonly lastNameTextBox: Locator; + readonly addressNotFoundMsg: Locator; + + constructor(page: Page) { + super(page); + this.page = page; + this.streetAddressTextBox = page.getByRole('textbox', { name: /Street (a|A)ddress$/ }); + this.cityTextBox = page.getByRole('textbox', { name: 'City' }); + this.stateDrpDwn = page.getByRole('combobox', { name: 'State' }); + this.zipCodeTextBox = page.getByRole('textbox', { name: 'ZIP code' }); + this.firstNameTextBox = page.getByRole('textbox', { name: 'First name' }); + this.lastNameTextBox = page.getByRole('textbox', { name: 'Last name' }); + this.addressNotFoundMsg = page.getByText('Address not found.'); + } + + async forceAddressFormToAppear() { + await expect(async () => { + await this.streetAddressTextBox.click(); + await this.streetAddressTextBox.pressSequentially('7400 Safelite Way'); + await this.streetAddressTextBox.press('Tab'); + await expect(this.zipCodeTextBox).toBeVisible({ timeout: 100 }); + }).toPass(); + } + + async populateAddress(customerDetails: Partial) { + + if (customerDetails.address) { + // Force address form to appear + await this.forceAddressFormToAppear(); + + // Fill address + await this.fillAndValidate(this.streetAddressTextBox, customerDetails.address.street); + await this.fillAndValidate(this.zipCodeTextBox, customerDetails.address.postalCode) + await this.fillAndValidate(this.cityTextBox, customerDetails.address.city); + await this.stateDrpDwn.selectOption(customerDetails.address.state); + } + + if (customerDetails.firstName) { + await this.fillAndValidate(this.firstNameTextBox, customerDetails.firstName); + } + + if (customerDetails.lastName) { + await this.fillAndValidate(this.lastNameTextBox, customerDetails.lastName); + } + } +} \ No newline at end of file diff --git a/playwright-tests/pages/forms/VehicleSelectionForm.ts b/playwright-tests/pages/forms/VehicleSelectionForm.ts new file mode 100644 index 000000000..e9125cfd5 --- /dev/null +++ b/playwright-tests/pages/forms/VehicleSelectionForm.ts @@ -0,0 +1,16 @@ +import { type Page } from '@playwright/test'; +import { BasePage } from '../BasePage'; +import { IVehicleDetails } from '@business-logic/types/CustomerDetails'; + +export class VehicleSelectionForm extends BasePage { + readonly page: Page; + + constructor(page: Page) { + super(page); + this.page = page; + } + + async selectVehicle(vehicleDetails: IVehicleDetails){ + await this.page.locator('label').filter({hasText: vehicleDetails.model}).locator('div').first().click(); + } +} \ No newline at end of file diff --git a/playwright-tests/playwright.config.ts b/playwright-tests/playwright.config.ts new file mode 100644 index 000000000..a77283e51 --- /dev/null +++ b/playwright-tests/playwright.config.ts @@ -0,0 +1,116 @@ +import { defineConfig, devices } from '@playwright/test'; +import dotenv from 'dotenv-safe'; +import { OrtoniReportConfig } from "ortoni-report"; + +if (!process.env.CI) { + // Environment variables are present in CI environment, no need to read from file + if (process.env.NODE_ENV == 'undefined' || process.env.NODE_ENV == null) { + dotenv.config({ path: `playwright-tests/.env.dev`, example: 'playwright-tests/.env.example' }); + } + else { + dotenv.config({ path: `playwright-tests/.env.${process.env.NODE_ENV}`, example: 'playwright-tests/.env.example' }); + } +} + + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const reportConfig: OrtoniReportConfig = { + port: 1994, + open: "never", + folderPath: "playwright-tests/artifacts/test-results", + filename: "index.html", + logo: "../data/logo.png", + title: "Test Report", + showProject: false, + projectName: "FMG-Nextgen-Playwright-Report", + testType: `E2E- Environment: ${process.env.NODE_ENV} `, + preferredTheme: "light", + base64Image: true, +}; + +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 1 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 4 : 5, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['ortoni-report', reportConfig], + ['junit'], + ['list'] + ], + timeout: 180_000, + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + outputDir: 'artifacts/test-results', + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + headless: process.env.CI ? true : false, + screenshot: "only-on-failure", + actionTimeout: 60_000, + navigationTimeout: 60_000, + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/playwright-tests/tests/0000__M.test.ts b/playwright-tests/tests/0000__M.test.ts new file mode 100644 index 000000000..6bef5a2f3 --- /dev/null +++ b/playwright-tests/tests/0000__M.test.ts @@ -0,0 +1,624 @@ +import TestCase from "@business-logic/types/TestCase"; +import { Page } from "@playwright/test"; +import { addSmokeTagToRandomTest, prepareTest, test, TestInfo } from "@business-logic/types/Test"; +import { RuleEngine, ValidationOptions } from "@business-logic/types/RuleEngine"; +import heavyTruckTests from "./alert-validation/alert0001_HeavyTruck"; +import repairAndReplaceTests from "./alert-validation/alert0002_RepairAndReplace"; +import splitWindshieldTests from "./alert-validation/alert0003_SplitWindshield"; +import repairOnlyTests from "./alert-validation/alert0004_RepairOnly"; +import unserviceableZipTests from "./alert-validation/alert0005_UnserviceableZip"; +import invalidZipTests from "./alert-validation/alert0006_InvalidZip"; +import vinNotFoundTests from "./alert-validation/alert0007_VinNotFound"; +import TestSuccessAlert from "@business-logic/types/TestSuccessAlert"; +import { AppointmentType, PaymentMethod, ServicePackage, VehicleLookupType, VehicleDamage, PaymentType } from "@business-logic/types/Enums"; +import cashRepairMobileCCTests from "./CashRepairMobileCreditCard"; +import cashReplaceDynamicRecalMobileTests from "./CashReplaceDynamicRecalMobile"; +import cashReplaceGlassAddressLookupInshopAfterPayTests from "./CashReplaceGlassAddressLookupInshopAfterPay"; +import cashReplaceGlassLicensePlateLookupInshopPaypalTests from "./CashReplaceGlassLicensePlateLookupInshopPaypal"; +import ApiResponseInterceptUtil from "@impl/API/ApiResponseInterceptUtil"; +import cashReplaceGlassPromoInshopTests from "./CashReplaceGlassPromoInshop"; +import cashReplaceMultiGlassPromoInshopTests from "./CashReplaceMultiGlassPromoInshop"; +import cashReplaceRainDefensePromoInshopTests from "./CashReplaceRainDefensePromoInshop"; +import cashReplaceSafeliteCanNotRecalMobileTests from "./CashReplaceSafeliteCanNotRecalMobile"; +import cashReplaceVinMobileTests from "./CashReplaceVinMobile"; +import cashReplaceWiperDropoffTests from "./CashReplaceWiperDropoff"; +import cashReplaceWiperPromoInShopTests from "./CashReplaceWiperPromoInshop"; +import insuranceAcuityPaypalTests from "./InsuranceAcuityPaypal"; +import insuranceITAC21stCenturyTests from "./InsuranceITAC21stCentury"; +import insuranceGeicoTests from "./InsuranceGeico"; +import insuranceITACOptimizedPriceValidationAllStateTests from "./InsuranceITACOptimizedPriceValidationAllState"; +import cashRepairInShopAfterPayTests from "./CashRepairInShopAfterPay"; +import cashRepairInShopPayPalTests from "./CashRepairInShopPayPal"; +import cashReplaceMultiSlidingGlassDropoffTests from "./CashReplaceMultiSlidingGlassDropoff"; +import cashReplaceMultiGlassMobileTests from "./CashReplaceMultiGlassMobile"; + +/** + * Master Test Runner + * + * This file orchestrates the execution of all test scenarios and defines the primary test workflow. + * It imports all test cases and executes them through a common test runner function to ensure + * consistent behavior across different test scenarios. + */ + +// Initialize shared rule engine and validation options +const ruleEngine = new RuleEngine(); +const options = new ValidationOptions(); + +// Add smoke tag to selected tests for CI/CD pipelines +addSmokeTagToRandomTest(splitWindshieldTests); + + +// Standard test scenarios +const allStandardTests = [ + {name: "CashRepairMobileCreditCard", tests: cashRepairMobileCCTests}, + {name: "CashRepairInShopAfterPay", tests: cashRepairInShopAfterPayTests}, + {name: "CashRepairInShopPayPal", tests: cashRepairInShopPayPalTests}, + {name: "CashReplaceDynamicRecalMobile", tests: cashReplaceDynamicRecalMobileTests}, + {name: "CashReplaceGlassAddressLookupInshopAfterPay", tests: cashReplaceGlassAddressLookupInshopAfterPayTests}, + {name: "CashReplaceGlassLicensePlateLookupInshopPaypal", tests: cashReplaceGlassLicensePlateLookupInshopPaypalTests}, + {name: "CashReplaceGlassPromoInShop", tests: cashReplaceGlassPromoInshopTests}, + {name: "CashReplaceMultiGlassPromoInShop", tests: cashReplaceMultiGlassPromoInshopTests}, + {name: "CashReplaceMultiSlidingGlassDropoff",tests: cashReplaceMultiSlidingGlassDropoffTests}, + {name: "CashReplaceMultiGlassMobile",tests: cashReplaceMultiGlassMobileTests}, + {name: "CashReplaceRainDefensePromoInshop", tests: cashReplaceRainDefensePromoInshopTests}, + {name: "CashReplaceSafeliteCanNotRecalMobile", tests: cashReplaceSafeliteCanNotRecalMobileTests}, + {name: "CashReplaceVinMobile", tests: cashReplaceVinMobileTests}, + {name: "CashReplaceWiperDropoff", tests: cashReplaceWiperDropoffTests}, + {name: "CashReplaceWiperPromoInshop", tests: cashReplaceWiperPromoInShopTests}, + {name: "InsuranceAcuityPaypal", tests: insuranceAcuityPaypalTests}, + {name: "InsuranceITAC21stCentury", tests: insuranceITAC21stCenturyTests}, + // {name: "InsuranceGeico", tests: insuranceGeicoTests}, + // {name: "InsuranceITACOptimizedPriceValidationAllState", tests: insuranceITACOptimizedPriceValidationAllStateTests} + +]; + +// Alert validation scenarios +const allAlertTests = [ + { name: "Alert Scenario 1: Heavy Truck", tests: heavyTruckTests }, + { name: "Alert Scenario 2: Repair and Replace", tests: repairAndReplaceTests }, + { name: "Alert Scenario 3: Split Windshield", tests: splitWindshieldTests }, + { name: "Alert Scenario 4: Repair Only", tests: repairOnlyTests }, + { name: "Alert Scenario 5: Unserviceable Zip", tests: unserviceableZipTests }, + { name: "Alert Scenario 6: Invalid Zip", tests: invalidZipTests }, + { name: "Alert Scenario 7: VIN Not Found", tests: vinNotFoundTests } +]; + +// Standard test cases +test.describe.parallel('Standard E2E Test Flows', () => { + allStandardTests.forEach(scenario => { + scenario.tests.forEach(testCase => { + test(...prepareTest(testCase, run, options, ruleEngine)); + }); + }); +}); + +// Alert validation test cases +test.describe.parallel('Alert Validation Tests', () => { + allAlertTests.forEach(scenario => { + test.describe(scenario.name, () => { + scenario.tests.forEach(testCase => { + test(...prepareTest(testCase, run, options, ruleEngine)); + }); + }); + }); +}); + + +/** + * After each test, capture screenshot and handle cleanup + */ +test.afterEach(async ({ page, testInfo }) => { + await TestCase.afterEachMethod(page, testInfo); +}); + +/** + * Main test runner function that executes each test case + * @param page - The Playwright page object + * @param testInfo - Test information and context + */ +async function run(page: Page, testInfo: TestInfo): Promise { + try { + await testInfo.testCase.setup(); + testInfo.testCase.setupPages(page); + await testInfo.testCase.pages.homePage.goto(); + await runWorkflow(page, testInfo.testCase); + } catch (error) { + // Catch successful alert tests and log message + if (error instanceof TestSuccessAlert) { + console.log(error.message); + // Catch and throw other errors + } else { + throw error; + } + } +} + +/** + * Main workflow that executes the test steps in sequence + * + * This function contains the common test flow for all test scenarios: + * 1. Start at home page and enter vehicle/damage information + * 2. Select lookup method and provide details + * 3. Answer part questions if applicable + * 4. Select service package and payment method + * 5. Handle insurance flow if applicable + * 6. Select service location and schedule appointment + * 7. Complete order and validate confirmation + * + * @param page - The Playwright page object + * @param testCase - The test case to execute + */ +async function runWorkflow(page: Page, testCase: TestCase) { + + // Intercept API Responses + const apiResponseInterceptUtil = new ApiResponseInterceptUtil(testCase.testData); + page.on('response', apiResponseInterceptUtil.handleInterceptResponse); + + // Destructure test data for easier access + const { + servicePackage, paymentMethod, customerDetails, vehicleDetails, vehicleDamage, + appointmentDetails, paymentDetails, claimDetails, partQuestions, enterFunnelWithZip, + capabilityQuestions, vehiclePartQuestions, moldingQuestions, isPolicyFound, + otherVehiclesOnPolicy, isUseVehicleOnPolicy, isDuplicateClaim, isRecalNotification, + alertFlags, endorsements, isPolicyDriver, skipEstimatePage, isRecalVehicle, canNotRecal, + dynamicRecal, hasOemEndorsement, promoCode + } = testCase.testData; + + // Destructure alert flag data + const { + isHeavyTruckVehicle, isRepairReplace, isSplitWindshield, isRepairOnly, + isUnserviceableZip, isInvalidZip, isVinNotFound + } = testCase.testData.alertFlags || {}; + + // Define repair damage types (vs. replacement types) + const repairTypes: VehicleDamage[] = [ + VehicleDamage.WindshieldOneChip, + VehicleDamage.WindshieldTwoChips, + VehicleDamage.WindshieldThreeChips + ]; + + // Determine if we're replacing or repairing + const isReplace = !repairTypes.some(damageType => { + return vehicleDamage!.includes(damageType); + }); + + // Check if the insurance policy has endorsements + const hasEndorsements = endorsements && endorsements.length > 0; + + // Check if the vehicle damage includes a windshield crack + const hasWindshieldCrack = vehicleDamage!.some(damage => + damage === VehicleDamage.WindshieldCrack + ); + + //============================= TEST WORKFLOW STEPS ============================= + + // Execute home page for qa and dev environments (skip for sys) + if (process.env.NODE_ENV !== 'sys') { + await test.step('HomePage >> Lets Get Started', async () => { + let homePage = testCase.pages.homePage; + console.log(`Customer for this test: ${customerDetails?.firstName} ${customerDetails?.lastName}`); + await homePage.letsGetStarted(customerDetails?.address.postalCode!, !!enterFunnelWithZip!); + }); + } else { + // If enterFunnelWithZip is true, add zip code to url + if (!!enterFunnelWithZip!) { + const currentUrl = page.url(); + const zipParam = `&zipCode=${customerDetails?.address.postalCode!}`; + if (!currentUrl.includes('zipCode=')) { + await page.goto(currentUrl + zipParam); + } + } + console.log(`Customer for this test: ${customerDetails?.firstName} ${customerDetails?.lastName}`); + } + + + await test.step('VehicleSelectionPage >> Select Vehicle', async () => { + let vehicleSelectionPage = testCase.pages.vehicleSelectionPage; + await vehicleSelectionPage.selectVehicle(vehicleDetails!); + + // Handle alert conditions for vehicle selection + if (isHeavyTruckVehicle || isSplitWindshield) { + await vehicleSelectionPage.checkForAlertMessages(); + throw new TestSuccessAlert('Both assertions are met successfully.'); + } + + await vehicleSelectionPage.nextPage(); + }); + + await test.step('VehicleDamagePage >> Select Damage', async () => { + let vehicleDamagePage = testCase.pages.vehicleDamagePage; + await vehicleDamagePage.selectDamage(vehicleDamage!); + + // Handle alert conditions for vehicle damage + if (isRepairReplace) { + await vehicleDamagePage.checkForBothRepairReplaceAlertMessage(); + throw new TestSuccessAlert('Both assertions are met successfully.'); + } + if (isRepairOnly) { + await vehicleDamagePage.checkForRepairOnlyAlertMessage(); + throw new TestSuccessAlert('Both assertions are met successfully.'); + } + + await vehicleDamagePage.nextPage(); + }); + + // If the vehicle has a windshield crack as part of its damage, go to estimate page and select lookup type + if (hasWindshieldCrack && !skipEstimatePage) { + await test.step('EstimatePage >> Select Lookup Type', async () => { + let estimatePage = testCase.pages.estimatePage; + await estimatePage.vehicleLookup(vehicleDetails!); + }); + + // Handle different vehicle lookup methods + switch (vehicleDetails!.vehicleLookupType!) { + case VehicleLookupType.Address: + await test.step('VehicleLookupAddressPage >> Lookup by address: ' + customerDetails!.address.street, async () => { + let vehicleLookupAddressPage = testCase.pages.vehicleLookupAddressPage; + await vehicleLookupAddressPage.lookupVehicleByAddress(customerDetails!, vehicleDetails!); + await vehicleLookupAddressPage.handleZipValidation(customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!); + }); + break; + + case VehicleLookupType.LicensePlateNumber: + await test.step('VehicleLookupLicensePage >> Lookup by license plate: ' + vehicleDetails!.licensePlateNumber, async () => { + let vehicleLookupLicensePage = testCase.pages.vehicleLookupLicensePage; + await vehicleLookupLicensePage.enterPlateDetails(vehicleDetails!); + await vehicleLookupLicensePage.enterZip(customerDetails!.address.postalCode!); + await vehicleLookupLicensePage.handleZipValidation(customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!); + }); + break; + + case VehicleLookupType.Vin: + await test.step('VinLookupPage >> Lookup by VIN: ' + vehicleDetails!.vin!, async () => { + let vinLookupPage = testCase.pages.vinLookupPage; + await vinLookupPage.enterVin(vehicleDetails!.vin!); + await vinLookupPage.enterZip(customerDetails!.address.postalCode!); + await vinLookupPage.handleZipValidation(customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!); + }); + break; + + case VehicleLookupType.Zip: + await test.step('ZipLookupPage >> Lookup by service ZIP: ' + customerDetails!.address.postalCode!, async () => { + let zipLookupPage = testCase.pages.zipLookupPage; + let vinLookupPage = testCase.pages.vinLookupPage; + await vinLookupPage.enterZip(customerDetails!.address.postalCode!); + await zipLookupPage.handleZipValidation(customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!); + }); + } + } else { + // Otherwise just use zip lookup + await test.step('ZipLookupPage >> Lookup by service ZIP: ' + customerDetails!.address.postalCode!, async () => { + let zipLookupPage = testCase.pages.zipLookupPage; + let vinLookupPage = testCase.pages.vinLookupPage; + await vinLookupPage.enterZip(customerDetails!.address.postalCode!); + await zipLookupPage.handleZipValidation(customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!); + }); + } + + // Handle part questions if applicable + if (partQuestions && partQuestions.length > 0) { + await test.step('PartQuestionsPage >> Select Vehicle Part Question Responses', async () => { + let partQuestionsPage = testCase.pages.partQuestionsPage; + await partQuestionsPage.validatePartQuestions(partQuestions); + await partQuestionsPage.selectPartQuestionResponses(partQuestions); + await partQuestionsPage.nextPage(); + }); + } + + // Handle molding questions if applicable + if (moldingQuestions && moldingQuestions.length > 0) { + await test.step('MoldingQuestionsPage >> Select Molding Question Responses', async () => { + let moldingQuestionsPage = testCase.pages.moldingQuestionsPage; + await moldingQuestionsPage.validatePartQuestions(moldingQuestions); + await moldingQuestionsPage.selectPartQuestionResponses(moldingQuestions); + await moldingQuestionsPage.nextPage(); + }); + } + + // Handle vehicle part questions if applicable + if (vehiclePartQuestions && vehiclePartQuestions.length > 0) { + await test.step('VehiclePartsPage >> Select Vehicle Part Responses', async () => { + let vehiclePartsPage = testCase.pages.vehiclePartsPage; + await vehiclePartsPage.validatePartQuestions(vehiclePartQuestions); + await vehiclePartsPage.selectPartQuestionResponses(vehiclePartQuestions); + await vehiclePartsPage.nextPage(); + }); + } + + // Handle capability questions if applicable + if (capabilityQuestions && capabilityQuestions.length > 0) { + await test.step('CapabilityQuestionsPage >> Select Capability Question Responses', async () => { + let capabilityQuestionsPage = testCase.pages.capabilityQuestionsPage; + await capabilityQuestionsPage.validatePartQuestions(capabilityQuestions); + await capabilityQuestionsPage.selectPartQuestionResponses(capabilityQuestions); + await capabilityQuestionsPage.nextPage(); + }); + } + + // Select service package and payment method + await test.step('ServicePackagePage >> Select Payment Method and Service Type', async() => { + let servicePackagePage = testCase.pages.servicePackagePage; + await servicePackagePage.handleQuotePopup(customerDetails!.email!); + await servicePackagePage.selectPaymentMethod(paymentMethod!); + await servicePackagePage.selectServicePackage(servicePackage!); + // Enter promo code + if (promoCode) { + await servicePackagePage.enterPromo(promoCode); + } + + // Backend Validations + // Validate backend for can not recal if applicable + if (canNotRecal) { + await servicePackagePage.verifyCanNotRecal(); + } + // Validate backend for dynamic recal if applicable + if (dynamicRecal) { + await servicePackagePage.verifyDynamicRecal(); + } + // Validate backend for repair info (including chip verification) + await servicePackagePage.verifyIsRepair(!isReplace, vehicleDamage!); + if (isReplace) { + // Validate backend for parts info + await servicePackagePage.verifyVehicleParts(vehicleDamage!); + } + // Validate backend for OEM endorsement + if (hasOemEndorsement) { + await servicePackagePage.verifyOEMPart(); + } + + await servicePackagePage.nextPage(); + }); + + //============================= INSURANCE FLOW =============================` + + // Insurance flow - if user selected Insurance as Payment Method + if (paymentMethod == PaymentMethod.Insurance) { + await test.step('InsuranceCoveragePage >> Select your insurance', async() => { + let insuranceCompanyPage = testCase.pages.insuranceCompanyPage; + await insuranceCompanyPage.enterInsuranceCompany(claimDetails!.client!); + await insuranceCompanyPage.nextPage(); + }); + + await test.step('CCPolicyInfoPage >> Fill out claim information', async() => { + let ccPolicyInfoPage = testCase.pages.ccPolicyInfoPage; + await ccPolicyInfoPage.populatePage(customerDetails!, claimDetails!, await ccPolicyInfoPage.hasCityInfo()); + await ccPolicyInfoPage.nextPage(); + }); + + // Handle duplicate claim case if applicable + if (isDuplicateClaim) { + await test.step('DuplicateCheckPage >> Start New Claim', async () => { + let duplicateCheckPage = testCase.pages.duplicateCheckPage; + await duplicateCheckPage.startNewClaim(); + await duplicateCheckPage.nextPage(); + }); + } + + // Handle policy found vs. not found flows + if (isPolicyFound) { + await test.step('PolicyVehiclesPage >> Select vehicle', async () => { + let policyVehiclesPage = testCase.pages.policyVehiclesPage; + let vehicleSelectionPage = testCase.pages.vehicleSelectionPage; + + // Validate other vehicles on policy + if (otherVehiclesOnPolicy && otherVehiclesOnPolicy.length > 0) { + for (const vehicle of otherVehiclesOnPolicy) { + await policyVehiclesPage.validateVehicleIsOnPolicy(vehicle); + } + } + + // If vehicle is not on the policy, select new vehicle + if (!(isUseVehicleOnPolicy ?? true)) { + await policyVehiclesPage.selectVehicleNotListed(); + await policyVehiclesPage.nextPage(); + await vehicleSelectionPage.selectVehicle(vehicleDetails!); + await policyVehiclesPage.nextPage(); + } else { + // Otherwise, select the vehicle entered in Safelite.com + await policyVehiclesPage.selectVehicle(vehicleDetails!); + await policyVehiclesPage.nextPage(); + } + }); + + // Handle policy driver selection if applicable + if (isPolicyDriver) { + await test.step('PolicyDriverPage >> Confirm Driver at time of damage', async () => { + let policyDriverPage = testCase.pages.policyDriverPage; + await policyDriverPage.selectPolicyDriver(customerDetails!); + await policyDriverPage.nextPage(); + }); + } + + // Handle endorsements if applicable + if (hasEndorsements) { + await test.step('EndorsementsPage >> Select Endorsements', async () => { + let endorsementsPage = testCase.pages.endorsementsPage; + await endorsementsPage.verifyEndorsements(endorsements); + await endorsementsPage.selectEndorsements(endorsements); + await endorsementsPage.nextPage(); + }); + } + } else { + // Policy not found flow + await test.step('VerifyDetailsPage >> Verify Details', async () => { + let verifyDetailsPage = testCase.pages.verifyDetailsPage; + await verifyDetailsPage.verifyPolicyDetails(customerDetails!, claimDetails!); + await verifyDetailsPage.nextPage(); + }); + } + + // Continue with the insurance flow after policy information + await test.step('PolicyInfoSubmittedPage >> Continue With Safelite Autoglass', async () => { + let policyInfoSubmittedPage = testCase.pages.policyInfoSubmittedPage; + await policyInfoSubmittedPage.verifyPolicyInfoSubmitted(); + await policyInfoSubmittedPage.nextPage(); + }); + + // Handle recalibration notification if applicable + if (isRecalNotification) { + await test.step('RecallibrationInfoPage >> Continue With Recalibration Information', async () => { + let recalibrationInfoPage = testCase.pages.recalibrationInfoPage; + await recalibrationInfoPage.nextPage(); + }); + } + + // Continue to coverage statement + await test.step('CoverageStatementPage >> Next page', async () => { + let coverageStatementPage = testCase.pages.coverageStatementPage; + await coverageStatementPage.validateDeductibleAmount(claimDetails!); + await coverageStatementPage.nextPage(); + }); + } + + //============================= SERVICE SCHEDULING ============================= + + // Select service location + await test.step('ServiceLocationPage >> Select service location', async () => { + let serviceLocationPage = testCase.pages.serviceLocationPage; + await serviceLocationPage.selectLocation(appointmentDetails!); + await serviceLocationPage.nextPage(); + }); + + // Schedule appointment + await test.step('SchedulePage >> Select day and time', async () => { + let schedulePage = testCase.pages.schedulePage; + customerDetails!.apptDate! = await schedulePage.scheduleFirstAppointment(appointmentDetails!.serviceLocation); + }); + + // Enter contact details + await test.step('ContactDetailsPage >> Enter contact details', async () => { + let contactDetailsPage = testCase.pages.contactDetailsPage; + await contactDetailsPage.enterContactDetails(customerDetails!); + await contactDetailsPage.nextPage(); + }); + + //============================= PAYMENT PROCESSING ============================= + + // Handle payment + await test.step('PaymentMethodPage >> Execute Payment', async () => { + let paymentMethodPage = testCase.pages.paymentMethodPage; + await paymentMethodPage.validatePaymentDetailsPage(testCase.testData); + // Verify VAPS wipers on backend for standard and premium packages + if (servicePackage === ServicePackage.Standard || servicePackage === ServicePackage.Premium) { + await paymentMethodPage.verifyVAPS(); + } + if (paymentDetails?.paymentType) { + await paymentMethodPage.executePayment(paymentDetails!, isRecalVehicle!); + } else { + await paymentMethodPage.nextPage(); + } + }); + + // If user selected Pay with Insurance as Payment Method, Enter Insurance Flow + if (paymentDetails?.paymentType === PaymentType.PayWithInsurance) { + await test.step('InsuranceCoveragePage >> Select your insurance', async() => { + let insuranceCompanyPage = testCase.pages.insuranceCompanyPage; + await insuranceCompanyPage.enterInsuranceCompany(claimDetails!.client!); + await insuranceCompanyPage.nextPage(); + }); + + await test.step('CCPolicyInfoPage >> Fill out claim information', async() => { + const ccPolicyInfoPage = testCase.pages.ccPolicyInfoPage; + await ccPolicyInfoPage.populatePage(customerDetails!, claimDetails!, await ccPolicyInfoPage.hasCityInfo()); + await ccPolicyInfoPage.nextPage(); + }); + + // Handle duplicate claim case if applicable + if (isDuplicateClaim) { + await test.step('DuplicateCheckPage >> Start New Claim', async () => { + const duplicateCheckPage = testCase.pages.duplicateCheckPage; + await duplicateCheckPage.startNewClaim(); + await duplicateCheckPage.nextPage(); + }); + } + + // Handle policy found vs. not found flows + if (isPolicyFound) { + await test.step('PolicyVehiclesPage >> Select vehicle', async () => { + const policyVehiclesPage = testCase.pages.policyVehiclesPage; + const vehicleSelectionPage = testCase.pages.vehicleSelectionPage; + + // Validate other vehicles on policy + if (otherVehiclesOnPolicy && otherVehiclesOnPolicy.length > 0) { + for (const vehicle of otherVehiclesOnPolicy) { + await policyVehiclesPage.validateVehicleIsOnPolicy(vehicle); + } + } + + // If vehicle is not on the policy, select new vehicle + if (!(isUseVehicleOnPolicy ?? true)) { + await policyVehiclesPage.selectVehicleNotListed(); + await policyVehiclesPage.nextPage(); + await vehicleSelectionPage.selectVehicle(vehicleDetails!); + await policyVehiclesPage.nextPage(); + } else { + // Otherwise, select the vehicle entered in Safelite.com + await policyVehiclesPage.selectVehicle(vehicleDetails!); + await policyVehiclesPage.nextPage(); + } + }); + + // Handle policy driver selection if applicable + if (isPolicyDriver) { + await test.step('PolicyDriverPage >> Confirm Driver at time of damage', async () => { + const policyDriverPage = testCase.pages.policyDriverPage; + await policyDriverPage.selectPolicyDriver(customerDetails!); + await policyDriverPage.nextPage(); + }); + } + + // Handle endorsements if applicable + if (hasEndorsements) { + await test.step('EndorsementsPage >> Select Endorsements', async () => { + const endorsementsPage = testCase.pages.endorsementsPage; + await endorsementsPage.verifyEndorsements(endorsements); + await endorsementsPage.selectEndorsements(endorsements); + await endorsementsPage.nextPage(); + }); + } + } else { + // Policy not found flow + await test.step('VerifyDetailsPage >> Verify Details', async () => { + const verifyDetailsPage = testCase.pages.verifyDetailsPage; + await verifyDetailsPage.verifyPolicyDetails(customerDetails!, claimDetails!); + await verifyDetailsPage.nextPage(); + }); + } + + // Continue with the insurance flow after policy information + await test.step('PolicyInfoSubmittedPage >> Continue With Safelite Autoglass', async () => { + const policyInfoSubmittedPage = testCase.pages.policyInfoSubmittedPage; + await policyInfoSubmittedPage.verifyPolicyInfoSubmitted(); + await policyInfoSubmittedPage.nextPage(); + }); + + // Handle recalibration notification if applicable + if (isRecalNotification) { + await test.step('RecallibrationInfoPage >> Continue With Recalibration Information', async () => { + const recalibrationInfoPage = testCase.pages.recalibrationInfoPage; + await recalibrationInfoPage.nextPage(); + }); + } + + // Continue to coverage statement + await test.step('CoverageStatementPage >> Next page', async () => { + const coverageStatementPage = testCase.pages.coverageStatementPage; + await coverageStatementPage.validateDeductibleAmount(claimDetails!); + await coverageStatementPage.nextPage(); + }); + } + + //============================= ORDER CONFIRMATION ============================= + + // Validate order confirmation + await test.step('OrderConfirmationPage >> Validate order', async () => { + let orderConfirmationPage = testCase.pages.orderConfirmationPage; + await orderConfirmationPage.validateOrderConfirmationPage(testCase.testData); + }); + + // Get the order number and wrap it in a test step + const workOrderNumber = await testCase.pages.orderConfirmationPage.logOrderNumber(); + await test.step(`Session Storage Work Order Number: ${workOrderNumber}`, async () => { + console.log(`Session Storage Work Order Number: ${workOrderNumber}`); + }); +} \ No newline at end of file diff --git a/playwright-tests/tests/CashRepairInShopAfterPay.ts b/playwright-tests/tests/CashRepairInShopAfterPay.ts new file mode 100644 index 000000000..c3954db9f --- /dev/null +++ b/playwright-tests/tests/CashRepairInShopAfterPay.ts @@ -0,0 +1,46 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { VehicleDamage, AppointmentType, ServicePackage } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import PaymentData from "@business-logic/Data/PaymentData"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashRepairInShopAfterPay"); + +// Now get the test data with the seeded faker +const cashRepairInShopAfterPayData : Partial = { + ...getDefaultTestData(), // Get default data with current seed + + //Override default vehicle damage (Windshield Crack) + vehicleDamage: [VehicleDamage.WindshieldTwoChips], + + // Key feature: Standard Package + servicePackage: ServicePackage.Standard, + + // Override specific fields with test-specific data + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + vin: '1HGCV2E35LA005448' + }, + + // Override appointment details + appointmentDetails: { + ...getDefaultTestData().appointmentDetails!, + shopAddress: "6826 Sawmill Rd, Columbus, OH 43235" + }, + + // Use predefined payment data + paymentDetails: PaymentData.getDefaultAfterpayDetails() +} + +const cashRepairInShopAfterPayTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashRepairInShopAfterPay`, + tags: ['@E2E','@CashRepairInShopAfterPay', '@test_report', '@CASH'], + testData: cashRepairInShopAfterPayData +}, undefined, 'CashRepairInShopAfterPay'); +cashRepairInShopAfterPayTests.push(tc); + +export default cashRepairInShopAfterPayTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashRepairInShopPayPal.ts b/playwright-tests/tests/CashRepairInShopPayPal.ts new file mode 100644 index 000000000..e17bcaed1 --- /dev/null +++ b/playwright-tests/tests/CashRepairInShopPayPal.ts @@ -0,0 +1,46 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { VehicleDamage, ServicePackage } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import PaymentData from "@business-logic/Data/PaymentData"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashRepairInShopPayPal"); + +// Now get the test data with the seeded faker +const cashRepairInShopPayPalData : Partial = { + ...getDefaultTestData(), // Get default data with current seed + + //Override default vehicle damage (Windshield Crack) + vehicleDamage: [VehicleDamage.WindshieldThreeChips], + + // Key feature: Standard Package + servicePackage: ServicePackage.Premium, + + // Override specific fields with test-specific data + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + vin: '1HGCV2E35LA005448' + }, + + // Override appointment details + appointmentDetails: { + ...getDefaultTestData().appointmentDetails!, + shopAddress: "6826 Sawmill Rd, Columbus, OH 43235" + }, + + // Use predefined payment data + paymentDetails: PaymentData.getDefaultPaypalDetails() +} + +const cashRepairInShopPayPalTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashRepairInShopPayPal`, + tags: ['@E2E','@CashRepairInShopPayPal', '@test_report', '@CASH'], + testData: cashRepairInShopPayPalData +}, undefined, 'CashRepairInShopPayPal'); +cashRepairInShopPayPalTests.push(tc); + +export default cashRepairInShopPayPalTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashRepairMobileCreditCard.ts b/playwright-tests/tests/CashRepairMobileCreditCard.ts new file mode 100644 index 000000000..3674159aa --- /dev/null +++ b/playwright-tests/tests/CashRepairMobileCreditCard.ts @@ -0,0 +1,50 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { VehicleDamage, AppointmentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import PaymentData from "@business-logic/Data/PaymentData"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashRepairMobileCreditCard"); + +// Now get the test data with the seeded faker +const cashRepairMobileCCData : Partial = { + ...getDefaultTestData(), // Get default data with current seed + + //Override default vehicle damage (Windshield Crack) + vehicleDamage: [VehicleDamage.WindshieldOneChip], + + // Override specific fields with test-specific data + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + vin: '1HGCV2E35LA005448' + }, + + // Override appointment details + appointmentDetails: { + serviceLocation: AppointmentType.Mobile, + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate, + serviceAddress: { + street: '13735 San Antonio Ave', + city: 'Chino', + state: 'California', + postalCode: '91710', + country: 'United States' + } + }, + + // Use predefined payment data + paymentDetails: PaymentData.getDefaultCreditCardDetails() +} + +const cashRepairMobileCCTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashRepairMobileCreditCard`, + tags: ['@E2E','@CashRepairMobileCreditCard', '@test_report', '@CASH'], + testData: cashRepairMobileCCData +}, undefined, 'CashRepairMobileCreditCard'); +cashRepairMobileCCTests.push(tc); + +export default cashRepairMobileCCTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceDynamicRecalMobile.ts b/playwright-tests/tests/CashReplaceDynamicRecalMobile.ts new file mode 100644 index 000000000..50c1d72b5 --- /dev/null +++ b/playwright-tests/tests/CashReplaceDynamicRecalMobile.ts @@ -0,0 +1,63 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { AppointmentType, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashReplaceDynamicRecalMobile"); + +// Now get the test data with the seeded faker +const cashReplaceDynamicRecalMobileData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Flag for recalibration vehicle + isRecalVehicle: true, + + // Flag for dynamic Recalibration vehicle + dynamicRecal: true, + + // Override vehicle details + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2018', + make: 'Ford', + model: 'Expedition', + style: '4 door utility', + vin: '1FMJU1JT2JEA59123', + vehicleLookupType: VehicleLookupType.Vin + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Override appointment details + appointmentDetails: { + serviceLocation: AppointmentType.Mobile, + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate, + serviceAddress: { + // Use street address from current faker seed + street: getDefaultTestData().customerDetails!.address.street, + city: 'Rosedale', + state: 'Maryland', + postalCode: '21237', + country: 'United States' + }, + }, + + // Override payment details + paymentDetails: { + paymentType: PaymentType.PayAtService + } +} + +const cashReplaceDynamicRecalMobileTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceDynamicRecalMobile`, + tags: ['@E2E','@CashReplaceDynamicRecalMobile', '@test_report', '@CASH'], + testData: cashReplaceDynamicRecalMobileData +}, undefined, 'CashReplaceDynamicRecalMobile'); +cashReplaceDynamicRecalMobileTests.push(tc); + +export default cashReplaceDynamicRecalMobileTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceGlassAddressLookupInshopAfterPay.ts b/playwright-tests/tests/CashReplaceGlassAddressLookupInshopAfterPay.ts new file mode 100644 index 000000000..34017c603 --- /dev/null +++ b/playwright-tests/tests/CashReplaceGlassAddressLookupInshopAfterPay.ts @@ -0,0 +1,62 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import PaymentData from "@business-logic/Data/PaymentData"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashReplaceGlassAddressLookupInshopAfterPay"); + +// Now get the test data with the seeded faker +const cashReplaceGlassAddressLookupInshopAfterPayData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Enable entering funnel with ZIP + enterFunnelWithZip: true, + + // Override customer details + customerDetails: { + ...getDefaultTestData().customerDetails!, + lastName: 'Patel', + address: { + street: '4076 Spectacle Dr', + city: 'Columbus', + state: 'Ohio', + postalCode: '43230', + country: 'United States' + } + }, + + // Override vehicle details + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2013', + make: 'Hyundai', + model: 'Sonata', + style: '4 door sedan', + vehicleLookupType: VehicleLookupType.Address + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Override appointment details + appointmentDetails: { + ...getDefaultTestData().appointmentDetails!, + shopAddress: "6826 Sawmill Rd, Columbus, OH 43235" + }, + + // Override payment details + paymentDetails: PaymentData.getDefaultAfterpayDetails() +} + +const cashReplaceGlassAddressLookupInshopAfterPayTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceGlassAddressLookupInshopAfterPay`, + tags: ['@E2E','@CashReplaceGlassAddressLookupInshopAfterPay', '@test_report', '@CASH'], + testData: cashReplaceGlassAddressLookupInshopAfterPayData +}, undefined, 'CashReplaceGlassAddressLookupInshopAfterPay'); +cashReplaceGlassAddressLookupInshopAfterPayTests.push(tc); + +export default cashReplaceGlassAddressLookupInshopAfterPayTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceGlassLicensePlateLookupInshopPaypal.ts b/playwright-tests/tests/CashReplaceGlassLicensePlateLookupInshopPaypal.ts new file mode 100644 index 000000000..d9b6d3afd --- /dev/null +++ b/playwright-tests/tests/CashReplaceGlassLicensePlateLookupInshopPaypal.ts @@ -0,0 +1,53 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import PaymentData from "@business-logic/Data/PaymentData"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashReplaceGlassLicensePlateLookupInshopPaypal"); + +// Now get the test data with the seeded faker +const cashReplaceGlassLicensePlateLookupInshopPaypalData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Override customer details - using a different ZIP + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + street: '4076 Spectacle Dr', + city: 'Columbus', + state: 'Ohio', + postalCode: '44125', + country: 'United States' + } + }, + + // Override vehicle details with license plate lookup + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2013', + make: 'Hyundai', + model: 'Sonata', + style: '4 door sedan', + licensePlateNumber: 'FTY 7776', + vehicleLookupType: VehicleLookupType.LicensePlateNumber + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Override payment details to use PayPal + paymentDetails: PaymentData.getDefaultPaypalDetails() +} + +const cashReplaceGlassLicensePlateLookupInshopPaypalTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceGlassLicensePlateLookupInshopPaypal`, + tags: ['@E2E','@CashReplaceGlassLicensePlateLookupInshopPaypal', '@test_report', '@CASH'], + testData: cashReplaceGlassLicensePlateLookupInshopPaypalData +}, undefined, 'CashReplaceGlassLicensePlateLookupInshopPaypal'); +cashReplaceGlassLicensePlateLookupInshopPaypalTests.push(tc); + +export default cashReplaceGlassLicensePlateLookupInshopPaypalTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceGlassPromoInshop.ts b/playwright-tests/tests/CashReplaceGlassPromoInshop.ts new file mode 100644 index 000000000..900cebeff --- /dev/null +++ b/playwright-tests/tests/CashReplaceGlassPromoInshop.ts @@ -0,0 +1,55 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashReplaceGlassPromoInshop"); + +// Now get the test data with the seeded faker +const cashReplaceGlassPromoInshopData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Add promo code - key feature of this test + promoCode: '20CALL', + + // Flag for recalibration vehicle + isRecalVehicle: true, + + // Override vehicle details with VIN lookup + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2020', + make: 'Ford', + model: 'Fusion', + style: '4 door sedan', + vin: '3FA6P0HD8LR234510', + vehicleLookupType: VehicleLookupType.Vin + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Override appointment details for in-shop service + appointmentDetails: { + ...getDefaultTestData().appointmentDetails!, + shopAddress: "6826 Sawmill Rd, Columbus, OH 43235" + }, + + // Override payment details + paymentDetails: { + paymentType: PaymentType.PayAtService + } +} + +const cashReplaceGlassPromoInshopTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceGlassPromoInshop`, + tags: ['@E2E','@CashReplaceGlassPromoInshop', '@test_report', '@CASH'], + testData: cashReplaceGlassPromoInshopData +}, undefined, 'CashReplaceGlassPromoInshop'); +cashReplaceGlassPromoInshopTests.push(tc); + +export default cashReplaceGlassPromoInshopTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceMultiGlassMobile.ts b/playwright-tests/tests/CashReplaceMultiGlassMobile.ts new file mode 100644 index 000000000..54da96bb6 --- /dev/null +++ b/playwright-tests/tests/CashReplaceMultiGlassMobile.ts @@ -0,0 +1,99 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { VehicleDamage, PartQuestionType, PaymentType, ServicePackage, AppointmentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashReplaceMultiGlassMobile"); + +// Now get the test data with the seeded faker +const cashReplaceMultiGlassMobileData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Override customer postal code + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + ...getDefaultTestData().customerDetails!.address, + postalCode: '43085' + } + }, + + // Flag for recalibration vehicle + isRecalVehicle: true, + + // Key feature: Premium Package + servicePackage: ServicePackage.Premium, + + // Override appointment details + appointmentDetails: { + serviceLocation: AppointmentType.Mobile, + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate, + serviceAddress: { + // Use street address from current faker seed + street: getDefaultTestData().customerDetails!.address.street, + city: 'Rosedale', + state: 'Maryland', + postalCode: '21237', + country: 'United States' + }, + }, + + // Override vehicle details with VIN lookup + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2014', + make: 'Jeep', + model: 'Cherokee', + style: '4 door utility', + vin: '1C4PJLCS6EW288461', + vehicleLookupType: VehicleLookupType.Vin + }, + + // Key feature: multiple damaged glasses + vehicleDamage: [ + VehicleDamage.WindshieldCrack, + VehicleDamage.DriverQuarterPanel, + VehicleDamage.PassengerQuarterPanel, + ], + + // Override payment details + paymentDetails: { + paymentType: PaymentType.PayAtService + }, + + // Vehicle part questions for multiple glass parts + vehiclePartQuestions: [ + { + partQuestionType: PartQuestionType.WindshieldColor, + isOnPage: true, + optionToSelect: 'Green Tint', + secondaryQuestionOptionToSelect: 'solar' + }, + { + partQuestionType: PartQuestionType.DriverQuarterColor, + isOnPage: true, + optionToSelect: 'Green Tint', + secondaryQuestionOptionToSelect: 'solar, driver side, encap, chrome molding' + }, + { + partQuestionType: PartQuestionType.PassengerQuarterColor, + isOnPage: true, + optionToSelect: 'Gray Tint Privacy', + secondaryQuestionOptionToSelect: 'solar, passenger side, encap, chrome molding' + } + ] +} + +const cashReplaceMultiGlassMobileTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceMultiGlassMobile`, + tags: ['@E2E','@CashReplaceMultiGlassMobile', '@test_report', '@CASH'], + testData: cashReplaceMultiGlassMobileData +}, undefined, 'CashReplaceMultiGlassMobile'); +cashReplaceMultiGlassMobileTests.push(tc); + +export default cashReplaceMultiGlassMobileTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceMultiGlassPromoInshop.ts b/playwright-tests/tests/CashReplaceMultiGlassPromoInshop.ts new file mode 100644 index 000000000..9682685ef --- /dev/null +++ b/playwright-tests/tests/CashReplaceMultiGlassPromoInshop.ts @@ -0,0 +1,106 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { VehicleDamage, PartQuestionType, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashReplaceMultiGlassPromoInshop"); + +// Now get the test data with the seeded faker +const cashReplaceMultiGlassPromoInshopData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Add promo code + promoCode: '20CALL', + + // Flag for recalibration vehicle + isRecalVehicle: true, + + // Override customer details - different postal code + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + ...getDefaultTestData().customerDetails!.address, + postalCode: '43085' + } + }, + + // Override vehicle details with VIN lookup + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2019', + make: 'Subaru', + model: 'Outback', + style: '4 door station wagon', + vin: '4S4BSENC4K3221004', + vehicleLookupType: VehicleLookupType.Vin + }, + + // Key feature: multiple damaged glasses + vehicleDamage: [ + VehicleDamage.WindshieldCrack, + VehicleDamage.DriverFrontDoor, + VehicleDamage.DriverRearDoor, + VehicleDamage.DriverVentGlass, + VehicleDamage.PassengerVentGlass, + VehicleDamage.RearWindow + ], + + // Override payment details + paymentDetails: { + paymentType: PaymentType.PayAtService + }, + + // Vehicle part questions for multiple glass parts + vehiclePartQuestions: [ + { + partQuestionType: PartQuestionType.WindshieldColor, + isOnPage: true, + optionToSelect: 'Green Tint, Blue Shade', + secondaryQuestionOptionToSelect: 'solar, lane departure warning system, heated glass wiper park, high beam assist, soundproofing' + }, + { + partQuestionType: PartQuestionType.DriverFrontColor, + isOnPage: true, + optionToSelect: 'Green Tint', + secondaryQuestionOptionToSelect: 'solar, driver side, front, soundproofing, laminated' + }, + { + partQuestionType: PartQuestionType.DriverRearColor, + isOnPage: true, + optionToSelect: 'Gray Tint Privacy', + secondaryQuestionOptionToSelect: 'solar, driver side, rear' + }, + { + partQuestionType: PartQuestionType.DriverVentColor, + isOnPage: true, + optionToSelect: 'Gray Tint Privacy', + secondaryQuestionOptionToSelect: 'solar, driver side, rear' + }, + { + partQuestionType: PartQuestionType.PassengerVentColor, + isOnPage: true, + optionToSelect: 'Gray Tint Privacy', + secondaryQuestionOptionToSelect: 'solar, passenger side, rear' + }, + { + partQuestionType: PartQuestionType.RearWindowColor, + isOnPage: true, + optionToSelect: 'Green Tint', + secondaryQuestionOptionToSelect: 'heated glass, solar, antenna, manual liftgate, 1 hole' + } + ] +} + +const cashReplaceMultiGlassPromoInshopTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceMultiGlassPromoInshop`, + tags: ['@E2E','@CashReplaceMultiGlassPromoInshop', '@test_report', '@CASH'], + testData: cashReplaceMultiGlassPromoInshopData +}, undefined, 'CashReplaceMultiGlassPromoInshop'); +cashReplaceMultiGlassPromoInshopTests.push(tc); + +export default cashReplaceMultiGlassPromoInshopTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceMultiSlidingGlassDropoff.ts b/playwright-tests/tests/CashReplaceMultiSlidingGlassDropoff.ts new file mode 100644 index 000000000..e11f8dce1 --- /dev/null +++ b/playwright-tests/tests/CashReplaceMultiSlidingGlassDropoff.ts @@ -0,0 +1,115 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { VehicleDamage, PartQuestionType, PaymentType, ServicePackage, AppointmentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashReplaceMultiSlidingGlassDropoff"); + +// Now get the test data with the seeded faker +const cashReplaceMultiSlidingGlassDropoffData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Override customer postal code + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + ...getDefaultTestData().customerDetails!.address, + postalCode: '43085' + } + }, + + // Flag for recalibration vehicle + isRecalVehicle: true, + + // Key feature: Standard Package + servicePackage: ServicePackage.Standard, + + // Override for drop-off service + appointmentDetails: { + serviceLocation: AppointmentType.DropOff, + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate + }, + + // Override vehicle details + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2018', + make: 'Ford', + model: 'F Series F150', + style: '4 door crew cab', + vehicleLookupType: VehicleLookupType.Zip + }, + + // Key feature: multiple damaged glasses + vehicleDamage: [ + VehicleDamage.WindshieldCrack, + VehicleDamage.PassengerFrontDoor, + VehicleDamage.PassengerRearDoor, + VehicleDamage.RearSliding + ], + + // Override payment details + paymentDetails: { + paymentType: PaymentType.PayAtService + }, + + // Part questions related to rain sensing + partQuestions: [ + { + partQuestionType: PartQuestionType.GeneralQuestion1, + isOnPage: true, + optionToSelect: 'Yes' + }, + ], + + // Capability Questions + capabilityQuestions: [ + { + partQuestionType: PartQuestionType.GeneralQuestion1, + isOnPage: true, + optionToSelect: 'Yes' + }, + ], + + // Vehicle part questions for multiple glass parts + vehiclePartQuestions: [ + { + partQuestionType: PartQuestionType.WindshieldColor, + isOnPage: true, + optionToSelect: 'Green Tint', + secondaryQuestionOptionToSelect: 'rain sensor, solar, soundproofing, third visor frit, lane departure warning system, heated glass wiper park, w/combination bracket' + }, + { + partQuestionType: PartQuestionType.PassengerFrontColor, + isOnPage: true, + optionToSelect: 'Green Tint', + secondaryQuestionOptionToSelect: 'solar, passenger side, front, laminated, soundproofing' + }, + { + partQuestionType: PartQuestionType.PassengerRearColor, + isOnPage: true, + optionToSelect: 'Gray Tint Privacy', + secondaryQuestionOptionToSelect: 'solar, passenger side, rear, platinum edition, 2 hole' + }, + { + partQuestionType: PartQuestionType.RearSlidingWindowColor, + isOnPage: true, + optionToSelect: 'Gray Tint Privacy', + secondaryQuestionOptionToSelect: 'heated glass, solar, slider, power, kit' + } + ] +} + +const cashReplaceMultiSlidingGlassDropoffTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceMultiSlidingGlassDropoff`, + tags: ['@E2E','@CashReplaceMultiSlidingGlassDropoff', '@test_report', '@CASH'], + testData: cashReplaceMultiSlidingGlassDropoffData +}, undefined, 'CashReplaceMultiSlidingGlassDropoff'); +cashReplaceMultiSlidingGlassDropoffTests.push(tc); + +export default cashReplaceMultiSlidingGlassDropoffTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceRainDefensePromoInshop.ts b/playwright-tests/tests/CashReplaceRainDefensePromoInshop.ts new file mode 100644 index 000000000..d87da9129 --- /dev/null +++ b/playwright-tests/tests/CashReplaceRainDefensePromoInshop.ts @@ -0,0 +1,61 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { ServicePackage, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashReplaceRainDefensePromoInshop"); + +// Now get the test data with the seeded faker +const cashReplaceRainDefensePromoInshopData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Key feature: Premium package with Rain Defense + servicePackage: ServicePackage.Premium, + + // Rain Defense promo code + promoCode: 'rd50', + + // Flag for recalibration vehicle + isRecalVehicle: true, + + // Override customer details + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + ...getDefaultTestData().customerDetails!.address, + postalCode: '43085' + } + }, + + // Override vehicle details + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2020', + make: 'Ford', + model: 'Fusion', + style: '4 door sedan', + vin: '3FA6P0HD8LR234510', + vehicleLookupType: VehicleLookupType.Vin + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Payment at service + paymentDetails: { + paymentType: PaymentType.PayAtService + }, +} + +const cashReplaceRainDefensePromoInshopTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceRainDefensePromoInshop`, + tags: ['@E2E','@CashReplaceRainDefensePromoInshop', '@test_report', '@CASH'], + testData: cashReplaceRainDefensePromoInshopData +}, undefined, 'CashReplaceRainDefensePromoInshop'); +cashReplaceRainDefensePromoInshopTests.push(tc); + +export default cashReplaceRainDefensePromoInshopTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceSafeliteCanNotRecalMobile.ts b/playwright-tests/tests/CashReplaceSafeliteCanNotRecalMobile.ts new file mode 100644 index 000000000..9ad33f23d --- /dev/null +++ b/playwright-tests/tests/CashReplaceSafeliteCanNotRecalMobile.ts @@ -0,0 +1,81 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { AppointmentType, PartQuestionType, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed before generating any data +setFakerSeedFromTestName("CashReplaceSafeliteCanNotRecalMobile"); + +// Now get the test data with the seeded faker +const cashReplaceSafeliteCanNotRecalMobileData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Special flag to skip estimate page + skipEstimatePage: true, + + // Special flag to verify safelite can not recalibrate in the backend + canNotRecal: true, + + // Override customer postal code + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + ...getDefaultTestData().customerDetails!.address, + postalCode: '43085' + } + }, + + // Override vehicle details - vehicle with recalibration that Safelite cannot perform + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2009', + make: 'Volkswagen', + model: 'Passat CC', + style: '4 door sedan', + vin: 'VWML73C79E552439', + vehicleLookupType: VehicleLookupType.Zip + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Override for mobile service + appointmentDetails: { + serviceLocation: AppointmentType.Mobile, + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate, + serviceAddress: { + // Use street address from current faker seed + street: getDefaultTestData().customerDetails!.address.street, + city: 'Rosedale', + state: 'Maryland', + postalCode: '21237', + country: 'United States' + }, + }, + + // Payment at service + paymentDetails: { + paymentType: PaymentType.PayAtService + }, + + // Part questions related to recalibration + partQuestions: [ + { + partQuestionType: PartQuestionType.GeneralQuestion1, + isOnPage: true, + optionToSelect: 'Yes' + }, + ] +} + +const cashReplaceSafeliteCanNotRecalMobileTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceSafeliteCanNotRecalMobile`, + tags: ['@E2E','@CashReplaceSafeliteCanNotRecalMobile', '@test_report', '@CASH'], + testData: cashReplaceSafeliteCanNotRecalMobileData +}, undefined, 'CashReplaceSafeliteCanNotRecalMobile'); +cashReplaceSafeliteCanNotRecalMobileTests.push(tc); + +export default cashReplaceSafeliteCanNotRecalMobileTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceVinMobile.ts b/playwright-tests/tests/CashReplaceVinMobile.ts new file mode 100644 index 000000000..59930b1ac --- /dev/null +++ b/playwright-tests/tests/CashReplaceVinMobile.ts @@ -0,0 +1,69 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { AppointmentType, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed based on test name for consistent but unique data +setFakerSeedFromTestName("CashReplaceVinMobile"); + +// Now get the test data with the seeded faker +const cashReplaceVinMobileData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Flag for recalibration vehicle + isRecalVehicle: true, + + // Override customer postal code + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + ...getDefaultTestData().customerDetails!.address, + postalCode: '43085' + } + }, + + // Override vehicle details - using VIN lookup + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2012', + make: 'Honda', + model: 'Accord', + style: '4 door sedan', + vin: '1HGCP3F83CA040466', + vehicleLookupType: VehicleLookupType.Vin + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Override for mobile service + appointmentDetails: { + serviceLocation: AppointmentType.Mobile, + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate, + serviceAddress: { + // Use street address from current faker seed + street: getDefaultTestData().customerDetails!.address.street, + city: 'Rosedale', + state: 'Maryland', + postalCode: '21237', + country: 'United States' + } + }, + + // Payment at service + paymentDetails: { + paymentType: PaymentType.PayAtService + }, +} + +const cashReplaceVinMobileTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceVinMobile`, + tags: ['@E2E','@CashReplaceVinMobile', '@test_report', '@CASH'], + testData: cashReplaceVinMobileData +}, undefined, 'CashReplaceVinMobile'); +cashReplaceVinMobileTests.push(tc); + +export default cashReplaceVinMobileTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceWiperDropoff.ts b/playwright-tests/tests/CashReplaceWiperDropoff.ts new file mode 100644 index 000000000..a76858b97 --- /dev/null +++ b/playwright-tests/tests/CashReplaceWiperDropoff.ts @@ -0,0 +1,79 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { ServicePackage, AppointmentType, PartQuestionType, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed based on test name for consistent but unique data +setFakerSeedFromTestName("CashReplaceWiperDropoff"); + +// Now get the test data with the seeded faker +const cashReplaceWiperDropoffData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Key feature: Standard package which includes wipers + servicePackage: ServicePackage.Standard, + + // Special flags + skipEstimatePage: true, + isRecalVehicle: true, + + // Override customer postal code + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + ...getDefaultTestData().customerDetails!.address, + postalCode: '43085' + } + }, + + // Override vehicle details - 2023 Audi A8 + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2023', + make: 'Audi', + model: 'A8', + style: '4 door sedan', + vin: 'WAULDAF81PN003021', + vehicleLookupType: VehicleLookupType.Zip + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Override for drop-off service + appointmentDetails: { + serviceLocation: AppointmentType.DropOff, + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate + }, + + // Payment at service + paymentDetails: { + paymentType: PaymentType.PayAtService + }, + + // Part questions related to recalibration + partQuestions: [ + { + partQuestionType: PartQuestionType.GeneralQuestion1, + isOnPage: true, + optionToSelect: 'Yes' + }, + { + partQuestionType: PartQuestionType.GeneralQuestion2, + isOnPage: false, + optionToSelect: 'Yes' + }, + ] +} + +const cashReplaceWiperDropoffTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceWiperDropoff`, + tags: ['@E2E','@CashReplaceWiperDropoff', '@test_report', '@CASH'], + testData: cashReplaceWiperDropoffData +}, undefined, 'CashReplaceWiperDropoff'); +cashReplaceWiperDropoffTests.push(tc); + +export default cashReplaceWiperDropoffTests; \ No newline at end of file diff --git a/playwright-tests/tests/CashReplaceWiperPromoInshop.ts b/playwright-tests/tests/CashReplaceWiperPromoInshop.ts new file mode 100644 index 000000000..66b6acae7 --- /dev/null +++ b/playwright-tests/tests/CashReplaceWiperPromoInshop.ts @@ -0,0 +1,58 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { ServicePackage, AppointmentType, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed based on test name for consistent but unique data +setFakerSeedFromTestName("CashReplaceWiperPromoInShop"); + +// Now get the test data with the seeded faker +const cashReplaceWiperPromoInShopData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Key feature: Standard package with wipers + servicePackage: ServicePackage.Standard, + + // Specific wiper promo code + promoCode: '1WIPER0', + + // Override customer postal code + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + ...getDefaultTestData().customerDetails!.address, + postalCode: '43085' + } + }, + + // Override vehicle details - Truck with VIN lookup + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2015', + make: 'Ford', + model: 'F Series F150', + style: '2 door standard cab', + vin: '1FTMF1C87FKD85044', + vehicleLookupType: VehicleLookupType.Vin + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Payment at service + paymentDetails: { + paymentType: PaymentType.PayAtService + } +} + +const cashReplaceWiperPromoInShopTests: TestCase[] = []; + +const tc = new TestCase({ + name: `CashReplaceWiperPromoInShop`, + tags: ['@E2E','@CashReplaceWiperPromoInShop', '@test_report', '@CASH'], + testData: cashReplaceWiperPromoInShopData +}, undefined, 'CashReplaceWiperPromoInShop'); +cashReplaceWiperPromoInShopTests.push(tc); + +export default cashReplaceWiperPromoInShopTests; \ No newline at end of file diff --git a/playwright-tests/tests/InsuranceAcuityPaypal.ts b/playwright-tests/tests/InsuranceAcuityPaypal.ts new file mode 100644 index 000000000..be8af6e67 --- /dev/null +++ b/playwright-tests/tests/InsuranceAcuityPaypal.ts @@ -0,0 +1,97 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { PaymentMethod, AppointmentType, DamageType, PartQuestionType, VehicleDamage, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed based on test name for consistent but unique data +setFakerSeedFromTestName("InsuranceAcuityPaypal"); + +// Now get the test data with the seeded faker +const insuranceAcuityPaypalData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Key feature: Insurance flow with Acuity + paymentMethod: PaymentMethod.Insurance, + + // Insurance claim flags + isDuplicateClaim: true, + isPolicyFound: true, + isUseVehicleOnPolicy: true, + + // Override customer details for Kentucky location + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + street: getDefaultTestData().customerDetails!.address.street, + city: 'Concord', + state: 'Kentucky', + postalCode: '42071', + country: 'United States' + } + }, + + // Insurance claim details + claimDetails: { + client: 'ACUITY INSURANCE', + policyNumber: 'Mock532809F', + policyDeductible: 0, + damageDate: new Date(new Date().setDate(new Date().getDate() - 1)).toLocaleDateString('en-US', {month: '2-digit', day: '2-digit', year: 'numeric'}), + damageCause: DamageType.Hail + }, + + // Heavy duty truck details with VIN lookup + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2006', + make: 'Ford', + model: 'F Series F550', + style: '2 door standard cab', + vin: '1FDAF57P86EA68105', + vehicleLookupType: VehicleLookupType.Vin, + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Override for in-shop appointment + appointmentDetails: { + serviceLocation: AppointmentType.InShop, + shopAddress: '8985 Yellow Brick Rd, Rosedale, MD 21237', + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate + }, + + // Part questions for windshield + vehiclePartQuestions: [ + { + partQuestionType: PartQuestionType.WindshieldColor, + isOnPage: true, + optionToSelect: 'Green Tint', + secondaryQuestionOptionToSelect: 'solar, third visor frit, aftermarket' + }, + ], + + // Other vehicles on policy + otherVehiclesOnPolicy: [ + { + year: '2000', + make: 'Ford', + model: 'F250 Super Duty', + vehicleLookupType: VehicleLookupType.Vin + } + ], + + // Override payment details (empty because we skip payment method page in insurance flow) + paymentDetails: {} +} + +const insuranceAcuityPaypalTests: TestCase[] = []; + +const tc = new TestCase({ + name: `InsuranceAcuityPaypal`, + tags: ['@E2E','@InsuranceAcuityPaypal', '@test_report', '@Insurance'], + testData: insuranceAcuityPaypalData +}, undefined, 'InsuranceAcuityPaypal'); +insuranceAcuityPaypalTests.push(tc); + +export default insuranceAcuityPaypalTests; \ No newline at end of file diff --git a/playwright-tests/tests/InsuranceGeico.ts b/playwright-tests/tests/InsuranceGeico.ts new file mode 100644 index 000000000..1490eed7f --- /dev/null +++ b/playwright-tests/tests/InsuranceGeico.ts @@ -0,0 +1,78 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { PaymentMethod, AppointmentType, DamageType, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed based on test name for consistent but unique data +setFakerSeedFromTestName("InsuranceGeico"); + +// Now get the test data with the seeded faker +const insuranceGeicoData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Key feature: Insurance flow with GEICO + paymentMethod: PaymentMethod.Insurance, + + // Insurance claim flags + isDuplicateClaim: true, + isPolicyFound: true, + isUseVehicleOnPolicy: true, + + // Override customer details with specific name and California location + customerDetails: { + ...getDefaultTestData().customerDetails!, + firstName: 'Albina', + lastName: 'Klint', + address: { + ...getDefaultTestData().customerDetails!.address, + city: 'Ontario', + state: 'California', + postalCode: '91761' + } + }, + + // Insurance claim details + claimDetails: { + client: 'GEICO', + policyNumber: 'Mock250034C', + policyDeductible: 0, + damageDate: new Date(new Date().setDate(new Date().getDate() - 1)).toLocaleDateString('en-US', {month: '2-digit', day: '2-digit', year: 'numeric'}), + damageCause: DamageType.Hail + }, + + // Hyundai vehicle details with VIN lookup + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2018', + make: 'Hyundai', + model: 'Elantra', + style: '4 door sedan', + vin: '5NPD84LFXJH285828', + vehicleLookupType: VehicleLookupType.Vin, + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Override for in-shop appointment + appointmentDetails: { + serviceLocation: AppointmentType.InShop, + shopAddress: '8985 Yellow Brick Rd, Rosedale, MD 21237', + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate + }, + + // Override payment details (empty because we skip payment method page in insurance flow) + paymentDetails: {} +} + +const insuranceGeicoTests: TestCase[] = []; + +const tc = new TestCase({ + name: `InsuranceGeico`, + tags: ['@E2E','@InsuranceGeico', '@test_report', '@Insurance'], + testData: insuranceGeicoData +}, undefined, 'InsuranceGeico'); +insuranceGeicoTests.push(tc); + +export default insuranceGeicoTests; \ No newline at end of file diff --git a/playwright-tests/tests/InsuranceITAC21stCentury.ts b/playwright-tests/tests/InsuranceITAC21stCentury.ts new file mode 100644 index 000000000..70ce6b528 --- /dev/null +++ b/playwright-tests/tests/InsuranceITAC21stCentury.ts @@ -0,0 +1,82 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { PaymentMethod, AppointmentType, DamageType, PartQuestionType, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed based on test name for consistent but unique data +setFakerSeedFromTestName("InsuranceITAC21stCentury"); + +// Now get the test data with the seeded faker +const insuranceITAC21stCenturyData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Key feature: Insurance flow with 21st Century - ITAC (Insurance Total Assumption of Coverage) + paymentMethod: PaymentMethod.Insurance, + + // Insurance claim flags + isDuplicateClaim: true, + isPolicyFound: true, + isUseVehicleOnPolicy: true, + isRecalNotification: true, // Special flag for recalibration notification + + // Override customer details for California location + customerDetails: { + ...getDefaultTestData().customerDetails!, + address: { + ...getDefaultTestData().customerDetails!.address, + city: 'Ontario', + state: 'California', + postalCode: '91761' + } + }, + + // Insurance claim details with high deductible + claimDetails: { + client: '21st Century', + policyNumber: 'Mock200122P', + policyDeductible: 3500, // High deductible test case + damageDate: new Date(new Date().setDate(new Date().getDate() - 1)).toLocaleDateString('en-US', {month: '2-digit', day: '2-digit', year: 'numeric'}), + damageCause: DamageType.Hail + }, + + // Vehicle details using ZIP lookup + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2018', + make: 'Honda', + model: 'Accord', + style: '4 door sedan', + vehicleLookupType: VehicleLookupType.Zip, + }, + + // No need to override vehicleDamage as it already defaults to WindshieldCrack + + // Override for in-shop appointment with specific shop + appointmentDetails: { + serviceLocation: AppointmentType.InShop, + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate, + shopAddress: "8160 Masi Dr, Rancho Cucamonga, CA 91730" + }, + + // Part questions related to recalibration + partQuestions: [ + { + partQuestionType: PartQuestionType.GeneralQuestion1, + isOnPage: true, + optionToSelect: 'Yes' + }, + ] +} + +const insuranceITAC21stCenturyTests: TestCase[] = []; + +const tc = new TestCase({ + name: `InsuranceITAC21stCentury`, + tags: ['@E2E','@InsuranceITAC21stCentury', '@test_report', '@Insurance'], + testData: insuranceITAC21stCenturyData +}, undefined, 'InsuranceITAC21stCentury'); +insuranceITAC21stCenturyTests.push(tc); + +export default insuranceITAC21stCenturyTests; \ No newline at end of file diff --git a/playwright-tests/tests/InsuranceITACOptimizedPriceValidationAllState.ts b/playwright-tests/tests/InsuranceITACOptimizedPriceValidationAllState.ts new file mode 100644 index 000000000..00bac631d --- /dev/null +++ b/playwright-tests/tests/InsuranceITACOptimizedPriceValidationAllState.ts @@ -0,0 +1,79 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { VehicleDamage, PaymentMethod, AppointmentType, DamageType, PaymentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleLookupType } from "@business-logic/types/Enums"; +import { getDefaultTestData, setFakerSeedFromTestName } from "@business-logic/constants/DefaultTestData"; + +// Set the seed based on test name for consistent but unique data +setFakerSeedFromTestName("InsuranceITACOptimizedPriceValidationAllState"); + +// Now get the test data with the seeded faker +const insuranceITACOptimizedPriceValidationAllStateData: Partial = { + ...getDefaultTestData(), // Get default data with current seed + + // Key feature: Insurance flow with Allstate - ITAC with Optimized Price Validation + paymentMethod: PaymentMethod.Insurance, + + // Insurance claim flags + isDuplicateClaim: true, + isPolicyFound: true, + isUseVehicleOnPolicy: true, + + // Override customer details with specific name and location + customerDetails: { + ...getDefaultTestData().customerDetails!, + firstName: 'Deborah L', + lastName: 'Grist', + address: { + ...getDefaultTestData().customerDetails!.address, + city: 'Franklin County', + state: 'Ohio', + postalCode: '43235' + } + }, + + // Insurance claim details + claimDetails: { + client: 'Allstate Insurance', + policyNumber: 'Mock294523C', + policyDeductible: 0, + damageDate: new Date(new Date().setDate(new Date().getDate() - 1)).toLocaleDateString('en-US', {month: '2-digit', day: '2-digit', year: 'numeric'}), + damageCause: DamageType.Rock + }, + + // Vehicle details for truck with ZIP lookup + vehicleDetails: { + ...getDefaultTestData().vehicleDetails!, + year: '2015', + make: 'Toyota', + model: 'Tacoma Pickup', + style: '4 door crew cab', + vehicleLookupType: VehicleLookupType.Zip, + }, + + // Driver door damage instead of windshield + vehicleDamage: [ + VehicleDamage.DriverFrontDoor, + ], + + // Override for in-shop appointment with specific shop + appointmentDetails: { + serviceLocation: AppointmentType.InShop, + shopAddress: '8985 Yellow Brick Rd, Rosedale, MD 21237', + appointmentDate: getDefaultTestData().appointmentDetails?.appointmentDate + }, + // Override payment details (empty because we skip payment method page in insurance flow) + paymentDetails: {} +} + +const insuranceITACOptimizedPriceValidationAllStateTests: TestCase[] = []; + +const tc = new TestCase({ + name: `InsuranceITACOptimizedPriceValidationAllState`, + tags: ['@E2E','@InsuranceITACOptimizedPriceValidationAllState', '@test_report', '@Insurance'], + testData: insuranceITACOptimizedPriceValidationAllStateData +}, undefined, 'InsuranceITACOptimizedPriceValidationAllState'); +insuranceITACOptimizedPriceValidationAllStateTests.push(tc); + +export default insuranceITACOptimizedPriceValidationAllStateTests; \ No newline at end of file diff --git a/playwright-tests/tests/alert-validation/alert0001_HeavyTruck.ts b/playwright-tests/tests/alert-validation/alert0001_HeavyTruck.ts new file mode 100644 index 000000000..dad46ecd5 --- /dev/null +++ b/playwright-tests/tests/alert-validation/alert0001_HeavyTruck.ts @@ -0,0 +1,42 @@ +//Imports here +import { ITestData } from "@business-logic/types/ITestData" +import { VehicleDamage, AppointmentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { getNextWeekday } from "@impl/utils/DateUtils"; +import { defaultTestData } from "@business-logic/constants/DefaultTestData"; + +// Heavy Truck (alert) Test Data +const heavyTruckData: Partial = { + ...defaultTestData, // Start with all defaults + + // Override specific fields with test-specific data + vehicleDetails: { + ...defaultTestData.vehicleDetails!, + year: '2017', + make: 'Freightliner', + model: '114sd', + style: 'conventional cab' + }, + alertFlags: { + isHeavyTruckVehicle: true + }, + vehicleDamage: [ + VehicleDamage.WindshieldOneChip, + VehicleDamage.RearWindow + ], + appointmentDetails: { + serviceLocation: AppointmentType.DropOff, + appointmentDate: getNextWeekday() + } +}; + +const heavyTruckTests: TestCase[] = []; + +const tc = new TestCase({ + name: `alert0001 Heavy Truck`, + tags: ['@Alert', '@HeavyTruck', '@test_report'], + testData: heavyTruckData +}, undefined, 'HeavyTruck'); +heavyTruckTests.push(tc); + +export default heavyTruckTests; \ No newline at end of file diff --git a/playwright-tests/tests/alert-validation/alert0002_RepairAndReplace.ts b/playwright-tests/tests/alert-validation/alert0002_RepairAndReplace.ts new file mode 100644 index 000000000..aa52ac4e5 --- /dev/null +++ b/playwright-tests/tests/alert-validation/alert0002_RepairAndReplace.ts @@ -0,0 +1,42 @@ +// Imports here +import { ITestData } from "@business-logic/types/ITestData"; +import { VehicleDamage, AppointmentType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { getNextWeekday } from "@impl/utils/DateUtils"; +import { defaultTestData } from "@business-logic/constants/DefaultTestData"; + +// Both repair and replace selected (alert) Test Data +const repairAndReplaceData: Partial = { + ...defaultTestData, // Start with all defaults + + // Override specific fields with test-specific data + vehicleDetails: { + ...defaultTestData.vehicleDetails!, + year: '2020', + make: 'Acura', + model: 'ILX', + style: '4 door sedan' + }, + vehicleDamage: [ + VehicleDamage.WindshieldOneChip, + VehicleDamage.RearWindow + ], + appointmentDetails: { + serviceLocation: AppointmentType.DropOff, + appointmentDate: getNextWeekday() + }, + alertFlags: { + isRepairReplace: true + } +}; + +const repairAndReplaceTests: TestCase[] = []; + +const tc = new TestCase({ + name: `alert0002 Both repair and replace selected`, + tags: ['@Alert', '@RepairReplace', '@test_report'], + testData: repairAndReplaceData +}, undefined, 'RepairReplace'); +repairAndReplaceTests.push(tc); + +export default repairAndReplaceTests; \ No newline at end of file diff --git a/playwright-tests/tests/alert-validation/alert0003_SplitWindshield.ts b/playwright-tests/tests/alert-validation/alert0003_SplitWindshield.ts new file mode 100644 index 000000000..f01151b05 --- /dev/null +++ b/playwright-tests/tests/alert-validation/alert0003_SplitWindshield.ts @@ -0,0 +1,62 @@ +// Imports here +import { ITestData } from "@business-logic/types/ITestData"; +import TestCase from "@business-logic/types/TestCase"; +import { VehicleDamage } from "@business-logic/types/Enums"; +import { defaultTestData } from "@business-logic/constants/DefaultTestData"; + +// Windshield selected, when vehicle has split windshield (alert) Test Data +const splitWindshieldData: Partial = { + ...defaultTestData, // Start with all defaults + + // Override specific fields with test-specific data + vehicleDamage: [ + VehicleDamage.WindshieldOneChip + ], + alertFlags: { + isSplitWindshield: true + } +}; + +const vehiclesToTest = [ + { + year: '2000', + make: 'Kenworth', + model: 'T450', + style: 'conventional cab' + }, + { + year: '2006', + make: 'Navistar', + model: '5000 I', + style: '2 door conventional cab' + }, + { + year: '1990', + make: 'Kenworth', + model: 'T600', + style: 'conventional cab' + } +]; + +const splitWindshieldTests: TestCase[] = []; + +// Generate test cases for each vehicle +vehiclesToTest.forEach((vehicle, index) => { + const testData = { + ...splitWindshieldData, + vehicleDetails: { + ...defaultTestData.vehicleDetails!, + ...vehicle + } + }; + + const tc = new TestCase({ + name: `alert0003_${vehicle.make.toLowerCase()}_${vehicle.model.toLowerCase()} Windshield selected, when vehicle has split windshield - ${vehicle.make} ${vehicle.model} ${vehicle.year}`, + tags: ['@Alert', '@SplitWindshield', '@test_report', `@${vehicle.make.toLowerCase()}`], + testData: testData + }, undefined, `SpliWindshield${index + 1}`); + + splitWindshieldTests.push(tc); +}); + +export default splitWindshieldTests; \ No newline at end of file diff --git a/playwright-tests/tests/alert-validation/alert0004_RepairOnly.ts b/playwright-tests/tests/alert-validation/alert0004_RepairOnly.ts new file mode 100644 index 000000000..3dc245ca4 --- /dev/null +++ b/playwright-tests/tests/alert-validation/alert0004_RepairOnly.ts @@ -0,0 +1,41 @@ +// Imports here +import { ITestData } from "@business-logic/types/ITestData"; +import { VehicleDamage, AppointmentType, VehicleLookupType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { getNextWeekday } from "@impl/utils/DateUtils"; +import { defaultTestData } from "@business-logic/constants/DefaultTestData"; + +// Replace selected when not offered (alert) Test Data +const repairOnlyData: Partial = { + ...defaultTestData, // Start with all defaults + + // Override specific fields with test-specific data + vehicleDetails: { + ...defaultTestData.vehicleDetails!, + year: '2023', + make: 'Motor Home', + model: 'Motor Home', + style: 'motor home' + }, + vehicleDamage: [ + VehicleDamage.WindshieldCrack + ], + appointmentDetails: { + serviceLocation: AppointmentType.DropOff, + appointmentDate: getNextWeekday() + }, + alertFlags: { + isRepairOnly: true + } +}; + +const repairOnlyTests: TestCase[] = []; + +const tc = new TestCase({ + name: `alert0004 Replace selected when not offered`, + tags: ['@Alert', '@RepairOnly', '@test_report'], + testData: repairOnlyData +}, undefined, 'RepairOnly'); +repairOnlyTests.push(tc); + +export default repairOnlyTests; \ No newline at end of file diff --git a/playwright-tests/tests/alert-validation/alert0005_UnserviceableZip.ts b/playwright-tests/tests/alert-validation/alert0005_UnserviceableZip.ts new file mode 100644 index 000000000..0e70dd79b --- /dev/null +++ b/playwright-tests/tests/alert-validation/alert0005_UnserviceableZip.ts @@ -0,0 +1,118 @@ +// Imports here +import { ITestData } from "@business-logic/types/ITestData"; +import { VehicleDamage, AppointmentType, VehicleLookupType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { getNextWeekday } from "@impl/utils/DateUtils"; +import { faker } from "@faker-js/faker"; +import { defaultTestData } from "@business-logic/constants/DefaultTestData"; + +// Unserviceable zip (alert) - Combined test cases for all lookup types +const nextWeekday = getNextWeekday(); + +const baseUnserviceableZipData: Partial = { + ...defaultTestData, // Start with all defaults + + // Override specific fields with test-specific data + customerDetails: { + ...defaultTestData.customerDetails!, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + address: { + street: faker.location.streetAddress(), + city: 'Saco', + state: 'Montana', + postalCode: '59261', + country: 'United States' + } + }, + vehicleDamage: [ + VehicleDamage.WindshieldCrack + ], + appointmentDetails: { + serviceLocation: AppointmentType.DropOff, + appointmentDate: nextWeekday + }, + alertFlags: { + isUnserviceableZip: true + } +}; + +interface LookupTestCase { + type: VehicleLookupType; + vehicleDetails: { + year: string; + make: string; + model: string; + style: string; + licensePlateNumber?: string; + licensePlateState?: string; + vin?: string; + }; + enterFunnelWithZip?: boolean; + customerDetails?: { + address: { + street: string; + city: string; + state: string; + postalCode: string; + country: string; + }; + }; + tag: string; +} + +const lookupTypesToTest: LookupTestCase[] = [ + { + type: VehicleLookupType.Zip, + vehicleDetails: { + year: '2020', + make: 'Acura', + model: 'ILX', + style: '4 door sedan' + }, + tag: 'Zip' + }, + { + type: VehicleLookupType.Vin, + vehicleDetails: { + year: '2019', + make: 'Toyota', + model: 'C-HR', + style: '4 door hatchback', + vin: 'NMTKHMBX5KR086519' + }, + tag: 'Vin' + } +]; + +const unserviceableZipTests: TestCase[] = []; + +// Generate test cases for each lookup type +lookupTypesToTest.forEach((lookupType, index) => { + const testData = { + ...baseUnserviceableZipData, + vehicleDetails: { + ...lookupType.vehicleDetails, + vehicleLookupType: lookupType.type + }, + enterFunnelWithZip: lookupType.enterFunnelWithZip + }; + + // Handle special case for Address lookup which has different customer details + if (lookupType.type === VehicleLookupType.Address && lookupType.customerDetails) { + testData.customerDetails = { + ...baseUnserviceableZipData.customerDetails!, + address: lookupType.customerDetails.address + }; + } + + const tc = new TestCase({ + name: `alert0005 ${lookupType.tag} Lookup Unserviceable zip`, + tags: ['@Alert', `@UnserviceableZip_${lookupType.tag}`, '@test_report'], + testData: testData + }, undefined, `UnserviceableZip_${lookupType.tag}`); + + unserviceableZipTests.push(tc); +}); + +export default unserviceableZipTests; \ No newline at end of file diff --git a/playwright-tests/tests/alert-validation/alert0006_InvalidZip.ts b/playwright-tests/tests/alert-validation/alert0006_InvalidZip.ts new file mode 100644 index 000000000..5ffcd3afe --- /dev/null +++ b/playwright-tests/tests/alert-validation/alert0006_InvalidZip.ts @@ -0,0 +1,134 @@ +// Imports here +import { ITestData } from "@business-logic/types/ITestData"; +import { VehicleDamage, AppointmentType, VehicleLookupType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { getNextWeekday } from "@impl/utils/DateUtils"; +import { faker } from "@faker-js/faker"; +import { defaultTestData } from "@business-logic/constants/DefaultTestData"; + +// Invalid zip (alert) - Combined test cases for all lookup types +const nextWeekday = getNextWeekday(); + +const baseInvalidZipData: Partial = { + ...defaultTestData, // Start with all defaults + + // Override specific fields with test-specific data + customerDetails: { + ...defaultTestData.customerDetails!, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + address: { + street: faker.location.streetAddress(), + city: 'Saco', + state: 'Montana', + postalCode: '99999', + country: 'United States' + } + }, + vehicleDamage: [ + VehicleDamage.WindshieldCrack + ], + appointmentDetails: { + serviceLocation: AppointmentType.DropOff, + appointmentDate: nextWeekday + }, + alertFlags: { + isInvalidZip: true + } +}; + +interface LookupTestCase { + type: VehicleLookupType; + vehicleDetails: { + year: string; + make: string; + model: string; + style: string; + licensePlateNumber?: string; + licensePlateState?: string; + vin?: string; + }; + enterFunnelWithZip?: boolean; + customerDetails?: { + address: { + street: string; + city: string; + state: string; + postalCode: string; + country: string; + }; + }; + tag: string; + displayName: string; +} + +const lookupTypesToTest: LookupTestCase[] = [ + { + type: VehicleLookupType.Zip, + displayName: 'Zip', + tag: 'Zip', + vehicleDetails: { + year: '2020', + make: 'Acura', + model: 'ILX', + style: '4 door sedan' + } + }, + { + type: VehicleLookupType.LicensePlateNumber, + displayName: 'License Plate', + tag: 'Plate', + vehicleDetails: { + year: '2013', + make: 'Hyundai', + model: 'Sonata', + style: '4 door sedan', + licensePlateNumber: 'FTY 7776', + licensePlateState: 'Texas' + } + }, + { + type: VehicleLookupType.Vin, + displayName: 'VIN', + tag: 'Vin', + vehicleDetails: { + year: '2019', + make: 'Toyota', + model: 'C-HR', + style: '4 door hatchback', + vin: 'NMTKHMBX5KR086519' + } + } +]; + +const invalidZipTests: TestCase[] = []; + +// Generate test cases for each lookup type +lookupTypesToTest.forEach((lookupType) => { + const testData = { + ...baseInvalidZipData, + vehicleDetails: { + ...lookupType.vehicleDetails, + vehicleLookupType: lookupType.type + }, + enterFunnelWithZip: lookupType.enterFunnelWithZip + }; + + // Handle special case for Address lookup which has different customer details + if (lookupType.type === VehicleLookupType.Address && lookupType.customerDetails) { + testData.customerDetails = { + ...baseInvalidZipData.customerDetails!, + address: lookupType.customerDetails.address + }; + } + + const tc = new TestCase({ + name: `alert0006 ${lookupType.displayName} Lookup Invalid zip`, + tags: ['@Alert', `@InvalidZip_${lookupType.tag}`, '@test_report'], + testData: testData + }, undefined, `InvalidZip_${lookupType.tag}`); + + invalidZipTests.push(tc); +}); + +export default invalidZipTests; \ No newline at end of file diff --git a/playwright-tests/tests/alert-validation/alert0007_VinNotFound.ts b/playwright-tests/tests/alert-validation/alert0007_VinNotFound.ts new file mode 100644 index 000000000..0ee058903 --- /dev/null +++ b/playwright-tests/tests/alert-validation/alert0007_VinNotFound.ts @@ -0,0 +1,144 @@ +// Imports here +import { ITestData } from "@business-logic/types/ITestData"; +import { VehicleDamage, AppointmentType, VehicleLookupType } from "@business-logic/types/Enums"; +import TestCase from "@business-logic/types/TestCase"; +import { getNextWeekday } from "@impl/utils/DateUtils"; +import { faker } from "@faker-js/faker"; +import { defaultTestData } from "@business-logic/constants/DefaultTestData"; + +// Vin not found (alert) - Combined test cases for all lookup types +const nextWeekday = getNextWeekday(); + +const baseVinNotFoundData: Partial = { + ...defaultTestData, // Start with all defaults + + // Override specific fields with test-specific data + customerDetails: { + ...defaultTestData.customerDetails!, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + address: { + street: faker.location.streetAddress(), + city: 'Raleigh', + state: 'North Carolina', + postalCode: '44089', + country: 'United States' + } + }, + vehicleDamage: [ + VehicleDamage.WindshieldCrack + ], + appointmentDetails: { + serviceLocation: AppointmentType.DropOff, + appointmentDate: nextWeekday + }, + alertFlags: { + isVinNotFound: true + } +}; + +interface LookupTestCase { + type: VehicleLookupType; + vehicleDetails: { + year: string; + make: string; + model: string; + style: string; + licensePlateNumber?: string; + licensePlateState?: string; + vin?: string; + }; + enterFunnelWithZip?: boolean; + customerDetails?: { + address: { + street: string; + city: string; + state: string; + postalCode: string; + country: string; + }; + }; + tag: string; + displayName: string; +} + +const lookupTypesToTest: LookupTestCase[] = [ + { + type: VehicleLookupType.Address, + displayName: 'Address', + tag: 'Addr', + vehicleDetails: { + year: '2013', + make: 'Hyundai', + model: 'Sonata', + style: '4 door sedan' + }, + customerDetails: { + address: { + street: '4076 Spectacle Dr', + city: 'Columbus', + state: 'Ohio', + postalCode: '59261', + country: 'United States' + } + }, + enterFunnelWithZip: true + }, + { + type: VehicleLookupType.LicensePlateNumber, + displayName: 'License Plate', + tag: 'Plate', + vehicleDetails: { + year: '2013', + make: 'Hyundai', + model: 'Sonata', + style: '4 door sedan', + licensePlateNumber: '6WYN462', + licensePlateState: 'Texas' + } + }, + { + type: VehicleLookupType.Vin, + displayName: 'VIN', + tag: 'Vin', + vehicleDetails: { + year: '2019', + make: 'Toyota', + model: 'C-HR', + style: '4 door hatchback', + vin: '1MMHK8F81CGA45982' + } + } +]; + +const vinNotFoundTests: TestCase[] = []; + +// Generate test cases for each lookup type +lookupTypesToTest.forEach((lookupType) => { + const testData = { + ...baseVinNotFoundData, + vehicleDetails: { + ...lookupType.vehicleDetails, + vehicleLookupType: lookupType.type + }, + enterFunnelWithZip: lookupType.enterFunnelWithZip + }; + + // Handle special case for Address lookup which has different customer details + if (lookupType.type === VehicleLookupType.Address && lookupType.customerDetails) { + testData.customerDetails = { + ...baseVinNotFoundData.customerDetails!, + address: lookupType.customerDetails.address + }; + } + + const tc = new TestCase({ + name: `alert0007 ${lookupType.displayName} Lookup Vin Not Found`, + tags: ['@Alert', `@VinNotFound_${lookupType.tag}`, '@test_report'], + testData: testData + }, undefined, `VinNotFound_${lookupType.tag}`); + + vinNotFoundTests.push(tc); +}); + +export default vinNotFoundTests; \ No newline at end of file diff --git a/playwright-tests/tsconfig.json b/playwright-tests/tsconfig.json new file mode 100644 index 000000000..069ee9286 --- /dev/null +++ b/playwright-tests/tsconfig.json @@ -0,0 +1,62 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "strictNullChecks": true, + "noImplicitAny": false, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "rootDirs": [ + "./impl", + "./business-logic" + ], + "paths": { + "@impl/*": [ + "./impl/*" + ], + "@api/*": [ + "./impl/api/*" + ], + "@controller/*": [ + "./impl/api/controller/*" + ], + "@model/*": [ + "./impl/api/model/*" + ], + "@gui/*": [ + "./impl/gui/*" + ], + "@lfm/*": [ + "./impl/gui/lfm/*" + ], + "@pom/*": [ + "./impl/gui/pom/*" + ], + "@mixins/*": [ + "./impl/gui/mixins" + ], + "@business-logic/*": [ + "./business-logic/*" + ], + "@validations/*": [ + "./business-logic/validations/*" + ], + "@workflows/*": [ + "./business-logic/workflows/*" + ], + "@helpers/*": [ + "./helpers/*" + ], + "@tests/*": [ + "./tests/*" + ], + "@utils/*": [ + "./impl/utils/*" + ], + } + } +} \ No newline at end of file