Merge branch 'release/2026.06.18' into feature/CASH-2856

This commit is contained in:
mvalaiyapathi 2026-06-11 13:45:16 -04:00 committed by GitHub
commit 429961d94a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 756 additions and 26 deletions

View file

@ -258,10 +258,36 @@ body {
return match?.value; return match?.value;
} }
// Refactor opportunity: sessionStorage keys and cookie names below are duplicated from the
// Vue app because this static page is not webpack-bundled. Canonical sources:
// - src/constants/session-storage.js (sessionStorageKeyConstants)
// - src/constants/cookie-names.js (cookieNames; env-suffixed funnel cookies)
// - src/router/methods/error.js (clearBailoutSessionStorage — keep in sync on bailout keys)
// Options: build-time inject into this file, shared plain JS under public/static/, or generate
// from constants during CI. Until then, update all three places when keys change.
function clearApplicationData() { function clearApplicationData() {
window.localStorage.removeItem('vuex'); window.localStorage.removeItem('vuex');
// Keys match sessionStorageKeyConstants in src/constants/session-storage.js and
// clearBailoutSessionStorage() in src/router/methods/error.js — keep in sync.
const sessionStorageKeysToClear = [
'submittedState',
'heritageSuppressRedirectCount',
'beforeEachErrorRecoveryCount',
'externalParameterState',
];
// Clear sessionStorage keys that can re-poison the funnel on re-entry.
sessionStorageKeysToClear.forEach((key) => {
try {
window.sessionStorage.removeItem(key);
} catch {
// sessionStorage unavailable
}
});
const environmentData = getCurrentEnvironmentData(); const environmentData = getCurrentEnvironmentData();
// Funnel cookie prefixes match cookie-names.js; suffix is environmentData.name here
// (equivalent to applicationConfig.CURRENT_ENVIRONMENT in the Vue app).
const cookieNames = [ const cookieNames = [
`FunnelUserId-${environmentData?.name}`, `FunnelUserId-${environmentData?.name}`,
`FunnelSessionKey-${environmentData?.name}`, `FunnelSessionKey-${environmentData?.name}`,

View file

@ -2,4 +2,8 @@ export const sessionStorageKeyConstants = {
SUBMITTED_STATE: "submittedState", SUBMITTED_STATE: "submittedState",
/** Counts SuppressConceptFunnel → heritage redirects to avoid an infinite loop in one tab session. */ /** Counts SuppressConceptFunnel → heritage redirects to avoid an infinite loop in one tab session. */
HERITAGE_SUPPRESS_REDIRECT_COUNT: "heritageSuppressRedirectCount", HERITAGE_SUPPRESS_REDIRECT_COUNT: "heritageSuppressRedirectCount",
/** Counts beforeEach uncaught-exception recovery attempts to avoid an infinite ERROR redirect loop. */
BEFORE_EACH_ERROR_RECOVERY_COUNT: "beforeEachErrorRecoveryCount",
/** Persists deep-link / querystring funnel entry state across navigations. */
EXTERNAL_PARAMETER_STATE: "externalParameterState",
}; };

View file

@ -355,7 +355,15 @@ export default {
} }
} else { } else {
// No VINS found. // No VINS found.
this.displayVinNotFoundAlert = true; const isVinLookupRequired = this.$store.getters.vehicle.vinRequired;
if (isVinLookupRequired) {
this.continueWithFailedLookup(
this.customerQuestions,
resultMap.serviceZipValidationResponse
);
} else {
this.displayVinNotFoundAlert = true;
}
return this.$refs.navbar.removeLoader(); return this.$refs.navbar.removeLoader();
} }
@ -462,6 +470,28 @@ export default {
this.displayVinLookupByHomeAddressNotAllowedAlert = false; this.displayVinLookupByHomeAddressNotAllowedAlert = false;
this.displayNoServiceAlert = false; this.displayNoServiceAlert = false;
}, },
async continueWithFailedLookup(customerQuestions, zipCodeValidationResponse) {
// Save the entered customer questions and service zip code (if applicable) so that it can be used in the next page if needed
await this.dispatchStoreAction(
this.storeActions.SAVE_CUSTOMER_DETAILS,
{
firstName: customerQuestions.firstName,
lastName: customerQuestions.lastName,
},
false
);
await this.dispatchStoreAction(
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
{
state: zipCodeValidationResponse.state,
zipCode: this.serviceZipCode || this.customerQuestions.addressQuestions.zipCode,
zipCodeCtu: zipCodeValidationResponse.zipCodeCtu,
},
false
);
this.navigateForwardWithSingleCarMatch();
},
}, },
mounted() { mounted() {
this.attachCustomEvents(); this.attachCustomEvents();

View file

@ -254,7 +254,12 @@ export default {
resultMap.registrationZipValidationResponse.state resultMap.registrationZipValidationResponse.state
).catch(() => { ).catch(() => {
// No VIN found. // No VIN found.
this.displayVinNotFoundAlert = true; const isVinLookupRequired = this.$store.getters.vehicle.vinRequired;
if (isVinLookupRequired) {
this.continueWithFailedLookup(resultMap.serviceZipValidationResponse);
} else {
this.displayVinNotFoundAlert = true;
}
return this.$refs.navbar.removeLoader(); return this.$refs.navbar.removeLoader();
}); });
// If no VIN was found, stop processing after displaying alert // If no VIN was found, stop processing after displaying alert
@ -393,6 +398,18 @@ export default {
this.displayInvalidZipAlert = false; this.displayInvalidZipAlert = false;
this.displayNoServiceAlert = false; this.displayNoServiceAlert = false;
}, },
async continueWithFailedLookup(zipCodeData) {
await this.dispatchStoreAction(
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
{
state: zipCodeData.state,
zipCode: this.serviceZipCode || this.registrationZipCode,
zipCodeCtu: zipCodeData.zipCodeCtu,
},
false
);
this.navigateForwardWithSingleCarMatch();
},
}, },
mounted() { mounted() {
this.attachCustomEvents(); this.attachCustomEvents();

View file

@ -567,14 +567,14 @@ export default {
this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE); this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
const shouldUseAdyen = experimentMixin.methods.hasSettingEqualTo( const shouldUseCybersource = experimentMixin.methods.hasSettingEqualTo(
experimentSettings.USE_ADYEN_PAYMENT, experimentSettings.USE_ADYEN_PAYMENT,
"true" "false"
); );
const scenarioName = shouldUseAdyen const scenarioName = shouldUseCybersource
? this.navigationScenarios.CLICKED_PAY_NOW_ADYEN ? this.navigationScenarios.CLICKED_PAY_NOW
: this.navigationScenarios.CLICKED_PAY_NOW; : this.navigationScenarios.CLICKED_PAY_NOW_ADYEN;
this.$router.navigateWithoutSaving(scenarioName, this.pageName); this.$router.navigateWithoutSaving(scenarioName, this.pageName);
}, },

