Merge pull request #3136 from Safelite/feature/CASH-2521

CASH-2521, CASH-2526 - Handle infinite loop and add logging
This commit is contained in:
matthew-sykes 2026-04-07 16:46:27 -04:00 committed by GitHub
commit 5cf8816b0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 227 additions and 8 deletions

View file

@ -15,6 +15,7 @@ module.exports = {
"!src/main.js",
"!src/constants/*.js",
"!src/router/**/*.js",
"src/router/methods/error.js", // Re-include after the exclusion above (Jest applies later positive globs as overrides).
"!src/helpers/unit-test-helper.js",
"!src/helpers/logger.js",
"!src/layouts/vehicle-damage/windshield-options/windshield-options.vue",

View file

@ -64,6 +64,16 @@ window.onerror = (msg, url, line, col, error) => {
var suppressErrorAlert = true;
return suppressErrorAlert;
};
// Log Promise rejections that are never handled (.catch / await try/catch), e.g. fire-and-forget async.
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
const message = reason instanceof Error ? reason.message : String(reason);
global.$logger.logError(`unhandledrejection: ${message}`, {
stack: reason instanceof Error ? reason.stack : undefined,
reason: reason instanceof Error ? undefined : reason,
});
});
</script>
<style lang="scss">

View file

@ -1,3 +1,5 @@
export const sessionStorageKeyConstants = {
SUBMITTED_STATE: "submittedState",
/** Counts SuppressConceptFunnel → heritage redirects to avoid an infinite loop in one tab session. */
HERITAGE_SUPPRESS_REDIRECT_COUNT: "heritageSuppressRedirectCount",
};

View file

