From 90d619acb510d7c3698ccd23b4ed5ea42537872c Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Mon, 17 Jun 2024 13:49:29 -0500 Subject: [PATCH] [PM-8027] Inline menu appears within input fields that do not relate to user login (#9110) * [PM-8027] Inlin menu appears within input fields that do not relate to user login * [PM-8027] Inlin menu appears within input fields that do not relate to user login * [PM-8027] Inlin menu appears within input fields that do not relate to user login * [PM-8027] Working through logic heuristics that will help us determine login form fields * [PM-8027] Fixing jest test * [PM-8027] Reworking inline menu to qualify and setup the listeners for each form field after page deatils have been collected * [PM-8027] Cleaning up implementation details * [PM-8027] Cleaning up implementation details * [PM-8027] Cleaning up implementation details * [PM-8027] Updating update of page details after mutation to act on an idle moment in the browser * [PM-8027] Updating how we guard against excessive getPageDetails calls * [PM-8027] Refining how we identify a username login form field * [PM-8027] Refining how we identify a password login form field * [PM-8027] Refining how we identify a username login form field * [PM-8027] Fixing jest tests for the overlay * [PM-8027] Fixing jest tests for the collectPageDetails method * [PM-8027] Removing unnecessary code * [PM-8027] Removing unnecessary code * [PM-8027] Adding jest test to validate new behavior * [PM-8027] Working through jest tests for the InlineMenuFieldQualificationService * [PM-8027] Working through jest tests for the InlineMenuFieldQualificationService * [PM-8027] Working through jest tests for the InlineMenuFieldQualificationService * [PM-8027] Working through jest tests for the InlineMenuFieldQualificationService * [PM-8027] Working through jest tests for the InlineMenuFieldQualificationService * [PM-8027] Finalization of Jest test for the implementation * [PM-8027] Fixing a typo * [PM-8027] Incorporating a feature flag to allow us to fallback to the basic inline menu fielld qualification method if needed * [PM-8027] Incorporating a feature flag to allow us to fallback to the basic inline menu fielld qualification method if needed * [PM-8027] Fixing issue with username fields not qualifyng as a valid login field if a viewable password field is not present * [PM-8027] Fixing an issue where a field that has no form and no visible password fields should be qualified if a single password field exists in the page * [PM-8027] Fixing an issue where a field that has no form and no visible password fields should be qualified if a single password field exists in the page * [PM-8869] Autofill features broken on Safari * [PM-8869] Autofill features broken on Safari * [PM-5189] Fixing an issue found within Safari * [PM-8027] Reverting flag from a fallback flag to an enhancement feature flag * [PM-8027] Fixing jest tests --- .../autofill-overlay-content.service.ts | 2 + ...nline-menu-field-qualifications.service.ts | 6 + .../autofill-overlay-content.service.spec.ts | 69 +- .../autofill-overlay-content.service.ts | 77 +- .../collect-autofill-content.service.spec.ts | 9 +- .../collect-autofill-content.service.ts | 105 ++- ...e-menu-field-qualification.service.spec.ts | 662 ++++++++++++++++++ ...inline-menu-field-qualification.service.ts | 438 ++++++++++++ apps/browser/src/autofill/utils/index.ts | 18 +- .../src/background/runtime.background.ts | 4 + libs/common/src/enums/feature-flag.enum.ts | 2 + 11 files changed, 1300 insertions(+), 92 deletions(-) create mode 100644 apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts create mode 100644 apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts create mode 100644 apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts diff --git a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts index ec594ac829f..4a6d87e0a4c 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill-overlay-content.service.ts @@ -1,6 +1,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import AutofillField from "../../models/autofill-field"; +import AutofillPageDetails from "../../models/autofill-page-details"; import { ElementWithOpId, FormFieldElement } from "../../types"; type OpenAutofillOverlayOptions = { @@ -19,6 +20,7 @@ interface AutofillOverlayContentService { setupAutofillOverlayListenerOnField( autofillFieldElement: ElementWithOpId, autofillFieldData: AutofillField, + pageDetails: AutofillPageDetails, ): Promise; openAutofillOverlay(options: OpenAutofillOverlayOptions): void; removeAutofillOverlay(): void; diff --git a/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts b/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts new file mode 100644 index 00000000000..c8303c0f81b --- /dev/null +++ b/apps/browser/src/autofill/services/abstractions/inline-menu-field-qualifications.service.ts @@ -0,0 +1,6 @@ +import AutofillField from "../../models/autofill-field"; +import AutofillPageDetails from "../../models/autofill-page-details"; + +export interface InlineMenuFieldQualificationsService { + isFieldForLoginForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean; +} diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts index 96a1b4c8512..6b8cb91a164 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.spec.ts @@ -4,6 +4,8 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio import { EVENTS, AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import AutofillField from "../models/autofill-field"; +import AutofillForm from "../models/autofill-form"; +import AutofillPageDetails from "../models/autofill-page-details"; import { createAutofillFieldMock } from "../spec/autofill-mocks"; import { flushPromises } from "../spec/testing-utils"; import { ElementWithOpId, FormFieldElement } from "../types"; @@ -146,6 +148,7 @@ describe("AutofillOverlayContentService", () => { describe("setupAutofillOverlayListenerOnField", () => { let autofillFieldElement: ElementWithOpId; let autofillFieldData: AutofillField; + let pageDetailsMock: AutofillPageDetails; beforeEach(() => { document.body.innerHTML = ` @@ -166,11 +169,27 @@ describe("AutofillOverlayContentService", () => { placeholder: "username", elementNumber: 1, }); + const passwordFieldData = createAutofillFieldMock({ + opid: "password-field", + form: "validFormId", + elementNumber: 2, + autocompleteType: "current-password", + type: "password", + }); + pageDetailsMock = mock({ + forms: { validFormId: mock() }, + fields: [autofillFieldData, passwordFieldData], + }); }); describe("skips setup for ignored form fields", () => { beforeEach(() => { - autofillFieldData = mock(); + autofillFieldData = mock({ + type: "text", + htmlName: "username", + htmlID: "username", + placeholder: "username", + }); }); it("ignores fields that are readonly", async () => { @@ -179,6 +198,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); @@ -190,6 +210,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); @@ -201,6 +222,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); @@ -213,6 +235,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); @@ -225,6 +248,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); @@ -236,6 +260,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); @@ -247,6 +272,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); @@ -259,6 +285,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled(); @@ -272,6 +299,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(sendExtensionMessageSpy).toHaveBeenCalledWith("getAutofillOverlayVisibility"); @@ -287,6 +315,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillOverlayContentService["autofillOverlayVisibility"]).toEqual( @@ -310,6 +339,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillFieldElement.removeEventListener).toHaveBeenNthCalledWith( @@ -334,6 +364,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); }); @@ -357,6 +388,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); jest.spyOn(globalThis.customElements, "define").mockImplementation(); }); @@ -440,6 +472,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( spanAutofillFieldElement, autofillFieldData, + pageDetailsMock, ); spanAutofillFieldElement.dispatchEvent(new Event("input")); @@ -451,6 +484,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("input")); @@ -467,6 +501,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( passwordFieldElement, autofillFieldData, + pageDetailsMock, ); passwordFieldElement.dispatchEvent(new Event("input")); @@ -486,6 +521,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("input")); @@ -504,6 +540,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("input")); @@ -517,6 +554,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("input")); @@ -531,6 +569,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("input")); @@ -546,6 +585,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("input")); @@ -563,6 +603,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); }); @@ -613,6 +654,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("focus")); @@ -624,6 +666,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("focus")); @@ -641,6 +684,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("focus")); @@ -660,6 +704,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("focus")); @@ -678,6 +723,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("focus")); @@ -695,6 +741,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("focus")); @@ -711,6 +758,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillFieldElement.dispatchEvent(new Event("focus")); @@ -733,6 +781,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(sendExtensionMessageSpy).toHaveBeenCalledWith("openAutofillOverlay"); @@ -747,6 +796,7 @@ describe("AutofillOverlayContentService", () => { await autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); expect(autofillOverlayContentService["mostRecentlyFocusedField"]).toEqual( @@ -1589,6 +1639,7 @@ describe("AutofillOverlayContentService", () => { describe("destroy", () => { let autofillFieldElement: ElementWithOpId; let autofillFieldData: AutofillField; + let pageDetailsMock: AutofillPageDetails; beforeEach(() => { document.body.innerHTML = ` @@ -1608,11 +1659,21 @@ describe("AutofillOverlayContentService", () => { placeholder: "username", elementNumber: 1, }); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - autofillOverlayContentService.setupAutofillOverlayListenerOnField( + const passwordFieldData = createAutofillFieldMock({ + opid: "password-field", + form: "validFormId", + elementNumber: 2, + autocompleteType: "current-password", + type: "password", + }); + pageDetailsMock = mock({ + forms: { validFormId: mock() }, + fields: [autofillFieldData, passwordFieldData], + }); + void autofillOverlayContentService.setupAutofillOverlayListenerOnField( autofillFieldElement, autofillFieldData, + pageDetailsMock, ); autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement; }); diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 43c0817eaf2..d56a8a80cc6 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -7,6 +7,7 @@ import { EVENTS, AutofillOverlayVisibility } from "@bitwarden/common/autofill/co import { FocusedFieldData } from "../background/abstractions/overlay.background"; import AutofillField from "../models/autofill-field"; +import AutofillPageDetails from "../models/autofill-page-details"; import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe"; import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe"; import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types"; @@ -23,8 +24,10 @@ import { OpenAutofillOverlayOptions, } from "./abstractions/autofill-overlay-content.service"; import { AutoFillConstants } from "./autofill-constants"; +import { InlineMenuFieldQualificationService } from "./inline-menu-field-qualification.service"; class AutofillOverlayContentService implements AutofillOverlayContentServiceInterface { + private readonly inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; isFieldCurrentlyFocused = false; isCurrentlyFilling = false; isOverlayCiphersPopulated = false; @@ -62,6 +65,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte zIndex: "2147483647", }; + constructor() { + this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + } + /** * Initializes the autofill overlay content service by setting up the mutation observers. * The observers will be instantiated on DOMContentLoaded if the page is current loading. @@ -81,12 +88,17 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte * * @param formFieldElement - Form field elements identified during the page details collection process. * @param autofillFieldData - Autofill field data captured from the form field element. + * @param pageDetails - The collected page details from the tab. */ async setupAutofillOverlayListenerOnField( formFieldElement: ElementWithOpId, autofillFieldData: AutofillField, + pageDetails: AutofillPageDetails, ) { - if (this.isIgnoredField(autofillFieldData) || this.formFieldElements.has(formFieldElement)) { + if ( + this.formFieldElements.has(formFieldElement) || + this.isIgnoredField(autofillFieldData, pageDetails) + ) { return; } @@ -524,51 +536,6 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte return this.authStatus === AuthenticationStatus.Unlocked; } - /** - * Identifies if the autofill field's data contains any of - * the keyboards matching the passed list of keywords. - * - * @param autofillFieldData - Autofill field data captured from the form field element. - * @param keywords - Keywords to search for in the autofill field data. - */ - private keywordsFoundInFieldData(autofillFieldData: AutofillField, keywords: string[]) { - const searchedString = this.getAutofillFieldDataKeywords(autofillFieldData); - return keywords.some((keyword) => searchedString.includes(keyword)); - } - - /** - * Aggregates the autofill field's data into a single string - * that can be used to search for keywords. - * - * @param autofillFieldData - Autofill field data captured from the form field element. - */ - private getAutofillFieldDataKeywords(autofillFieldData: AutofillField) { - if (this.autofillFieldKeywordsMap.has(autofillFieldData)) { - return this.autofillFieldKeywordsMap.get(autofillFieldData); - } - - const keywordValues = [ - autofillFieldData.htmlID, - autofillFieldData.htmlName, - autofillFieldData.htmlClass, - autofillFieldData.type, - autofillFieldData.title, - autofillFieldData.placeholder, - autofillFieldData.autoCompleteType, - autofillFieldData["label-data"], - autofillFieldData["label-aria"], - autofillFieldData["label-left"], - autofillFieldData["label-right"], - autofillFieldData["label-tag"], - autofillFieldData["label-top"], - ] - .join(",") - .toLowerCase(); - this.autofillFieldKeywordsMap.set(autofillFieldData, keywordValues); - - return keywordValues; - } - /** * Validates that the most recently focused field is currently * focused within the root node relative to the field. @@ -739,23 +706,25 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte * updated in the future to support other types of forms. * * @param autofillFieldData - Autofill field data captured from the form field element. + * @param pageDetails - The collected page details from the tab. */ - private isIgnoredField(autofillFieldData: AutofillField): boolean { + private isIgnoredField( + autofillFieldData: AutofillField, + pageDetails: AutofillPageDetails, + ): boolean { if ( autofillFieldData.readonly || autofillFieldData.disabled || !autofillFieldData.viewable || - this.ignoredFieldTypes.has(autofillFieldData.type) || - this.keywordsFoundInFieldData(autofillFieldData, ["search", "captcha"]) + this.ignoredFieldTypes.has(autofillFieldData.type) ) { return true; } - const isLoginCipherField = - autofillFieldData.type === "password" || - this.keywordsFoundInFieldData(autofillFieldData, AutoFillConstants.UsernameFieldNames); - - return !isLoginCipherField; + return !this.inlineMenuFieldQualificationService.isFieldForLoginForm( + autofillFieldData, + pageDetails, + ); } /** 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 a44fcd0b793..9bb0e717a26 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 @@ -35,6 +35,7 @@ describe("CollectAutofillContentService", () => { beforeEach(() => { globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100)); + globalThis.cancelIdleCallback = jest.fn((id) => clearTimeout(id)); document.body.innerHTML = mockLoginForm; collectAutofillContentService = new CollectAutofillContentService( domElementVisibilityService, @@ -247,11 +248,16 @@ describe("CollectAutofillContentService", () => { const isFormFieldViewableSpy = jest .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") .mockResolvedValue(true); + const setupAutofillOverlayListenerOnFieldSpy = jest.spyOn( + collectAutofillContentService["autofillOverlayContentService"], + "setupAutofillOverlayListenerOnField", + ); await collectAutofillContentService.getPageDetails(); expect(autofillField.viewable).toBe(true); expect(isFormFieldViewableSpy).toHaveBeenCalledWith(fieldElement); + expect(setupAutofillOverlayListenerOnFieldSpy).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 () => { @@ -1191,7 +1197,7 @@ describe("CollectAutofillContentService", () => { "aria-disabled": false, "aria-haspopup": false, "aria-hidden": false, - autoCompleteType: null, + autoCompleteType: "off", checked: false, "data-stripe": hiddenField.dataStripe, disabled: false, @@ -2606,6 +2612,7 @@ describe("CollectAutofillContentService", () => { expect(setupAutofillOverlayListenerOnFieldSpy).toHaveBeenCalledWith( formFieldElement, autofillField, + expect.anything(), ); }); }); 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 4205590973b..75c564e868e 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -4,32 +4,33 @@ import AutofillPageDetails from "../models/autofill-page-details"; import { ElementWithOpId, FillableFormFieldElement, - FormFieldElement, FormElementWithAttribute, + FormFieldElement, } from "../types"; import { elementIsDescriptionDetailsElement, elementIsDescriptionTermElement, elementIsFillableFormField, elementIsFormElement, + elementIsInputElement, elementIsLabelElement, elementIsSelectElement, elementIsSpanElement, nodeIsElement, - elementIsInputElement, elementIsTextAreaElement, nodeIsFormElement, nodeIsInputElement, // sendExtensionMessage, requestIdleCallbackPolyfill, + cancelIdleCallbackPolyfill, } from "../utils"; import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service"; import { - UpdateAutofillDataAttributeParams, AutofillFieldElements, AutofillFormElements, CollectAutofillContentService as CollectAutofillContentServiceInterface, + UpdateAutofillDataAttributeParams, } from "./abstractions/collect-autofill-content.service"; import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service"; @@ -44,9 +45,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte private intersectionObserver: IntersectionObserver; private elementInitializingIntersectionObserver: Set = new Set(); private mutationObserver: MutationObserver; - private updateAutofillElementsAfterMutationTimeout: number | NodeJS.Timeout; private mutationsQueue: MutationRecord[][] = []; - private readonly updateAfterMutationTimeoutDelay = 1000; + private updateAfterMutationIdleCallback: NodeJS.Timeout | number; + private readonly updateAfterMutationTimeout = 1000; private readonly formFieldQueryString; private readonly nonInputFormFieldTags = new Set(["textarea", "select"]); private readonly ignoredInputTypes = new Set([ @@ -120,7 +121,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte } this.domRecentlyMutated = false; - return this.getFormattedPageDetails(autofillFormsData, autofillFieldsData); + const pageDetails = this.getFormattedPageDetails(autofillFormsData, autofillFieldsData); + this.setupInlineMenuListeners(pageDetails); + + return pageDetails; } /** @@ -277,11 +281,14 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @private */ private updateCachedAutofillFieldVisibility() { - this.autofillFieldElements.forEach( - async (autofillField, element) => - (autofillField.viewable = - await this.domElementVisibilityService.isFormFieldViewable(element)), - ); + this.autofillFieldElements.forEach(async (autofillField, element) => { + const previouslyViewable = autofillField.viewable; + autofillField.viewable = await this.domElementVisibilityService.isFormFieldViewable(element); + + if (!previouslyViewable && autofillField.viewable) { + this.setupInlineMenuListenerOnField(element, autofillField); + } + }); } /** @@ -453,10 +460,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte if (elementIsSpanElement(element)) { this.cacheAutofillFieldElement(index, element, autofillFieldBase); - void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( - element, - autofillFieldBase, - ); return autofillFieldBase; } @@ -496,10 +499,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte }; this.cacheAutofillFieldElement(index, element, autofillField); - void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( - element, - autofillField, - ); return autofillField; }; @@ -531,11 +530,11 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @private */ private getAutoCompleteAttribute(element: ElementWithOpId): string { - const autoCompleteType = + return ( this.getPropertyOrAttribute(element, "x-autocompletetype") || this.getPropertyOrAttribute(element, "autocompletetype") || - this.getPropertyOrAttribute(element, "autocomplete"); - return autoCompleteType !== "off" ? autoCompleteType : null; + this.getPropertyOrAttribute(element, "autocomplete") + ); } /** @@ -1229,13 +1228,13 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @private */ private updateAutofillElementsAfterMutation() { - if (this.updateAutofillElementsAfterMutationTimeout) { - clearTimeout(this.updateAutofillElementsAfterMutationTimeout); + if (this.updateAfterMutationIdleCallback) { + cancelIdleCallbackPolyfill(this.updateAfterMutationIdleCallback); } - this.updateAutofillElementsAfterMutationTimeout = setTimeout( + this.updateAfterMutationIdleCallback = requestIdleCallbackPolyfill( this.getPageDetails.bind(this), - this.updateAfterMutationTimeoutDelay, + { timeout: this.updateAfterMutationTimeout }, ); } @@ -1425,22 +1424,64 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte cachedAutofillFieldElement.viewable = true; - void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField( - formFieldElement, - cachedAutofillFieldElement, - ); + this.setupInlineMenuListenerOnField(formFieldElement, cachedAutofillFieldElement); this.intersectionObserver?.unobserve(entry.target); } }; + /** + * Iterates over all cached field elements and sets up the inline menu listeners on each field. + * + * @param pageDetails - The page details to use for the inline menu listeners + */ + private setupInlineMenuListeners(pageDetails: AutofillPageDetails) { + if (!this.autofillOverlayContentService) { + return; + } + + this.autofillFieldElements.forEach((autofillField, formFieldElement) => { + this.setupInlineMenuListenerOnField(formFieldElement, autofillField, pageDetails); + }); + } + + /** + * Sets up the inline menu listener on the passed field element. + * + * @param formFieldElement - The form field element to set up the inline menu listener on + * @param autofillField - The metadata for the form field + * @param pageDetails - The page details to use for the inline menu listeners + */ + private setupInlineMenuListenerOnField( + formFieldElement: ElementWithOpId, + autofillField: AutofillField, + pageDetails?: AutofillPageDetails, + ) { + if (!this.autofillOverlayContentService) { + return; + } + + const autofillPageDetails = + pageDetails || + this.getFormattedPageDetails( + this.getFormattedAutofillFormsData(), + this.getFormattedAutofillFieldsData(), + ); + + void this.autofillOverlayContentService.setupAutofillOverlayListenerOnField( + formFieldElement, + autofillField, + autofillPageDetails, + ); + } + /** * Destroys the CollectAutofillContentService. Clears all * timeouts and disconnects the mutation observer. */ destroy() { - if (this.updateAutofillElementsAfterMutationTimeout) { - clearTimeout(this.updateAutofillElementsAfterMutationTimeout); + if (this.updateAfterMutationIdleCallback) { + cancelIdleCallbackPolyfill(this.updateAfterMutationIdleCallback); } this.mutationObserver?.disconnect(); this.intersectionObserver?.disconnect(); diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts new file mode 100644 index 00000000000..2942ba545ea --- /dev/null +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.spec.ts @@ -0,0 +1,662 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import AutofillField from "../models/autofill-field"; +import AutofillForm from "../models/autofill-form"; +import AutofillPageDetails from "../models/autofill-page-details"; + +import { AutoFillConstants } from "./autofill-constants"; +import { InlineMenuFieldQualificationService } from "./inline-menu-field-qualification.service"; + +describe("InlineMenuFieldQualificationService", () => { + let pageDetails: MockProxy; + let inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; + + beforeEach(() => { + pageDetails = mock({ + forms: {}, + fields: [], + }); + inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService(); + inlineMenuFieldQualificationService["inlineMenuFieldQualificationFlagSet"] = true; + }); + + describe("isFieldForLoginForm", () => { + describe("qualifying a password field for a login form", () => { + describe("an invalid password field", () => { + it("has a `new-password` autoCompleteType", () => { + const field = mock({ + type: "password", + autoCompleteType: "new-password", + }); + + expect(inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails)).toBe( + false, + ); + }); + + it("has a type that is an excluded type", () => { + AutoFillConstants.ExcludedAutofillLoginTypes.forEach((excludedType) => { + const field = mock({ + type: excludedType, + }); + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + }); + + it("has an attribute present on the FieldIgnoreList, indicating that the field is a captcha", () => { + AutoFillConstants.FieldIgnoreList.forEach((attribute, index) => { + const field = mock({ + type: "password", + htmlID: index === 0 ? attribute : "", + htmlName: index === 1 ? attribute : "", + placeholder: index > 1 ? attribute : "", + }); + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + }); + + it("has a type other than `password` or `text`", () => { + const field = mock({ + type: "number", + htmlID: "not-password", + htmlName: "not-password", + placeholder: "not-password", + }); + + expect(inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails)).toBe( + false, + ); + }); + + it("has a type of `text` without an attribute that indicates the field is a password field", () => { + const field = mock({ + type: "text", + htmlID: "something-else", + htmlName: "something-else", + placeholder: "something-else", + }); + + expect(inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails)).toBe( + false, + ); + }); + + it("has a type of `text` and contains attributes that indicates the field is a search field", () => { + const field = mock({ + type: "text", + htmlID: "search", + htmlName: "something-else", + placeholder: "something-else", + }); + + expect(inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails)).toBe( + false, + ); + }); + + describe("does not have a parent form element", () => { + beforeEach(() => { + pageDetails.forms = {}; + }); + + it("on a page that has more than one password field", () => { + const field = mock({ + type: "password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "", + }); + const secondField = mock({ + type: "password", + htmlID: "some-other-password", + htmlName: "some-other-password", + placeholder: "some-other-password", + }); + pageDetails.fields = [field, secondField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + + it("on a page that has more than one visible username field", () => { + const field = mock({ + type: "password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "", + }); + const usernameField = mock({ + type: "text", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + }); + const secondUsernameField = mock({ + type: "text", + htmlID: "some-other-user-username", + htmlName: "some-other-user-username", + placeholder: "some-other-user-username", + }); + pageDetails.fields = [field, usernameField, secondUsernameField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + + it("has a disabled `autocompleteType` value", () => { + const field = mock({ + type: "password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "", + autoCompleteType: "off", + }); + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + }); + + describe("has a parent form element", () => { + let form: MockProxy; + + beforeEach(() => { + form = mock({ opid: "validFormId" }); + pageDetails.forms = { + validFormId: form, + }; + }); + + it("is structured with other password fields in the same form", () => { + const field = mock({ + type: "password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "validFormId", + }); + const secondField = mock({ + type: "password", + htmlID: "some-other-password", + htmlName: "some-other-password", + placeholder: "some-other-password", + form: "validFormId", + }); + pageDetails.fields = [field, secondField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + }); + }); + + describe("a valid password field", () => { + it("has an autoCompleteType of `current-password`", () => { + const field = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + }); + + expect(inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails)).toBe( + true, + ); + }); + + it("has a type of `text` with an attribute that indicates the field is a password field", () => { + const field = mock({ + type: "text", + htmlID: null, + htmlName: "user-password", + placeholder: "user-password", + }); + + expect(inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails)).toBe( + true, + ); + }); + + describe("does not have a parent form element", () => { + it("is the only password field on the page, has one username field on the page, and has a non-disabled `autocompleteType` value", () => { + pageDetails.forms = {}; + const field = mock({ + type: "password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "", + autoCompleteType: "current-password", + }); + const usernameField = mock({ + type: "text", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + }); + pageDetails.fields = [field, usernameField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(true); + }); + }); + + describe("has a parent form element", () => { + let form: MockProxy; + + beforeEach(() => { + form = mock({ opid: "validFormId" }); + pageDetails.forms = { + validFormId: form, + }; + }); + + it("is the only password field within the form and has a visible username field", () => { + const field = mock({ + type: "password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "validFormId", + }); + const secondPasswordField = mock({ + type: "password", + htmlID: "some-other-password", + htmlName: "some-other-password", + placeholder: "some-other-password", + form: "anotherFormId", + }); + const usernameField = mock({ + type: "text", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + form: "validFormId", + }); + pageDetails.fields = [field, secondPasswordField, usernameField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(true); + }); + + it("is the only password field within the form and has a non-disabled `autocompleteType` value", () => { + const field = mock({ + type: "password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "validFormId", + autoCompleteType: "", + }); + const secondPasswordField = mock({ + type: "password", + htmlID: "some-other-password", + htmlName: "some-other-password", + placeholder: "some-other-password", + form: "anotherFormId", + }); + pageDetails.fields = [field, secondPasswordField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(true); + }); + }); + }); + }); + + describe("qualifying a username field for a login form", () => { + describe("an invalid username field", () => { + ["username", "email"].forEach((autoCompleteType) => { + it(`has a ${autoCompleteType} 'autoCompleteType' value when structured on a page with new password fields`, () => { + const field = mock({ + type: "text", + autoCompleteType, + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + }); + const passwordField = mock({ + type: "password", + autoCompleteType: "new-password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + }); + pageDetails.fields = [field, passwordField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + }); + + ["new", "change", "neue", "ändern"].forEach((keyword) => { + it(`has a keyword of ${keyword} that indicates a 'new or changed' username is being filled`, () => { + const field = mock({ + type: "text", + autoCompleteType: "", + htmlID: "user-username", + htmlName: "user-username", + placeholder: `${keyword} username`, + }); + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + }); + + describe("does not have a parent form element", () => { + beforeEach(() => { + pageDetails.forms = {}; + }); + + it("is structured on a page with multiple password fields", () => { + const field = mock({ + type: "text", + autoCompleteType: "", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + }); + const passwordField = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + }); + const secondPasswordField = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "some-other-password", + htmlName: "some-other-password", + placeholder: "some-other-password", + }); + pageDetails.fields = [field, passwordField, secondPasswordField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + }); + + describe("has a parent form element", () => { + let form: MockProxy; + + beforeEach(() => { + form = mock({ opid: "validFormId" }); + pageDetails.forms = { + validFormId: form, + }; + }); + + it("is structured on a page with no password fields and has a disabled `autoCompleteType` value", () => { + const field = mock({ + type: "text", + autoCompleteType: "off", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + form: "validFormId", + }); + pageDetails.fields = [field]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + + it("is structured on a page with no password fields but has other types of fields in the form", () => { + const field = mock({ + type: "text", + autoCompleteType: "", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + form: "validFormId", + }); + const otherField = mock({ + type: "number", + autoCompleteType: "", + htmlID: "some-other-field", + htmlName: "some-other-field", + placeholder: "some-other-field", + form: "validFormId", + }); + pageDetails.fields = [field, otherField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + + it("is structured on a page with multiple viewable password field", () => { + const field = mock({ + type: "text", + autoCompleteType: "", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + form: "validFormId", + }); + const passwordField = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "validFormId", + }); + const secondPasswordField = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "some-other-password", + htmlName: "some-other-password", + placeholder: "some-other-password", + form: "validFormId", + }); + pageDetails.fields = [field, passwordField, secondPasswordField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + + it("is structured on a page with a with no visible password fields and but contains a disabled autocomplete type", () => { + const field = mock({ + type: "text", + autoCompleteType: "off", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + form: "validFormId", + }); + const passwordField = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "validFormId", + viewable: false, + }); + pageDetails.fields = [field, passwordField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(false); + }); + }); + }); + + describe("a valid username field", () => { + ["username", "email"].forEach((autoCompleteType) => { + it(`has a ${autoCompleteType} 'autoCompleteType' value`, () => { + const field = mock({ + type: "text", + autoCompleteType, + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + }); + const passwordField = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + }); + pageDetails.fields = [field, passwordField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(true); + }); + }); + + describe("does not have a parent form element", () => { + beforeEach(() => { + pageDetails.forms = {}; + }); + + it("is structured on a page with a single visible password field", () => { + const field = mock({ + type: "text", + autoCompleteType: "off", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + }); + const passwordField = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + }); + pageDetails.fields = [field, passwordField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(true); + }); + + it("is structured on a page with a single non-visible password field", () => { + const field = mock({ + type: "text", + autoCompleteType: "off", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + }); + const passwordField = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + viewable: false, + }); + pageDetails.fields = [field, passwordField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(true); + }); + + it("has a non-disabled autoCompleteType and is structured on a page with no other password fields", () => { + const field = mock({ + type: "text", + autoCompleteType: "", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + }); + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(true); + }); + }); + + describe("has a parent form element", () => { + let form: MockProxy; + + beforeEach(() => { + form = mock({ opid: "validFormId" }); + pageDetails.forms = { + validFormId: form, + }; + }); + + it("is structured on a page with a single password field", () => { + const field = mock({ + type: "text", + autoCompleteType: "", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + form: "validFormId", + }); + const passwordField = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "validFormId", + }); + pageDetails.fields = [field, passwordField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(true); + }); + + it("is structured on a page with a with no visible password fields and a non-disabled autocomplete type", () => { + const field = mock({ + type: "text", + autoCompleteType: "", + htmlID: "user-username", + htmlName: "user-username", + placeholder: "user-username", + form: "validFormId", + }); + const passwordField = mock({ + type: "password", + autoCompleteType: "current-password", + htmlID: "user-password", + htmlName: "user-password", + placeholder: "user-password", + form: "validFormId", + viewable: false, + }); + pageDetails.fields = [field, passwordField]; + + expect( + inlineMenuFieldQualificationService.isFieldForLoginForm(field, pageDetails), + ).toBe(true); + }); + }); + }); + }); + }); +}); diff --git a/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts new file mode 100644 index 00000000000..582f8889daa --- /dev/null +++ b/apps/browser/src/autofill/services/inline-menu-field-qualification.service.ts @@ -0,0 +1,438 @@ +import AutofillField from "../models/autofill-field"; +import AutofillPageDetails from "../models/autofill-page-details"; +import { sendExtensionMessage } from "../utils"; + +import { InlineMenuFieldQualificationsService as InlineMenuFieldQualificationsServiceInterface } from "./abstractions/inline-menu-field-qualifications.service"; +import { AutoFillConstants } from "./autofill-constants"; + +export class InlineMenuFieldQualificationService + implements InlineMenuFieldQualificationsServiceInterface +{ + private searchFieldNamesSet = new Set(AutoFillConstants.SearchFieldNames); + private excludedAutofillLoginTypesSet = new Set(AutoFillConstants.ExcludedAutofillLoginTypes); + private usernameFieldTypes = new Set(["text", "email", "number", "tel"]); + private usernameAutocompleteValues = new Set(["username", "email"]); + private fieldIgnoreListString = AutoFillConstants.FieldIgnoreList.join(","); + private passwordFieldExcludeListString = AutoFillConstants.PasswordFieldExcludeList.join(","); + private autofillFieldKeywordsMap: WeakMap = new WeakMap(); + private autocompleteDisabledValues = new Set(["off", "false"]); + private newFieldKeywords = new Set(["new", "change", "neue", "ändern"]); + private inlineMenuFieldQualificationFlagSet = false; + + constructor() { + void sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag").then( + (getInlineMenuFieldQualificationFlag) => + (this.inlineMenuFieldQualificationFlagSet = !!getInlineMenuFieldQualificationFlag?.result), + ); + } + + /** + * Validates the provided field as a field for a login form. + * + * @param field - The field to validate, should be a username or password field. + * @param pageDetails - The details of the page that the field is on. + */ + isFieldForLoginForm(field: AutofillField, pageDetails: AutofillPageDetails): boolean { + if (!this.inlineMenuFieldQualificationFlagSet) { + return this.isFieldForLoginFormFallback(field); + } + + const isCurrentPasswordField = this.isCurrentPasswordField(field); + if (isCurrentPasswordField) { + return this.isPasswordFieldForLoginForm(field, pageDetails); + } + + const isUsernameField = this.isUsernameField(field); + if (!isUsernameField) { + return false; + } + + return this.isUsernameFieldForLoginForm(field, pageDetails); + } + + /** + * Validates the provided field as a password field for a login form. + * + * @param field - The field to validate + * @param pageDetails - The details of the page that the field is on. + */ + private isPasswordFieldForLoginForm( + field: AutofillField, + pageDetails: AutofillPageDetails, + ): boolean { + // If the provided field is set with an autocomplete value of "current-password", we should assume that + // the page developer intends for this field to be interpreted as a password field for a login form. + if (field.autoCompleteType === "current-password") { + return true; + } + + const usernameFieldsInPageDetails = pageDetails.fields.filter(this.isUsernameField); + const passwordFieldsInPageDetails = pageDetails.fields.filter(this.isCurrentPasswordField); + + // If a single username and a single password field exists on the page, we + // should assume that this field is part of a login form. + if (usernameFieldsInPageDetails.length === 1 && passwordFieldsInPageDetails.length === 1) { + return true; + } + + // If the field is not structured within a form, we need to identify if the field is present on + // a page with multiple password fields. If that isn't the case, we can assume this is a login form field. + const parentForm = pageDetails.forms[field.form]; + if (!parentForm) { + // If no parent form is found, and multiple password fields are present, we should assume that + // the passed field belongs to a user account creation form. + if (passwordFieldsInPageDetails.length > 1) { + return false; + } + + // If multiple username fields exist on the page, we should assume that + // the provided field is part of an account creation form. + const visibleUsernameFields = usernameFieldsInPageDetails.filter((f) => f.viewable); + if (visibleUsernameFields.length > 1) { + return false; + } + + // If a single username field or less is present on the page, then we can assume that the + // provided field is for a login form. This will only be the case if the field does not + // explicitly have its autocomplete attribute set to "off" or "false". + return !this.autocompleteDisabledValues.has(field.autoCompleteType); + } + + // If the field has a form parent and there are multiple visible password fields + // in the form, this is not a login form field + const visiblePasswordFieldsInPageDetails = passwordFieldsInPageDetails.filter( + (f) => f.form === field.form && f.viewable, + ); + if (visiblePasswordFieldsInPageDetails.length > 1) { + return false; + } + + // If the form has any visible username fields, we should treat the field as part of a login form + const visibleUsernameFields = usernameFieldsInPageDetails.filter( + (f) => f.form === field.form && f.viewable, + ); + if (visibleUsernameFields.length > 0) { + return true; + } + + // If the field has a form parent and no username field exists and the field has an + // autocomplete attribute set to "off" or "false", this is not a password field + return !this.autocompleteDisabledValues.has(field.autoCompleteType); + } + + /** + * Validates the provided field as a username field for a login form. + * + * @param field - The field to validate + * @param pageDetails - The details of the page that the field is on. + */ + private isUsernameFieldForLoginForm( + field: AutofillField, + pageDetails: AutofillPageDetails, + ): boolean { + // If the provided field is set with an autocomplete of "username", we should assume that + // the page developer intends for this field to be interpreted as a username field. + if (this.usernameAutocompleteValues.has(field.autoCompleteType)) { + const newPasswordFieldsInPageDetails = pageDetails.fields.filter(this.isNewPasswordField); + return newPasswordFieldsInPageDetails.length === 0; + } + + // If any keywords in the field's data indicates that this is a field for a "new" or "changed" + // username, we should assume that this field is not for a login form. + if (this.keywordsFoundInFieldData(field, [...this.newFieldKeywords])) { + return false; + } + + // If the field is not explicitly set as a username field, we need to qualify + // the field based on the other fields that are present on the page. + const parentForm = pageDetails.forms[field.form]; + const passwordFieldsInPageDetails = pageDetails.fields.filter(this.isCurrentPasswordField); + + // If the field is not structured within a form, we need to identify if the field is used in conjunction + // with a password field. If that's the case, then we should assume that it is a form field element. + if (!parentForm) { + // If a formless field is present in a webpage with a single password field, we + // should assume that it is part of a login workflow. + const visiblePasswordFieldsInPageDetails = passwordFieldsInPageDetails.filter( + (passwordField) => passwordField.viewable, + ); + if (visiblePasswordFieldsInPageDetails.length === 1) { + return true; + } + + // If more than a single password field exists on the page, we should assume that the field + // is part of an account creation form. + if (visiblePasswordFieldsInPageDetails.length > 1) { + return false; + } + + // If no visible fields are found on the page, but we have a single password + // field we should assume that the field is part of a login form. + if (passwordFieldsInPageDetails.length === 1) { + return true; + } + + // If the page does not contain any password fields, it might be part of a multistep login form. + // That will only be the case if the field does not explicitly have its autocomplete attribute + // set to "off" or "false". + return !this.autocompleteDisabledValues.has(field.autoCompleteType); + } + + // If the field is structured within a form, but no password fields are present in the form, + // we need to consider whether the field is part of a multistep login form. + if (passwordFieldsInPageDetails.length === 0) { + // If the field's autocomplete is set to a disabled value, we should assume that the field is + // not part of a login form. + if (this.autocompleteDisabledValues.has(field.autoCompleteType)) { + return false; + } + + // If the form that contains the field has more than one visible field, we should assume + // that the field is part of an account creation form. + const fieldsWithinForm = pageDetails.fields.filter( + (pageDetailsField) => pageDetailsField.form === field.form && pageDetailsField.viewable, + ); + return fieldsWithinForm.length === 1; + } + + // If a single password field exists within the page details, and that password field is part of + // the same form as the provided field, we should assume that the field is part of a login form. + const visiblePasswordFieldsInPageDetails = passwordFieldsInPageDetails.filter( + (passwordField) => passwordField.form === field.form && passwordField.viewable, + ); + if (visiblePasswordFieldsInPageDetails.length === 1) { + return true; + } + + // If multiple visible password fields exist within the page details, we need to assume that the + // provided field is part of an account creation form. + if (visiblePasswordFieldsInPageDetails.length > 1) { + return false; + } + + // If no visible password fields are found, this field might be part of a multipart form. + // Check for an invalid autocompleteType to determine if the field is part of a login form. + return !this.autocompleteDisabledValues.has(field.autoCompleteType); + } + + /** + * Validates the provided field as a username field. + * + * @param field - The field to validate + */ + private isUsernameField = (field: AutofillField): boolean => { + if ( + !this.usernameFieldTypes.has(field.type) || + this.isExcludedFieldType(field, this.excludedAutofillLoginTypesSet) + ) { + return false; + } + + return this.keywordsFoundInFieldData(field, AutoFillConstants.UsernameFieldNames); + }; + + /** + * Validates the provided field as a current password field. + * + * @param field - The field to validate + */ + private isCurrentPasswordField = (field: AutofillField): boolean => { + if (field.autoCompleteType === "new-password") { + return false; + } + + return this.isPasswordField(field); + }; + + /** + * Validates the provided field as a new password field. + * + * @param field - The field to validate + */ + private isNewPasswordField = (field: AutofillField): boolean => { + if (field.autoCompleteType === "current-password") { + return false; + } + + return this.isPasswordField(field); + }; + + /** + * Validates the provided field as a password field. + * + * @param field - The field to validate + */ + private isPasswordField = (field: AutofillField): boolean => { + const isInputPasswordType = field.type === "password"; + if ( + (!isInputPasswordType && + this.isExcludedFieldType(field, this.excludedAutofillLoginTypesSet)) || + this.fieldHasDisqualifyingAttributeValue(field) + ) { + return false; + } + + return isInputPasswordType || this.isLikePasswordField(field); + }; + + /** + * Validates the provided field as a field to indicate if the + * field potentially acts as a password field. + * + * @param field - The field to validate + */ + private isLikePasswordField(field: AutofillField): boolean { + if (field.type !== "text") { + return false; + } + + const testedValues = [field.htmlID, field.htmlName, field.placeholder]; + for (let i = 0; i < testedValues.length; i++) { + if (this.valueIsLikePassword(testedValues[i])) { + return true; + } + } + + return false; + } + + /** + * Validates the provided value to indicate if the value is like a password. + * + * @param value - The value to validate + */ + private valueIsLikePassword(value: string): boolean { + if (value == null) { + return false; + } + // Removes all whitespace, _ and - characters + const cleanedValue = value.toLowerCase().replace(/[\s_-]/g, ""); + + if (cleanedValue.indexOf("password") < 0) { + return false; + } + + return !(this.passwordFieldExcludeListString.indexOf(cleanedValue) > -1); + } + + /** + * Validates the provided field to indicate if the field has a + * disqualifying attribute that would impede autofill entirely. + * + * @param field - The field to validate + */ + private fieldHasDisqualifyingAttributeValue(field: AutofillField): boolean { + const checkedAttributeValues = [field.htmlID, field.htmlName, field.placeholder]; + + for (let i = 0; i < checkedAttributeValues.length; i++) { + const checkedAttributeValue = checkedAttributeValues[i]; + const cleanedValue = checkedAttributeValue?.toLowerCase().replace(/[\s_-]/g, ""); + + if (cleanedValue && this.fieldIgnoreListString.indexOf(cleanedValue) > -1) { + return true; + } + } + + return false; + } + + /** + * Validates the provided field to indicate if the field is excluded from autofill. + * + * @param field - The field to validate + * @param excludedTypes - The set of excluded types + */ + private isExcludedFieldType(field: AutofillField, excludedTypes: Set): boolean { + if (excludedTypes.has(field.type)) { + return true; + } + + return this.isSearchField(field); + } + + /** + * Validates the provided field to indicate if the field is a search field. + * + * @param field - The field to validate + */ + private isSearchField(field: AutofillField): boolean { + const matchFieldAttributeValues = [field.type, field.htmlName, field.htmlID, field.placeholder]; + for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) { + if (!matchFieldAttributeValues[attrIndex]) { + continue; + } + + // Separate camel case words and case them to lower case values + const camelCaseSeparatedFieldAttribute = matchFieldAttributeValues[attrIndex] + .replace(/([a-z])([A-Z])/g, "$1 $2") + .toLowerCase(); + // Split the attribute by non-alphabetical characters to get the keywords + const attributeKeywords = camelCaseSeparatedFieldAttribute.split(/[^a-z]/gi); + + for (let keywordIndex = 0; keywordIndex < attributeKeywords.length; keywordIndex++) { + if (this.searchFieldNamesSet.has(attributeKeywords[keywordIndex])) { + return true; + } + } + } + + return false; + } + + /** + * Validates the provided field to indicate if the field has any of the provided keywords. + * + * @param autofillFieldData - The field data to search for keywords + * @param keywords - The keywords to search for + */ + private keywordsFoundInFieldData(autofillFieldData: AutofillField, keywords: string[]) { + const searchedString = this.getAutofillFieldDataKeywords(autofillFieldData); + return keywords.some((keyword) => searchedString.includes(keyword)); + } + + /** + * Retrieves the keywords from the provided autofill field data. + * + * @param autofillFieldData - The field data to search for keywords + */ + private getAutofillFieldDataKeywords(autofillFieldData: AutofillField) { + if (this.autofillFieldKeywordsMap.has(autofillFieldData)) { + return this.autofillFieldKeywordsMap.get(autofillFieldData); + } + + const keywordValues = [ + autofillFieldData.htmlID, + autofillFieldData.htmlName, + autofillFieldData.htmlClass, + autofillFieldData.type, + autofillFieldData.title, + autofillFieldData.placeholder, + autofillFieldData.autoCompleteType, + autofillFieldData["label-data"], + autofillFieldData["label-aria"], + autofillFieldData["label-left"], + autofillFieldData["label-right"], + autofillFieldData["label-tag"], + autofillFieldData["label-top"], + ] + .join(",") + .toLowerCase(); + this.autofillFieldKeywordsMap.set(autofillFieldData, keywordValues); + + return keywordValues; + } + + /** + * This method represents the previous rudimentary approach to qualifying fields for login forms. + * + * @param field - The field to validate + * @deprecated - This method will only be used when the fallback flag is set to true. + */ + private isFieldForLoginFormFallback(field: AutofillField): boolean { + if (field.type === "password") { + return true; + } + + return this.isUsernameField(field); + } +} diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index e5d20cf9f59..873012d1dbb 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -7,7 +7,10 @@ import { FillableFormFieldElement, FormFieldElement } from "../types"; * @param callback - The callback function to run when the browser is idle. * @param options - The options to pass to the requestIdleCallback function. */ -export function requestIdleCallbackPolyfill(callback: () => void, options?: Record) { +export function requestIdleCallbackPolyfill( + callback: () => void, + options?: Record, +): number | NodeJS.Timeout { if ("requestIdleCallback" in globalThis) { return globalThis.requestIdleCallback(() => callback(), options); } @@ -15,6 +18,19 @@ export function requestIdleCallbackPolyfill(callback: () => void, options?: Reco return globalThis.setTimeout(() => callback(), 1); } +/** + * Polyfills the cancelIdleCallback API with a clearTimeout fallback. + * + * @param id - The ID of the idle callback to cancel. + */ +export function cancelIdleCallbackPolyfill(id: NodeJS.Timeout | number) { + if ("cancelIdleCallback" in globalThis) { + return globalThis.cancelIdleCallback(id as number); + } + + return globalThis.clearTimeout(id); +} + /** * Generates a random string of characters that formatted as a custom element name. */ diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index a1a5de54d21..94e96e2dc89 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -69,6 +69,7 @@ export default class RuntimeBackground { const messagesWithResponse = [ "biometricUnlock", "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag", + "getInlineMenuFieldQualificationFeatureFlag", ]; if (messagesWithResponse.includes(msg.command)) { @@ -186,6 +187,9 @@ export default class RuntimeBackground { FeatureFlag.UseTreeWalkerApiForPageDetailsCollection, ); } + case "getInlineMenuFieldQualificationFeatureFlag": { + return await this.configService.getFeatureFlag(FeatureFlag.InlineMenuFieldQualification); + } } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ec93dfcb2a0..552712f4d0a 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -18,6 +18,7 @@ export enum FeatureFlag { UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection", BulkDeviceApproval = "bulk-device-approval", EmailVerification = "email-verification", + InlineMenuFieldQualification = "inline-menu-field-qualification", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -46,6 +47,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE, [FeatureFlag.BulkDeviceApproval]: FALSE, [FeatureFlag.EmailVerification]: FALSE, + [FeatureFlag.InlineMenuFieldQualification]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;