diff --git a/apps/browser/src/autofill/models/autofill-form.ts b/apps/browser/src/autofill/models/autofill-form.ts index e23539bd30..3f06e28a91 100644 --- a/apps/browser/src/autofill/models/autofill-form.ts +++ b/apps/browser/src/autofill/models/autofill-form.ts @@ -2,6 +2,7 @@ * Represents an HTML form whose elements can be autofilled */ export default class AutofillForm { + [key: string]: any; /** * The unique identifier assigned to this field during collection of the page details */ diff --git a/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts index 7ff85a7e8e..e4a409eb59 100644 --- a/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts @@ -1,8 +1,32 @@ +import AutofillField from "../../models/autofill-field"; +import AutofillForm from "../../models/autofill-form"; import AutofillPageDetails from "../../models/autofill-page-details"; +import { ElementWithOpId, FormFieldElement } from "../../types"; + +type AutofillFormElements = Map, AutofillForm>; + +type AutofillFieldElements = Map, AutofillField>; + +type UpdateAutofillDataAttributeParams = { + element: ElementWithOpId; + attributeName: string; + dataTarget?: AutofillForm | AutofillField; + dataTargetKey?: string; +}; interface CollectAutofillContentService { getPageDetails(): Promise; getAutofillFieldElementByOpid(opid: string): HTMLElement | null; + queryAllTreeWalkerNodes( + rootNode: Node, + filterCallback: CallableFunction, + isObservingShadowRoot?: boolean + ): Node[]; } -export { CollectAutofillContentService }; +export { + AutofillFormElements, + AutofillFieldElements, + UpdateAutofillDataAttributeParams, + CollectAutofillContentService, +}; 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 706ceb0fe0..b21f530e57 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 @@ -1,3 +1,7 @@ +import { mock } from "jest-mock-extended"; + +import AutofillField from "../models/autofill-field"; +import AutofillForm from "../models/autofill-form"; import { ElementWithOpId, FillableFormFieldElement, @@ -32,7 +36,128 @@ describe("CollectAutofillContentService", () => { }); describe("getPageDetails", () => { - it("returns an object containing information about the curren page as well as autofill data for the forms and fields of the page", async () => { + beforeEach(() => { + jest + .spyOn(collectAutofillContentService as any, "setupMutationObserver") + .mockImplementationOnce(() => { + collectAutofillContentService["mutationObserver"] = mock(); + }); + }); + + it("sets up the mutation observer the first time getPageDetails is called", async () => { + await collectAutofillContentService.getPageDetails(); + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["setupMutationObserver"]).toHaveBeenCalledTimes(1); + }); + + it("returns an object with empty forms and fields if no fields were found on a previous iteration", async () => { + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + jest.spyOn(collectAutofillContentService as any, "getFormattedPageDetails"); + jest.spyOn(collectAutofillContentService as any, "queryAutofillFormAndFieldElements"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["getFormattedPageDetails"]).toHaveBeenCalledWith({}, []); + expect( + collectAutofillContentService["queryAutofillFormAndFieldElements"] + ).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFormsData"]).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).not.toHaveBeenCalled(); + }); + + it("returns an object with cached form and field data values", async () => { + const formId = "validFormId"; + const formAction = "https://example.com/"; + const formMethod = "post"; + const formName = "validFormName"; + const usernameFieldId = "usernameField"; + const usernameFieldName = "username"; + const usernameFieldLabel = "User Name"; + const passwordFieldId = "passwordField"; + const passwordFieldName = "password"; + const passwordFieldLabel = "Password"; + document.body.innerHTML = ` +
+ + + + +
+ `; + const formElement = document.getElementById(formId) as ElementWithOpId; + const autofillForm: AutofillForm = { + opid: "__form__0", + htmlAction: formAction, + htmlName: formName, + htmlID: formId, + htmlMethod: formMethod, + }; + const fieldElement = document.getElementById( + usernameFieldId + ) as ElementWithOpId; + const autofillField: AutofillField = { + opid: "__0", + elementNumber: 0, + maxLength: 999, + viewable: true, + htmlID: usernameFieldId, + htmlName: usernameFieldName, + htmlClass: null, + tabindex: null, + title: "", + tagName: "input", + "label-tag": usernameFieldLabel, + "label-data": null, + "label-aria": null, + "label-top": null, + "label-right": passwordFieldLabel, + "label-left": usernameFieldLabel, + placeholder: "", + rel: null, + type: "text", + value: "", + checked: false, + autoCompleteType: "", + disabled: false, + readonly: false, + selectInfo: null, + form: "__form__0", + "aria-hidden": false, + "aria-disabled": false, + "aria-haspopup": false, + "data-stripe": null, + }; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["autofillFormElements"] = new Map([ + [formElement, autofillForm], + ]); + collectAutofillContentService["autofillFieldElements"] = new Map([ + [fieldElement, autofillField], + ]); + jest.spyOn(collectAutofillContentService as any, "getFormattedPageDetails"); + jest.spyOn(collectAutofillContentService as any, "getFormattedAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "getFormattedAutofillFieldsData"); + jest.spyOn(collectAutofillContentService as any, "queryAutofillFormAndFieldElements"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["getFormattedPageDetails"]).toHaveBeenCalled(); + expect(collectAutofillContentService["getFormattedAutofillFormsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["getFormattedAutofillFieldsData"]).toHaveBeenCalled(); + expect( + collectAutofillContentService["queryAutofillFormAndFieldElements"] + ).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFormsData"]).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).not.toHaveBeenCalled(); + }); + + it("returns an object containing information about the current page as well as autofill data for the forms and fields of the page", async () => { const documentTitle = "Test Page"; const formId = "validFormId"; const formAction = "https://example.com/"; @@ -145,6 +270,19 @@ describe("CollectAutofillContentService", () => { collectedTimestamp: expect.any(Number), }); }); + + it("sets the noFieldsFond property to true if the page has no forms or fields", async function () { + document.body.innerHTML = ""; + collectAutofillContentService["noFieldsFound"] = false; + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["buildAutofillFormsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["noFieldsFound"]).toBe(true); + }); }); describe("getAutofillFieldElementByOpid", () => { @@ -213,6 +351,44 @@ describe("CollectAutofillContentService", () => { }); describe("buildAutofillFormsData", () => { + it("will not attempt to gather data from a cached form element", () => { + const documentTitle = "Test Page"; + const formId = "validFormId"; + const formAction = "https://example.com/"; + const formMethod = "post"; + const formName = "validFormName"; + document.title = documentTitle; + document.body.innerHTML = ` +
+ + + + +
+ + `; + const formElement = document.getElementById(formId) as ElementWithOpId; + const existingAutofillForm: AutofillForm = { + opid: "__form__0", + htmlAction: formAction, + htmlName: formName, + htmlID: formId, + htmlMethod: formMethod, + }; + collectAutofillContentService["autofillFormElements"] = new Map([ + [formElement, existingAutofillForm], + ]); + const formElements = Array.from(document.querySelectorAll("form")); + jest.spyOn(collectAutofillContentService as any, "getFormActionAttribute"); + + const autofillFormsData = collectAutofillContentService["buildAutofillFormsData"]( + formElements as Node[] + ); + + expect(collectAutofillContentService["getFormActionAttribute"]).not.toHaveBeenCalled(); + expect(autofillFormsData).toStrictEqual({ __form__0: existingAutofillForm }); + }); + it("returns an object of AutofillForm objects with the form id as a key", () => { const documentTitle = "Test Page"; const formId1 = "validFormId"; @@ -237,7 +413,9 @@ describe("CollectAutofillContentService", () => { `; - const autofillFormsData = collectAutofillContentService["buildAutofillFormsData"](); + const { formElements } = collectAutofillContentService["queryAutofillFormAndFieldElements"](); + const autofillFormsData = + collectAutofillContentService["buildAutofillFormsData"](formElements); expect(autofillFormsData).toStrictEqual({ __form__0: { @@ -266,10 +444,17 @@ describe("CollectAutofillContentService", () => { .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") .mockResolvedValue(true); - const autofillFieldsPromise = collectAutofillContentService["buildAutofillFieldsData"](); + const { formFieldElements } = + collectAutofillContentService["queryAutofillFormAndFieldElements"](); + const autofillFieldsPromise = collectAutofillContentService["buildAutofillFieldsData"]( + formFieldElements as FormFieldElement[] + ); const autofillFieldsData = await Promise.resolve(autofillFieldsPromise); - expect(collectAutofillContentService["getAutofillFieldElements"]).toHaveBeenCalledWith(50); + expect(collectAutofillContentService["getAutofillFieldElements"]).toHaveBeenCalledWith( + 100, + formFieldElements + ); expect(collectAutofillContentService["buildAutofillFieldItem"]).toHaveBeenCalledTimes(2); expect(autofillFieldsPromise).toBeInstanceOf(Promise); expect(autofillFieldsData).toStrictEqual([ @@ -372,9 +557,6 @@ describe("CollectAutofillContentService", () => { const formElements: FormFieldElement[] = collectAutofillContentService["getAutofillFieldElements"](); - expect(document.querySelectorAll).toHaveBeenCalledWith( - 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]):not([type="file"]):not([data-bwignore]), textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]' - ); expect(collectAutofillContentService["getPropertyOrAttribute"]).not.toHaveBeenCalled(); expect(formElements).toEqual([ usernameInput, @@ -538,6 +720,105 @@ describe("CollectAutofillContentService", () => { }); describe("buildAutofillFieldItem", () => { + it("returns an existing autofill field item if it exists", async () => { + const index = 0; + const usernameField = { + labelText: "Username", + id: "username-id", + classes: "username input classes", + name: "username", + type: "text", + maxLength: 42, + tabIndex: 0, + title: "Username Input Title", + autocomplete: "username-autocomplete", + dataLabel: "username-data-label", + ariaLabel: "username-aria-label", + placeholder: "username-placeholder", + rel: "username-rel", + value: "username-value", + dataStripe: "data-stripe", + }; + document.body.innerHTML = ` +
+ + +
+ `; + document.body.innerHTML = ` +
+ + +
+ `; + const existingFieldData: AutofillField = { + elementNumber: index, + htmlClass: usernameField.classes, + htmlID: usernameField.id, + htmlName: usernameField.name, + maxLength: usernameField.maxLength, + opid: `__${index}`, + tabindex: String(usernameField.tabIndex), + tagName: "input", + title: usernameField.title, + viewable: true, + }; + const usernameInput = document.getElementById( + usernameField.id + ) as ElementWithOpId; + usernameInput.opid = "__0"; + collectAutofillContentService["autofillFieldElements"].set(usernameInput, existingFieldData); + jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + jest.spyOn(collectAutofillContentService as any, "getElementValue"); + + const autofillFieldItem = await collectAutofillContentService["buildAutofillFieldItem"]( + usernameInput, + 0 + ); + + expect(collectAutofillContentService["getAutofillFieldMaxLength"]).not.toHaveBeenCalled(); + expect( + collectAutofillContentService["domElementVisibilityService"].isFormFieldViewable + ).not.toHaveBeenCalled(); + expect(collectAutofillContentService["getPropertyOrAttribute"]).not.toHaveBeenCalled(); + expect(collectAutofillContentService["getElementValue"]).not.toHaveBeenCalled(); + expect(autofillFieldItem).toEqual(existingFieldData); + }); + it("returns the AutofillField base data values without the field labels or input values if the passed element is a span element", async () => { const index = 0; const spanElementId = "span-element"; @@ -958,6 +1239,20 @@ describe("CollectAutofillContentService", () => { expect(labels).toEqual(document.querySelectorAll("label[for='username']")); }); + + it("removes any new lines generated for the query selector", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toEqual(document.querySelectorAll("label[for='username-id']")); + }); }); describe("createLabelElementsTag", () => { @@ -1585,4 +1880,466 @@ describe("CollectAutofillContentService", () => { expect(selectWithoutOptionsOptions).toEqual({ options: [] }); }); }); + + describe("getShadowRoot", () => { + it("returns null if the passed node is not an HTMLElement instance", () => { + const textNode = document.createTextNode("Hello, world!"); + const shadowRoot = collectAutofillContentService["getShadowRoot"](textNode); + + expect(shadowRoot).toEqual(null); + }); + + it("returns a value provided by Chrome's openOrClosedShadowRoot API", () => { + // eslint-disable-next-line + // @ts-ignore + globalThis.chrome.dom = { + openOrClosedShadowRoot: jest.fn(), + }; + const element = document.createElement("div"); + collectAutofillContentService["getShadowRoot"](element); + + // eslint-disable-next-line + // @ts-ignore + expect(chrome.dom.openOrClosedShadowRoot).toBeCalled(); + }); + }); + + describe("buildTreeWalkerNodesQueryResults", () => { + it("will recursively call itself if a shadowDOM element is found and will observe the element for mutations", () => { + collectAutofillContentService["mutationObserver"] = mock({ + observe: jest.fn(), + }); + jest.spyOn(collectAutofillContentService as any, "buildTreeWalkerNodesQueryResults"); + const shadowRoot = document.createElement("div"); + jest + .spyOn(collectAutofillContentService as any, "getShadowRoot") + .mockReturnValueOnce(shadowRoot); + const callbackFilter = jest.fn(); + + collectAutofillContentService["buildTreeWalkerNodesQueryResults"]( + document.body, + [], + callbackFilter, + true + ); + + expect(collectAutofillContentService["buildTreeWalkerNodesQueryResults"]).toBeCalledTimes(2); + expect(collectAutofillContentService["mutationObserver"].observe).toBeCalled(); + }); + + it("will not observe the shadowDOM element if required to skip", () => { + collectAutofillContentService["mutationObserver"] = mock({ + observe: jest.fn(), + }); + const shadowRoot = document.createElement("div"); + jest + .spyOn(collectAutofillContentService as any, "getShadowRoot") + .mockReturnValueOnce(shadowRoot); + const callbackFilter = jest.fn(); + + collectAutofillContentService["buildTreeWalkerNodesQueryResults"]( + document.body, + [], + callbackFilter, + false + ); + + expect(collectAutofillContentService["mutationObserver"].observe).not.toBeCalled(); + }); + }); + + describe("setupMutationObserver", () => { + it("sets up a mutation observer and observes the document element", () => { + jest.spyOn(MutationObserver.prototype, "observe"); + + collectAutofillContentService["setupMutationObserver"](); + + expect(collectAutofillContentService["mutationObserver"]).toBeInstanceOf(MutationObserver); + expect(collectAutofillContentService["mutationObserver"].observe).toBeCalled(); + }); + }); + + describe("handleMutationObserverMutation", () => { + it("will set the domRecentlyMutated value to true and the noFieldsFound value to false if a form or field node has been added ", () => { + const form = document.createElement("form"); + document.body.appendChild(form); + const addedNodes = document.querySelectorAll("form"); + const removedNodes = document.querySelectorAll("li"); + + const mutationRecord: MutationRecord = { + type: "childList", + addedNodes: addedNodes, + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: removedNodes, + target: document.body, + }; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + collectAutofillContentService["currentLocationHref"] = window.location.href; + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true); + expect(collectAutofillContentService["noFieldsFound"]).toEqual(false); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).toBeCalledWith( + removedNodes, + true + ); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).toBeCalledWith( + addedNodes + ); + }); + + it("will handle updating the autofill element if any attribute mutations are encountered", () => { + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "value", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.body, + }; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + collectAutofillContentService["currentLocationHref"] = window.location.href; + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(false); + expect(collectAutofillContentService["noFieldsFound"]).toEqual(true); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).not.toBeCalled(); + expect(collectAutofillContentService["handleAutofillElementAttributeMutation"]).toBeCalled(); + }); + + it("will handle window location mutations", () => { + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "value", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.body, + }; + collectAutofillContentService["currentLocationHref"] = "https://someotherurl.com"; + jest.spyOn(collectAutofillContentService as any, "handleWindowLocationMutation"); + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(collectAutofillContentService["handleWindowLocationMutation"]).toBeCalled(); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).not.toBeCalled(); + expect( + collectAutofillContentService["handleAutofillElementAttributeMutation"] + ).not.toBeCalled(); + }); + }); + + describe("deleteCachedAutofillElement", () => { + it("removes the autofill form element from the map of elements", () => { + const formElement = document.createElement("form") as ElementWithOpId; + const autofillForm: AutofillForm = { + opid: "1234", + htmlName: "formEl", + htmlID: "formEl-id", + htmlAction: "https://example.com", + htmlMethod: "POST", + }; + collectAutofillContentService["autofillFormElements"] = new Map([ + [formElement, autofillForm], + ]); + + collectAutofillContentService["deleteCachedAutofillElement"](formElement); + + expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0); + }); + + it("removes the autofill field element form the map of elements", () => { + const fieldElement = document.createElement("input") as ElementWithOpId; + const autofillField: AutofillField = { + elementNumber: 0, + htmlClass: "", + tabindex: "", + title: "", + viewable: false, + opid: "1234", + htmlName: "username", + htmlID: "username-id", + htmlType: "text", + htmlAutocomplete: "username", + htmlAutofocus: false, + htmlDisabled: false, + htmlMaxLength: 999, + htmlReadonly: false, + htmlRequired: false, + htmlValue: "jsmith", + }; + collectAutofillContentService["autofillFieldElements"] = new Map([ + [fieldElement, autofillField], + ]); + + collectAutofillContentService["deleteCachedAutofillElement"](fieldElement); + + expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0); + }); + }); + + describe("handleWindowLocationMutation", () => { + it("will set the current location to the global location href, set the dom recently mutated flag and the no fields found flag, clear out the autofill form and field maps, and update the autofill elements after mutation", () => { + collectAutofillContentService["currentLocationHref"] = "https://example.com/login"; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + jest.spyOn(collectAutofillContentService as any, "updateAutofillElementsAfterMutation"); + + collectAutofillContentService["handleWindowLocationMutation"](); + + expect(collectAutofillContentService["currentLocationHref"]).toEqual(window.location.href); + expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true); + expect(collectAutofillContentService["noFieldsFound"]).toEqual(false); + expect(collectAutofillContentService["updateAutofillElementsAfterMutation"]).toBeCalled(); + expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0); + expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0); + }); + }); + + describe("handleAutofillElementAttributeMutation", () => { + it("returns early if the target node is not an HTMLElement instance", () => { + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "value", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.createTextNode("Hello, world!"), + }; + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + + collectAutofillContentService["handleAutofillElementAttributeMutation"](mutationRecord); + + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).not.toBeCalled(); + }); + + it("will update the autofill form element data if the target node can be found in the autofillFormElements map", () => { + const targetNode = document.createElement("form") as ElementWithOpId; + targetNode.setAttribute("name", "username"); + targetNode.setAttribute("value", "jsmith"); + const autofillForm: AutofillForm = { + opid: "1234", + htmlName: "formEl", + htmlID: "formEl-id", + htmlAction: "https://example.com", + htmlMethod: "POST", + }; + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "id", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: targetNode, + }; + collectAutofillContentService["autofillFormElements"] = new Map([[targetNode, autofillForm]]); + jest.spyOn(collectAutofillContentService as any, "updateAutofillFormElementData"); + + collectAutofillContentService["handleAutofillElementAttributeMutation"](mutationRecord); + + expect(collectAutofillContentService["updateAutofillFormElementData"]).toBeCalledWith( + mutationRecord.attributeName, + mutationRecord.target, + autofillForm + ); + }); + + it("will update the autofill field element data if the target node can be found in the autofillFieldElements map", () => { + const targetNode = document.createElement("input") as ElementWithOpId; + targetNode.setAttribute("name", "username"); + targetNode.setAttribute("value", "jsmith"); + const autofillField: AutofillField = { + elementNumber: 0, + htmlClass: "", + tabindex: "", + title: "", + viewable: false, + opid: "1234", + htmlName: "username", + htmlID: "username-id", + htmlType: "text", + htmlAutocomplete: "username", + htmlAutofocus: false, + htmlDisabled: false, + htmlMaxLength: 999, + htmlReadonly: false, + htmlRequired: false, + htmlValue: "jsmith", + }; + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "id", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: targetNode, + }; + collectAutofillContentService["autofillFieldElements"] = new Map([ + [targetNode, autofillField], + ]); + jest.spyOn(collectAutofillContentService as any, "updateAutofillFieldElementData"); + + collectAutofillContentService["handleAutofillElementAttributeMutation"](mutationRecord); + + expect(collectAutofillContentService["updateAutofillFieldElementData"]).toBeCalledWith( + mutationRecord.attributeName, + mutationRecord.target, + autofillField + ); + }); + }); + + describe("updateAutofillFormElementData", () => { + const formElement = document.createElement("form") as ElementWithOpId; + const autofillForm: AutofillForm = { + opid: "1234", + htmlName: "formEl", + htmlID: "formEl-id", + htmlAction: "https://example.com", + htmlMethod: "POST", + }; + const updatedAttributes = ["action", "name", "id", "method"]; + + updatedAttributes.forEach((attribute) => { + it(`will update the ${attribute} value for the form element`, () => { + jest.spyOn(collectAutofillContentService["autofillFormElements"], "set"); + + collectAutofillContentService["updateAutofillFormElementData"]( + attribute, + formElement, + autofillForm + ); + + expect(collectAutofillContentService["autofillFormElements"].set).toBeCalledWith( + formElement, + autofillForm + ); + }); + }); + + it("will not update an attribute value if it is not present in the updateActions object", () => { + jest.spyOn(collectAutofillContentService["autofillFormElements"], "set"); + + collectAutofillContentService["updateAutofillFormElementData"]( + "aria-label", + formElement, + autofillForm + ); + + expect(collectAutofillContentService["autofillFormElements"].set).not.toBeCalled(); + }); + }); + + describe("updateAutofillFieldElementData", () => { + const fieldElement = document.createElement("input") as ElementWithOpId; + const autofillField: AutofillField = { + htmlClass: "value", + htmlID: "", + htmlName: "", + opid: "", + tabindex: "", + title: "", + viewable: false, + elementNumber: 0, + }; + const updatedAttributes = [ + "maxlength", + "name", + "id", + "type", + "autocomplete", + "class", + "tabindex", + "title", + "value", + "rel", + "tagname", + "checked", + "disabled", + "readonly", + "data-label", + "aria-label", + "aria-hidden", + "aria-disabled", + "aria-haspopup", + "data-stripe", + ]; + + updatedAttributes.forEach((attribute) => { + it(`will update the ${attribute} value for the field element`, async () => { + jest.spyOn(collectAutofillContentService["autofillFieldElements"], "set"); + + await collectAutofillContentService["updateAutofillFieldElementData"]( + attribute, + fieldElement, + autofillField + ); + + expect(collectAutofillContentService["autofillFieldElements"].set).toBeCalledWith( + fieldElement, + autofillField + ); + }); + }); + + it("will check the dom element's visibility if the `style` or `class` attribute has updated ", async () => { + jest.spyOn( + collectAutofillContentService["domElementVisibilityService"], + "isFormFieldViewable" + ); + const attributes = ["class", "style"]; + + for (const attribute of attributes) { + await collectAutofillContentService["updateAutofillFieldElementData"]( + attribute, + fieldElement, + autofillField + ); + + expect( + collectAutofillContentService["domElementVisibilityService"].isFormFieldViewable + ).toBeCalledWith(fieldElement); + } + }); + + it("will not update an attribute value if it is not present in the updateActions object", async () => { + jest.spyOn(collectAutofillContentService["autofillFieldElements"], "set"); + + await collectAutofillContentService["updateAutofillFieldElementData"]( + "random-attribute", + fieldElement, + autofillField + ); + + expect(collectAutofillContentService["autofillFieldElements"].set).not.toBeCalled(); + }); + }); }); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index ec7658c986..4780c294ab 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -8,34 +8,70 @@ import { FormElementWithAttribute, } from "../types"; -import { CollectAutofillContentService as CollectAutofillContentServiceInterface } from "./abstractions/collect-autofill-content.service"; +import { + UpdateAutofillDataAttributeParams, + AutofillFieldElements, + AutofillFormElements, + CollectAutofillContentService as CollectAutofillContentServiceInterface, +} from "./abstractions/collect-autofill-content.service"; import DomElementVisibilityService from "./dom-element-visibility.service"; class CollectAutofillContentService implements CollectAutofillContentServiceInterface { private readonly domElementVisibilityService: DomElementVisibilityService; + private noFieldsFound = false; + private domRecentlyMutated = true; + private autofillFormElements: AutofillFormElements = new Map(); + private autofillFieldElements: AutofillFieldElements = new Map(); + private currentLocationHref = ""; + private mutationObserver: MutationObserver; + private updateAutofillElementsAfterMutationTimeout: NodeJS.Timeout; + private readonly updateAfterMutationTimeoutDelay = 1000; constructor(domElementVisibilityService: DomElementVisibilityService) { this.domElementVisibilityService = domElementVisibilityService; } /** - * Builds the data for all the forms and fields - * that are found within the page DOM. + * Builds the data for all forms and fields found within the page DOM. + * Sets up a mutation observer to verify DOM changes and returns early + * with cached data if no changes are detected. * @returns {Promise} * @public */ async getPageDetails(): Promise { - const autofillFormsData: Record = this.buildAutofillFormsData(); - const autofillFieldsData: AutofillField[] = await this.buildAutofillFieldsData(); + if (!this.mutationObserver) { + this.setupMutationObserver(); + } - return { - title: document.title, - url: (document.defaultView || window).location.href, - documentUrl: document.location.href, - forms: autofillFormsData, - fields: autofillFieldsData, - collectedTimestamp: Date.now(), - }; + if (!this.domRecentlyMutated && this.noFieldsFound) { + return this.getFormattedPageDetails({}, []); + } + + if ( + !this.domRecentlyMutated && + this.autofillFormElements.size && + this.autofillFieldElements.size + ) { + return this.getFormattedPageDetails( + this.getFormattedAutofillFormsData(), + this.getFormattedAutofillFieldsData() + ); + } + + const { formElements, formFieldElements } = this.queryAutofillFormAndFieldElements(); + const autofillFormsData: Record = + this.buildAutofillFormsData(formElements); + const autofillFieldsData: AutofillField[] = await this.buildAutofillFieldsData( + formFieldElements as FormFieldElement[] + ); + this.sortAutofillFieldElementsMap(); + + if (!Object.values(autofillFormsData).length || !autofillFieldsData.length) { + this.noFieldsFound = true; + } + + this.domRecentlyMutated = false; + return this.getFormattedPageDetails(autofillFormsData, autofillFieldsData); } /** @@ -46,15 +82,18 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @returns {FormFieldElement | null} */ getAutofillFieldElementByOpid(opid: string): FormFieldElement | null { - const fieldElements = this.getAutofillFieldElements(); - const fieldElementsWithOpid = fieldElements.filter( + const cachedFormFieldElements = Array.from(this.autofillFieldElements.keys()); + const formFieldElements = cachedFormFieldElements?.length + ? cachedFormFieldElements + : this.getAutofillFieldElements(); + const fieldElementsWithOpid = formFieldElements.filter( (fieldElement) => (fieldElement as ElementWithOpId).opid === opid ) as ElementWithOpId[]; if (!fieldElementsWithOpid.length) { const elementIndex = parseInt(opid.split("__")[1], 10); - return fieldElements[elementIndex] || null; + return formFieldElements[elementIndex] || null; } if (fieldElementsWithOpid.length > 1) { @@ -65,30 +104,120 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return fieldElementsWithOpid[0]; } + /** + * Queries the DOM for all the nodes that match the given filter callback + * and returns a collection of nodes. + * @param {Node} rootNode + * @param {Function} filterCallback + * @param {boolean} isObservingShadowRoot + * @returns {Node[]} + */ + queryAllTreeWalkerNodes( + rootNode: Node, + filterCallback: CallableFunction, + isObservingShadowRoot = true + ): Node[] { + const treeWalkerQueryResults: Node[] = []; + + this.buildTreeWalkerNodesQueryResults( + rootNode, + treeWalkerQueryResults, + filterCallback, + isObservingShadowRoot + ); + + return treeWalkerQueryResults; + } + + /** + * Sorts the AutofillFieldElements map by the elementNumber property. + * @private + */ + private sortAutofillFieldElementsMap() { + if (!this.autofillFieldElements.size) { + return; + } + + this.autofillFieldElements = new Map( + [...this.autofillFieldElements].sort((a, b) => a[1].elementNumber - b[1].elementNumber) + ); + } + + /** + * Formats and returns the AutofillPageDetails object + * @param {Record} autofillFormsData + * @param {AutofillField[]} autofillFieldsData + * @returns {AutofillPageDetails} + * @private + */ + private getFormattedPageDetails( + autofillFormsData: Record, + autofillFieldsData: AutofillField[] + ): AutofillPageDetails { + return { + title: document.title, + url: (document.defaultView || window).location.href, + documentUrl: document.location.href, + forms: autofillFormsData, + fields: autofillFieldsData, + collectedTimestamp: Date.now(), + }; + } + /** * Queries the DOM for all the forms elements and * returns a collection of AutofillForm objects. * @returns {Record} * @private */ - private buildAutofillFormsData(): Record { - const autofillForms: Record = {}; - const documentFormElements = document.querySelectorAll("form"); - - documentFormElements.forEach((formElement: HTMLFormElement, index: number) => { + private buildAutofillFormsData(formElements: Node[]): Record { + for (let index = 0; index < formElements.length; index++) { + const formElement = formElements[index] as ElementWithOpId; formElement.opid = `__form__${index}`; - autofillForms[formElement.opid] = { + const existingAutofillForm = this.autofillFormElements.get(formElement); + if (existingAutofillForm) { + existingAutofillForm.opid = formElement.opid; + this.autofillFormElements.set(formElement, existingAutofillForm); + continue; + } + + this.autofillFormElements.set(formElement, { opid: formElement.opid, - htmlAction: new URL( - this.getPropertyOrAttribute(formElement, "action"), - window.location.href - ).href, + htmlAction: this.getFormActionAttribute(formElement), htmlName: this.getPropertyOrAttribute(formElement, "name"), htmlID: this.getPropertyOrAttribute(formElement, "id"), htmlMethod: this.getPropertyOrAttribute(formElement, "method"), - }; - }); + }); + } + + return this.getFormattedAutofillFormsData(); + } + + /** + * Returns the action attribute of the form element. If the action attribute + * is a relative path, it will be converted to an absolute path. + * @param {ElementWithOpId} element + * @returns {string} + * @private + */ + private getFormActionAttribute(element: ElementWithOpId): string { + return new URL(this.getPropertyOrAttribute(element, "action"), window.location.href).href; + } + + /** + * Iterates over all known form elements and returns an AutofillForm object + * containing a key value pair of the form element's opid and the form data. + * @returns {Record} + * @private + */ + private getFormattedAutofillFormsData(): Record { + const autofillForms: Record = {}; + const autofillFormElements = Array.from(this.autofillFormElements); + for (let index = 0; index < autofillFormElements.length; index++) { + const [formElement, autofillForm] = autofillFormElements[index]; + autofillForms[formElement.opid] = autofillForm; + } return autofillForms; } @@ -99,8 +228,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @returns {Promise} * @private */ - private async buildAutofillFieldsData(): Promise { - const autofillFieldElements = this.getAutofillFieldElements(50); + private async buildAutofillFieldsData( + formFieldElements: FormFieldElement[] + ): Promise { + const autofillFieldElements = this.getAutofillFieldElements(100, formFieldElements); const autofillFieldDataPromises = autofillFieldElements.map(this.buildAutofillFieldItem); return Promise.all(autofillFieldDataPromises); @@ -111,18 +242,19 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * and returns a list limited to the given `fieldsLimit` number that * is ordered by priority. * @param {number} fieldsLimit - The maximum number of fields to return + * @param {FormFieldElement[]} previouslyFoundFormFieldElements - The list of all the field elements * @returns {FormFieldElement[]} * @private */ - private getAutofillFieldElements(fieldsLimit?: number): FormFieldElement[] { - const formFieldElements: FormFieldElement[] = [ - ...(document.querySelectorAll( - 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]):not([type="file"]):not([data-bwignore]), ' + - "textarea:not([data-bwignore]), " + - "select:not([data-bwignore]), " + - "span[data-bwautofill]" - ) as NodeListOf), - ]; + private getAutofillFieldElements( + fieldsLimit?: number, + previouslyFoundFormFieldElements?: FormFieldElement[] + ): FormFieldElement[] { + const formFieldElements = + previouslyFoundFormFieldElements || + (this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => + this.isNodeFormFieldElement(node) + ) as FormFieldElement[]); if (!fieldsLimit || formFieldElements.length <= fieldsLimit) { return formFieldElements; @@ -168,6 +300,15 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte ): Promise => { element.opid = `__${index}`; + const existingAutofillField = this.autofillFieldElements.get(element); + if (existingAutofillField) { + existingAutofillField.opid = element.opid; + existingAutofillField.elementNumber = index; + this.autofillFieldElements.set(element, existingAutofillField); + + return existingAutofillField; + } + const autofillFieldBase = { opid: element.opid, elementNumber: index, @@ -178,19 +319,16 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte htmlClass: this.getPropertyOrAttribute(element, "class"), tabindex: this.getPropertyOrAttribute(element, "tabindex"), title: this.getPropertyOrAttribute(element, "title"), - tagName: this.getPropertyOrAttribute(element, "tagName")?.toLowerCase(), + tagName: this.getAttributeLowerCase(element, "tagName"), }; if (element instanceof HTMLSpanElement) { + this.autofillFieldElements.set(element, autofillFieldBase); return autofillFieldBase; } let autofillFieldLabels = {}; - const autoCompleteType = - this.getPropertyOrAttribute(element, "x-autocompletetype") || - this.getPropertyOrAttribute(element, "autocompletetype") || - this.getPropertyOrAttribute(element, "autocomplete"); - const elementType = this.getPropertyOrAttribute(element, "type")?.toLowerCase(); + const elementType = this.getAttributeLowerCase(element, "type"); if (elementType !== "hidden") { autofillFieldLabels = { "label-tag": this.createAutofillFieldLabelTag(element), @@ -203,26 +341,87 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte }; } - return { + const autofillField = { ...autofillFieldBase, ...autofillFieldLabels, rel: this.getPropertyOrAttribute(element, "rel"), type: elementType, value: this.getElementValue(element), - checked: Boolean(this.getPropertyOrAttribute(element, "checked")), - autoCompleteType: autoCompleteType !== "off" ? autoCompleteType : null, - disabled: Boolean(this.getPropertyOrAttribute(element, "disabled")), - readonly: Boolean(this.getPropertyOrAttribute(element, "readOnly")), + checked: this.getAttributeBoolean(element, "checked"), + autoCompleteType: this.getAutoCompleteAttribute(element), + disabled: this.getAttributeBoolean(element, "disabled"), + readonly: this.getAttributeBoolean(element, "readonly"), selectInfo: element instanceof HTMLSelectElement ? this.getSelectElementOptions(element) : null, form: element.form ? this.getPropertyOrAttribute(element.form, "opid") : null, - "aria-hidden": this.getPropertyOrAttribute(element, "aria-hidden") === "true", - "aria-disabled": this.getPropertyOrAttribute(element, "aria-disabled") === "true", - "aria-haspopup": this.getPropertyOrAttribute(element, "aria-haspopup") === "true", + "aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true), + "aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true), + "aria-haspopup": this.getAttributeBoolean(element, "aria-haspopup", true), "data-stripe": this.getPropertyOrAttribute(element, "data-stripe"), }; + + this.autofillFieldElements.set(element, autofillField); + return autofillField; }; + /** + * Identifies the autocomplete attribute associated with an element and returns + * the value of the attribute if it is not set to "off". + * @param {ElementWithOpId} element + * @returns {string} + * @private + */ + private getAutoCompleteAttribute(element: ElementWithOpId): string { + const autoCompleteType = + this.getPropertyOrAttribute(element, "x-autocompletetype") || + this.getPropertyOrAttribute(element, "autocompletetype") || + this.getPropertyOrAttribute(element, "autocomplete"); + return autoCompleteType !== "off" ? autoCompleteType : null; + } + + /** + * Returns a boolean representing the attribute value of an element. + * @param {ElementWithOpId} element + * @param {string} attributeName + * @param {boolean} checkString + * @returns {boolean} + * @private + */ + private getAttributeBoolean( + element: ElementWithOpId, + attributeName: string, + checkString = false + ): boolean { + if (checkString) { + return this.getPropertyOrAttribute(element, attributeName) === "true"; + } + + return Boolean(this.getPropertyOrAttribute(element, attributeName)); + } + + /** + * Returns the attribute of an element as a lowercase value. + * @param {ElementWithOpId} element + * @param {string} attributeName + * @returns {string} + * @private + */ + private getAttributeLowerCase( + element: ElementWithOpId, + attributeName: string + ): string { + return this.getPropertyOrAttribute(element, attributeName)?.toLowerCase(); + } + + /** + * Returns the value of an element's property or attribute. + * @returns {AutofillField[]} + * @private + */ + private getFormattedAutofillFieldsData(): AutofillField[] { + return Array.from(this.autofillFieldElements.values()); + } + /** * Creates a label tag used to autofill the element pulled from a label * associated with the element's id, name, parent element or from an @@ -235,13 +434,14 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte */ private createAutofillFieldLabelTag(element: FillableFormFieldElement): string { const labelElementsSet: Set = new Set(element.labels); - if (labelElementsSet.size) { return this.createLabelElementsTag(labelElementsSet); } const labelElements: NodeListOf | null = this.queryElementLabels(element); - labelElements?.forEach((labelElement) => labelElementsSet.add(labelElement)); + for (let labelIndex = 0; labelIndex < labelElements?.length; labelIndex++) { + labelElementsSet.add(labelElements[labelIndex]); + } let currentElement: HTMLElement | null = element; while (currentElement && currentElement !== document.documentElement) { @@ -286,7 +486,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return null; } - return document.querySelectorAll(labelQuerySelectors); + return (element.getRootNode() as Document | ShadowRoot).querySelectorAll( + labelQuerySelectors.replace(/\n/g, "") + ); } /** @@ -297,7 +499,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @private */ private createLabelElementsTag = (labelElementsSet: Set): string => { - return [...labelElementsSet] + return Array.from(labelElementsSet) .map((labelElement) => { const textContent: string | null = labelElement ? labelElement.textContent || labelElement.innerText @@ -561,7 +763,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @private */ private getSelectElementOptions(element: HTMLSelectElement): { options: (string | null)[][] } { - const options = [...element.options].map((option) => { + const options = Array.from(element.options).map((option) => { const optionText = option.text ? String(option.text) .toLowerCase() @@ -573,6 +775,425 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return { options }; } + + /** + * Queries all potential form and field elements from the DOM and returns + * a collection of form and field elements. Leverages the TreeWalker API + * to deep query Shadow DOM elements. + * @returns {{formElements: Node[], formFieldElements: Node[]}} + * @private + */ + private queryAutofillFormAndFieldElements(): { + formElements: Node[]; + formFieldElements: Node[]; + } { + const formElements: Node[] = []; + const formFieldElements: Node[] = []; + this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => { + if (node instanceof HTMLFormElement) { + formElements.push(node); + return true; + } + + if (this.isNodeFormFieldElement(node)) { + formFieldElements.push(node); + return true; + } + + return false; + }); + + return { formElements, formFieldElements }; + } + + /** + * Checks if the passed node is a form field element. + * @param {Node} node + * @returns {boolean} + * @private + */ + private isNodeFormFieldElement(node: Node): boolean { + const nodeIsSpanElementWithAutofillAttribute = + node instanceof HTMLSpanElement && node.hasAttribute("data-bwautofill"); + + const ignoredInputTypes = new Set(["hidden", "submit", "reset", "button", "image", "file"]); + const nodeIsValidInputElement = + node instanceof HTMLInputElement && !ignoredInputTypes.has(node.type); + + const nodeIsTextAreaOrSelectElement = + node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement; + + const nodeIsNonIgnoredFillableControlElement = + (nodeIsTextAreaOrSelectElement || nodeIsValidInputElement) && + !node.hasAttribute("data-bwignore"); + + return nodeIsSpanElementWithAutofillAttribute || nodeIsNonIgnoredFillableControlElement; + } + + /** + * Attempts to get the ShadowRoot of the passed node. If support for the + * extension based openOrClosedShadowRoot API is available, it will be used. + * @param {Node} node + * @returns {ShadowRoot | null} + * @private + */ + private getShadowRoot(node: Node): ShadowRoot | null { + if (!(node instanceof HTMLElement)) { + return null; + } + + if ((chrome as any).dom?.openOrClosedShadowRoot) { + return (chrome as any).dom.openOrClosedShadowRoot(node); + } + + return (node as any).openOrClosedShadowRoot || node.shadowRoot; + } + + /** + * Recursively builds a collection of nodes that match the given filter callback. + * If a node has a ShadowRoot, it will be observed for mutations. + * @param {Node} rootNode + * @param {Node[]} treeWalkerQueryResults + * @param {Function} filterCallback + * @param {boolean} isObservingShadowRoot + * @private + */ + private buildTreeWalkerNodesQueryResults( + rootNode: Node, + treeWalkerQueryResults: Node[], + filterCallback: CallableFunction, + isObservingShadowRoot: boolean + ) { + const treeWalker = document?.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT); + let currentNode = treeWalker?.currentNode; + + while (currentNode) { + if (filterCallback(currentNode)) { + treeWalkerQueryResults.push(currentNode); + } + + const nodeShadowRoot = this.getShadowRoot(currentNode); + if (nodeShadowRoot) { + if (isObservingShadowRoot) { + this.mutationObserver.observe(nodeShadowRoot, { + attributes: true, + childList: true, + subtree: true, + }); + } + + this.buildTreeWalkerNodesQueryResults( + nodeShadowRoot, + treeWalkerQueryResults, + filterCallback, + isObservingShadowRoot + ); + } + + currentNode = treeWalker?.nextNode(); + } + } + + /** + * Sets up a mutation observer on the body of the document. Observes changes to + * DOM elements to ensure we have an updated set of autofill field data. + * @private + */ + private setupMutationObserver() { + this.currentLocationHref = globalThis.location.href; + this.mutationObserver = new MutationObserver(this.handleMutationObserverMutation); + this.mutationObserver.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + } + + /** + * Handles observed DOM mutations and identifies if a mutation is related to + * an autofill element. If so, it will update the autofill element data. + * @param {MutationRecord[]} mutations + * @private + */ + private handleMutationObserverMutation = (mutations: MutationRecord[]) => { + if (this.currentLocationHref !== globalThis.location.href) { + this.handleWindowLocationMutation(); + + return; + } + + for (let mutationsIndex = 0; mutationsIndex < mutations.length; mutationsIndex++) { + const mutation = mutations[mutationsIndex]; + if ( + mutation.type === "childList" && + (this.isAutofillElementNodeMutated(mutation.removedNodes, true) || + this.isAutofillElementNodeMutated(mutation.addedNodes)) + ) { + this.domRecentlyMutated = true; + this.noFieldsFound = false; + continue; + } + + if (mutation.type === "attributes") { + this.handleAutofillElementAttributeMutation(mutation); + } + } + + if (this.domRecentlyMutated) { + this.updateAutofillElementsAfterMutation(); + } + }; + + /** + * Handles a mutation to the window location. Clears the autofill elements + * and updates the autofill elements after a timeout. + * @private + */ + private handleWindowLocationMutation() { + this.currentLocationHref = globalThis.location.href; + + this.domRecentlyMutated = true; + this.noFieldsFound = false; + + this.autofillFormElements.clear(); + this.autofillFieldElements.clear(); + + this.updateAutofillElementsAfterMutation(); + } + + /** + * Checks if the passed nodes either contain or are autofill elements. + * @param {NodeList} nodes + * @param {boolean} isRemovingNodes + * @returns {boolean} + * @private + */ + private isAutofillElementNodeMutated(nodes: NodeList, isRemovingNodes = false): boolean { + if (!nodes.length) { + return false; + } + + let isElementMutated = false; + const mutatedElements = []; + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index]; + if (!(node instanceof HTMLElement)) { + continue; + } + + if (node instanceof HTMLFormElement || this.isNodeFormFieldElement(node)) { + isElementMutated = true; + mutatedElements.push(node); + continue; + } + + const childNodes = this.queryAllTreeWalkerNodes( + node, + (node: Node) => node instanceof HTMLFormElement || this.isNodeFormFieldElement(node) + ) as HTMLElement[]; + if (childNodes.length) { + isElementMutated = true; + mutatedElements.push(...childNodes); + } + } + + if (isRemovingNodes) { + for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) { + this.deleteCachedAutofillElement( + mutatedElements[elementIndex] as + | ElementWithOpId + | ElementWithOpId + ); + } + } + + return isElementMutated; + } + + /** + * Deletes any cached autofill elements that have been + * removed from the DOM. + * @param {ElementWithOpId | ElementWithOpId} element + * @private + */ + private deleteCachedAutofillElement( + element: ElementWithOpId | ElementWithOpId + ) { + if (element instanceof HTMLFormElement && this.autofillFormElements.has(element)) { + this.autofillFormElements.delete(element); + return; + } + + if (this.autofillFieldElements.has(element)) { + this.autofillFieldElements.delete(element); + } + } + + /** + * Updates the autofill elements after a DOM mutation has occurred. + * Is debounced to prevent excessive updates. + * @private + */ + private updateAutofillElementsAfterMutation() { + if (this.updateAutofillElementsAfterMutationTimeout) { + clearTimeout(this.updateAutofillElementsAfterMutationTimeout); + } + + this.updateAutofillElementsAfterMutationTimeout = setTimeout( + this.getPageDetails.bind(this), + this.updateAfterMutationTimeoutDelay + ); + } + + /** + * Handles observed DOM mutations related to an autofill element attribute. + * @param {MutationRecord} mutation + * @private + */ + private handleAutofillElementAttributeMutation(mutation: MutationRecord) { + const targetElement = mutation.target; + if (!(targetElement instanceof HTMLElement)) { + return; + } + + const attributeName = mutation.attributeName?.toLowerCase(); + const autofillForm = this.autofillFormElements.get( + targetElement as ElementWithOpId + ); + + if (autofillForm) { + this.updateAutofillFormElementData( + attributeName, + targetElement as ElementWithOpId, + autofillForm + ); + + return; + } + + const autofillField = this.autofillFieldElements.get( + targetElement as ElementWithOpId + ); + if (!autofillField) { + return; + } + + this.updateAutofillFieldElementData( + attributeName, + targetElement as ElementWithOpId, + autofillField + ); + } + + /** + * Updates the autofill form element data based on the passed attribute name. + * @param {string} attributeName + * @param {ElementWithOpId} element + * @param {AutofillForm} dataTarget + * @private + */ + private updateAutofillFormElementData( + attributeName: string, + element: ElementWithOpId, + dataTarget: AutofillForm + ) { + const updateAttribute = (dataTargetKey: string) => { + this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey }); + }; + const updateActions: Record = { + action: () => (dataTarget.htmlAction = this.getFormActionAttribute(element)), + name: () => updateAttribute("htmlName"), + id: () => updateAttribute("htmlID"), + method: () => updateAttribute("htmlMethod"), + }; + + if (!updateActions[attributeName]) { + return; + } + + updateActions[attributeName](); + this.autofillFormElements.set(element, dataTarget); + } + + /** + * Updates the autofill field element data based on the passed attribute name. + * @param {string} attributeName + * @param {ElementWithOpId} element + * @param {AutofillField} dataTarget + * @returns {Promise} + * @private + */ + private async updateAutofillFieldElementData( + attributeName: string, + element: ElementWithOpId, + dataTarget: AutofillField + ) { + const updateAttribute = (dataTargetKey: string) => { + this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey }); + }; + const updateActions: Record = { + maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), + id: () => updateAttribute("htmlID"), + name: () => updateAttribute("htmlName"), + class: () => updateAttribute("htmlClass"), + tabindex: () => updateAttribute("tabindex"), + title: () => updateAttribute("tabindex"), + rel: () => updateAttribute("rel"), + tagname: () => (dataTarget.tagName = this.getAttributeLowerCase(element, "tagName")), + type: () => (dataTarget.type = this.getAttributeLowerCase(element, "type")), + value: () => (dataTarget.value = this.getElementValue(element)), + checked: () => (dataTarget.checked = this.getAttributeBoolean(element, "checked")), + disabled: () => (dataTarget.disabled = this.getAttributeBoolean(element, "disabled")), + readonly: () => (dataTarget.readonly = this.getAttributeBoolean(element, "readonly")), + autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + "data-label": () => updateAttribute("label-data"), + "aria-label": () => updateAttribute("label-aria"), + "aria-hidden": () => + (dataTarget["aria-hidden"] = this.getAttributeBoolean(element, "aria-hidden", true)), + "aria-disabled": () => + (dataTarget["aria-disabled"] = this.getAttributeBoolean(element, "aria-disabled", true)), + "aria-haspopup": () => + (dataTarget["aria-haspopup"] = this.getAttributeBoolean(element, "aria-haspopup", true)), + "data-stripe": () => updateAttribute("data-stripe"), + }; + + if (!updateActions[attributeName]) { + return; + } + + updateActions[attributeName](); + + const visibilityAttributesSet = new Set(["class", "style"]); + if ( + visibilityAttributesSet.has(attributeName) && + !dataTarget.htmlClass?.includes("com-bitwarden-browser-animated-fill") + ) { + dataTarget.viewable = await this.domElementVisibilityService.isFormFieldViewable(element); + } + + this.autofillFieldElements.set(element, dataTarget); + } + + /** + * Gets the attribute value for the passed element, and returns it. If the dataTarget + * and dataTargetKey are passed, it will set the value of the dataTarget[dataTargetKey]. + * @param UpdateAutofillDataAttributeParams + * @returns {string} + * @private + */ + private updateAutofillDataAttribute({ + element, + attributeName, + dataTarget, + dataTargetKey, + }: UpdateAutofillDataAttributeParams) { + const attributeValue = this.getPropertyOrAttribute(element, attributeName); + if (dataTarget && dataTargetKey) { + dataTarget[dataTargetKey] = attributeValue; + } + + return attributeValue; + } } export default CollectAutofillContentService; diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.ts index 4be59d7f27..2797ee0eb3 100644 --- a/apps/browser/src/autofill/services/dom-element-visibility.service.ts +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.ts @@ -13,7 +13,6 @@ class DomElementVisibilityService implements domElementVisibilityServiceInterfac */ async isFormFieldViewable(element: FormFieldElement): Promise { const elementBoundingClientRect = element.getBoundingClientRect(); - if ( this.isElementOutsideViewportBounds(element, elementBoundingClientRect) || this.isElementHiddenByCss(element) @@ -176,7 +175,10 @@ class DomElementVisibilityService implements domElementVisibilityServiceInterfac ): boolean { const elementBoundingClientRect = targetElementBoundingClientRect || targetElement.getBoundingClientRect(); - const elementAtCenterPoint = targetElement.ownerDocument.elementFromPoint( + const elementRootNode = targetElement.getRootNode(); + const rootElement = + elementRootNode instanceof ShadowRoot ? elementRootNode : targetElement.ownerDocument; + const elementAtCenterPoint = rootElement.elementFromPoint( elementBoundingClientRect.left + elementBoundingClientRect.width / 2, elementBoundingClientRect.top + elementBoundingClientRect.height / 2 ); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts index 4e47e73704..ad40b76fbc 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -82,7 +82,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf if ( !savedUrls?.some((url) => url.startsWith(`https://${window.location.hostname}`)) || window.location.protocol !== "http:" || - !document.querySelectorAll("input[type=password]")?.length + !this.isPasswordFieldWithinDocument() ) { return false; } @@ -95,6 +95,22 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf return !confirm(confirmationWarning); } + /** + * Checks if there is a password field within the current document. Includes + * password fields that are present within the shadow DOM. + * @returns {boolean} + * @private + */ + private isPasswordFieldWithinDocument(): boolean { + return Boolean( + this.collectAutofillContentService.queryAllTreeWalkerNodes( + document.documentElement, + (node: Node) => node instanceof HTMLInputElement && node.type === "password", + false + )?.length + ); + } + /** * Checking if the autofill is occurring within an untrusted iframe. If the page is within an untrusted iframe, * the user is prompted to confirm that they want to autofill on the page. If the user cancels the autofill,