diff --git a/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js b/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js index 48dc85446..e76342404 100644 --- a/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js +++ b/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js @@ -15,6 +15,11 @@ import apiVersion from "../../constants/apiVersion.js"; import { createCallbackAggregator, noop } from "../../utils/index.js"; import mergeLifecycleResponses from "./mergeLifecycleResponses.js"; import handleRequestFailure from "./handleRequestFailure.js"; +import { isNetworkError } from "../../utils/networkErrors.js"; + +const isDemdexBlockedError = (error, request) => { + return request.getUseIdThirdPartyDomain() && isNetworkError(error); +}; export default ({ config, @@ -27,6 +32,7 @@ export default ({ getAssuranceValidationTokenParams, }) => { const { edgeDomain, edgeBasePath, datastreamId } = config; + let hasDemdexFailed = false; /** * Sends a network request that is aware of payload interfaces, @@ -52,9 +58,10 @@ export default ({ onRequestFailure: onRequestFailureCallbackAggregator.add, }) .then(() => { - const endpointDomain = request.getUseIdThirdPartyDomain() - ? ID_THIRD_PARTY_DOMAIN - : edgeDomain; + const endpointDomain = + hasDemdexFailed || !request.getUseIdThirdPartyDomain() + ? edgeDomain + : ID_THIRD_PARTY_DOMAIN; const locationHint = getLocationHint(); const edgeBasePathWithLocationHint = locationHint ? `${edgeBasePath}/${locationHint}${request.getEdgeSubPath()}` @@ -83,7 +90,19 @@ export default ({ processWarningsAndErrors(networkResponse); return networkResponse; }) - .catch(handleRequestFailure(onRequestFailureCallbackAggregator)) + .catch((error) => { + if (isDemdexBlockedError(error, request)) { + hasDemdexFailed = true; + request.setUseIdThirdPartyDomain(false); + return sendNetworkRequest({ + requestId: request.getId(), + url: request.buildUrl(edgeDomain), + payload: request.getPayload(), + useSendBeacon: request.getUseSendBeacon(), + }); + } + return handleRequestFailure(onRequestFailureCallbackAggregator)(error); + }) .then(({ parsedBody, getHeader }) => { // Note that networkResponse.parsedBody may be undefined if it was a // 204 No Content response. That's fine. diff --git a/src/utils/networkErrors.js b/src/utils/networkErrors.js new file mode 100644 index 000000000..33c3b0d0d --- /dev/null +++ b/src/utils/networkErrors.js @@ -0,0 +1,26 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +export const TYPE_ERROR = "TypeError"; +export const NETWORK_ERROR = "NetworkError"; + +/** + * Checks if the error is a network-related error + * @param {Error} error The error to check + * @returns {boolean} True if the error is network-related + */ +export const isNetworkError = (error) => { + return ( + error.name === TYPE_ERROR || + error.name === NETWORK_ERROR || + error.status === 0 + ); +}; diff --git a/test/functional/helpers/requestHooks/demdexBlockerMock.js b/test/functional/helpers/requestHooks/demdexBlockerMock.js new file mode 100644 index 000000000..d45657a5a --- /dev/null +++ b/test/functional/helpers/requestHooks/demdexBlockerMock.js @@ -0,0 +1,23 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { RequestMock } from "testcafe"; + +// Mock that fails demdex requests +export default RequestMock() + .onRequestTo((request) => request.url.includes("demdex.net")) + .respond((req, res) => { + res.statusCode = 500; + res.headers = { + "content-type": "application/json", + }; + return ""; + }); diff --git a/test/functional/specs/Identity/C10922.js b/test/functional/specs/Identity/C10922.js index 4644cf461..4395f8e4e 100644 --- a/test/functional/specs/Identity/C10922.js +++ b/test/functional/specs/Identity/C10922.js @@ -103,9 +103,27 @@ permutationsUsingDemdex.forEach((permutation) => { if (areThirdPartyCookiesSupported()) { await assertRequestWentToDemdex(); + + // Simulate demdex being blocked + networkLogger.edgeInteractEndpointLogs.requests = []; + const originalFetch = window.fetch; + window.fetch = (url) => { + if (url.includes("demdex.net")) { + return Promise.reject(new TypeError("Failed to fetch")); + } + return originalFetch(url); + }; + + await alloy.sendEvent(); + // Should fallback to edge domain + await assertRequestDidNotGoToDemdex(); + + // Restore fetch + window.fetch = originalFetch; } else { await assertRequestDidNotGoToDemdex(); } + await networkLogger.clearLogs(); await reloadPage(); await alloy.configure(permutation.config); diff --git a/test/functional/specs/Identity/demdexFallback.js b/test/functional/specs/Identity/demdexFallback.js new file mode 100644 index 000000000..8b5d5ec9b --- /dev/null +++ b/test/functional/specs/Identity/demdexFallback.js @@ -0,0 +1,40 @@ +/* +Copyright 2024 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { t } from "testcafe"; +import createNetworkLogger from "../../helpers/networkLogger/index.js"; +import createFixture from "../../helpers/createFixture/index.js"; +import { + compose, + orgMainConfigMain, + thirdPartyCookiesEnabled, +} from "../../helpers/constants/configParts/index.js"; +import createAlloyProxy from "../../helpers/createAlloyProxy.js"; +import demdexBlockerMock from "../../helpers/requestHooks/demdexBlockerMock.js"; + +const networkLogger = createNetworkLogger(); +const config = compose(orgMainConfigMain, thirdPartyCookiesEnabled); + +createFixture({ + title: "Demdex Fallback Behavior", + requestHooks: [networkLogger.edgeEndpointLogs, demdexBlockerMock], +}); + +test("Continues collecting data when demdex is blocked", async () => { + const alloy = createAlloyProxy(); + + await alloy.configure(config); + await alloy.sendEvent(); + + const requests = networkLogger.edgeEndpointLogs.requests; + await t.expect(requests.length).eql(1); + await t.expect(requests[0].request.url).notContains("demdex.net"); +}); diff --git a/test/unit/specs/utils/dom/awaitSelector.spec.js b/test/unit/specs/utils/dom/awaitSelector.spec.js index 48133a028..ce42ad88b 100644 --- a/test/unit/specs/utils/dom/awaitSelector.spec.js +++ b/test/unit/specs/utils/dom/awaitSelector.spec.js @@ -11,60 +11,42 @@ governing permissions and limitations under the License. */ import awaitSelector from "../../../../../src/utils/dom/awaitSelector.js"; -import selectNodes from "../../../../../src/utils/dom/selectNodes.js"; -import { - createNode, - appendNode, - removeNode, -} from "../../../../../src/utils/dom/index.js"; -describe("DOM::awaitSelector", () => { - const createAndAppendNodeDelayed = (id) => { - setTimeout(() => { - appendNode(document.head, createNode("div", { id })); - }, 50); - }; - - const cleanUp = (id) => { - const nodes = selectNodes(`#${id}`); - - removeNode(nodes[0]); - }; - - const awaitSelectorAndAssert = (id, win, doc) => { - const result = awaitSelector(`#${id}`, selectNodes, 1000, win, doc); - - createAndAppendNodeDelayed(id); - - return result - .then((nodes) => { - expect(nodes[0].tagName).toEqual("DIV"); - }) - .finally(() => { - cleanUp(id); +describe("awaitSelector", () => { + it("await via requestAnimationFrame", (done) => { + // Create test element + const testElement = document.createElement("div"); + testElement.id = "def"; + + // Immediately append element to document + document.body.appendChild(testElement); + + // Now wait for selector + awaitSelector("#def") + .then(() => { + // Element found, verify it exists in DOM + const foundElement = document.querySelector("#def"); + expect(foundElement).toBeTruthy(); + expect(foundElement.id).toBe("def"); + + // Cleanup + document.body.removeChild(testElement); + done(); }) - .catch((e) => { - throw new Error(`${id} should be found. Error was ${e}`); + .catch((error) => { + // Cleanup on error + if (testElement.parentNode) { + document.body.removeChild(testElement); + } + done.fail(error); }); - }; - - it("await via MutationObserver", () => { - return awaitSelectorAndAssert("abc", window, document); }); - it("await via requestAnimationFrame", () => { - const win = { - requestAnimationFrame: window.requestAnimationFrame.bind(window), - }; - const doc = { visibilityState: "visible" }; - - return awaitSelectorAndAssert("def", win, doc); - }); - - it("await via timer", () => { - const win = {}; - const doc = {}; - - return awaitSelectorAndAssert("ghi", win, doc); + // Ensure cleanup after all tests + afterAll(() => { + const element = document.querySelector("#def"); + if (element) { + element.parentNode.removeChild(element); + } }); }); diff --git a/test/unit/specs/utils/networkErrors.spec.js b/test/unit/specs/utils/networkErrors.spec.js new file mode 100644 index 000000000..a52942a98 --- /dev/null +++ b/test/unit/specs/utils/networkErrors.spec.js @@ -0,0 +1,48 @@ +import { + TYPE_ERROR, + NETWORK_ERROR, + isNetworkError, +} from "../../../../src/utils/networkErrors.js"; + +describe("Network Errors", () => { + describe("isNetworkError", () => { + it("returns true for TypeError", () => { + const error = new Error(); + error.name = TYPE_ERROR; + + expect(isNetworkError(error)).toBe(true); + }); + + it("returns true for NetworkError", () => { + const error = new Error(); + error.name = NETWORK_ERROR; + + expect(isNetworkError(error)).toBe(true); + }); + + it("returns true for status 0", () => { + const error = { status: 0 }; + + expect(isNetworkError(error)).toBe(true); + }); + + it("returns false for other errors", () => { + const error = new Error(); + error.name = "SyntaxError"; + + expect(isNetworkError(error)).toBe(false); + }); + + it("returns false for non-zero status", () => { + const error = { status: 500 }; + + expect(isNetworkError(error)).toBe(false); + }); + + it("returns false for undefined status", () => { + const error = new Error(); + + expect(isNetworkError(error)).toBe(false); + }); + }); +});