View file

@ -11,9 +11,20 @@ jest.mock("@/store", () => ({
commit: jest.fn(), commit: jest.fn(),
dispatch: jest.fn(), dispatch: jest.fn(),
getters: { getters: {
applicationUser: {
bailoutCode: null,
},
externalParameterState: {
isExternalParameter: false,
},
emailOrSms: "email",
vehicle: { vehicle: {
year: 2019, year: 2019,
carId: "C00000", carId: "C00000",
vin: null,
vinRequired: false,
isBigTruck: false,
canSafeliteService: true,
}, },
order: { order: {
serviceLocation: { serviceLocation: {
@ -23,6 +34,11 @@ jest.mock("@/store", () => ({
emailAddress: "builddigitaltest@safelite.com", emailAddress: "builddigitaltest@safelite.com",
}, },
referralNumber: "666666", referralNumber: "666666",
vehicle: {
make: null,
model: null,
year: null,
},
}, },
payment: { payment: {
insuranceCoverage: { insuranceCoverage: {

View file

@ -93,7 +93,8 @@
vinPopulatedOnPageLoad && vinPopulatedOnPageLoad &&
!isInsuranceVerified && !isInsuranceVerified &&
!displayInvalidZipAlert && !displayInvalidZipAlert &&
!displayNonServiceableZipAlert !displayNonServiceableZipAlert &&
!requiredVinNotFound
" "
alertClass="alert-success" /> alertClass="alert-success" />
@ -144,6 +145,7 @@ import baseMixin from "@/mixins/base-mixin.js";
import store from "@/store"; import store from "@/store";
import vinPagesMixin from "@/mixins/vin-pages-mixin"; import vinPagesMixin from "@/mixins/vin-pages-mixin";
import { routeData } from "@/router/constants/routes"; import { routeData } from "@/router/constants/routes";
import { bailoutCodes } from "@/constants/bailout-codes";
// DEFINE VALIDATION RULES // DEFINE VALIDATION RULES
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED)); defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
@ -202,6 +204,7 @@ export default {
displayMatchedDifferentVehicleAlert: false, displayMatchedDifferentVehicleAlert: false,
displayVinScanFailedAlert: false, displayVinScanFailedAlert: false,
displayNoServiceAlert: false, displayNoServiceAlert: false,
requiredVinNotFound: this.getRequiredVinNotFound(),
}; };
}, },
methods: { methods: {
@ -289,7 +292,13 @@ export default {
if (!resultMap.vehicleLookupResponse || !resultMap.zipCodeData.isServiceable) { if (!resultMap.vehicleLookupResponse || !resultMap.zipCodeData.isServiceable) {
// If the vehicle result is undefined, the vin entered was invalid. // If the vehicle result is undefined, the vin entered was invalid.
if (!resultMap.vehicleLookupResponse) { if (!resultMap.vehicleLookupResponse) {
this.displayVinNotFoundAlert = true; const isVinLookupRequired = this.$store.getters.vehicle.vinRequired;
if (isVinLookupRequired) {
this.requiredVinNotFound = true;
this.continueWithFailedVin(this.vin, resultMap.zipCodeData);
} else {
this.displayVinNotFoundAlert = true;
}
} }
// Check if Service Zip entered is serviceable, if not display an alert // Check if Service Zip entered is serviceable, if not display an alert
@ -303,18 +312,18 @@ export default {
// Check if the CarId is different from the lookup vs what is in state currently. // Check if the CarId is different from the lookup vs what is in state currently.
this.isCarIdDifferent = this.isCarIdDifferent =
resultMap.vehicleLookupResponse.carId !== this.$store.getters.vehicle.carId; resultMap.vehicleLookupResponse?.carId !== this.$store.getters.vehicle.carId;
if ( if (
this.isCarIdDifferent && this.isCarIdDifferent &&
resultMap.vehicleLookupResponse.carId !== this.previouslyEnteredCarId resultMap.vehicleLookupResponse?.carId !== this.previouslyEnteredCarId
) { ) {
this.previouslyEnteredCarId = resultMap.vehicleLookupResponse.carId; this.previouslyEnteredCarId = resultMap.vehicleLookupResponse?.carId;
this.customAlertData.vehicleInfo = resultMap.vehicleLookupResponse; this.customAlertData.vehicleInfo = resultMap.vehicleLookupResponse;
this.displayMatchedDifferentVehicleAlert = true; this.displayMatchedDifferentVehicleAlert = true;
this.isSelectedGlassAvailableForVehicle = await isGlassAvailableForCarId( this.isSelectedGlassAvailableForVehicle = await isGlassAvailableForCarId(
resultMap.vehicleLookupResponse.carId, resultMap.vehicleLookupResponse?.carId,
"vin-lookup" "vin-lookup"
); );
@ -486,10 +495,47 @@ export default {
this.displayVinNotFoundAlert = false; this.displayVinNotFoundAlert = false;
this.displayVinScanFailedAlert = false; this.displayVinScanFailedAlert = false;
this.displayNoServiceAlert = false; this.displayNoServiceAlert = false;
this.requiredVinNotFound = false;
},
async continueWithFailedVin(vin, zipCodeData) {
var selectedVehicle = {
vin: vin,
vehicle: this.$store.getters.vehicle,
};
await this.dispatchStoreAction(
storeActions.SAVE_VIN,
{
vehicleInfo: Object.assign(selectedVehicle.vehicle, {
vin: vin,
}),
isSelectedGlassAvailableForVehicle: this.isSelectedGlassAvailableForVehicle,
},
false
);
await this.dispatchStoreAction(
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
{
state: zipCodeData.state,
zipCode: this.serviceZipCode,
zipCodeCtu: zipCodeData.zipCodeCtu,
},
false
);
this.navigateForwardWithSingleCarMatch();
},
getRequiredVinNotFound() {
// Prevents success alert from showing
var bailoutCode = this.$store.getters.applicationUser.bailoutCode;
var vinRequired = this.$store.getters.vehicle.vinRequired;
if (vinRequired && this.vin && bailoutCode == bailoutCodes.PART_NOT_FOUND) {
return true;
}
return false;
}, },
}, },
mounted() { async mounted() {
this.attachCustomEvents(); this.attachCustomEvents();
this.requiredVinNotFound = this.getRequiredVinNotFound();
}, },
computed: { computed: {
AlertMatchedDifferentVehicleHeader() { AlertMatchedDifferentVehicleHeader() {

View file

@ -38,7 +38,10 @@ export default {
}); });
if (result.PartNotFound) { if (result.PartNotFound) {
bailoutMixin.methods.navigateToBailoutPage(this, bailoutCodes.PART_NOT_FOUND); return bailoutMixin.methods.navigateToBailoutPage(
this,
bailoutCodes.PART_NOT_FOUND
);
} }
const partsOrQuestions = result.data.partsOrQuestions; const partsOrQuestions = result.data.partsOrQuestions;

View file

@ -181,6 +181,10 @@ const routingTable = function () {
scenario: navigationScenarios.CLICKED_VIN_RETRY, scenario: navigationScenarios.CLICKED_VIN_RETRY,
destinationPageData: routeData.ESTIMATE, destinationPageData: routeData.ESTIMATE,
}, },
{
scenario: navigationScenarios.BAILOUT,
destinationPageData: routeData.BAILOUT,
},
], ],
}, },
{ {

View file

@ -0,0 +1,51 @@
import { sessionStorageKeyConstants } from "@/constants/session-storage";
const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT;
/** Used when sessionStorage is missing, throws, or does not persist (e.g. some bots). */
let inMemoryRecoveryCount = 0;
export function getBeforeEachErrorRecoveryCount() {
try {
const stored = window.sessionStorage?.getItem(recoveryKey);
if (stored !== null && stored !== undefined) {
return Number(stored) || 0;
}
} catch {
// sessionStorage unavailable; use in-memory fallback
}
return inMemoryRecoveryCount;
}
export function incrementBeforeEachErrorRecoveryCount() {
const nextCount = getBeforeEachErrorRecoveryCount() + 1;
inMemoryRecoveryCount = nextCount;
try {
window.sessionStorage?.setItem(recoveryKey, String(nextCount));
} catch {
// in-memory count already updated
}
return nextCount;
}
export function clearBeforeEachErrorRecoveryCount() {
inMemoryRecoveryCount = 0;
try {
window.sessionStorage?.removeItem(recoveryKey);
} catch {
// in-memory count already cleared
}
}
export function hasBeforeEachErrorRecoveryBeenAttempted() {
return getBeforeEachErrorRecoveryCount() >= 1;
}
/** Test-only: resets in-memory and sessionStorage recovery state between unit tests. */
export function resetBeforeEachErrorRecoveryStateForTests() {
clearBeforeEachErrorRecoveryCount();
}

View file

@ -0,0 +1,80 @@
import {
clearBeforeEachErrorRecoveryCount,
getBeforeEachErrorRecoveryCount,
hasBeforeEachErrorRecoveryBeenAttempted,
incrementBeforeEachErrorRecoveryCount,
resetBeforeEachErrorRecoveryStateForTests,
} from "./before-each-error-recovery";
import { sessionStorageKeyConstants } from "@/constants/session-storage";
describe("router/methods/before-each-error-recovery", () => {
const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT;
let originalSessionStorage;
beforeEach(() => {
resetBeforeEachErrorRecoveryStateForTests();
originalSessionStorage = window.sessionStorage;
});
afterEach(() => {
Object.defineProperty(window, "sessionStorage", {
configurable: true,
value: originalSessionStorage,
});
resetBeforeEachErrorRecoveryStateForTests();
});
it("reads and writes recovery count via sessionStorage when available", () => {
incrementBeforeEachErrorRecoveryCount();
expect(window.sessionStorage.getItem(recoveryKey)).toBe("1");
expect(getBeforeEachErrorRecoveryCount()).toBe(1);
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
});
it("clears sessionStorage and in-memory count", () => {
incrementBeforeEachErrorRecoveryCount();
clearBeforeEachErrorRecoveryCount();
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
expect(getBeforeEachErrorRecoveryCount()).toBe(0);
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(false);
});
it("uses in-memory count when sessionStorage getItem throws", () => {
Object.defineProperty(window, "sessionStorage", {
configurable: true,
value: {
getItem: () => {
throw new Error("sessionStorage blocked");
},
setItem: jest.fn(),
removeItem: jest.fn(),
},
});
incrementBeforeEachErrorRecoveryCount();
incrementBeforeEachErrorRecoveryCount();
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
expect(getBeforeEachErrorRecoveryCount()).toBe(2);
});
it("uses in-memory count when sessionStorage setItem throws", () => {
Object.defineProperty(window, "sessionStorage", {
configurable: true,
value: {
getItem: () => null,
setItem: () => {
throw new Error("sessionStorage blocked");
},
removeItem: jest.fn(),
},
});
incrementBeforeEachErrorRecoveryCount();
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
expect(getBeforeEachErrorRecoveryCount()).toBe(1);
});
});

View file

@ -9,7 +9,16 @@ import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes";
import { runExperiments } from "@/router/methods/helpers/run-experiments"; import { runExperiments } from "@/router/methods/helpers/run-experiments";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { handleSoftError, handleHardError } from "@/router/methods/error"; import {
handleSoftError,
handleHardError,
redirectToStaticErrorPage,
} from "@/router/methods/error";
import {
clearBeforeEachErrorRecoveryCount,
hasBeforeEachErrorRecoveryBeenAttempted,
incrementBeforeEachErrorRecoveryCount,
} from "@/router/methods/before-each-error-recovery";
import { checkPagePrerequisites } from "@/router/methods/page-prerequisites"; import { checkPagePrerequisites } from "@/router/methods/page-prerequisites";
import { checkLogParam } from "@/helpers/debug-log-helper"; import { checkLogParam } from "@/helpers/debug-log-helper";
import { debugLog } from "@/helpers/debug-log-helper"; import { debugLog } from "@/helpers/debug-log-helper";
@ -19,8 +28,21 @@ import router from "@/router";
const MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION = 5; const MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION = 5;
function isRecoveryRoute(routeName) {
return routeName === routeData.ERROR.name || routeName === routeData.RESTART.name;
}
export async function beforeEach(to, from) { export async function beforeEach(to, from) {
try { try {
if (isRecoveryRoute(to.name)) {
debugLog(`--- before-each.js recovery route ${to.name} ---`);
// Do NOT clear the recovery counter here. The counter must accumulate across
// consecutive failures that bounce through the ERROR/RESTART route so the
// circuit breaker can trip and fall back to the static error page. It is only
// cleared after a fully successful guard pass (end of try block).
return;
}
await analyticsMixin.methods.validateSession(); await analyticsMixin.methods.validateSession();
// prettier-ignore // prettier-ignore
@ -63,7 +85,7 @@ export async function beforeEach(to, from) {
} }
} }
// Ensure session has not expired // Ensure session has not expired - not primary check for session expiration and uses FunnelSessionInfo-{environment} cookie.
if (getFunnelCookie() !== null && !isSavedSessionStillActive()) { if (getFunnelCookie() !== null && !isSavedSessionStillActive()) {
const errorPayload = { const errorPayload = {
cause: "expired session", cause: "expired session",
@ -141,18 +163,31 @@ export async function beforeEach(to, from) {
{ {
debugLog("--- before-each.js end ---"); debugLog("--- before-each.js end ---");
} }
clearBeforeEachErrorRecoveryCount();
} catch (error) { } catch (error) {
const errorPayload = { const errorPayload = {
cause: "Uncaught exception in `beforeEach`.", cause: "Uncaught exception in `beforeEach`.",
currentPage: from?.name, currentPage: from?.name,
nextPage: to?.name, nextPage: to?.name,
errorMessage: error?.message,
errorStack: error?.stack,
}; };
console.log(error); console.log(error);
global.$logger?.logError("beforeEach uncaught exception", errorPayload);
if (to.name === routeData.ERROR.name || hasBeforeEachErrorRecoveryBeenAttempted()) {
clearBeforeEachErrorRecoveryCount();
await redirectToStaticErrorPage(errorPayload);
return false;
}
incrementBeforeEachErrorRecoveryCount();
// Eject user from Vue app in this scenario and clear localstorage. // Eject user from Vue app in this scenario and clear localstorage.
await handleHardError(errorPayload); await handleHardError(errorPayload);
return; return false;
} }
} }

View file

@ -0,0 +1,257 @@
import analyticsMixin from "@/mixins/analytics-mixin";
import { runExperiments } from "@/router/methods/helpers/run-experiments";
import { handleHardError, redirectToStaticErrorPage } from "@/router/methods/error";
import { sessionStorageKeyConstants } from "@/constants/session-storage";
import { routeData } from "@/router/constants/routes";
import { debugLog } from "@/helpers/debug-log-helper";
jest.mock("@/mixins/analytics-mixin", () => ({
__esModule: true,
default: {
methods: {
validateSession: jest.fn(() => Promise.resolve()),
},
},
}));
jest.mock("@/router/methods/helpers/run-experiments", () => ({
runExperiments: jest.fn(() => Promise.resolve()),
}));
jest.mock("@/helpers/heritage-integration/cookie-helper", () => ({
getFunnelCookie: jest.fn(() => null),
updateOrCreateFunnelCookie: jest.fn(),
}));
jest.mock("@/helpers/heritage-integration/session-helper", () => ({
isSavedSessionStillActive: jest.fn(() => true),
getDateForSavedSessionTimeout: jest.fn(() => "2099-01-01T00:00:00.000Z"),
}));
jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({
navigateToHeritageFunnel: jest.fn(),
}));
jest.mock("@/router/methods/helpers/handle-heritage-return", () => ({
handleHeritageReturn: jest.fn(() => Promise.resolve()),
}));
jest.mock("@/router/methods/page-prerequisites", () => ({
checkPagePrerequisites: jest.fn(() => Promise.resolve(true)),
}));
jest.mock("@/helpers/debug-log-helper", () => ({
debugLog: jest.fn(),
checkLogParam: jest.fn(),
}));
jest.mock("@/router/methods/error", () => ({
handleSoftError: jest.fn(() => Promise.resolve()),
handleHardError: jest.fn(() => Promise.resolve()),
redirectToStaticErrorPage: jest.fn(() => Promise.resolve()),
}));
jest.mock("@/router", () => ({
__esModule: true,
default: {
push: jest.fn(),
},
}));
import { beforeEach as routerBeforeEach } from "./before-each";
import {
hasBeforeEachErrorRecoveryBeenAttempted,
incrementBeforeEachErrorRecoveryCount,
resetBeforeEachErrorRecoveryStateForTests,
} from "./before-each-error-recovery";
describe("router/methods/before-each", () => {
const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT;
beforeEach(() => {
jest.clearAllMocks();
resetBeforeEachErrorRecoveryStateForTests();
window.sessionStorage.removeItem(recoveryKey);
window.sessionStorage.removeItem(sessionStorageKeyConstants.SUBMITTED_STATE);
analyticsMixin.methods.validateSession.mockResolvedValue(undefined);
debugLog.mockImplementation(() => undefined);
global.$logger = {
logError: jest.fn(),
};
});
it("skips validateSession and runExperiments when navigating to ERROR", async () => {
await routerBeforeEach({ name: routeData.ERROR.name }, { name: routeData.VEHICLE.name });
expect(analyticsMixin.methods.validateSession).not.toHaveBeenCalled();
expect(runExperiments).not.toHaveBeenCalled();
});
it("skips validateSession and runExperiments when navigating to RESTART", async () => {
await routerBeforeEach({ name: routeData.RESTART.name }, { name: routeData.VEHICLE.name });
expect(analyticsMixin.methods.validateSession).not.toHaveBeenCalled();
expect(runExperiments).not.toHaveBeenCalled();
});
it("clears recovery counter after successful funnel navigation", async () => {
incrementBeforeEachErrorRecoveryCount();
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
await routerBeforeEach(
{ name: routeData.VEHICLE_DAMAGE.name },
{ name: routeData.VEHICLE.name }
);
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(false);
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
});
it("preserves recovery counter when passing through the ERROR recovery route", async () => {
incrementBeforeEachErrorRecoveryCount();
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
await routerBeforeEach({ name: routeData.ERROR.name }, { name: routeData.VEHICLE.name });
// The counter must survive the ERROR bounce so consecutive failures can trip the
// circuit breaker; it is only cleared after a fully successful guard pass.
expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true);
expect(window.sessionStorage.getItem(recoveryKey)).toBe("1");
});
it("runs validateSession on a normal funnel page", async () => {
await routerBeforeEach(
{ name: routeData.VEHICLE_DAMAGE.name },
{ name: routeData.VEHICLE.name }
);
expect(analyticsMixin.methods.validateSession).toHaveBeenCalled();
expect(runExperiments).toHaveBeenCalledWith(routeData.VEHICLE_DAMAGE.name);
});
it("calls handleHardError once on uncaught exception", async () => {
analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure"));
const result = await routerBeforeEach(
{ name: routeData.VEHICLE_DAMAGE.name },
{ name: routeData.VEHICLE.name }
);
expect(handleHardError).toHaveBeenCalledTimes(1);
expect(handleHardError).toHaveBeenCalledWith(
expect.objectContaining({
cause: "Uncaught exception in `beforeEach`.",
errorMessage: "session failure",
errorStack: expect.any(String),
})
);
expect(global.$logger.logError).toHaveBeenCalledWith(
"beforeEach uncaught exception",
expect.objectContaining({ errorMessage: "session failure" })
);
expect(redirectToStaticErrorPage).not.toHaveBeenCalled();
expect(window.sessionStorage.getItem(recoveryKey)).toBe("1");
expect(result).toBe(false);
});
it("redirects to static error page when exception occurs on ERROR route", async () => {
debugLog.mockImplementation(() => {
throw new Error("recovery route failure");
});
const result = await routerBeforeEach(
{ name: routeData.ERROR.name },
{ name: routeData.VEHICLE.name }
);
expect(global.$logger.logError).toHaveBeenCalledWith(
"beforeEach uncaught exception",
expect.objectContaining({
errorMessage: "recovery route failure",
nextPage: routeData.ERROR.name,
})
);
expect(redirectToStaticErrorPage).toHaveBeenCalledWith(
expect.objectContaining({
cause: "Uncaught exception in `beforeEach`.",
errorMessage: "recovery route failure",
nextPage: routeData.ERROR.name,
})
);
expect(handleHardError).not.toHaveBeenCalled();
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
expect(result).toBe(false);
});
it("redirects to static error page on second uncaught exception in session", async () => {
analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure"));
window.sessionStorage.setItem(recoveryKey, "1");
const result = await routerBeforeEach(
{ name: routeData.VEHICLE_DAMAGE.name },
{ name: routeData.VEHICLE.name }
);
expect(global.$logger.logError).toHaveBeenCalledWith(
"beforeEach uncaught exception",
expect.objectContaining({ cause: "Uncaught exception in `beforeEach`." })
);
expect(redirectToStaticErrorPage).toHaveBeenCalled();
expect(handleHardError).not.toHaveBeenCalled();
expect(window.sessionStorage.getItem(recoveryKey)).toBeNull();
expect(result).toBe(false);
});
it("redirects to static error page on second failure when sessionStorage throws", async () => {
analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure"));
const storageBlockedError = new Error("sessionStorage blocked");
const underlyingStorage = window.sessionStorage;
Object.defineProperty(window, "sessionStorage", {
configurable: true,
value: {
getItem(key) {
if (key === recoveryKey) {
throw storageBlockedError;
}
return underlyingStorage.getItem(key);
},
setItem(key, value) {
if (key === recoveryKey) {
throw storageBlockedError;
}
underlyingStorage.setItem(key, value);
},
removeItem(key) {
if (key === recoveryKey) {
throw storageBlockedError;
}
underlyingStorage.removeItem(key);
},
},
});
await routerBeforeEach(
{ name: routeData.VEHICLE_DAMAGE.name },
{ name: routeData.VEHICLE.name }
);
expect(handleHardError).toHaveBeenCalledTimes(1);
expect(redirectToStaticErrorPage).not.toHaveBeenCalled();
jest.clearAllMocks();
const result = await routerBeforeEach(
{ name: routeData.VEHICLE_DAMAGE.name },
{ name: routeData.VEHICLE.name }
);
expect(redirectToStaticErrorPage).toHaveBeenCalled();
expect(handleHardError).not.toHaveBeenCalled();
expect(result).toBe(false);
Object.defineProperty(window, "sessionStorage", {
configurable: true,
value: underlyingStorage,
});
});
});

View file

@ -3,9 +3,41 @@ import { routeData } from "@/router/constants/routes";
import router from "@/router"; import router from "@/router";
import store from "@/store"; import store from "@/store";
import { storeActions } from "@/constants/store-actions"; import { storeActions } from "@/constants/store-actions";
import { sessionStorageKeyConstants } from "@/constants/session-storage";
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { clearBeforeEachErrorRecoveryCount } from "@/router/methods/before-each-error-recovery";
import experimentMixin from "../../mixins/experiment-mixin"; import experimentMixin from "../../mixins/experiment-mixin";
const STATIC_ERROR_PAGE_PATH = "/fmg/static/error";
/** Clears sessionStorage keys that can re-poison the funnel after a bailout. */
export function clearBailoutSessionStorage() {
clearBeforeEachErrorRecoveryCount();
try {
window.sessionStorage?.removeItem(sessionStorageKeyConstants.SUBMITTED_STATE);
window.sessionStorage?.removeItem(
sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT
);
window.sessionStorage?.removeItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE);
} catch {
// sessionStorage unavailable
}
}
export async function redirectToStaticErrorPage(errorPayload) {
await logErrorToDataLayerAndLogger("redirectToStaticErrorPage", errorPayload, null);
clearBailoutSessionStorage();
// Prefer top-level navigation so the static page loads outside any iframe wrapper.
// Assigning window.top.location throws SecurityError when cross-origin; fall back to this frame.
try {
window.top.location = STATIC_ERROR_PAGE_PATH;
} catch {
window.location = STATIC_ERROR_PAGE_PATH;
}
}
export async function handleSoftError(errorPayload, forceRestart = false) { export async function handleSoftError(errorPayload, forceRestart = false) {
if (forceRestart) { if (forceRestart) {
await store.dispatch(storeActions.RESET_STATE); await store.dispatch(storeActions.RESET_STATE);
@ -27,8 +59,7 @@ export async function handleHardError(errorPayload) {
); );
if (isInStaticErrorExperiment) { if (isInStaticErrorExperiment) {
logErrorToDataLayerAndLogger("handleHardError", errorPayload, null); await redirectToStaticErrorPage(errorPayload);
window.top.location = "/fmg/static/error";
return; return;
} else { } else {
await handleSoftError(errorPayload, true); await handleSoftError(errorPayload, true);

View file

@ -1,10 +1,11 @@
import { handleSoftError, handleHardError } from "./error"; import { handleSoftError, handleHardError, redirectToStaticErrorPage } from "./error";
import router from "@/router"; import router from "@/router";
import store from "@/store"; import store from "@/store";
import analyticsMixin from "@/mixins/analytics-mixin"; import analyticsMixin from "@/mixins/analytics-mixin";
import experimentMixin from "../../mixins/experiment-mixin"; import experimentMixin from "../../mixins/experiment-mixin";
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { storeActions } from "@/constants/store-actions"; import { storeActions } from "@/constants/store-actions";
import { sessionStorageKeyConstants } from "@/constants/session-storage";
import { routeData } from "@/router/constants/routes"; import { routeData } from "@/router/constants/routes";
jest.mock("@/router", () => ({ jest.mock("@/router", () => ({
@ -98,6 +99,126 @@ describe("router/methods/error", () => {
}); });
}); });
describe("redirectToStaticErrorPage", () => {
let topLocationValue;
beforeEach(() => {
topLocationValue = undefined;
Object.defineProperty(window, "top", {
configurable: true,
value: {},
});
Object.defineProperty(window.top, "location", {
configurable: true,
set(value) {
topLocationValue = value;
},
get() {
return topLocationValue;
},
});
});
it("logs and sets static error location", async () => {
await redirectToStaticErrorPage(errorPayload);
expect(analyticsMixin.methods.pushPageErrorToDataLayer).toHaveBeenCalledWith(
errorPayload
);
expect(global.$logger.logError).toHaveBeenCalledWith(
"redirectToStaticErrorPage: test cause",
errorPayload
);
expect(topLocationValue).toBe("/fmg/static/error");
});
it("clears bailout sessionStorage keys before navigating away", async () => {
window.sessionStorage.setItem(sessionStorageKeyConstants.SUBMITTED_STATE, "not-json");
window.sessionStorage.setItem(
sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT,
"3"
);
window.sessionStorage.setItem(
sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT,
"1"
);
window.sessionStorage.setItem(
sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE,
"{}"
);
await redirectToStaticErrorPage(errorPayload);
expect(
window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE)
).toBeNull();
expect(
window.sessionStorage.getItem(
sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT
)
).toBeNull();
expect(
window.sessionStorage.getItem(
sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT
)
).toBeNull();
expect(
window.sessionStorage.getItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE)
).toBeNull();
});
it("falls back to window.location when window.top.location setter throws", async () => {
let windowLocationValue;
Object.defineProperty(window, "location", {
configurable: true,
set(value) {
windowLocationValue = value;
},
get() {
return windowLocationValue;
},
});
Object.defineProperty(window.top, "location", {
configurable: true,
set() {
throw new Error("cross-origin frame");
},
get() {
return undefined;
},
});
await redirectToStaticErrorPage(errorPayload);
expect(windowLocationValue).toBe("/fmg/static/error");
expect(topLocationValue).toBeUndefined();
});
it("awaits logging before navigating away", async () => {
const callOrder = [];
analyticsMixin.methods.pushPageErrorToDataLayer.mockImplementation(() => {
callOrder.push("datalayer");
});
global.$logger.logError.mockImplementation(() => {
callOrder.push("logger");
});
Object.defineProperty(window.top, "location", {
configurable: true,
set(value) {
callOrder.push("navigate");
topLocationValue = value;
},
get() {
return topLocationValue;
},
});
await redirectToStaticErrorPage(errorPayload);
expect(callOrder).toEqual(["datalayer", "logger", "navigate"]);
});
});
describe("handleHardError", () => { describe("handleHardError", () => {
let topLocationValue; let topLocationValue;
@ -131,7 +252,7 @@ describe("router/methods/error", () => {
errorPayload errorPayload
); );
expect(global.$logger.logError).toHaveBeenCalledWith( expect(global.$logger.logError).toHaveBeenCalledWith(
"handleHardError: test cause", "redirectToStaticErrorPage: test cause",
errorPayload errorPayload
); );
expect(topLocationValue).toBe("/fmg/static/error"); expect(topLocationValue).toBe("/fmg/static/error");

View file

@ -25,6 +25,8 @@ export async function errorBeforeEnter(to, from) {
// If we've already encountered one error, clear session to avoid more. // If we've already encountered one error, clear session to avoid more.
// Otherwise mark that we encountered an error here. // Otherwise mark that we encountered an error here.
// Note: an earlier breaker also runs in before-each.js via before-each-error-recovery.js
// (consecutive uncaught guard failures before this route handler runs).
if (store.getters.applicationUser.hasAlreadyTriggeredError) { if (store.getters.applicationUser.hasAlreadyTriggeredError) {
const errorPayload = { const errorPayload = {
cause: "Successive errors triggered.", cause: "Successive errors triggered.",

View file

@ -3737,7 +3737,7 @@ export const actions = {
context.commit(storeMutations.UPDATE_EXPERIMENTS, experiments); context.commit(storeMutations.UPDATE_EXPERIMENTS, experiments);
//clear externalParameter session storage //clear externalParameter session storage
window.sessionStorage.removeItem("externalParameterState"); window.sessionStorage.removeItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE);
//restore affiliate cookies //restore affiliate cookies
context.commit(storeMutations.UPDATE_AFFILIATE_COOKIES, affiliateCookies); context.commit(storeMutations.UPDATE_AFFILIATE_COOKIES, affiliateCookies);
@ -4451,14 +4451,21 @@ function createExternalParameterDefaultState() {
saveExternalParameterState(externalParameterDefaultState); saveExternalParameterState(externalParameterDefaultState);
} }
function getExternalParameterDefaultState() { function getExternalParameterDefaultState() {
const externalParameterState = window.sessionStorage.getItem("externalParameterState"); const externalParameterState = window.sessionStorage.getItem(
sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE
);
if (externalParameterState === null) { if (externalParameterState === null) {
createExternalParameterDefaultState(); createExternalParameterDefaultState();
} }
return JSON.parse(window.sessionStorage.getItem("externalParameterState")); return JSON.parse(
window.sessionStorage.getItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE)
);
} }
function saveExternalParameterState(externalParameterState) { function saveExternalParameterState(externalParameterState) {
window.sessionStorage.setItem("externalParameterState", JSON.stringify(externalParameterState)); window.sessionStorage.setItem(
sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE,
JSON.stringify(externalParameterState)
);
} }
const timeSlotCallFlags = { const timeSlotCallFlags = {