diff --git a/playwright-tests/.env.dev b/playwright-tests/.env.dev index 1dad92994..f02f656d0 100644 --- a/playwright-tests/.env.dev +++ b/playwright-tests/.env.dev @@ -9,11 +9,11 @@ SKIP_CONTENT_SITE=false # Base URLs by environment # qa -BASE_URL="https://www-qa2.safelite.com/" +# BASE_URL="https://www-qa2.safelite.com/" # local version of FMG (after running the local server) # BASE_URL="http://localhost:8080/fmg/" # qa with skipToInsurance Turned Off -# BASE_URL="https://fixmyglassqa.safelite.com/?cns=all&experiments=ConceptFunnel=ConceptFunnel_V1=ConceptFunnel_VinAndEmailOptional=true,NextGen_IGQSkipToInsurance=NextGen_IGQSkipToInsurance_V1=NextGen_IGQSkipToInsurance_CONTROL=true" +BASE_URL="https://www-qa2.safelite.com/?&experiments=ConceptFunnel=ConceptFunnel_V1=ConceptFunnel_VinAndEmailOptional=true" # sys # BASE_URL="https://www-test2.safelite.com/fmg/?fmgPage=vehicle" # dev diff --git a/playwright-tests/framework/TestData.ts b/playwright-tests/framework/TestData.ts index cc9e44c23..9ed648426 100644 --- a/playwright-tests/framework/TestData.ts +++ b/playwright-tests/framework/TestData.ts @@ -5,4 +5,5 @@ export interface ITestData extends base { // Put any project-specific data here. Anything useful to other Safelite projects should be submitted as a pull request to safelite-playwright-core. paymentMethod: PaymentMethod isOptedInForTextMessages: boolean + totalAmount?: number } \ No newline at end of file diff --git a/playwright-tests/pages/BasePage.ts b/playwright-tests/pages/BasePage.ts index 1de8c0b7f..0239ecd3d 100644 --- a/playwright-tests/pages/BasePage.ts +++ b/playwright-tests/pages/BasePage.ts @@ -3,6 +3,7 @@ import { Soft } from 'safelite-playwright-core'; import { waitUntil } from 'safelite-playwright-core'; import test, { expect, type Locator, type Page } from '@playwright/test'; import { error } from 'console'; +import { ITestData } from 'framework/TestData'; /** * Base class for all page objects. @@ -18,7 +19,7 @@ export class BasePage { readonly hamburgerMenu: Locator; readonly progressBar: Locator; - constructor(page: Page){ + constructor(page: Page) { this.page = page; this.continueButton = page.locator('[id="infoBox"]').getByRole('button'); this.backButton = page.locator('[id="infoBox"]').getByRole('link'); @@ -27,21 +28,22 @@ export class BasePage { this.hamburgerMenu = this.page.getByRole('button', { name: 'Hamburger Menu (modal window)' }); this.progressBar = this.page.locator('.progress-bar-outer .progress-bar-inner'); } - + async nextPage() { - await waitUntil(async () => {; - return (await this.continueButton.getAttribute('aria-disabled')) !== 'true' - }); - - await this.continueButton.click(); - await waitUntil(async () => { - let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all(); - return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false)); - }); + ; + return (await this.continueButton.getAttribute('aria-disabled')) !== 'true' + }); + + await this.continueButton.click(); + + await waitUntil(async () => { + let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all(); + return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false)); + }); } - - + + async previousPage() { const startingUrl = this.page.url(); await expect(async () => { @@ -49,14 +51,14 @@ export class BasePage { if (currentUrl === startingUrl) { await this.backButton.click({ timeout: 1000 }); } - expect(currentUrl).not.toEqual(startingUrl); + expect(currentUrl).not.toEqual(startingUrl); }).toPass({ timeout: 240_000 }); } - async fillAndValidate(element: Locator, value: string){ + async fillAndValidate(element: Locator, value: string) { await expect(async () => { var text = await element.textContent(); - if(text !== value) { + if (text !== value) { await element.clear(); await element.fill(value); } @@ -79,7 +81,7 @@ export class BasePage { } await page.waitForTimeout(100); // Small delay before retrying } - console.log(`Failed to click the the element within ${timeout/1000} seconds` + error); + console.log(`Failed to click the the element within ${timeout / 1000} seconds` + error); } async logReferralNumber() { @@ -107,7 +109,7 @@ export class BasePage { const apiUrl = `https://digitalapi.${process.env['PLAYWRIGHT_ENV']!.replace('sys', 'test').toLowerCase()}.safelite.io/schedule/api/v1/schedule/mobile-time-slots`; await this.page.route(apiUrl, async (route) => { const currentDate = new Date().toISOString().split('T')[0]; // e.g., "2025-07-23" - if(route.request().postDataJSON().startDate === currentDate) { + if (route.request().postDataJSON().startDate === currentDate) { const response = await route.fetch(); const responseBody = await response.json(); @@ -120,28 +122,45 @@ export class BasePage { }); customerDetails.apptDate = responseBody.days.find((day: any) => day.timeSlots.some((slot: any) => slot.offerPremium === true)).date || undefined; - + // Mock the response await route.fulfill({ response, body: JSON.stringify(responseBody), }); } - }); + }); + } + + async getRepairPartsTotal(testData: Partial) { + const apiUrl = `https://digitalapi.${process.env['PLAYWRIGHT_ENV']!.replace('sys', 'test').toLowerCase()}.safelite.io/price/api/v1/price/order-items`; + await this.page.on('response', async (response) => { + const orderItemRequest = response.request(); + if (response.request().url() === apiUrl && orderItemRequest.postDataJSON().lineItems.some(item => item.partNumber === "WSREPAIR")) { + const responseBody = await response.json(); + const targetParts = ['WSREPAIR']; // ["SUPPLIES-REPAIR", "WSREPAIR"]; + testData.totalAmount = responseBody.lineItems + .filter(item => targetParts.includes(item.partNumber)) + .reduce((sum: number, item: any) => { + return sum + item.laborAmount + item.sellingPrice + item.kitPrice; + }, 0).toFixed(2); + } + }); + testData.totalAmount! > 0 ? console.log(`Total Amount: ${testData.totalAmount}`) : console.error('No repair parts found in the order items.'); } async validateProgressBar(progressPercentage: string, timeout: number = 60000) { // This section has been commented out until progress bar work is completed for parity. - + await waitUntil(async () => { - let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all(); - return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false)); - }); + let loaderElements = await this.page.locator('button .loader, .buy-loader, timeout, .modal-loader').all(); + return !(await Promise.any(loaderElements.map(el => el.isVisible())).catch(() => false)); + }); // Take a screenshot of the page before validating the progress bar await this.page.screenshot({ path: `test-results\\ortoni-data\\progress-bar-${Date.now()}.png`, fullPage: true }); // Wait until the progress bar element is attached and visible - + const actualProgressPercentage = await this.progressBar.evaluate( async (element) => { await new Promise(resolve => setTimeout(resolve, 200)); diff --git a/playwright-tests/pages/HomePage.ts b/playwright-tests/pages/HomePage.ts index 3f934f9c9..b28dd75a4 100644 --- a/playwright-tests/pages/HomePage.ts +++ b/playwright-tests/pages/HomePage.ts @@ -30,7 +30,7 @@ export class HomePage extends BasePage { super(page); this.page = page; - this.letsGetStartedButton = this.page.locator('a.btn.btn-primary.ghost'); + this.letsGetStartedButton = this.page.locator('.hero-content #zipCodeTextboxButton'); this.cusmodalPopup = this.page.locator('#Cusmodalpopup'); this.closePopupButton = this.page.getByRole('button', { name: '×' }); diff --git a/playwright-tests/pages/LookupPage.ts b/playwright-tests/pages/LookupPage.ts index b964b3452..e5f2fca04 100644 --- a/playwright-tests/pages/LookupPage.ts +++ b/playwright-tests/pages/LookupPage.ts @@ -1,6 +1,7 @@ import { type Locator, type Page, expect } from '@playwright/test'; import { BasePage } from './BasePage'; -import { IAlertFlags, TestSuccessAlert } from 'safelite-playwright-core'; +import { IAlertFlags, TestSuccessAlert, VehicleDamage } from 'safelite-playwright-core'; +import { ITestData } from 'framework/TestData'; import { VehicleLookupType } from 'safelite-playwright-core'; export class LookupPage extends BasePage { @@ -105,8 +106,9 @@ export class LookupPage extends BasePage { } } - async handleZipValidation(zip: string, lookupType: VehicleLookupType, alertFlags?: IAlertFlags): Promise { + async handleZipValidation(testData: Partial, zip: string, lookupType: VehicleLookupType, alertFlags?: IAlertFlags): Promise { + let { vehicleDamage } = testData; // Only proceed with validation if alertFlags is provided if (alertFlags) { if (alertFlags.isUnserviceableZip) { @@ -122,6 +124,10 @@ export class LookupPage extends BasePage { } } + + if (vehicleDamage?.some(damage => damage === VehicleDamage.WindshieldThreeChips)) { + await this.getRepairPartsTotal(testData); + }; // If no alert flags or no matching condition, just continue await this.nextPage(); diff --git a/playwright-tests/pages/OrderConfirmationPage.ts b/playwright-tests/pages/OrderConfirmationPage.ts index e227479fd..02951bdd2 100644 --- a/playwright-tests/pages/OrderConfirmationPage.ts +++ b/playwright-tests/pages/OrderConfirmationPage.ts @@ -169,9 +169,10 @@ export class OrderConfirmationPage extends BasePage { : appointmentDetails?.serviceLocation == ServiceLocation.InShop ? formattedExpectedAppointmentDate + `${customerDetails!.apptTime}` : formattedExpectedAppointmentDate + "Drop off before 9:30 AM" + // : formattedExpectedAppointmentDate + "before 9:30 AM" ); appointmentSummary.push("Add to calendar"); - appointmentSummary.push( + appointmentSummary.push( appointmentDetails?.serviceLocation == ServiceLocation.Mobile ? appointmentDetails?.serviceAddress ? ("We're coming to you at" + appointmentDetails.serviceAddress.street + ", " + appointmentDetails.serviceAddress.city + ", " + appointmentDetails.serviceAddress.state + " " + appointmentDetails.serviceAddress.postalCode + "to service your " + `${vehicleDetails!.year} ${vehicleDetails!.make} ${vehicleDetails!.model}`) diff --git a/playwright-tests/pages/PaymentMethodPage.ts b/playwright-tests/pages/PaymentMethodPage.ts index 00bb9c2e1..4ea1a8b82 100644 --- a/playwright-tests/pages/PaymentMethodPage.ts +++ b/playwright-tests/pages/PaymentMethodPage.ts @@ -1,7 +1,7 @@ import { expect, type Locator, type Page } from '@playwright/test'; import { BasePage } from './BasePage'; import { IPaymentDetails, Soft, waitUntil } from 'safelite-playwright-core'; -import { AppointmentTimeslot, ServiceLocation, PaymentType, ServicePackage, VehicleDamage, } from 'safelite-playwright-core'; +import { AppointmentTimeslot, ServiceLocation, PaymentType, ServicePackage, VehicleDamage, } from 'safelite-playwright-core'; import { PaymentMethod, ProgressBarPercentages } from 'framework/localTypes/Enums'; import { PaymentPage } from './PaymentPage'; import { AfterpayPage } from './AfterpayPage'; @@ -26,6 +26,8 @@ export class PaymentMethodPage extends BasePage { readonly recalibrationCheckbox: Locator; readonly paymentPage: PaymentPage; readonly paypalPage: PaypalPage; + readonly afterPayBreakoutSection: Locator; + readonly afterPayToggle: Locator; // Payment detail page validation locators readonly reviewTable: Locator; @@ -61,12 +63,12 @@ export class PaymentMethodPage extends BasePage { // Payment details validation locators this.reviewTable = this.page.locator('div.review-table'); - + // Section locators - find by heading text this.serviceLocationDateandTimeSection = this.page.locator('.review-table').locator('div .service-location'); - this.vehicleDamageLocationsSection= this.page.locator('.review-table').locator('div.py-3[damagelocationswidgetname="DamageLocationsWidget"]'); + this.vehicleDamageLocationsSection = this.page.locator('.review-table').locator('div.py-3[damagelocationswidgetname="DamageLocationsWidget"]'); 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'); @@ -75,6 +77,8 @@ export class PaymentMethodPage extends BasePage { 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'); + this.afterPayBreakoutSection = this.page.locator('div.alert-info:has(.after-pay)'); + this.afterPayToggle = this.page.locator('.after-pay-toggle'); } async validatePaymentDetailsPage(testData: Partial) { @@ -84,15 +88,15 @@ export class PaymentMethodPage extends BasePage { 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.waitFor({ state: "visible" }); await this.appointmentDetailsDropdown.click().then( async () => { - await waitUntil(async () => { - return (await this.page.locator('.review-toggle.expanded').evaluate(el => window.getComputedStyle(el, ':after').transform)) === 'matrix(-1, 0, 0, -1, 0, 0)'; + await waitUntil(async () => { + return (await this.page.locator('.review-toggle.expanded').evaluate(el => window.getComputedStyle(el, ':after').transform)) === 'matrix(-1, 0, 0, -1, 0, 0)'; }); } ); - + let expectedAppointmentDetails = new Map(); expectedAppointmentDetails = await this.getExpectedServiceLocationDateAndTimeSection(testData, expectedAppointmentDetails); expectedAppointmentDetails = await this.getExpectedVehicleDamageAndVehicle(testData, expectedAppointmentDetails); @@ -102,7 +106,7 @@ export class PaymentMethodPage extends BasePage { let actualAppointmentDetails = await this.getActualAppointmentDetails(); for (const key in expectedAppointmentDetails) { Soft.expect(actualAppointmentDetails[key]?.map(item => item.toLowerCase())) - .toEqual(expectedAppointmentDetails[key].map(item => item.toLowerCase())); + .toEqual(expectedAppointmentDetails[key].map(item => item.toLowerCase())); } // Expand cart to see all details @@ -120,16 +124,16 @@ export class PaymentMethodPage extends BasePage { // Extract amounts for self-pay customers const subtotalAmount = this.extractAmount(subtotalValue); const finalAmountDueAmount = this.extractAmount(finalAmountDueValue); - + // Subtotal should be greater than 0 Soft.expect(subtotalAmount).toBeGreaterThan(0); - + // Final amount differs based on payment type if (paymentDetails?.paymentType === PaymentType.PayAtService) { Soft.expect(finalAmountDueAmount).toBeGreaterThan(0); } else if (paymentDetails?.paymentType === PaymentType.Credit || - paymentDetails?.paymentType === PaymentType.Paypal || - paymentDetails?.paymentType === PaymentType.AfterPay) { + 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 } @@ -186,11 +190,11 @@ export class PaymentMethodPage extends BasePage { 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) { + switch (paymentDetails.paymentType) { case PaymentType.Credit: await this.selectCreditCard(); await this.nextPage(); @@ -228,15 +232,15 @@ export class PaymentMethodPage extends BasePage { } } - async selectPaypal(){ + async selectPaypal() { await this.payNowButton.click(); } -l - async selectCreditCard(){ + l + async selectCreditCard() { await this.payNowButton.click(); } - async selectPayAtService(isRecalVehicle: boolean){ + async selectPayAtService(isRecalVehicle: boolean) { if (await this.payAtServiceButton.isVisible()) { await this.payAtServiceButton.click(); } else if (isRecalVehicle) { @@ -248,13 +252,13 @@ l 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"); @@ -264,7 +268,7 @@ l async getActualAppointmentDetails(): Promise { let actualAppointmentDetails = new Map(); let appointmentDetailsSubSections = await this.appointmentDetailsSection.locator('.review-table .py-3').all(); - + for (let element of appointmentDetailsSubSections) { let label = await element.locator('div .text-block').innerText(); //added to trim the text to remove any leading or trailing spaces (example: "expert installation " to "expert installation") @@ -282,9 +286,9 @@ l async getExpectedVehicleDamageAndVehicle(testdata: Partial, expectedServicePackageDetails: Map): Promise { - const {vehicleDetails, vehicleDamage} = testdata; + const { vehicleDetails, vehicleDamage } = testdata; - let vehicleDamageText: string= ''; + let vehicleDamageText: string = ''; if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.WindshieldOneChip || item == VehicleDamage.WindshieldTwoChips || item == VehicleDamage.WindshieldThreeChips)) { vehicleDamageText = "Repair the windshield of your"; } @@ -300,17 +304,17 @@ l if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverFrontDoor)) { vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Front Glass" : "Replace the Driver Front Glass"; } - + if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverRearDoor)) { vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Back Glass" : "Replace the Driver Back Glass"; } - + if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverVentGlass) && vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.WindshieldCrack)) { vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Vent Glass" : "Replace the Driver Vent Glass"; } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.DriverQuarterPanel)) { - vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Quarter Glass" : "Replace the Driver Quarter Glass"; + vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Driver Quarter Glass" : "Replace the Driver Quarter Glass"; } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerVentGlass)) { @@ -320,35 +324,34 @@ l if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerFrontDoor)) { vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Front Glass" : "Replace the Passenger Front Glass"; } - + if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerRearDoor)) { vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Back Glass" : "Replace the Passenger Back Glass"; - } + } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.PassengerQuarterPanel)) { - vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Quarter Glass" : "Replace the Passenger Quarter Glass"; + vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Passenger Quarter Glass" : "Replace the Passenger Quarter Glass"; } if (vehicleDamage?.find((item: VehicleDamage) => item == VehicleDamage.RearWindow || item == VehicleDamage.RearSliding)) { - vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Back Glass" : "Replace the Back Glass"; + vehicleDamageText.includes("Replace") ? vehicleDamageText += ", Back Glass" : "Replace the Back Glass"; } // Replace last comma with " and" if there are multiple damages - if (vehicleDamageText.includes(",") && vehicleDamageText !== "Replace the windshield") - { + if (vehicleDamageText.includes(",") && vehicleDamageText !== "Replace the windshield") { vehicleDamageText = vehicleDamageText.replace(/,([^,]*)$/, " and$1") + " of your"; } - + expectedServicePackageDetails[vehicleDamageText] = [vehicleDetails?.year + " " + vehicleDetails?.make + " " + vehicleDetails?.model]; return expectedServicePackageDetails; } async expectedServicePackageDetails(testData: Partial, expectedServicePackageDetails: Map): Promise { - const {servicePackage} = testData; + const { servicePackage } = testData; - let localStorage= JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + let localStorage = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); let isRepair = localStorage.order.damage.isRepair as boolean; - let isInsurance = localStorage.order.payment.isInsurance as boolean; + let isInsurance = localStorage.order.payment.isInsurance as boolean; let hasNonWindshieldGlass: boolean = false; if (!isRepair) { hasNonWindshieldGlass = localStorage.order.lineItems.glassParts.find((item: any) => item.partType !== "WINDSHIELD") ? true : false; @@ -362,33 +365,32 @@ l } let recalRequired = !isRepair && (isInsurance || isCaliforniaState) && hasRecalPart && canSafeliteRecalibrate let stringForRepair: string[] = ["Expert windshield repair", "Exclusive resin sealant", "Nationwide lifetime guarantee"]; - let stringIfRecal = isRepair - ? "" - : recalRequired - ? " and recalibration" - : ""; + let stringIfRecal = isRepair + ? "" + : recalRequired + ? " and recalibration" + : ""; let stringForReplace: string[] = [hasNonWindshieldGlass ? "New replacement glass" : "New replacement windshield", "Expert installation" + `${stringIfRecal}`, "Nationwide lifetime warranty"]; - switch (servicePackage) - { - case ServicePackage.GlassOnly: - expectedServicePackageDetails["Glass service only"] = isRepair - ? stringForRepair - : stringForReplace; - break; - case ServicePackage.Standard: - stringForRepair.push("New wiper blades"); - stringForReplace.push("New wiper blades"); - expectedServicePackageDetails["Standard service"] = isRepair - ? stringForRepair - : stringForReplace; - break; - case ServicePackage.Premium: - stringForRepair.push("New wiper blades", "Rain repel treatment"); - stringForReplace.push("New wiper blades", "Rain repel treatment"); - expectedServicePackageDetails["Premium service"] = isRepair - ? stringForRepair - : stringForReplace; - break; + switch (servicePackage) { + case ServicePackage.GlassOnly: + expectedServicePackageDetails["Glass service only"] = isRepair + ? stringForRepair + : stringForReplace; + break; + case ServicePackage.Standard: + stringForRepair.push("New wiper blades"); + stringForReplace.push("New wiper blades"); + expectedServicePackageDetails["Standard service"] = isRepair + ? stringForRepair + : stringForReplace; + break; + case ServicePackage.Premium: + stringForRepair.push("New wiper blades", "Rain repel treatment"); + stringForReplace.push("New wiper blades", "Rain repel treatment"); + expectedServicePackageDetails["Premium service"] = isRepair + ? stringForRepair + : stringForReplace; + break; } return expectedServicePackageDetails; } @@ -396,8 +398,8 @@ l async getExpectedServiceLocationDateAndTimeSection(testData: Partial, expectedServicePackageDetails: Map): Promise { const { customerDetails, appointmentDetails } = testData; let serviceLocationTitle = appointmentDetails?.serviceLocation == ServiceLocation.Mobile - ? "We're coming to you" - : "You're coming to us"; + ? "We're coming to you" + : "You're coming to us"; let serviceLocationText = appointmentDetails?.serviceLocation == ServiceLocation.Mobile ? appointmentDetails?.serviceAddress ? appointmentDetails.serviceAddress.street + ", " + appointmentDetails.serviceAddress.city + ", " + appointmentDetails.serviceAddress.state + " " + appointmentDetails.serviceAddress.postalCode @@ -443,7 +445,7 @@ l const { customerDetails } = testData; let isOptedInForTextMessages = testData.isOptedInForTextMessages ?? false; - + let expectedText = isOptedInForTextMessages ? customerDetails?.phoneNumber : "Not opted in"; @@ -451,6 +453,40 @@ l return expectedServicePackageDetails; } + async ValidateAfterPayBreakOutSection() { + /*const vuexState = JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); + + const isInsurance = vuexState.order.payment.isInsurance; + const currentDeductible = vuexState.order.policy.currentDeductible; + const isVerified = vuexState.order.payment.insuranceCoverage.isVerified;*/ + + const amountDueText = await this.amountDueTextField.first().textContent(); + + const verifyAfterpayBreakout = amountDueText?.includes('$') + ? Number.parseFloat(amountDueText.replace(/[^0-9.]/g, '')) > 0 + : false + + if (verifyAfterpayBreakout) { + const afterPayAmountString = await this.afterPayBreakoutSection.locator('.afterpay-amount').textContent(); + const afterPayAmount = Number.parseFloat(afterPayAmountString!.replace(/[^0-9.]/g, '')); + Soft.expect(afterPayAmount, `AfterPayAmount (${afterPayAmount}) is > 0`).toBeGreaterThan(0); + await this.afterPayToggle.click().then( + async () => { + await waitUntil(async () => { + return (await this.page.locator('.after-pay-toggle.expanded').evaluate(el => window.getComputedStyle(el, ':after').transform)) === 'matrix(-1, 0, 0, -1, 0, 0)'; + }); + } + ); + const afterPayCards = await this.page.locator('.after-pay-details .payment-card').all(); + Soft.expect(afterPayCards.length, `There are 4 afterpay cards`).toBe(4); + for (const afterPayCard of afterPayCards) { + const afterPayAmountWithinCardString = await afterPayCard.locator('.amount-due').textContent(); + const afterPayAmountWithinCard = Number.parseFloat(afterPayAmountWithinCardString!.replace(/[^0-9.]/g, '')); + Soft.expect(afterPayAmountWithinCard, `AfterPayAmount (${afterPayAmountWithinCard}) > 0`).toBeGreaterThan(0); + } + } + } + async getFormattedAppointmentDate(appointmentDate: string) { // Parse the original date @@ -467,7 +503,8 @@ l await this.validateProgressBar(ProgressBarPercentages.PaymentMethodPage); await this.validatePaymentDetailsPage(testData); - + await this.ValidateAfterPayBreakOutSection(); + // Verify VAPS wipers on backend for standard and premium packages if (servicePackage === ServicePackage.Standard || servicePackage === ServicePackage.Premium) { await this.verifyVAPS(); diff --git a/playwright-tests/pages/PaypalPage.ts b/playwright-tests/pages/PaypalPage.ts index ff2bbd548..ce5547f3c 100644 --- a/playwright-tests/pages/PaypalPage.ts +++ b/playwright-tests/pages/PaypalPage.ts @@ -13,19 +13,21 @@ export class PaypalPage extends BasePage { readonly completePurchaseButton: Locator; readonly payWithRadioButton: Locator; readonly payButton: Locator; + readonly tryAnotherWayButton: Locator; constructor(page: Page) { super(page); this.page = page; - this.usernameTextBox = page.getByPlaceholder('Email'); + this.usernameTextBox = page.locator('#email'); this.nextButton = page.getByRole('button', { name: 'Next' }); this.loginWithPasswordButton = page.getByRole('link', { name: 'Log in with a password instead' }) - this.usePasswordInsteadButton = page.getByRole('button', { name: 'Use Password Instead' }); + this.usePasswordInsteadButton = page.getByRole('button', { name: 'Use password instead' }); this.passwordTextBox = page.getByPlaceholder('Password'); this.paypalLoginButton = page.getByRole('button', { name: 'Log In', exact: true }); this.completePurchaseButton = page.getByTestId('submit-button-initial') this.payWithRadioButton = page.getByRole('button').filter({ hasText: 'Pay with' }); this.payButton = page.locator('#one-time-cta'); + this.tryAnotherWayButton = page.getByRole('button', { name: 'Try another way' }); } async completePaypalPurchase(paymentDetails: IPaymentDetails){ @@ -41,6 +43,7 @@ export class PaypalPage extends BasePage { } else { await this.usernameTextBox.fill(paymentDetails.username!); await this.nextButton.click(); + await this.tryAnotherWayButton.click(); await this.usePasswordInsteadButton.click(); await this.passwordTextBox.fill(paymentDetails.password!); await this.paypalLoginButton.click(); diff --git a/playwright-tests/pages/SchedulePage.ts b/playwright-tests/pages/SchedulePage.ts index bad1e4a08..77b330aff 100644 --- a/playwright-tests/pages/SchedulePage.ts +++ b/playwright-tests/pages/SchedulePage.ts @@ -212,7 +212,7 @@ export class SchedulePage extends BasePage { let formattedTimeSlot: string = ""; if (selectedTimeSlot.toLowerCase().includes("drop")) { - formattedTimeSlot = selectedTimeSlot.includes("overnight") ? "drop off by 5:30 pm on the night of your scheduled appointment. Pick-up time dependent on shop schedule" : "drop off before 9:30 AM"; + formattedTimeSlot = selectedTimeSlot.includes("overnight") ? "drop off by 5:30 pm on the night of your scheduled appointment. Pick-up time dependent on shop schedule" : "before 9:30 AM" // "drop off before 9:30 AM"; } else if (selectedTimeSlot.includes("-") || selectedTimeSlot.includes("Earlybird")) // - means Mobile time slot where service time is between 8:00 AM - 12:00 PM { diff --git a/playwright-tests/pages/ServicePackagesPage.ts b/playwright-tests/pages/ServicePackagesPage.ts index 95e31a746..63a60a6e6 100644 --- a/playwright-tests/pages/ServicePackagesPage.ts +++ b/playwright-tests/pages/ServicePackagesPage.ts @@ -1,6 +1,6 @@ import { expect, type Locator, type Page } from '@playwright/test'; import { BasePage } from './BasePage'; -import { AppointmentTimeslot, ServicePackage, VehicleDamage } from 'safelite-playwright-core'; +import { AppointmentTimeslot, ServicePackage, Soft, VehicleDamage } from 'safelite-playwright-core'; import { ProgressBarPercentages } from 'framework/localTypes/Enums'; import { PaymentMethod } from "framework/localTypes/Enums"; import { step } from 'framework/localTypes/Step'; @@ -14,6 +14,8 @@ export class ServicePackagesPage extends BasePage { readonly payOnMyOwnButton: Locator; readonly paywithInsuranceButton: Locator; readonly iHavePromoCodeButton: Locator; + readonly afterPayBanner: Locator; + readonly glassOnlypackagePrice: Locator; //Your quote is almost ready modal readonly skipQuoteEmailButton: Locator; @@ -32,16 +34,18 @@ export class ServicePackagesPage extends BasePage { 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.payOnMyOwnButton = this.page.locator('label').filter({ hasText: 'Pay on my own' }); + this.paywithInsuranceButton = this.page.locator('label').filter({ hasText: 'Pay with insurance' }); 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: 'Send' }); - this.closeButton = this.page. getByRole('dialog').locator('button').filter({ hasText: 'Close' }); + 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.locator('.QSISlider').locator('img[src*=\'close\']'); + this.afterPayBanner = this.page.locator('#afterpay-banner'); + this.glassOnlypackagePrice = this.page.locator('fieldset:has(legend#ServicePackageQuestion) label').filter({ hasText: 'Glass service only' }).locator('.pricing-info'); } async selectPaymentMethod(method: PaymentMethod): Promise { @@ -62,7 +66,11 @@ export class ServicePackagesPage extends BasePage { if (servicePackage != null) { await locators[servicePackage].click(); - } + } + } + + async getVuex() { + return JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); } async handleQuotePopup(email?: string): Promise { @@ -87,16 +95,16 @@ export class ServicePackagesPage extends BasePage { 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\')')); + const vuexState = await this.getVuex(); // 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); + expect(glassPart.canSafeliteRecalibrate).toBe(false); + expect(glassPart.requiresRecalibration).toBe(true); } return true; } @@ -106,7 +114,7 @@ export class ServicePackagesPage extends BasePage { 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 => @@ -114,7 +122,7 @@ export class ServicePackagesPage extends BasePage { childPart.partNumber.includes("RECAL DYNAMIC") ) ); - + await expect(hasDynamicRecalPart).toBe(true); console.log("Recal part line item is verified"); } else { @@ -144,25 +152,25 @@ export class ServicePackagesPage extends BasePage { 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; const WINDSHIELD_TYPES = [ -   "SINGLE WINDSHIELD", -   "DRIVER SPLIT WINDSHIELD", -   "PASSENGER SPLIT WINDSHIELD" + "SINGLE WINDSHIELD", + "DRIVER SPLIT WINDSHIELD", + "PASSENGER SPLIT WINDSHIELD" ]; if (glassParts?.length > 0) { for (const partType of vehicleDamage) { // Normalize part type to "WINDSHIELD" if it matches any of the defined types (Split Windshield types) -   const normalizedPartType = WINDSHIELD_TYPES.includes(partType) ? "WINDSHIELD" : partType; + const normalizedPartType = WINDSHIELD_TYPES.includes(partType) ? "WINDSHIELD" : partType; const hasPartType = glassParts.some(glassPart => glassPart.partType === normalizedPartType); @@ -173,14 +181,46 @@ export class ServicePackagesPage extends BasePage { } } + async VerifyAfterpayBreakout() { + const isAfterPayBannerVisible = await this.afterPayBanner.isVisible({ timeout: 2000 }) + await Soft.expect(isAfterPayBannerVisible, "AfterPay Banner is visible.").toBeTruthy(); + const localStorage = await this.getVuex(); + let isRepair = localStorage.order.damage.isRepair as boolean; + + const servicePackages = await this.page.locator('fieldset:has(legend#ServicePackageQuestion) label').filter({ visible: true }).all(); + for (const servicePackage of servicePackages) { + const servicePackageButtonLabel = await servicePackage.getAttribute('buttonlabel'); + if (!isRepair) { + const hasRecalPart = localStorage.order.lineItems.glassParts.find((item: any) => item.requiresRecalibration === true) ? true : false; + const canSafeliteRecalibrate = localStorage.order.lineItems.glassParts.find((item: any) => item.canSafeliteRecalibrate === true) ? true : false; + if (hasRecalPart && canSafeliteRecalibrate) { + const servicePackageTextContent = await servicePackage.textContent(); + await Soft.expect(servicePackageTextContent, `${servicePackageButtonLabel} package contains "Expert installation and recalibration"`).toContain('Expert installation and recalibration'); + } + } + const afterPayPricingInfo = await servicePackage.locator('.pricing-info').filter({ visible: true }).textContent(); + const classAttribute = await this.payOnMyOwnButton.getAttribute('class'); + const isPayOnMyOwnSelected = classAttribute?.includes('selected'); + if (isPayOnMyOwnSelected) { + const regex = /^\$\d+.\d{2}in\s4\sinterest-free\spayments\sor\s(\$\d+.\d{2}){1,2}\sin\ssingle\spayment\s$/; + const priceInfoRegexMatch = regex.test(afterPayPricingInfo!) + await Soft.expect(priceInfoRegexMatch, `${servicePackageButtonLabel} Package: Afterpay breakout text matches the regex`).toBeTruthy(); + } else { + const regex = /^As\slittle\sas\s\$\d+.\d{2}$/; + const priceInfoRegexMatch = regex.test(afterPayPricingInfo!) + await Soft.expect(priceInfoRegexMatch, `${servicePackageButtonLabel} Package: Afterpay breakout text matches the regex`).toBeTruthy(); + } + } + } + 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"); @@ -189,8 +229,8 @@ export class ServicePackagesPage extends BasePage { @step("ServicePackagePage >> Select Payment Method and Service Type: ") async handleServicePackagePage(testData: Partial) { - const { customerDetails, paymentMethod, paymentDetails, servicePackage, isCanNotRecal, isDynamicRecal, hasOemEndorsement, vehicleDamage, appointmentDetails } = testData; - + const { customerDetails, paymentMethod, paymentDetails, servicePackage, isCanNotRecal, isDynamicRecal, hasOemEndorsement, vehicleDamage, appointmentDetails, totalAmount } = testData; + await this.validateProgressBar(ProgressBarPercentages.ServicePackagePage); // Define repair damage types (vs. replacement types) const repairTypes: VehicleDamage[] = [ @@ -205,32 +245,50 @@ export class ServicePackagesPage extends BasePage { }); await this.handleQuotePopup(customerDetails!.email!); + + // validate total amount for 3 chip repair + if (vehicleDamage?.some(damage => damage === VehicleDamage.WindshieldThreeChips)) { + const priceText = await this.glassOnlypackagePrice.textContent() || '$0.00'; + + // Regex to match dollar amounts like $54.99 or $219.97 + const regex = /\$(\d+\.\d{2})/g; + + // Match all dollar amounts + const matches = priceText.match(regex); + const price = matches!.length > 1 ? matches![1] : matches![0]; + + const actualPrice = Number.parseFloat(price.replace('$', '')).toString(); + Soft.expect(actualPrice).toEqual(totalAmount); + } + await this.selectPaymentMethod(paymentMethod!); await this.selectServicePackage(servicePackage!); - + + await this.VerifyAfterpayBreakout(); + // Enter promo code if (paymentDetails?.promoCode) { await this.enterPromo(paymentDetails.promoCode); } - + // Backend Validations // Validate backend for can not recal if applicable if (isCanNotRecal) { await this.verifyCanNotRecal(); } - + // Validate backend for dynamic recal if applicable if (isDynamicRecal) { await this.verifyDynamicRecal(); } - + // Validate backend for repair info (including chip verification) await this.verifyIsRepair(!isReplace, vehicleDamage!); if (isReplace) { // Validate backend for parts info await this.verifyVehicleParts(vehicleDamage!); } - + // Validate backend for OEM endorsement if (hasOemEndorsement) { await this.verifyOEMPart(); @@ -239,7 +297,7 @@ export class ServicePackagesPage extends BasePage { if (appointmentDetails?.appointmentTimeSlot === AppointmentTimeslot.EarlyBird) { await this.mockScheduleResponseForEarlyBird(customerDetails!); } - + await this.nextPage(); } } \ No newline at end of file diff --git a/playwright-tests/pages/ServiceZipPage.ts b/playwright-tests/pages/ServiceZipPage.ts index 2aa446eca..91393f892 100644 --- a/playwright-tests/pages/ServiceZipPage.ts +++ b/playwright-tests/pages/ServiceZipPage.ts @@ -15,6 +15,6 @@ export class ServiceZipPage extends LookupPage { const { customerDetails, vehicleDetails, alertFlags } = testData; await this.validateProgressBar(ProgressBarPercentages.ServiceZipPage); await this.enterZip(customerDetails!.address.postalCode!); - await this.handleZipValidation(customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!); + await this.handleZipValidation(testData, customerDetails!.address.postalCode!, vehicleDetails!.vehicleLookupType!, alertFlags!); } } \ No newline at end of file diff --git a/playwright-tests/playwright.config.ts b/playwright-tests/playwright.config.ts index 40b1ddb01..016977e3c 100644 --- a/playwright-tests/playwright.config.ts +++ b/playwright-tests/playwright.config.ts @@ -54,6 +54,7 @@ const jiraReportConfig: JiraReporterConfig = { jiraProjectKey: process.env.JIRA_PROJECT_KEY || '', jiraEpicKey: process.env.JIRA_EPIC_KEY || '', jiraCardNumber: process.env.JIRA_CARD_NUMBER || '', + applicationName: 'FMG 2.0', jiraApiUtilConfig: { jiraUrl: process.env.JIRA_SERVER || '', jiraUsername: process.env.JIRA_USERNAME || '', @@ -99,7 +100,7 @@ export default defineConfig({ headless: process.env.CI ? true : false, screenshot: "only-on-failure", actionTimeout: 60_000, - navigationTimeout: 60_000, + navigationTimeout: 60_000 }, /* Configure projects for major browsers */ diff --git a/playwright-tests/tests/CashRepairInShopPayPal.ts b/playwright-tests/tests/CashRepairInShopPayPal.ts index 2a36127f4..0d559f03b 100644 --- a/playwright-tests/tests/CashRepairInShopPayPal.ts +++ b/playwright-tests/tests/CashRepairInShopPayPal.ts @@ -32,8 +32,8 @@ const cashRepairInShopPayPalData : Partial = { appointmentDetails: { ...getDefaultTestData().appointmentDetails!, shopAddress: "6826 Sawmill Rd, Columbus, OH 43235" - }, - + }, + // Use predefined payment data paymentDetails: ClientData.getDefaultPaypalDetails() }