diff --git a/public/static/error/index.html b/public/static/error/index.html
index 53bb9fc08..da15bd7a0 100644
--- a/public/static/error/index.html
+++ b/public/static/error/index.html
@@ -258,10 +258,36 @@ body {
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() {
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();
+ // Funnel cookie prefixes match cookie-names.js; suffix is environmentData.name here
+ // (equivalent to applicationConfig.CURRENT_ENVIRONMENT in the Vue app).
const cookieNames = [
`FunnelUserId-${environmentData?.name}`,
`FunnelSessionKey-${environmentData?.name}`,
diff --git a/src/constants/session-storage.js b/src/constants/session-storage.js
index 75cd1b578..9481c53d8 100644
--- a/src/constants/session-storage.js
+++ b/src/constants/session-storage.js
@@ -2,4 +2,8 @@ export const sessionStorageKeyConstants = {
SUBMITTED_STATE: "submittedState",
/** Counts SuppressConceptFunnel → heritage redirects to avoid an infinite loop in one tab session. */
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",
};
diff --git a/src/layouts/address-lookup/address-lookup.vue b/src/layouts/address-lookup/address-lookup.vue
index 081be8def..f79ee5667 100644
--- a/src/layouts/address-lookup/address-lookup.vue
+++ b/src/layouts/address-lookup/address-lookup.vue
@@ -355,7 +355,15 @@ export default {
}
} else {
// 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();
}
@@ -462,6 +470,28 @@ export default {
this.displayVinLookupByHomeAddressNotAllowedAlert = 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() {
this.attachCustomEvents();
diff --git a/src/layouts/license-plate-lookup/license-plate-lookup.vue b/src/layouts/license-plate-lookup/license-plate-lookup.vue
index a1477a664..a3873e437 100644
--- a/src/layouts/license-plate-lookup/license-plate-lookup.vue
+++ b/src/layouts/license-plate-lookup/license-plate-lookup.vue
@@ -254,7 +254,12 @@ export default {
resultMap.registrationZipValidationResponse.state
).catch(() => {
// 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();
});
// If no VIN was found, stop processing after displaying alert
@@ -393,6 +398,18 @@ export default {
this.displayInvalidZipAlert = 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() {
this.attachCustomEvents();
diff --git a/src/layouts/payment-method/payment-method.vue b/src/layouts/payment-method/payment-method.vue
index b0347f581..810414b96 100644
--- a/src/layouts/payment-method/payment-method.vue
+++ b/src/layouts/payment-method/payment-method.vue
@@ -567,14 +567,14 @@ export default {
this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
- const shouldUseAdyen = experimentMixin.methods.hasSettingEqualTo(
+ const shouldUseCybersource = experimentMixin.methods.hasSettingEqualTo(
experimentSettings.USE_ADYEN_PAYMENT,
- "true"
+ "false"
);
- const scenarioName = shouldUseAdyen
- ? this.navigationScenarios.CLICKED_PAY_NOW_ADYEN
- : this.navigationScenarios.CLICKED_PAY_NOW;
+ const scenarioName = shouldUseCybersource
+ ? this.navigationScenarios.CLICKED_PAY_NOW
+ : this.navigationScenarios.CLICKED_PAY_NOW_ADYEN;
this.$router.navigateWithoutSaving(scenarioName, this.pageName);
},
diff --git a/src/layouts/vin-lookup/vin-lookup.spec.js b/src/layouts/vin-lookup/vin-lookup.spec.js
index 7a5ba37eb..5fe09edb2 100644
--- a/src/layouts/vin-lookup/vin-lookup.spec.js
+++ b/src/layouts/vin-lookup/vin-lookup.spec.js
@@ -11,9 +11,20 @@ jest.mock("@/store", () => ({
commit: jest.fn(),
dispatch: jest.fn(),
getters: {
+ applicationUser: {
+ bailoutCode: null,
+ },
+ externalParameterState: {
+ isExternalParameter: false,
+ },
+ emailOrSms: "email",
vehicle: {
year: 2019,
carId: "C00000",
+ vin: null,
+ vinRequired: false,
+ isBigTruck: false,
+ canSafeliteService: true,
},
order: {
serviceLocation: {
@@ -23,6 +34,11 @@ jest.mock("@/store", () => ({
emailAddress: "builddigitaltest@safelite.com",
},
referralNumber: "666666",
+ vehicle: {
+ make: null,
+ model: null,
+ year: null,
+ },
},
payment: {
insuranceCoverage: {
diff --git a/src/layouts/vin-lookup/vin-lookup.vue b/src/layouts/vin-lookup/vin-lookup.vue
index ae80f670f..5d89c40c7 100644
--- a/src/layouts/vin-lookup/vin-lookup.vue
+++ b/src/layouts/vin-lookup/vin-lookup.vue
@@ -93,7 +93,8 @@
vinPopulatedOnPageLoad &&
!isInsuranceVerified &&
!displayInvalidZipAlert &&
- !displayNonServiceableZipAlert
+ !displayNonServiceableZipAlert &&
+ !requiredVinNotFound
"
alertClass="alert-success" />
@@ -144,6 +145,7 @@ import baseMixin from "@/mixins/base-mixin.js";
import store from "@/store";
import vinPagesMixin from "@/mixins/vin-pages-mixin";
import { routeData } from "@/router/constants/routes";
+import { bailoutCodes } from "@/constants/bailout-codes";
// DEFINE VALIDATION RULES
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
@@ -202,6 +204,7 @@ export default {
displayMatchedDifferentVehicleAlert: false,
displayVinScanFailedAlert: false,
displayNoServiceAlert: false,
+ requiredVinNotFound: this.getRequiredVinNotFound(),
};
},
methods: {
@@ -289,7 +292,13 @@ export default {
if (!resultMap.vehicleLookupResponse || !resultMap.zipCodeData.isServiceable) {
// If the vehicle result is undefined, the vin entered was invalid.
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
@@ -303,18 +312,18 @@ export default {
// Check if the CarId is different from the lookup vs what is in state currently.
this.isCarIdDifferent =
- resultMap.vehicleLookupResponse.carId !== this.$store.getters.vehicle.carId;
+ resultMap.vehicleLookupResponse?.carId !== this.$store.getters.vehicle.carId;
if (
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.displayMatchedDifferentVehicleAlert = true;
this.isSelectedGlassAvailableForVehicle = await isGlassAvailableForCarId(
- resultMap.vehicleLookupResponse.carId,
+ resultMap.vehicleLookupResponse?.carId,
"vin-lookup"
);
@@ -486,10 +495,47 @@ export default {
this.displayVinNotFoundAlert = false;
this.displayVinScanFailedAlert = 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.requiredVinNotFound = this.getRequiredVinNotFound();
},
computed: {
AlertMatchedDifferentVehicleHeader() {
diff --git a/src/mixins/vin-pages-mixin.js b/src/mixins/vin-pages-mixin.js
index c593d88b4..78cb134cf 100644
--- a/src/mixins/vin-pages-mixin.js
+++ b/src/mixins/vin-pages-mixin.js
@@ -38,7 +38,10 @@ export default {
});
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;
diff --git a/src/router/constants/routing-table.js b/src/router/constants/routing-table.js
index ea2020a21..3a5d12860 100644
--- a/src/router/constants/routing-table.js
+++ b/src/router/constants/routing-table.js
@@ -181,6 +181,10 @@ const routingTable = function () {
scenario: navigationScenarios.CLICKED_VIN_RETRY,
destinationPageData: routeData.ESTIMATE,
},
+ {
+ scenario: navigationScenarios.BAILOUT,
+ destinationPageData: routeData.BAILOUT,
+ },
],
},
{
diff --git a/src/router/methods/before-each-error-recovery.js b/src/router/methods/before-each-error-recovery.js
new file mode 100644
index 000000000..b925c510f
--- /dev/null
+++ b/src/router/methods/before-each-error-recovery.js
@@ -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();
+}
diff --git a/src/router/methods/before-each-error-recovery.spec.js b/src/router/methods/before-each-error-recovery.spec.js
new file mode 100644
index 000000000..e916ff356
--- /dev/null
+++ b/src/router/methods/before-each-error-recovery.spec.js
@@ -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);
+ });
+});
diff --git a/src/router/methods/before-each.js b/src/router/methods/before-each.js
index 568cf312c..84e653551 100644
--- a/src/router/methods/before-each.js
+++ b/src/router/methods/before-each.js
@@ -9,7 +9,16 @@ import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes";
import { runExperiments } from "@/router/methods/helpers/run-experiments";
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 { checkLogParam } 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;
+function isRecoveryRoute(routeName) {
+ return routeName === routeData.ERROR.name || routeName === routeData.RESTART.name;
+}
+
export async function beforeEach(to, from) {
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();
// 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()) {
const errorPayload = {
cause: "expired session",
@@ -141,18 +163,31 @@ export async function beforeEach(to, from) {
{
debugLog("--- before-each.js end ---");
}
+
+ clearBeforeEachErrorRecoveryCount();
} catch (error) {
const errorPayload = {
cause: "Uncaught exception in `beforeEach`.",
currentPage: from?.name,
nextPage: to?.name,
+ errorMessage: error?.message,
+ errorStack: error?.stack,
};
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.
await handleHardError(errorPayload);
- return;
+ return false;
}
}
diff --git a/src/router/methods/before-each.spec.js b/src/router/methods/before-each.spec.js
new file mode 100644
index 000000000..caac0ef6f
--- /dev/null
+++ b/src/router/methods/before-each.spec.js
@@ -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,
+ });
+ });
+});
diff --git a/src/router/methods/error.js b/src/router/methods/error.js
index 15cabb9a0..031f2367b 100644
--- a/src/router/methods/error.js
+++ b/src/router/methods/error.js
@@ -3,9 +3,41 @@ import { routeData } from "@/router/constants/routes";
import router from "@/router";
import store from "@/store";
import { storeActions } from "@/constants/store-actions";
+import { sessionStorageKeyConstants } from "@/constants/session-storage";
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
+import { clearBeforeEachErrorRecoveryCount } from "@/router/methods/before-each-error-recovery";
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) {
if (forceRestart) {
await store.dispatch(storeActions.RESET_STATE);
@@ -27,8 +59,7 @@ export async function handleHardError(errorPayload) {
);
if (isInStaticErrorExperiment) {
- logErrorToDataLayerAndLogger("handleHardError", errorPayload, null);
- window.top.location = "/fmg/static/error";
+ await redirectToStaticErrorPage(errorPayload);
return;
} else {
await handleSoftError(errorPayload, true);
diff --git a/src/router/methods/error.spec.js b/src/router/methods/error.spec.js
index c48d63fda..262347fc6 100644
--- a/src/router/methods/error.spec.js
+++ b/src/router/methods/error.spec.js
@@ -1,10 +1,11 @@
-import { handleSoftError, handleHardError } from "./error";
+import { handleSoftError, handleHardError, redirectToStaticErrorPage } from "./error";
import router from "@/router";
import store from "@/store";
import analyticsMixin from "@/mixins/analytics-mixin";
import experimentMixin from "../../mixins/experiment-mixin";
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { storeActions } from "@/constants/store-actions";
+import { sessionStorageKeyConstants } from "@/constants/session-storage";
import { routeData } from "@/router/constants/routes";
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", () => {
let topLocationValue;
@@ -131,7 +252,7 @@ describe("router/methods/error", () => {
errorPayload
);
expect(global.$logger.logError).toHaveBeenCalledWith(
- "handleHardError: test cause",
+ "redirectToStaticErrorPage: test cause",
errorPayload
);
expect(topLocationValue).toBe("/fmg/static/error");
diff --git a/src/router/methods/route-logic/error.js b/src/router/methods/route-logic/error.js
index 162e5b914..93cfc3cff 100644
--- a/src/router/methods/route-logic/error.js
+++ b/src/router/methods/route-logic/error.js
@@ -25,6 +25,8 @@ export async function errorBeforeEnter(to, from) {
// If we've already encountered one error, clear session to avoid more.
// 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) {
const errorPayload = {
cause: "Successive errors triggered.",
diff --git a/src/store/index.js b/src/store/index.js
index 503c2baef..4ca5c7a25 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -3740,7 +3740,7 @@ export const actions = {
context.commit(storeMutations.UPDATE_EXPERIMENTS, experiments);
//clear externalParameter session storage
- window.sessionStorage.removeItem("externalParameterState");
+ window.sessionStorage.removeItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE);
//restore affiliate cookies
context.commit(storeMutations.UPDATE_AFFILIATE_COOKIES, affiliateCookies);
@@ -4454,14 +4454,21 @@ function createExternalParameterDefaultState() {
saveExternalParameterState(externalParameterDefaultState);
}
function getExternalParameterDefaultState() {
- const externalParameterState = window.sessionStorage.getItem("externalParameterState");
+ const externalParameterState = window.sessionStorage.getItem(
+ sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE
+ );
if (externalParameterState === null) {
createExternalParameterDefaultState();
}
- return JSON.parse(window.sessionStorage.getItem("externalParameterState"));
+ return JSON.parse(
+ window.sessionStorage.getItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE)
+ );
}
function saveExternalParameterState(externalParameterState) {
- window.sessionStorage.setItem("externalParameterState", JSON.stringify(externalParameterState));
+ window.sessionStorage.setItem(
+ sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE,
+ JSON.stringify(externalParameterState)
+ );
}
const timeSlotCallFlags = {