import { expect, type Locator, type Page } from '@playwright/test'; import { BasePage } from './BasePage'; 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'; import { ITestData } from 'framework/TestData'; import { PaymentMethodPage } from './PaymentMethodPage'; 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; readonly afterPayBanner: Locator; readonly glassOnlypackagePrice: Locator; //Your quote is almost ready modal readonly skipQuoteEmailButton: Locator; readonly emailInput: Locator; readonly getMyQuoteButton: Locator; readonly closeButton: Locator; //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' }); 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.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('div.afterpay-modal-banner'); this.glassOnlypackagePrice = this.page.locator('fieldset:has(legend#ServicePackageQuestion) label').filter({ hasText: 'Glass service only' }).locator('.pricing-info'); } 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 getVuex() { return JSON.parse(await this.page.evaluate('localStorage.getItem(\'vuex\')')); } 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.page.waitForTimeout(3000); 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 = 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) { expect(glassPart.canSafeliteRecalibrate).toBe(false); 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; const WINDSHIELD_TYPES = [ "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 hasPartType = glassParts.some(glassPart => glassPart.partType === normalizedPartType); await expect(hasPartType, `Expected part type: ${partType}`).toBe(true); } } else { throw new Error("No glass parts found in the order"); } } 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"); } } async verifyGlassCashQuoteEvent() { // Get displayed price text for Glass Only package (e.g. "$41.25in 4 interest-free payments\nor $164.99 in single payment") const expectedGlassOnlyPrice = await this.glassOnlypackagePrice.innerText(); // Extract the single payment amount (e.g. 164.99) from the end of the string using regex const singlePaymentRegex = /\$([\d,]+\.\d{2})\s*in single payment/; const match = expectedGlassOnlyPrice.match(singlePaymentRegex); if (!match) { throw new Error(`Expected single payment price to be present in: ${expectedGlassOnlyPrice}`); } const expectedSinglePayAmount = parseFloat(match[1].replace(',', '')); // Retrieve the dataLayer from the browser. (Assumes dataLayer is in global scope. Adjust if needed.) const dataLayer = await this.page.evaluate(() => (window as any).dataLayer); // Find the first object with a GlassCashQuote property and extract its value. const glassCashQuote = dataLayer?.find((item: any) => item && item.GlassCashQuote)?.GlassCashQuote; if (glassCashQuote === undefined || glassCashQuote === null) { throw new Error("No GlassCashQuote found in dataLayer"); } // Validate that the GlassCashQuote value matches the extracted expectedSinglePayAmount (with currency float precision) Soft.expect(Number.parseFloat(glassCashQuote)).toBe(expectedSinglePayAmount); } @step("ServicePackagePage >> Select Payment Method and Service Type: ") async handleServicePackagePage(testData: Partial) { 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[] = [ VehicleDamage.WindshieldOneChip, VehicleDamage.WindshieldTwoChips, VehicleDamage.WindshieldThreeChips ]; // Determine if we're replacing or repairing const isReplace = !repairTypes.some(damageType => { return vehicleDamage!.includes(damageType); }); 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!); } if (testData.paymentMethod == PaymentMethod.SelfPay) { await this.verifyGlassCashQuoteEvent(); } // Validate backend for OEM endorsement if (hasOemEndorsement) { await this.verifyOEMPart(); } if (appointmentDetails?.appointmentTimeSlot === AppointmentTimeslot.EarlyBird) { await this.mockScheduleResponseForEarlyBird(customerDetails!); } if (testData.mockFirstInshopCallNoSchedule) { await this.mockScheduleResponseForFirstInshopCallNoSchedule(testData.customerDetails!); } await this.nextPage(); } }