1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-02-01 23:01:28 +01:00

[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
This commit is contained in:
Cesar Gonzalez 2024-06-17 13:49:29 -05:00 committed by GitHub
parent 1970abf723
commit 90d619acb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1300 additions and 92 deletions

View File

@ -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<FormFieldElement>,
autofillFieldData: AutofillField,
pageDetails: AutofillPageDetails,
): Promise<void>;
openAutofillOverlay(options: OpenAutofillOverlayOptions): void;
removeAutofillOverlay(): void;

View File

@ -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;
}

View File

@ -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<FormFieldElement>;
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<AutofillPageDetails>({
forms: { validFormId: mock<AutofillForm>() },
fields: [autofillFieldData, passwordFieldData],
});
});
describe("skips setup for ignored form fields", () => {
beforeEach(() => {
autofillFieldData = mock<AutofillField>();
autofillFieldData = mock<AutofillField>({
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<FormFieldElement>;
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<AutofillPageDetails>({
forms: { validFormId: mock<AutofillForm>() },
fields: [autofillFieldData, passwordFieldData],
});
void autofillOverlayContentService.setupAutofillOverlayListenerOnField(
autofillFieldElement,
autofillFieldData,
pageDetailsMock,
);
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
});

View File

@ -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<FormFieldElement>,
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,
);
}
/**

View File

@ -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(),
);
});
});

View File

@ -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<Element> = 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<FormFieldElement>): 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<FormFieldElement>,
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();

View File

@ -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<AutofillPageDetails>;
let inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
beforeEach(() => {
pageDetails = mock<AutofillPageDetails>({
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<AutofillField>({
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<AutofillField>({
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<AutofillField>({
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<AutofillField>({
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<AutofillField>({
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<AutofillField>({
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<AutofillField>({
type: "password",
htmlID: "user-password",
htmlName: "user-password",
placeholder: "user-password",
form: "",
});
const secondField = mock<AutofillField>({
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<AutofillField>({
type: "password",
htmlID: "user-password",
htmlName: "user-password",
placeholder: "user-password",
form: "",
});
const usernameField = mock<AutofillField>({
type: "text",
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
});
const secondUsernameField = mock<AutofillField>({
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<AutofillField>({
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<AutofillForm>;
beforeEach(() => {
form = mock<AutofillForm>({ opid: "validFormId" });
pageDetails.forms = {
validFormId: form,
};
});
it("is structured with other password fields in the same form", () => {
const field = mock<AutofillField>({
type: "password",
htmlID: "user-password",
htmlName: "user-password",
placeholder: "user-password",
form: "validFormId",
});
const secondField = mock<AutofillField>({
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<AutofillField>({
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<AutofillField>({
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<AutofillField>({
type: "password",
htmlID: "user-password",
htmlName: "user-password",
placeholder: "user-password",
form: "",
autoCompleteType: "current-password",
});
const usernameField = mock<AutofillField>({
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<AutofillForm>;
beforeEach(() => {
form = mock<AutofillForm>({ opid: "validFormId" });
pageDetails.forms = {
validFormId: form,
};
});
it("is the only password field within the form and has a visible username field", () => {
const field = mock<AutofillField>({
type: "password",
htmlID: "user-password",
htmlName: "user-password",
placeholder: "user-password",
form: "validFormId",
});
const secondPasswordField = mock<AutofillField>({
type: "password",
htmlID: "some-other-password",
htmlName: "some-other-password",
placeholder: "some-other-password",
form: "anotherFormId",
});
const usernameField = mock<AutofillField>({
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<AutofillField>({
type: "password",
htmlID: "user-password",
htmlName: "user-password",
placeholder: "user-password",
form: "validFormId",
autoCompleteType: "",
});
const secondPasswordField = mock<AutofillField>({
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<AutofillField>({
type: "text",
autoCompleteType,
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
});
const passwordField = mock<AutofillField>({
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<AutofillField>({
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<AutofillField>({
type: "text",
autoCompleteType: "",
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
});
const passwordField = mock<AutofillField>({
type: "password",
autoCompleteType: "current-password",
htmlID: "user-password",
htmlName: "user-password",
placeholder: "user-password",
});
const secondPasswordField = mock<AutofillField>({
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<AutofillForm>;
beforeEach(() => {
form = mock<AutofillForm>({ 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<AutofillField>({
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<AutofillField>({
type: "text",
autoCompleteType: "",
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
form: "validFormId",
});
const otherField = mock<AutofillField>({
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<AutofillField>({
type: "text",
autoCompleteType: "",
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
form: "validFormId",
});
const passwordField = mock<AutofillField>({
type: "password",
autoCompleteType: "current-password",
htmlID: "user-password",
htmlName: "user-password",
placeholder: "user-password",
form: "validFormId",
});
const secondPasswordField = mock<AutofillField>({
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<AutofillField>({
type: "text",
autoCompleteType: "off",
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
form: "validFormId",
});
const passwordField = mock<AutofillField>({
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<AutofillField>({
type: "text",
autoCompleteType,
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
});
const passwordField = mock<AutofillField>({
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<AutofillField>({
type: "text",
autoCompleteType: "off",
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
});
const passwordField = mock<AutofillField>({
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<AutofillField>({
type: "text",
autoCompleteType: "off",
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
});
const passwordField = mock<AutofillField>({
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<AutofillField>({
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<AutofillForm>;
beforeEach(() => {
form = mock<AutofillForm>({ opid: "validFormId" });
pageDetails.forms = {
validFormId: form,
};
});
it("is structured on a page with a single password field", () => {
const field = mock<AutofillField>({
type: "text",
autoCompleteType: "",
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
form: "validFormId",
});
const passwordField = mock<AutofillField>({
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<AutofillField>({
type: "text",
autoCompleteType: "",
htmlID: "user-username",
htmlName: "user-username",
placeholder: "user-username",
form: "validFormId",
});
const passwordField = mock<AutofillField>({
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);
});
});
});
});
});
});

View File

@ -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<AutofillField, string> = 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<string>): 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);
}
}

View File

@ -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<string, any>) {
export function requestIdleCallbackPolyfill(
callback: () => void,
options?: Record<string, any>,
): 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.
*/

View File

@ -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);
}
}
}

View File

@ -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<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;