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:
parent
1970abf723
commit
90d619acb5
@ -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;
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
});
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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.
|
||||
*/
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user