mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +00: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:
@@ -1,6 +1,7 @@
|
|||||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||||
|
|
||||||
import AutofillField from "../../models/autofill-field";
|
import AutofillField from "../../models/autofill-field";
|
||||||
|
import AutofillPageDetails from "../../models/autofill-page-details";
|
||||||
import { ElementWithOpId, FormFieldElement } from "../../types";
|
import { ElementWithOpId, FormFieldElement } from "../../types";
|
||||||
|
|
||||||
type OpenAutofillOverlayOptions = {
|
type OpenAutofillOverlayOptions = {
|
||||||
@@ -19,6 +20,7 @@ interface AutofillOverlayContentService {
|
|||||||
setupAutofillOverlayListenerOnField(
|
setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement: ElementWithOpId<FormFieldElement>,
|
autofillFieldElement: ElementWithOpId<FormFieldElement>,
|
||||||
autofillFieldData: AutofillField,
|
autofillFieldData: AutofillField,
|
||||||
|
pageDetails: AutofillPageDetails,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
openAutofillOverlay(options: OpenAutofillOverlayOptions): void;
|
openAutofillOverlay(options: OpenAutofillOverlayOptions): void;
|
||||||
removeAutofillOverlay(): 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 { EVENTS, AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
||||||
|
|
||||||
import AutofillField from "../models/autofill-field";
|
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 { createAutofillFieldMock } from "../spec/autofill-mocks";
|
||||||
import { flushPromises } from "../spec/testing-utils";
|
import { flushPromises } from "../spec/testing-utils";
|
||||||
import { ElementWithOpId, FormFieldElement } from "../types";
|
import { ElementWithOpId, FormFieldElement } from "../types";
|
||||||
@@ -146,6 +148,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
describe("setupAutofillOverlayListenerOnField", () => {
|
describe("setupAutofillOverlayListenerOnField", () => {
|
||||||
let autofillFieldElement: ElementWithOpId<FormFieldElement>;
|
let autofillFieldElement: ElementWithOpId<FormFieldElement>;
|
||||||
let autofillFieldData: AutofillField;
|
let autofillFieldData: AutofillField;
|
||||||
|
let pageDetailsMock: AutofillPageDetails;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
@@ -166,11 +169,27 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
placeholder: "username",
|
placeholder: "username",
|
||||||
elementNumber: 1,
|
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", () => {
|
describe("skips setup for ignored form fields", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
autofillFieldData = mock<AutofillField>();
|
autofillFieldData = mock<AutofillField>({
|
||||||
|
type: "text",
|
||||||
|
htmlName: "username",
|
||||||
|
htmlID: "username",
|
||||||
|
placeholder: "username",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores fields that are readonly", async () => {
|
it("ignores fields that are readonly", async () => {
|
||||||
@@ -179,6 +198,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
||||||
@@ -190,6 +210,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
||||||
@@ -201,6 +222,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
||||||
@@ -213,6 +235,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
||||||
@@ -225,6 +248,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
||||||
@@ -236,6 +260,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
||||||
@@ -247,6 +272,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
||||||
@@ -259,6 +285,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
expect(autofillFieldElement.addEventListener).not.toHaveBeenCalled();
|
||||||
@@ -272,6 +299,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("getAutofillOverlayVisibility");
|
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("getAutofillOverlayVisibility");
|
||||||
@@ -287,6 +315,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillOverlayContentService["autofillOverlayVisibility"]).toEqual(
|
expect(autofillOverlayContentService["autofillOverlayVisibility"]).toEqual(
|
||||||
@@ -310,6 +339,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillFieldElement.removeEventListener).toHaveBeenNthCalledWith(
|
expect(autofillFieldElement.removeEventListener).toHaveBeenNthCalledWith(
|
||||||
@@ -334,6 +364,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -357,6 +388,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
jest.spyOn(globalThis.customElements, "define").mockImplementation();
|
jest.spyOn(globalThis.customElements, "define").mockImplementation();
|
||||||
});
|
});
|
||||||
@@ -440,6 +472,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
spanAutofillFieldElement,
|
spanAutofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
spanAutofillFieldElement.dispatchEvent(new Event("input"));
|
spanAutofillFieldElement.dispatchEvent(new Event("input"));
|
||||||
@@ -451,6 +484,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
autofillFieldElement.dispatchEvent(new Event("input"));
|
autofillFieldElement.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
@@ -467,6 +501,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
passwordFieldElement,
|
passwordFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
passwordFieldElement.dispatchEvent(new Event("input"));
|
passwordFieldElement.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
@@ -486,6 +521,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
autofillFieldElement.dispatchEvent(new Event("input"));
|
autofillFieldElement.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
@@ -504,6 +540,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
autofillFieldElement.dispatchEvent(new Event("input"));
|
autofillFieldElement.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
@@ -517,6 +554,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
autofillFieldElement.dispatchEvent(new Event("input"));
|
autofillFieldElement.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
@@ -531,6 +569,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
autofillFieldElement.dispatchEvent(new Event("input"));
|
autofillFieldElement.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
@@ -546,6 +585,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
autofillFieldElement.dispatchEvent(new Event("input"));
|
autofillFieldElement.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
@@ -563,6 +603,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -613,6 +654,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
autofillFieldElement.dispatchEvent(new Event("focus"));
|
autofillFieldElement.dispatchEvent(new Event("focus"));
|
||||||
@@ -624,6 +666,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
autofillFieldElement.dispatchEvent(new Event("focus"));
|
autofillFieldElement.dispatchEvent(new Event("focus"));
|
||||||
@@ -641,6 +684,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
autofillFieldElement.dispatchEvent(new Event("focus"));
|
autofillFieldElement.dispatchEvent(new Event("focus"));
|
||||||
@@ -660,6 +704,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
autofillFieldElement.dispatchEvent(new Event("focus"));
|
autofillFieldElement.dispatchEvent(new Event("focus"));
|
||||||
@@ -678,6 +723,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
autofillFieldElement.dispatchEvent(new Event("focus"));
|
autofillFieldElement.dispatchEvent(new Event("focus"));
|
||||||
@@ -695,6 +741,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
autofillFieldElement.dispatchEvent(new Event("focus"));
|
autofillFieldElement.dispatchEvent(new Event("focus"));
|
||||||
@@ -711,6 +758,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
autofillFieldElement.dispatchEvent(new Event("focus"));
|
autofillFieldElement.dispatchEvent(new Event("focus"));
|
||||||
@@ -733,6 +781,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("openAutofillOverlay");
|
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("openAutofillOverlay");
|
||||||
@@ -747,6 +796,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
await autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(autofillOverlayContentService["mostRecentlyFocusedField"]).toEqual(
|
expect(autofillOverlayContentService["mostRecentlyFocusedField"]).toEqual(
|
||||||
@@ -1589,6 +1639,7 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
describe("destroy", () => {
|
describe("destroy", () => {
|
||||||
let autofillFieldElement: ElementWithOpId<FormFieldElement>;
|
let autofillFieldElement: ElementWithOpId<FormFieldElement>;
|
||||||
let autofillFieldData: AutofillField;
|
let autofillFieldData: AutofillField;
|
||||||
|
let pageDetailsMock: AutofillPageDetails;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
@@ -1608,11 +1659,21 @@ describe("AutofillOverlayContentService", () => {
|
|||||||
placeholder: "username",
|
placeholder: "username",
|
||||||
elementNumber: 1,
|
elementNumber: 1,
|
||||||
});
|
});
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
const passwordFieldData = createAutofillFieldMock({
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
opid: "password-field",
|
||||||
autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
form: "validFormId",
|
||||||
|
elementNumber: 2,
|
||||||
|
autocompleteType: "current-password",
|
||||||
|
type: "password",
|
||||||
|
});
|
||||||
|
pageDetailsMock = mock<AutofillPageDetails>({
|
||||||
|
forms: { validFormId: mock<AutofillForm>() },
|
||||||
|
fields: [autofillFieldData, passwordFieldData],
|
||||||
|
});
|
||||||
|
void autofillOverlayContentService.setupAutofillOverlayListenerOnField(
|
||||||
autofillFieldElement,
|
autofillFieldElement,
|
||||||
autofillFieldData,
|
autofillFieldData,
|
||||||
|
pageDetailsMock,
|
||||||
);
|
);
|
||||||
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
|
autofillOverlayContentService["mostRecentlyFocusedField"] = autofillFieldElement;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { EVENTS, AutofillOverlayVisibility } from "@bitwarden/common/autofill/co
|
|||||||
|
|
||||||
import { FocusedFieldData } from "../background/abstractions/overlay.background";
|
import { FocusedFieldData } from "../background/abstractions/overlay.background";
|
||||||
import AutofillField from "../models/autofill-field";
|
import AutofillField from "../models/autofill-field";
|
||||||
|
import AutofillPageDetails from "../models/autofill-page-details";
|
||||||
import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe";
|
import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe";
|
||||||
import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe";
|
import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe";
|
||||||
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
|
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
|
||||||
@@ -23,8 +24,10 @@ import {
|
|||||||
OpenAutofillOverlayOptions,
|
OpenAutofillOverlayOptions,
|
||||||
} from "./abstractions/autofill-overlay-content.service";
|
} from "./abstractions/autofill-overlay-content.service";
|
||||||
import { AutoFillConstants } from "./autofill-constants";
|
import { AutoFillConstants } from "./autofill-constants";
|
||||||
|
import { InlineMenuFieldQualificationService } from "./inline-menu-field-qualification.service";
|
||||||
|
|
||||||
class AutofillOverlayContentService implements AutofillOverlayContentServiceInterface {
|
class AutofillOverlayContentService implements AutofillOverlayContentServiceInterface {
|
||||||
|
private readonly inlineMenuFieldQualificationService: InlineMenuFieldQualificationService;
|
||||||
isFieldCurrentlyFocused = false;
|
isFieldCurrentlyFocused = false;
|
||||||
isCurrentlyFilling = false;
|
isCurrentlyFilling = false;
|
||||||
isOverlayCiphersPopulated = false;
|
isOverlayCiphersPopulated = false;
|
||||||
@@ -62,6 +65,10 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
|
|||||||
zIndex: "2147483647",
|
zIndex: "2147483647",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the autofill overlay content service by setting up the mutation observers.
|
* 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.
|
* 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 formFieldElement - Form field elements identified during the page details collection process.
|
||||||
* @param autofillFieldData - Autofill field data captured from the form field element.
|
* @param autofillFieldData - Autofill field data captured from the form field element.
|
||||||
|
* @param pageDetails - The collected page details from the tab.
|
||||||
*/
|
*/
|
||||||
async setupAutofillOverlayListenerOnField(
|
async setupAutofillOverlayListenerOnField(
|
||||||
formFieldElement: ElementWithOpId<FormFieldElement>,
|
formFieldElement: ElementWithOpId<FormFieldElement>,
|
||||||
autofillFieldData: AutofillField,
|
autofillFieldData: AutofillField,
|
||||||
|
pageDetails: AutofillPageDetails,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
this.formFieldElements.has(formFieldElement) ||
|
||||||
|
this.isIgnoredField(autofillFieldData, pageDetails)
|
||||||
) {
|
) {
|
||||||
if (this.isIgnoredField(autofillFieldData) || this.formFieldElements.has(formFieldElement)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,51 +536,6 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
|
|||||||
return this.authStatus === AuthenticationStatus.Unlocked;
|
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
|
* Validates that the most recently focused field is currently
|
||||||
* focused within the root node relative to the field.
|
* 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.
|
* updated in the future to support other types of forms.
|
||||||
*
|
*
|
||||||
* @param autofillFieldData - Autofill field data captured from the form field element.
|
* @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 (
|
if (
|
||||||
autofillFieldData.readonly ||
|
autofillFieldData.readonly ||
|
||||||
autofillFieldData.disabled ||
|
autofillFieldData.disabled ||
|
||||||
!autofillFieldData.viewable ||
|
!autofillFieldData.viewable ||
|
||||||
this.ignoredFieldTypes.has(autofillFieldData.type) ||
|
this.ignoredFieldTypes.has(autofillFieldData.type)
|
||||||
this.keywordsFoundInFieldData(autofillFieldData, ["search", "captcha"])
|
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLoginCipherField =
|
return !this.inlineMenuFieldQualificationService.isFieldForLoginForm(
|
||||||
autofillFieldData.type === "password" ||
|
autofillFieldData,
|
||||||
this.keywordsFoundInFieldData(autofillFieldData, AutoFillConstants.UsernameFieldNames);
|
pageDetails,
|
||||||
|
);
|
||||||
return !isLoginCipherField;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe("CollectAutofillContentService", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100));
|
globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100));
|
||||||
|
globalThis.cancelIdleCallback = jest.fn((id) => clearTimeout(id));
|
||||||
document.body.innerHTML = mockLoginForm;
|
document.body.innerHTML = mockLoginForm;
|
||||||
collectAutofillContentService = new CollectAutofillContentService(
|
collectAutofillContentService = new CollectAutofillContentService(
|
||||||
domElementVisibilityService,
|
domElementVisibilityService,
|
||||||
@@ -247,11 +248,16 @@ describe("CollectAutofillContentService", () => {
|
|||||||
const isFormFieldViewableSpy = jest
|
const isFormFieldViewableSpy = jest
|
||||||
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable")
|
.spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable")
|
||||||
.mockResolvedValue(true);
|
.mockResolvedValue(true);
|
||||||
|
const setupAutofillOverlayListenerOnFieldSpy = jest.spyOn(
|
||||||
|
collectAutofillContentService["autofillOverlayContentService"],
|
||||||
|
"setupAutofillOverlayListenerOnField",
|
||||||
|
);
|
||||||
|
|
||||||
await collectAutofillContentService.getPageDetails();
|
await collectAutofillContentService.getPageDetails();
|
||||||
|
|
||||||
expect(autofillField.viewable).toBe(true);
|
expect(autofillField.viewable).toBe(true);
|
||||||
expect(isFormFieldViewableSpy).toHaveBeenCalledWith(fieldElement);
|
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 () => {
|
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-disabled": false,
|
||||||
"aria-haspopup": false,
|
"aria-haspopup": false,
|
||||||
"aria-hidden": false,
|
"aria-hidden": false,
|
||||||
autoCompleteType: null,
|
autoCompleteType: "off",
|
||||||
checked: false,
|
checked: false,
|
||||||
"data-stripe": hiddenField.dataStripe,
|
"data-stripe": hiddenField.dataStripe,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -2606,6 +2612,7 @@ describe("CollectAutofillContentService", () => {
|
|||||||
expect(setupAutofillOverlayListenerOnFieldSpy).toHaveBeenCalledWith(
|
expect(setupAutofillOverlayListenerOnFieldSpy).toHaveBeenCalledWith(
|
||||||
formFieldElement,
|
formFieldElement,
|
||||||
autofillField,
|
autofillField,
|
||||||
|
expect.anything(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,32 +4,33 @@ import AutofillPageDetails from "../models/autofill-page-details";
|
|||||||
import {
|
import {
|
||||||
ElementWithOpId,
|
ElementWithOpId,
|
||||||
FillableFormFieldElement,
|
FillableFormFieldElement,
|
||||||
FormFieldElement,
|
|
||||||
FormElementWithAttribute,
|
FormElementWithAttribute,
|
||||||
|
FormFieldElement,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
elementIsDescriptionDetailsElement,
|
elementIsDescriptionDetailsElement,
|
||||||
elementIsDescriptionTermElement,
|
elementIsDescriptionTermElement,
|
||||||
elementIsFillableFormField,
|
elementIsFillableFormField,
|
||||||
elementIsFormElement,
|
elementIsFormElement,
|
||||||
|
elementIsInputElement,
|
||||||
elementIsLabelElement,
|
elementIsLabelElement,
|
||||||
elementIsSelectElement,
|
elementIsSelectElement,
|
||||||
elementIsSpanElement,
|
elementIsSpanElement,
|
||||||
nodeIsElement,
|
nodeIsElement,
|
||||||
elementIsInputElement,
|
|
||||||
elementIsTextAreaElement,
|
elementIsTextAreaElement,
|
||||||
nodeIsFormElement,
|
nodeIsFormElement,
|
||||||
nodeIsInputElement,
|
nodeIsInputElement,
|
||||||
// sendExtensionMessage,
|
// sendExtensionMessage,
|
||||||
requestIdleCallbackPolyfill,
|
requestIdleCallbackPolyfill,
|
||||||
|
cancelIdleCallbackPolyfill,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
|
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
|
||||||
import {
|
import {
|
||||||
UpdateAutofillDataAttributeParams,
|
|
||||||
AutofillFieldElements,
|
AutofillFieldElements,
|
||||||
AutofillFormElements,
|
AutofillFormElements,
|
||||||
CollectAutofillContentService as CollectAutofillContentServiceInterface,
|
CollectAutofillContentService as CollectAutofillContentServiceInterface,
|
||||||
|
UpdateAutofillDataAttributeParams,
|
||||||
} from "./abstractions/collect-autofill-content.service";
|
} from "./abstractions/collect-autofill-content.service";
|
||||||
import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service";
|
import { DomElementVisibilityService } from "./abstractions/dom-element-visibility.service";
|
||||||
|
|
||||||
@@ -44,9 +45,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
private intersectionObserver: IntersectionObserver;
|
private intersectionObserver: IntersectionObserver;
|
||||||
private elementInitializingIntersectionObserver: Set<Element> = new Set();
|
private elementInitializingIntersectionObserver: Set<Element> = new Set();
|
||||||
private mutationObserver: MutationObserver;
|
private mutationObserver: MutationObserver;
|
||||||
private updateAutofillElementsAfterMutationTimeout: number | NodeJS.Timeout;
|
|
||||||
private mutationsQueue: MutationRecord[][] = [];
|
private mutationsQueue: MutationRecord[][] = [];
|
||||||
private readonly updateAfterMutationTimeoutDelay = 1000;
|
private updateAfterMutationIdleCallback: NodeJS.Timeout | number;
|
||||||
|
private readonly updateAfterMutationTimeout = 1000;
|
||||||
private readonly formFieldQueryString;
|
private readonly formFieldQueryString;
|
||||||
private readonly nonInputFormFieldTags = new Set(["textarea", "select"]);
|
private readonly nonInputFormFieldTags = new Set(["textarea", "select"]);
|
||||||
private readonly ignoredInputTypes = new Set([
|
private readonly ignoredInputTypes = new Set([
|
||||||
@@ -120,7 +121,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.domRecentlyMutated = false;
|
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
|
||||||
*/
|
*/
|
||||||
private updateCachedAutofillFieldVisibility() {
|
private updateCachedAutofillFieldVisibility() {
|
||||||
this.autofillFieldElements.forEach(
|
this.autofillFieldElements.forEach(async (autofillField, element) => {
|
||||||
async (autofillField, element) =>
|
const previouslyViewable = autofillField.viewable;
|
||||||
(autofillField.viewable =
|
autofillField.viewable = await this.domElementVisibilityService.isFormFieldViewable(element);
|
||||||
await this.domElementVisibilityService.isFormFieldViewable(element)),
|
|
||||||
);
|
if (!previouslyViewable && autofillField.viewable) {
|
||||||
|
this.setupInlineMenuListenerOnField(element, autofillField);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -453,10 +460,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
|
|
||||||
if (elementIsSpanElement(element)) {
|
if (elementIsSpanElement(element)) {
|
||||||
this.cacheAutofillFieldElement(index, element, autofillFieldBase);
|
this.cacheAutofillFieldElement(index, element, autofillFieldBase);
|
||||||
void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField(
|
|
||||||
element,
|
|
||||||
autofillFieldBase,
|
|
||||||
);
|
|
||||||
return autofillFieldBase;
|
return autofillFieldBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,10 +499,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.cacheAutofillFieldElement(index, element, autofillField);
|
this.cacheAutofillFieldElement(index, element, autofillField);
|
||||||
void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField(
|
|
||||||
element,
|
|
||||||
autofillField,
|
|
||||||
);
|
|
||||||
return autofillField;
|
return autofillField;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -531,11 +530,11 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private getAutoCompleteAttribute(element: ElementWithOpId<FormFieldElement>): string {
|
private getAutoCompleteAttribute(element: ElementWithOpId<FormFieldElement>): string {
|
||||||
const autoCompleteType =
|
return (
|
||||||
this.getPropertyOrAttribute(element, "x-autocompletetype") ||
|
this.getPropertyOrAttribute(element, "x-autocompletetype") ||
|
||||||
this.getPropertyOrAttribute(element, "autocompletetype") ||
|
this.getPropertyOrAttribute(element, "autocompletetype") ||
|
||||||
this.getPropertyOrAttribute(element, "autocomplete");
|
this.getPropertyOrAttribute(element, "autocomplete")
|
||||||
return autoCompleteType !== "off" ? autoCompleteType : null;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1229,13 +1228,13 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private updateAutofillElementsAfterMutation() {
|
private updateAutofillElementsAfterMutation() {
|
||||||
if (this.updateAutofillElementsAfterMutationTimeout) {
|
if (this.updateAfterMutationIdleCallback) {
|
||||||
clearTimeout(this.updateAutofillElementsAfterMutationTimeout);
|
cancelIdleCallbackPolyfill(this.updateAfterMutationIdleCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateAutofillElementsAfterMutationTimeout = setTimeout(
|
this.updateAfterMutationIdleCallback = requestIdleCallbackPolyfill(
|
||||||
this.getPageDetails.bind(this),
|
this.getPageDetails.bind(this),
|
||||||
this.updateAfterMutationTimeoutDelay,
|
{ timeout: this.updateAfterMutationTimeout },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1425,22 +1424,64 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
|
|
||||||
cachedAutofillFieldElement.viewable = true;
|
cachedAutofillFieldElement.viewable = true;
|
||||||
|
|
||||||
void this.autofillOverlayContentService?.setupAutofillOverlayListenerOnField(
|
this.setupInlineMenuListenerOnField(formFieldElement, cachedAutofillFieldElement);
|
||||||
formFieldElement,
|
|
||||||
cachedAutofillFieldElement,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.intersectionObserver?.unobserve(entry.target);
|
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
|
* Destroys the CollectAutofillContentService. Clears all
|
||||||
* timeouts and disconnects the mutation observer.
|
* timeouts and disconnects the mutation observer.
|
||||||
*/
|
*/
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.updateAutofillElementsAfterMutationTimeout) {
|
if (this.updateAfterMutationIdleCallback) {
|
||||||
clearTimeout(this.updateAutofillElementsAfterMutationTimeout);
|
cancelIdleCallbackPolyfill(this.updateAfterMutationIdleCallback);
|
||||||
}
|
}
|
||||||
this.mutationObserver?.disconnect();
|
this.mutationObserver?.disconnect();
|
||||||
this.intersectionObserver?.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 callback - The callback function to run when the browser is idle.
|
||||||
* @param options - The options to pass to the requestIdleCallback function.
|
* @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) {
|
if ("requestIdleCallback" in globalThis) {
|
||||||
return globalThis.requestIdleCallback(() => callback(), options);
|
return globalThis.requestIdleCallback(() => callback(), options);
|
||||||
}
|
}
|
||||||
@@ -15,6 +18,19 @@ export function requestIdleCallbackPolyfill(callback: () => void, options?: Reco
|
|||||||
return globalThis.setTimeout(() => callback(), 1);
|
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.
|
* Generates a random string of characters that formatted as a custom element name.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default class RuntimeBackground {
|
|||||||
const messagesWithResponse = [
|
const messagesWithResponse = [
|
||||||
"biometricUnlock",
|
"biometricUnlock",
|
||||||
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
|
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
|
||||||
|
"getInlineMenuFieldQualificationFeatureFlag",
|
||||||
];
|
];
|
||||||
|
|
||||||
if (messagesWithResponse.includes(msg.command)) {
|
if (messagesWithResponse.includes(msg.command)) {
|
||||||
@@ -186,6 +187,9 @@ export default class RuntimeBackground {
|
|||||||
FeatureFlag.UseTreeWalkerApiForPageDetailsCollection,
|
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",
|
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
|
||||||
BulkDeviceApproval = "bulk-device-approval",
|
BulkDeviceApproval = "bulk-device-approval",
|
||||||
EmailVerification = "email-verification",
|
EmailVerification = "email-verification",
|
||||||
|
InlineMenuFieldQualification = "inline-menu-field-qualification",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||||
@@ -46,6 +47,7 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
||||||
[FeatureFlag.BulkDeviceApproval]: FALSE,
|
[FeatureFlag.BulkDeviceApproval]: FALSE,
|
||||||
[FeatureFlag.EmailVerification]: FALSE,
|
[FeatureFlag.EmailVerification]: FALSE,
|
||||||
|
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
|
||||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||||
|
|
||||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||||
|
|||||||
Reference in New Issue
Block a user