@ -25,9 +25,9 @@ export async function navigateToHeritageFunnel({ shouldSaveSession, pageNameToLo
}
var heritageParms = {
corid: store.getters.order.referralCorrelationId,
corid: store.getters.order.referralCorrelationId ?? "",
src: "concept-funnel",
conceptsqid: store.getters.applicationUser.savedSessionId,
conceptsqid: store.getters.applicationUser.savedSessionId ?? "",
isInsurance: store.getters.payment.isInsurance,
};

View file

@ -7,6 +7,7 @@ import { isSavedSessionStillActive } from "@/helpers/heritage-integration/sessio
import analyticsMixin from "@/mixins/analytics-mixin";
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 { checkPagePrerequisites } from "@/router/methods/page-prerequisites";
@ -16,6 +17,8 @@ import { handleHeritageReturn } from "@/router/methods/helpers/handle-heritage-r
import { isVirtualRoute } from "@/router/methods/helpers/is-virtual-route";
import router from "@/router";
const MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION = 5;
export async function beforeEach(to, from) {
try {
await analyticsMixin.methods.validateSession();
@ -27,10 +30,37 @@ export async function beforeEach(to, from) {
}
if (getFunnelCookie()?.SuppressConceptFunnel) {
// Redirect to heritage.
return {
name: routeData.HERITAGE.name,
};
const redirectKey = sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT;
const redirectCount = Number(window.sessionStorage.getItem(redirectKey) ?? 0);
if (redirectCount < MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION) {
window.sessionStorage.setItem(redirectKey, String(redirectCount + 1));
navigateToHeritageFunnel({
shouldSaveSession: false,
pageNameToLog: to.name,
});
return false;
} else {
// If the redirect count is greater than the max allowed, we need to handle the error.
// Reset the redirect count to 0.
window.sessionStorage.setItem(redirectKey, "0");
debugLog(
"SuppressConceptFunnel: skipped heritage redirect after max attempts this session",
{ redirectCount, to: to.name }
);
const errorPayload = {
cause: "Handling heritage redirect after max attempts this session.",
currentPage: from?.name,
nextPage: to?.name,
};
// Eject user from Vue app in this scenario and clear localstorage.
await handleSoftError(errorPayload, true);
return false;
}
}
// Ensure session has not expired

View file

@ -12,7 +12,7 @@ export async function handleSoftError(errorPayload, forceRestart = false) {
deleteFunnelCookie();
}
analyticsMixin.methods.pushPageErrorToDataLayer(errorPayload);
logErrorToDataLayerAndLogger("handleSoftError", errorPayload, forceRestart);
router.push({
name: routeData.ERROR.name,
@ -27,7 +27,7 @@ export async function handleHardError(errorPayload) {
);
if (isInStaticErrorExperiment) {
analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload);
logErrorToDataLayerAndLogger("handleHardError", errorPayload, null);
window.top.location = "/fmg/static/error";
return;
} else {
@ -38,3 +38,10 @@ export async function handleHardError(errorPayload) {
await handleSoftError(errorPayload, true);
}
}
async function logErrorToDataLayerAndLogger(context, errorPayload, forceRestart = null) {
const forceRestartSegment = forceRestart === true ? " (force restart)" : "";
const message = `${context}${forceRestartSegment}: ${errorPayload?.cause ?? "unknown cause"}`;
analyticsMixin?.methods?.pushPageErrorToDataLayer(errorPayload);
global.$logger.logError(message, errorPayload);
}

View file

@ -0,0 +1,168 @@
import { handleSoftError, handleHardError } 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 { routeData } from "@/router/constants/routes";
jest.mock("@/router", () => ({
__esModule: true,
default: {
push: jest.fn(),
},
}));
jest.mock("@/store", () => ({
__esModule: true,
default: {
dispatch: jest.fn(() => Promise.resolve()),
},
}));
jest.mock("@/helpers/heritage-integration/cookie-helper", () => ({
deleteFunnelCookie: jest.fn(),
}));
jest.mock("@/mixins/analytics-mixin", () => ({
__esModule: true,
default: {
methods: {
pushPageErrorToDataLayer: jest.fn(),
},
},
}));
jest.mock("../../mixins/experiment-mixin", () => ({
__esModule: true,
default: {
methods: {
hasSettingEqualTo: jest.fn(),
},
},
}));
describe("router/methods/error", () => {
const errorPayload = { cause: "test cause", currentPage: "from", nextPage: "to" };
beforeEach(() => {
jest.clearAllMocks();
store.dispatch.mockResolvedValue(undefined);
global.$logger = {
logInformation: jest.fn(),
logWarning: jest.fn(),
logError: jest.fn(),
logCritical: jest.fn(),
};
});
describe("handleSoftError", () => {
it("logs and navigates to the error route without reset when forceRestart is false", async () => {
await handleSoftError(errorPayload, false);
expect(store.dispatch).not.toHaveBeenCalled();
expect(deleteFunnelCookie).not.toHaveBeenCalled();
expect(analyticsMixin.methods.pushPageErrorToDataLayer).toHaveBeenCalledWith(
errorPayload
);
expect(global.$logger.logError).toHaveBeenCalledWith(
"handleSoftError: test cause",
errorPayload
);
expect(router.push).toHaveBeenCalledWith({ name: routeData.ERROR.name });
});
it("dispatches reset, deletes funnel cookie, logs, and navigates when forceRestart is true", async () => {
await handleSoftError(errorPayload, true);
expect(store.dispatch).toHaveBeenCalledWith(storeActions.RESET_STATE);
expect(deleteFunnelCookie).toHaveBeenCalled();
expect(analyticsMixin.methods.pushPageErrorToDataLayer).toHaveBeenCalledWith(
errorPayload
);
expect(global.$logger.logError).toHaveBeenCalledWith(
"handleSoftError (force restart): test cause",
errorPayload
);
expect(router.push).toHaveBeenCalledWith({ name: routeData.ERROR.name });
});
it("uses unknown cause in the log message when payload has no cause", async () => {
await handleSoftError({}, false);
expect(global.$logger.logError).toHaveBeenCalledWith(
"handleSoftError: unknown cause",
{}
);
});
});
describe("handleHardError", () => {
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 when UseStaticErrorPage experiment is on", async () => {
experimentMixin.methods.hasSettingEqualTo.mockReturnValue(true);
await handleHardError(errorPayload);
expect(experimentMixin.methods.hasSettingEqualTo).toHaveBeenCalledWith(
"UseStaticErrorPage",
"true"
);
expect(analyticsMixin.methods.pushPageErrorToDataLayer).toHaveBeenCalledWith(
errorPayload
);
expect(global.$logger.logError).toHaveBeenCalledWith(
"handleHardError: test cause",
errorPayload
);
expect(topLocationValue).toBe("/fmg/static/error");
expect(router.push).not.toHaveBeenCalled();
});
it("delegates to handleSoftError with force restart when static error page is off", async () => {
experimentMixin.methods.hasSettingEqualTo.mockReturnValue(false);
await handleHardError(errorPayload);
expect(store.dispatch).toHaveBeenCalledWith(storeActions.RESET_STATE);
expect(deleteFunnelCookie).toHaveBeenCalled();
expect(global.$logger.logError).toHaveBeenCalledWith(
"handleSoftError (force restart): test cause",
errorPayload
);
expect(router.push).toHaveBeenCalledWith({ name: routeData.ERROR.name });
expect(topLocationValue).toBeUndefined();
});
it("delegates to handleSoftError with force restart when experiment check throws", async () => {
experimentMixin.methods.hasSettingEqualTo.mockImplementation(() => {
throw new Error("experiment failure");
});
await handleHardError(errorPayload);
expect(store.dispatch).toHaveBeenCalledWith(storeActions.RESET_STATE);
expect(deleteFunnelCookie).toHaveBeenCalled();
expect(router.push).toHaveBeenCalledWith({ name: routeData.ERROR.name });
});
});
});

View file

@ -1,5 +1,6 @@
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
// Unsure if this is needed. I don't think its used anywhere
export async function heritageBeforeEnter(to, from) {
await navigateToHeritageFunnel({ shouldSaveSession: false, pageNameToLog: to.name });
}