diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 9ee329fa150..63f7c105d74 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -2240,6 +2240,121 @@ describe("CollectAutofillContentService", () => { expect(collectAutofillContentService["setupOverlayListenersOnMutatedElements"]).toBeCalled(); }); + + it("triggers debounced page details update when mutations occur in shadow roots", () => { + jest.useFakeTimers(); + const mutationRecord: MutationRecord = { + type: "childList", + addedNodes: document.querySelectorAll("div"), + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.body, + }; + collectAutofillContentService["currentLocationHref"] = window.location.href; + + jest.spyOn(domQueryService, "checkMutationsInShadowRoots").mockReturnValue(true); + jest.spyOn(collectAutofillContentService as any, "debouncedRequirePageDetailsUpdate"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(domQueryService.checkMutationsInShadowRoots).toHaveBeenCalledWith([mutationRecord]); + expect(collectAutofillContentService["debouncedRequirePageDetailsUpdate"]).toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it("does not trigger debounced update when mutations are not in shadow roots", () => { + jest.useFakeTimers(); + const mutationRecord: MutationRecord = { + type: "childList", + addedNodes: document.querySelectorAll("div"), + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.body, + }; + collectAutofillContentService["currentLocationHref"] = window.location.href; + + jest.spyOn(domQueryService, "checkMutationsInShadowRoots").mockReturnValue(false); + jest.spyOn(collectAutofillContentService as any, "debouncedRequirePageDetailsUpdate"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(domQueryService.checkMutationsInShadowRoots).toHaveBeenCalledWith([mutationRecord]); + expect( + collectAutofillContentService["debouncedRequirePageDetailsUpdate"], + ).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it("schedules a debounced check for new shadow roots", () => { + jest.useFakeTimers(); + const mutationRecord: MutationRecord = { + type: "childList", + addedNodes: document.querySelectorAll("div"), + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.body, + }; + collectAutofillContentService["currentLocationHref"] = window.location.href; + collectAutofillContentService["pendingShadowDomCheck"] = false; + + jest.spyOn(domQueryService, "checkMutationsInShadowRoots").mockReturnValue(false); + jest.spyOn(collectAutofillContentService as any, "checkForNewShadowRoots"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(collectAutofillContentService["pendingShadowDomCheck"]).toBe(true); + expect(collectAutofillContentService["shadowDomCheckTimeout"]).not.toBeNull(); + + // Fast-forward time to trigger the debounced check + jest.advanceTimersByTime(500); + + expect(collectAutofillContentService["checkForNewShadowRoots"]).toHaveBeenCalled(); + expect(collectAutofillContentService["pendingShadowDomCheck"]).toBe(false); + + jest.useRealTimers(); + }); + + it("does not schedule duplicate shadow root checks when already pending", () => { + jest.useFakeTimers(); + const mutationRecord: MutationRecord = { + type: "childList", + addedNodes: document.querySelectorAll("div"), + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.body, + }; + collectAutofillContentService["currentLocationHref"] = window.location.href; + collectAutofillContentService["pendingShadowDomCheck"] = true; + + const initialTimeout = setTimeout(() => {}, 500); + collectAutofillContentService["shadowDomCheckTimeout"] = initialTimeout; + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + // Should not change the timeout since check is already pending + expect(collectAutofillContentService["shadowDomCheckTimeout"]).toBe(initialTimeout); + + clearTimeout(initialTimeout); + jest.useRealTimers(); + }); }); describe("setupOverlayListenersOnMutatedElements", () => { @@ -2660,22 +2775,25 @@ describe("CollectAutofillContentService", () => { jest.useRealTimers(); }); - it("will require an update to page details if shadow DOM is present", () => { - jest - .spyOn(domQueryService as any, "checkPageContainsShadowDom") - .mockImplementationOnce(() => true); + it("processes queued mutations and clears the queue", () => { + const mutationRecord: MutationRecord = { + type: "childList", + addedNodes: document.querySelectorAll("div"), + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: document.querySelectorAll("li"), + target: document.body, + }; - collectAutofillContentService["requirePageDetailsUpdate"] = jest.fn(); - - collectAutofillContentService["mutationsQueue"] = [[], []]; + collectAutofillContentService["mutationsQueue"] = [[mutationRecord], [mutationRecord]]; + jest.spyOn(collectAutofillContentService as any, "processMutationRecords"); collectAutofillContentService["processMutations"](); - jest.runOnlyPendingTimers(); - - expect(domQueryService.checkPageContainsShadowDom).toHaveBeenCalled(); expect(collectAutofillContentService["mutationsQueue"]).toHaveLength(0); - expect(collectAutofillContentService["requirePageDetailsUpdate"]).toHaveBeenCalled(); }); }); }); diff --git a/apps/browser/src/autofill/services/dom-query.service.spec.ts b/apps/browser/src/autofill/services/dom-query.service.spec.ts index 87645c98a45..6ff520dad2d 100644 --- a/apps/browser/src/autofill/services/dom-query.service.spec.ts +++ b/apps/browser/src/autofill/services/dom-query.service.spec.ts @@ -147,4 +147,122 @@ describe("DomQueryService", () => { expect(formFieldElements).toStrictEqual([input]); }); }); + + describe("checkMutationsInShadowRoots", () => { + it("returns true when a mutation occurred within a shadow root", () => { + const customElement = document.createElement("custom-element"); + const shadowRoot = customElement.attachShadow({ mode: "open" }); + const input = document.createElement("input"); + shadowRoot.appendChild(input); + + const mutationRecord: MutationRecord = { + type: "childList", + addedNodes: NodeList.prototype, + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: NodeList.prototype, + target: input, + }; + + const result = domQueryService.checkMutationsInShadowRoots([mutationRecord]); + + expect(result).toBe(true); + }); + + it("returns false when mutations occurred in the light DOM", () => { + const div = document.createElement("div"); + document.body.appendChild(div); + + const mutationRecord: MutationRecord = { + type: "childList", + addedNodes: NodeList.prototype, + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: NodeList.prototype, + target: div, + }; + + const result = domQueryService.checkMutationsInShadowRoots([mutationRecord]); + + expect(result).toBe(false); + }); + + it("returns true if any mutation in the array is in a shadow root", () => { + const customElement = document.createElement("custom-element"); + const shadowRoot = customElement.attachShadow({ mode: "open" }); + const shadowInput = document.createElement("input"); + shadowRoot.appendChild(shadowInput); + + const lightDiv = document.createElement("div"); + document.body.appendChild(lightDiv); + + const shadowMutation: MutationRecord = { + type: "childList", + addedNodes: NodeList.prototype, + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: NodeList.prototype, + target: shadowInput, + }; + + const lightMutation: MutationRecord = { + type: "childList", + addedNodes: NodeList.prototype, + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: NodeList.prototype, + target: lightDiv, + }; + + const result = domQueryService.checkMutationsInShadowRoots([lightMutation, shadowMutation]); + + expect(result).toBe(true); + }); + }); + + describe("checkForNewShadowRoots", () => { + it("returns true when a shadow root is not in the observed set", () => { + const customElement = document.createElement("custom-element"); + customElement.attachShadow({ mode: "open" }); + document.body.appendChild(customElement); + + const result = domQueryService.checkForNewShadowRoots(); + + expect(result).toBe(true); + }); + + it("returns false when all shadow roots are already observed", () => { + const customElement = document.createElement("custom-element"); + const shadowRoot = customElement.attachShadow({ mode: "open" }); + document.body.appendChild(customElement); + + // Simulate the shadow root being observed by adding it to the tracked set + domQueryService["observedShadowRoots"].add(shadowRoot); + + const result = domQueryService.checkForNewShadowRoots(); + + expect(result).toBe(false); + }); + + it("returns false when there are no shadow roots on the page", () => { + const div = document.createElement("div"); + document.body.appendChild(div); + + const result = domQueryService.checkForNewShadowRoots(); + + expect(result).toBe(false); + }); + }); });