diff --git a/package-lock.json b/package-lock.json index 9513a720f..b257816a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "alloyBuilder": "scripts/alloyBuilder.js" }, "devDependencies": { - "@adobe/alloy": "^2.24.0", + "@adobe/alloy": "^2.24.0-beta.2", "@babel/cli": "^7.25.9", "@babel/plugin-transform-runtime": "^7.25.9", "@eslint/js": "^9.14.0", @@ -89,9 +89,9 @@ "license": "Apache-2.0" }, "node_modules/@adobe/alloy": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/@adobe/alloy/-/alloy-2.24.0.tgz", - "integrity": "sha512-oEKvJQn+P2w1d4xAj8xbzXPpa0jBPMbS3VSa1qT5nRku3jH4qOqMofEKv8jJWDg/YmQOK9GwrmJG14p0TIIOsw==", + "version": "2.24.0-beta.2", + "resolved": "https://registry.npmjs.org/@adobe/alloy/-/alloy-2.24.0-beta.2.tgz", + "integrity": "sha512-UdxG6VTtvA3kF4CTrahCHJbCExivyKUHR6eQvzGoJWCWcvvHI6tLPaE6ykSgSZWGNHOk/5+MmJhHh1Ed1NdypQ==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index e44dfd494..9ff738916 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "uuid": "^11.0.2" }, "devDependencies": { - "@adobe/alloy": "^2.24.0", + "@adobe/alloy": "^2.24.0-beta.2", "@babel/cli": "^7.25.9", "@babel/plugin-transform-runtime": "^7.25.9", "@eslint/js": "^9.14.0", diff --git a/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js b/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js index 48dc85446..8074ec1e6 100644 --- a/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js +++ b/src/core/edgeNetwork/injectSendEdgeNetworkRequest.js @@ -13,9 +13,14 @@ governing permissions and limitations under the License. import { ID_THIRD_PARTY as ID_THIRD_PARTY_DOMAIN } from "../../constants/domain.js"; import apiVersion from "../../constants/apiVersion.js"; import { createCallbackAggregator, noop } from "../../utils/index.js"; +import { isNetworkError } from "../../utils/networkErrors.js"; import mergeLifecycleResponses from "./mergeLifecycleResponses.js"; import handleRequestFailure from "./handleRequestFailure.js"; +const isDemdexBlockedError = (error, request) => { + return request.getUseIdThirdPartyDomain() && isNetworkError(error); +}; + export default ({ config, lifecycle, @@ -27,6 +32,27 @@ export default ({ getAssuranceValidationTokenParams, }) => { const { edgeDomain, edgeBasePath, datastreamId } = config; + let hasDemdexFailed = false; + + const buildEndpointUrl = (endpointDomain, request) => { + const locationHint = getLocationHint(); + const edgeBasePathWithLocationHint = locationHint + ? `${edgeBasePath}/${locationHint}${request.getEdgeSubPath()}` + : `${edgeBasePath}${request.getEdgeSubPath()}`; + const configId = request.getDatastreamIdOverride() || datastreamId; + + if (configId !== datastreamId) { + request.getPayload().mergeMeta({ + sdkConfig: { + datastream: { + original: datastreamId, + }, + }, + }); + } + + return `https://${endpointDomain}/${edgeBasePathWithLocationHint}/${apiVersion}/${request.getAction()}?configId=${configId}&requestId=${request.getId()}${getAssuranceValidationTokenParams()}`; + }; /** * Sends a network request that is aware of payload interfaces, @@ -52,26 +78,15 @@ export default ({ onRequestFailure: onRequestFailureCallbackAggregator.add, }) .then(() => { - const endpointDomain = request.getUseIdThirdPartyDomain() - ? ID_THIRD_PARTY_DOMAIN - : edgeDomain; - const locationHint = getLocationHint(); - const edgeBasePathWithLocationHint = locationHint - ? `${edgeBasePath}/${locationHint}${request.getEdgeSubPath()}` - : `${edgeBasePath}${request.getEdgeSubPath()}`; - const configId = request.getDatastreamIdOverride() || datastreamId; + const endpointDomain = + hasDemdexFailed || !request.getUseIdThirdPartyDomain() + ? edgeDomain + : ID_THIRD_PARTY_DOMAIN; + + const url = buildEndpointUrl(endpointDomain, request); const payload = request.getPayload(); - if (configId !== datastreamId) { - payload.mergeMeta({ - sdkConfig: { - datastream: { - original: datastreamId, - }, - }, - }); - } - const url = `https://${endpointDomain}/${edgeBasePathWithLocationHint}/${apiVersion}/${request.getAction()}?configId=${configId}&requestId=${request.getId()}${getAssuranceValidationTokenParams()}`; cookieTransfer.cookiesToPayload(payload, endpointDomain); + return sendNetworkRequest({ requestId: request.getId(), url, @@ -83,7 +98,23 @@ export default ({ processWarningsAndErrors(networkResponse); return networkResponse; }) - .catch(handleRequestFailure(onRequestFailureCallbackAggregator)) + .catch((error) => { + if (isDemdexBlockedError(error, request)) { + hasDemdexFailed = true; + request.setUseIdThirdPartyDomain(false); + const url = buildEndpointUrl(edgeDomain, request); + const payload = request.getPayload(); + cookieTransfer.cookiesToPayload(payload, edgeDomain); + + return sendNetworkRequest({ + requestId: request.getId(), + url, + payload, + 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/demdexFallback.js b/test/functional/specs/Identity/demdexFallback.js new file mode 100644 index 000000000..0a08d49d7 --- /dev/null +++ b/test/functional/specs/Identity/demdexFallback.js @@ -0,0 +1,54 @@ +/* +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(); + + // Get all requests + const requests = networkLogger.edgeEndpointLogs.requests; + + // Find the successful request (should be the last one) + const successfulRequest = requests[requests.length - 1]; + + // Verify the successful request + await t.expect(successfulRequest.request.url).notContains("demdex.net"); + await t.expect(successfulRequest.request.url).contains(config.edgeDomain); + await t.expect(successfulRequest.response.statusCode).eql(200); + + // Verify at least one request was successful + const successfulRequests = requests.filter( + (r) => + !r.request.url.includes("demdex.net") && r.response.statusCode === 200, + ); + await t.expect(successfulRequests.length).gte(1); +}); diff --git a/test/unit/specs/components/ActivityCollector/validateClickCollectionConfig.spec.js b/test/unit/specs/components/ActivityCollector/validateClickCollectionConfig.spec.js index cbab9c565..be8b93e26 100644 --- a/test/unit/specs/components/ActivityCollector/validateClickCollectionConfig.spec.js +++ b/test/unit/specs/components/ActivityCollector/validateClickCollectionConfig.spec.js @@ -10,6 +10,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ +import { describe, it, expect, beforeEach, vi } from "vitest"; import validateClickCollectionConfig from "../../../../../src/components/ActivityCollector/validateClickCollectionConfig.js"; import { DEFAULT_DOWNLOAD_QUALIFIER } from "../../../../../src/components/ActivityCollector/configValidators.js"; @@ -17,7 +18,9 @@ describe("ActivityCollector::validateClickCollectionConfig", () => { let logger; beforeEach(() => { - logger = jasmine.createSpyObj("logger", ["warn"]); + logger = { + warn: vi.fn(), + }; }); it("warns when onBeforeLinkClickSend provided with clickCollectionEnabled false", () => { diff --git a/test/unit/specs/utils/dom/awaitSelector.spec.js b/test/unit/specs/utils/dom/awaitSelector.spec.js index 17faee903..91988bb04 100644 --- a/test/unit/specs/utils/dom/awaitSelector.spec.js +++ b/test/unit/specs/utils/dom/awaitSelector.spec.js @@ -10,62 +10,39 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterAll } from "vitest"; 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); - }) - .catch((e) => { - throw new Error(`${id} should be found. Error was ${e}`); - }); - }; - - it("await via MutationObserver", () => { - return awaitSelectorAndAssert("abc", window, document); +describe("awaitSelector", () => { + it("await via requestAnimationFrame", async () => { + // Create test element + const testElement = document.createElement("div"); + testElement.id = "def"; + + // Immediately append element to document + document.body.appendChild(testElement); + + try { + // Now wait for selector + await awaitSelector("#def"); + + // Element found, verify it exists in DOM + const foundElement = document.querySelector("#def"); + expect(foundElement).toBeTruthy(); + expect(foundElement.id).toBe("def"); + } finally { + // Cleanup + if (testElement.parentNode) { + document.body.removeChild(testElement); + } + } }); - 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..053146775 --- /dev/null +++ b/test/unit/specs/utils/networkErrors.spec.js @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +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); + }); + }); +});