mirror of
https://github.com/bitwarden/browser.git
synced 2024-09-13 01:58:44 +02:00
[PM-5295] Improve autofill collection of page details performance (#9063)
* [PM-5295] Improve autofill collection of page details performance * [PM-5295] Reworking implementation to leverage requestIdleCallback instead of requestAnimationFrame * [PM-5295] Reworking implementation to leverage requestIdleCallback instead of requestAnimationFrame * [PM-5295] Incorporating documentation for added methods * [PM-5295] Reworking how we handle collection of shadowRoot elements * [PM-5295] Fixing jest tests relating to the defined pseudo selector * [PM-5295] Fixing jest tests relating to the defined pseudo selector * [PM-5295] Refactoring * [PM-5295] Refactoring * [PM-5295] Refactoring * [PM-5295] Starting the work to set up the tree walker strategy under a feature flag * [PM-5295] Incorporating methodology for triggering a fallback to the TreeWalker API if issues arise with the deepQuery approach * [PM-5295] Fixing jest test
This commit is contained in:
parent
db3d66dae1
commit
bbf1473022
@ -6,7 +6,11 @@ import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"
|
|||||||
import AutofillPageDetails from "../models/autofill-page-details";
|
import AutofillPageDetails from "../models/autofill-page-details";
|
||||||
import AutofillScript from "../models/autofill-script";
|
import AutofillScript from "../models/autofill-script";
|
||||||
import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
|
import AutofillOverlayContentService from "../services/autofill-overlay-content.service";
|
||||||
import { flushPromises, sendExtensionRuntimeMessage } from "../spec/testing-utils";
|
import {
|
||||||
|
flushPromises,
|
||||||
|
mockQuerySelectorAllDefinedCall,
|
||||||
|
sendExtensionRuntimeMessage,
|
||||||
|
} from "../spec/testing-utils";
|
||||||
import { RedirectFocusDirection } from "../utils/autofill-overlay.enum";
|
import { RedirectFocusDirection } from "../utils/autofill-overlay.enum";
|
||||||
|
|
||||||
import { AutofillExtensionMessage } from "./abstractions/autofill-init";
|
import { AutofillExtensionMessage } from "./abstractions/autofill-init";
|
||||||
@ -16,6 +20,7 @@ describe("AutofillInit", () => {
|
|||||||
let autofillInit: AutofillInit;
|
let autofillInit: AutofillInit;
|
||||||
const autofillOverlayContentService = mock<AutofillOverlayContentService>();
|
const autofillOverlayContentService = mock<AutofillOverlayContentService>();
|
||||||
const originalDocumentReadyState = document.readyState;
|
const originalDocumentReadyState = document.readyState;
|
||||||
|
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
chrome.runtime.connect = jest.fn().mockReturnValue({
|
chrome.runtime.connect = jest.fn().mockReturnValue({
|
||||||
@ -36,6 +41,10 @@ describe("AutofillInit", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mockQuerySelectorAll.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
describe("init", () => {
|
describe("init", () => {
|
||||||
it("sets up the extension message listeners", () => {
|
it("sets up the extension message listeners", () => {
|
||||||
jest.spyOn(autofillInit as any, "setupExtensionMessageListeners");
|
jest.spyOn(autofillInit as any, "setupExtensionMessageListeners");
|
||||||
@ -200,7 +209,12 @@ describe("AutofillInit", () => {
|
|||||||
|
|
||||||
expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled();
|
expect(autofillInit["collectAutofillContentService"].getPageDetails).toHaveBeenCalled();
|
||||||
expect(sendResponse).toBeCalledWith(pageDetails);
|
expect(sendResponse).toBeCalledWith(pageDetails);
|
||||||
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
|
expect(chrome.runtime.sendMessage).not.toHaveBeenCalledWith({
|
||||||
|
command: "collectPageDetailsResponse",
|
||||||
|
tab: message.tab,
|
||||||
|
details: pageDetails,
|
||||||
|
sender: message.sender,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -17,11 +17,11 @@ type UpdateAutofillDataAttributeParams = {
|
|||||||
interface CollectAutofillContentService {
|
interface CollectAutofillContentService {
|
||||||
getPageDetails(): Promise<AutofillPageDetails>;
|
getPageDetails(): Promise<AutofillPageDetails>;
|
||||||
getAutofillFieldElementByOpid(opid: string): HTMLElement | null;
|
getAutofillFieldElementByOpid(opid: string): HTMLElement | null;
|
||||||
queryAllTreeWalkerNodes(
|
deepQueryElements<T>(
|
||||||
rootNode: Node,
|
root: Document | ShadowRoot | Element,
|
||||||
filterCallback: CallableFunction,
|
selector: string,
|
||||||
isObservingShadowRoot?: boolean,
|
isObservingShadowRoot?: boolean,
|
||||||
): Node[];
|
): T[];
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { mock } from "jest-mock-extended";
|
|||||||
import AutofillField from "../models/autofill-field";
|
import AutofillField from "../models/autofill-field";
|
||||||
import AutofillForm from "../models/autofill-form";
|
import AutofillForm from "../models/autofill-form";
|
||||||
import { createAutofillFieldMock, createAutofillFormMock } from "../spec/autofill-mocks";
|
import { createAutofillFieldMock, createAutofillFormMock } from "../spec/autofill-mocks";
|
||||||
|
import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils";
|
||||||
import {
|
import {
|
||||||
ElementWithOpId,
|
ElementWithOpId,
|
||||||
FillableFormFieldElement,
|
FillableFormFieldElement,
|
||||||
@ -23,13 +24,17 @@ const mockLoginForm = `
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const waitForIdleCallback = () => new Promise((resolve) => globalThis.requestIdleCallback(resolve));
|
||||||
|
|
||||||
describe("CollectAutofillContentService", () => {
|
describe("CollectAutofillContentService", () => {
|
||||||
const domElementVisibilityService = new DomElementVisibilityService();
|
const domElementVisibilityService = new DomElementVisibilityService();
|
||||||
const autofillOverlayContentService = new AutofillOverlayContentService();
|
const autofillOverlayContentService = new AutofillOverlayContentService();
|
||||||
let collectAutofillContentService: CollectAutofillContentService;
|
let collectAutofillContentService: CollectAutofillContentService;
|
||||||
const mockIntersectionObserver = mock<IntersectionObserver>();
|
const mockIntersectionObserver = mock<IntersectionObserver>();
|
||||||
|
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100));
|
||||||
document.body.innerHTML = mockLoginForm;
|
document.body.innerHTML = mockLoginForm;
|
||||||
collectAutofillContentService = new CollectAutofillContentService(
|
collectAutofillContentService = new CollectAutofillContentService(
|
||||||
domElementVisibilityService,
|
domElementVisibilityService,
|
||||||
@ -40,9 +45,14 @@ describe("CollectAutofillContentService", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
jest.restoreAllMocks();
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mockQuerySelectorAll.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
describe("getPageDetails", () => {
|
describe("getPageDetails", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest
|
jest
|
||||||
@ -437,6 +447,51 @@ describe("CollectAutofillContentService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("deepQueryElements", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
collectAutofillContentService["mutationObserver"] = mock<MutationObserver>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queries form field elements that are nested within a ShadowDOM", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
const shadowRoot = root.attachShadow({ mode: "open" });
|
||||||
|
const form = document.createElement("form");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
form.appendChild(input);
|
||||||
|
shadowRoot.appendChild(form);
|
||||||
|
|
||||||
|
const formFieldElements = collectAutofillContentService.deepQueryElements(
|
||||||
|
shadowRoot,
|
||||||
|
"input",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(formFieldElements).toStrictEqual([input]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
||||||
|
const root2 = document.createElement("div");
|
||||||
|
const shadowRoot2 = root2.attachShadow({ mode: "open" });
|
||||||
|
const form = document.createElement("form");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
form.appendChild(input);
|
||||||
|
shadowRoot2.appendChild(form);
|
||||||
|
shadowRoot1.appendChild(root2);
|
||||||
|
|
||||||
|
const formFieldElements = collectAutofillContentService.deepQueryElements(
|
||||||
|
shadowRoot1,
|
||||||
|
"input",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(formFieldElements).toStrictEqual([input]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildAutofillFormsData", () => {
|
describe("buildAutofillFormsData", () => {
|
||||||
it("will not attempt to gather data from a cached form element", () => {
|
it("will not attempt to gather data from a cached form element", () => {
|
||||||
const documentTitle = "Test Page";
|
const documentTitle = "Test Page";
|
||||||
@ -1993,17 +2048,6 @@ describe("CollectAutofillContentService", () => {
|
|||||||
expect(shadowRoot).toEqual(null);
|
expect(shadowRoot).toEqual(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null if the passed node contains children elements", () => {
|
|
||||||
const element = document.createElement("div");
|
|
||||||
element.innerHTML = "<p>Hello, world!</p>";
|
|
||||||
const shadowRoot = collectAutofillContentService["getShadowRoot"](element);
|
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
// @ts-ignore
|
|
||||||
expect(chrome.dom.openOrClosedShadowRoot).not.toBeCalled();
|
|
||||||
expect(shadowRoot).toEqual(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an open shadow root if the passed node has a shadowDOM element", () => {
|
it("returns an open shadow root if the passed node has a shadowDOM element", () => {
|
||||||
const element = document.createElement("div");
|
const element = document.createElement("div");
|
||||||
element.attachShadow({ mode: "open" });
|
element.attachShadow({ mode: "open" });
|
||||||
@ -2023,50 +2067,6 @@ describe("CollectAutofillContentService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildTreeWalkerNodesQueryResults", () => {
|
|
||||||
it("will recursively call itself if a shadowDOM element is found and will observe the element for mutations", () => {
|
|
||||||
collectAutofillContentService["mutationObserver"] = mock<MutationObserver>({
|
|
||||||
observe: jest.fn(),
|
|
||||||
});
|
|
||||||
jest.spyOn(collectAutofillContentService as any, "buildTreeWalkerNodesQueryResults");
|
|
||||||
const shadowRoot = document.createElement("div");
|
|
||||||
jest
|
|
||||||
.spyOn(collectAutofillContentService as any, "getShadowRoot")
|
|
||||||
.mockReturnValueOnce(shadowRoot);
|
|
||||||
const callbackFilter = jest.fn();
|
|
||||||
|
|
||||||
collectAutofillContentService["buildTreeWalkerNodesQueryResults"](
|
|
||||||
document.body,
|
|
||||||
[],
|
|
||||||
callbackFilter,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(collectAutofillContentService["buildTreeWalkerNodesQueryResults"]).toBeCalledTimes(2);
|
|
||||||
expect(collectAutofillContentService["mutationObserver"].observe).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("will not observe the shadowDOM element if required to skip", () => {
|
|
||||||
collectAutofillContentService["mutationObserver"] = mock<MutationObserver>({
|
|
||||||
observe: jest.fn(),
|
|
||||||
});
|
|
||||||
const shadowRoot = document.createElement("div");
|
|
||||||
jest
|
|
||||||
.spyOn(collectAutofillContentService as any, "getShadowRoot")
|
|
||||||
.mockReturnValueOnce(shadowRoot);
|
|
||||||
const callbackFilter = jest.fn();
|
|
||||||
|
|
||||||
collectAutofillContentService["buildTreeWalkerNodesQueryResults"](
|
|
||||||
document.body,
|
|
||||||
[],
|
|
||||||
callbackFilter,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(collectAutofillContentService["mutationObserver"].observe).not.toBeCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setupMutationObserver", () => {
|
describe("setupMutationObserver", () => {
|
||||||
it("sets up a mutation observer and observes the document element", () => {
|
it("sets up a mutation observer and observes the document element", () => {
|
||||||
jest.spyOn(MutationObserver.prototype, "observe");
|
jest.spyOn(MutationObserver.prototype, "observe");
|
||||||
@ -2079,7 +2079,7 @@ describe("CollectAutofillContentService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleMutationObserverMutation", () => {
|
describe("handleMutationObserverMutation", () => {
|
||||||
it("will set the domRecentlyMutated value to true and the noFieldsFound value to false if a form or field node has been added ", () => {
|
it("will set the domRecentlyMutated value to true and the noFieldsFound value to false if a form or field node has been added ", async () => {
|
||||||
const form = document.createElement("form");
|
const form = document.createElement("form");
|
||||||
document.body.appendChild(form);
|
document.body.appendChild(form);
|
||||||
const addedNodes = document.querySelectorAll("form");
|
const addedNodes = document.querySelectorAll("form");
|
||||||
@ -2102,6 +2102,7 @@ describe("CollectAutofillContentService", () => {
|
|||||||
jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated");
|
jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated");
|
||||||
|
|
||||||
collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]);
|
collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]);
|
||||||
|
await waitForIdleCallback();
|
||||||
|
|
||||||
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true);
|
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true);
|
||||||
expect(collectAutofillContentService["noFieldsFound"]).toEqual(false);
|
expect(collectAutofillContentService["noFieldsFound"]).toEqual(false);
|
||||||
@ -2114,7 +2115,7 @@ describe("CollectAutofillContentService", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes cached autofill elements that are nested within a removed node", () => {
|
it("removes cached autofill elements that are nested within a removed node", async () => {
|
||||||
const form = document.createElement("form") as ElementWithOpId<HTMLFormElement>;
|
const form = document.createElement("form") as ElementWithOpId<HTMLFormElement>;
|
||||||
const usernameInput = document.createElement("input") as ElementWithOpId<FormFieldElement>;
|
const usernameInput = document.createElement("input") as ElementWithOpId<FormFieldElement>;
|
||||||
usernameInput.setAttribute("type", "text");
|
usernameInput.setAttribute("type", "text");
|
||||||
@ -2145,12 +2146,13 @@ describe("CollectAutofillContentService", () => {
|
|||||||
target: document.body,
|
target: document.body,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
await waitForIdleCallback();
|
||||||
|
|
||||||
expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0);
|
expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0);
|
||||||
expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0);
|
expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("will handle updating the autofill element if any attribute mutations are encountered", () => {
|
it("will handle updating the autofill element if any attribute mutations are encountered", async () => {
|
||||||
const mutationRecord: MutationRecord = {
|
const mutationRecord: MutationRecord = {
|
||||||
type: "attributes",
|
type: "attributes",
|
||||||
addedNodes: null,
|
addedNodes: null,
|
||||||
@ -2169,6 +2171,7 @@ describe("CollectAutofillContentService", () => {
|
|||||||
jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation");
|
jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation");
|
||||||
|
|
||||||
collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]);
|
collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]);
|
||||||
|
await waitForIdleCallback();
|
||||||
|
|
||||||
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(false);
|
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(false);
|
||||||
expect(collectAutofillContentService["noFieldsFound"]).toEqual(true);
|
expect(collectAutofillContentService["noFieldsFound"]).toEqual(true);
|
||||||
@ -2255,29 +2258,6 @@ describe("CollectAutofillContentService", () => {
|
|||||||
|
|
||||||
expect(collectAutofillContentService["buildAutofillFieldItem"]).not.toBeCalled();
|
expect(collectAutofillContentService["buildAutofillFieldItem"]).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds the autofill field item to ensure the overlay listeners are set", () => {
|
|
||||||
document.body.innerHTML = `
|
|
||||||
<form>
|
|
||||||
<label for="username-id">Username Label</label>
|
|
||||||
<input type="text" id="username-id">
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const inputElement = document.getElementById(
|
|
||||||
"username-id",
|
|
||||||
) as ElementWithOpId<HTMLInputElement>;
|
|
||||||
inputElement.setAttribute("type", "password");
|
|
||||||
const nodes = [inputElement];
|
|
||||||
jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldItem");
|
|
||||||
|
|
||||||
collectAutofillContentService["setupOverlayListenersOnMutatedElements"](nodes);
|
|
||||||
|
|
||||||
expect(collectAutofillContentService["buildAutofillFieldItem"]).toBeCalledWith(
|
|
||||||
inputElement,
|
|
||||||
-1,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deleteCachedAutofillElement", () => {
|
describe("deleteCachedAutofillElement", () => {
|
||||||
|
@ -15,10 +15,12 @@ import {
|
|||||||
elementIsLabelElement,
|
elementIsLabelElement,
|
||||||
elementIsSelectElement,
|
elementIsSelectElement,
|
||||||
elementIsSpanElement,
|
elementIsSpanElement,
|
||||||
nodeIsFormElement,
|
|
||||||
nodeIsElement,
|
nodeIsElement,
|
||||||
elementIsInputElement,
|
elementIsInputElement,
|
||||||
elementIsTextAreaElement,
|
elementIsTextAreaElement,
|
||||||
|
nodeIsFormElement,
|
||||||
|
nodeIsInputElement,
|
||||||
|
sendExtensionMessage,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
|
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
|
||||||
@ -42,7 +44,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
private elementInitializingIntersectionObserver: Set<Element> = new Set();
|
private elementInitializingIntersectionObserver: Set<Element> = new Set();
|
||||||
private mutationObserver: MutationObserver;
|
private mutationObserver: MutationObserver;
|
||||||
private updateAutofillElementsAfterMutationTimeout: number | NodeJS.Timeout;
|
private updateAutofillElementsAfterMutationTimeout: number | NodeJS.Timeout;
|
||||||
|
private mutationsQueue: MutationRecord[][] = [];
|
||||||
private readonly updateAfterMutationTimeoutDelay = 1000;
|
private readonly updateAfterMutationTimeoutDelay = 1000;
|
||||||
|
private readonly formFieldQueryString;
|
||||||
|
private readonly nonInputFormFieldTags = new Set(["textarea", "select"]);
|
||||||
private readonly ignoredInputTypes = new Set([
|
private readonly ignoredInputTypes = new Set([
|
||||||
"hidden",
|
"hidden",
|
||||||
"submit",
|
"submit",
|
||||||
@ -51,6 +56,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
"image",
|
"image",
|
||||||
"file",
|
"file",
|
||||||
]);
|
]);
|
||||||
|
private useTreeWalkerStrategyFlagSet = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
domElementVisibilityService: DomElementVisibilityService,
|
domElementVisibilityService: DomElementVisibilityService,
|
||||||
@ -58,6 +64,17 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
) {
|
) {
|
||||||
this.domElementVisibilityService = domElementVisibilityService;
|
this.domElementVisibilityService = domElementVisibilityService;
|
||||||
this.autofillOverlayContentService = autofillOverlayContentService;
|
this.autofillOverlayContentService = autofillOverlayContentService;
|
||||||
|
|
||||||
|
let inputQuery = "input:not([data-bwignore])";
|
||||||
|
for (const type of this.ignoredInputTypes) {
|
||||||
|
inputQuery += `:not([type="${type}"])`;
|
||||||
|
}
|
||||||
|
this.formFieldQueryString = `${inputQuery}, textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]`;
|
||||||
|
|
||||||
|
void sendExtensionMessage("getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag").then(
|
||||||
|
(useTreeWalkerStrategyFlag) =>
|
||||||
|
(this.useTreeWalkerStrategyFlagSet = !!useTreeWalkerStrategyFlag?.result),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -136,28 +153,86 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queries the DOM for all the nodes that match the given filter callback
|
* Queries all elements in the DOM that match the given query string.
|
||||||
* and returns a collection of nodes.
|
* Also, recursively queries all shadow roots for the element.
|
||||||
* @param {Node} rootNode
|
*
|
||||||
* @param {Function} filterCallback
|
* @param root - The root element to start the query from
|
||||||
* @param {boolean} isObservingShadowRoot
|
* @param queryString - The query string to match elements against
|
||||||
* @returns {Node[]}
|
* @param isObservingShadowRoot - Determines whether to observe shadow roots
|
||||||
*/
|
*/
|
||||||
queryAllTreeWalkerNodes(
|
deepQueryElements<T>(
|
||||||
rootNode: Node,
|
root: Document | ShadowRoot | Element,
|
||||||
filterCallback: CallableFunction,
|
queryString: string,
|
||||||
isObservingShadowRoot = true,
|
isObservingShadowRoot = false,
|
||||||
): Node[] {
|
): T[] {
|
||||||
const treeWalkerQueryResults: Node[] = [];
|
let elements = this.queryElements<T>(root, queryString);
|
||||||
|
const shadowRoots = this.recursivelyQueryShadowRoots(root, isObservingShadowRoot);
|
||||||
|
for (let index = 0; index < shadowRoots.length; index++) {
|
||||||
|
const shadowRoot = shadowRoots[index];
|
||||||
|
elements = elements.concat(this.queryElements<T>(shadowRoot, queryString));
|
||||||
|
}
|
||||||
|
|
||||||
this.buildTreeWalkerNodesQueryResults(
|
return elements;
|
||||||
rootNode,
|
}
|
||||||
treeWalkerQueryResults,
|
|
||||||
filterCallback,
|
|
||||||
isObservingShadowRoot,
|
|
||||||
);
|
|
||||||
|
|
||||||
return treeWalkerQueryResults;
|
/**
|
||||||
|
* Queries the DOM for elements based on the given query string.
|
||||||
|
*
|
||||||
|
* @param root - The root element to start the query from
|
||||||
|
* @param queryString - The query string to match elements against
|
||||||
|
*/
|
||||||
|
private queryElements<T>(root: Document | ShadowRoot | Element, queryString: string): T[] {
|
||||||
|
if (!root.querySelector(queryString)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(root.querySelectorAll(queryString)) as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively queries all shadow roots found within the given root element.
|
||||||
|
* Will also set up a mutation observer on the shadow root if the
|
||||||
|
* `isObservingShadowRoot` parameter is set to true.
|
||||||
|
*
|
||||||
|
* @param root - The root element to start the query from
|
||||||
|
* @param isObservingShadowRoot - Determines whether to observe shadow roots
|
||||||
|
*/
|
||||||
|
private recursivelyQueryShadowRoots(
|
||||||
|
root: Document | ShadowRoot | Element,
|
||||||
|
isObservingShadowRoot = false,
|
||||||
|
): ShadowRoot[] {
|
||||||
|
let shadowRoots = this.queryShadowRoots(root);
|
||||||
|
for (let index = 0; index < shadowRoots.length; index++) {
|
||||||
|
const shadowRoot = shadowRoots[index];
|
||||||
|
shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot));
|
||||||
|
if (isObservingShadowRoot) {
|
||||||
|
this.mutationObserver.observe(shadowRoot, {
|
||||||
|
attributes: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shadowRoots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries any immediate shadow roots found within the given root element.
|
||||||
|
*
|
||||||
|
* @param root - The root element to start the query from
|
||||||
|
*/
|
||||||
|
private queryShadowRoots(root: Document | ShadowRoot | Element): ShadowRoot[] {
|
||||||
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
|
const potentialShadowRoots = root.querySelectorAll(":defined");
|
||||||
|
for (let index = 0; index < potentialShadowRoots.length; index++) {
|
||||||
|
const shadowRoot = this.getShadowRoot(potentialShadowRoots[index]);
|
||||||
|
if (shadowRoot) {
|
||||||
|
shadowRoots.push(shadowRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shadowRoots;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -294,11 +369,12 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
fieldsLimit?: number,
|
fieldsLimit?: number,
|
||||||
previouslyFoundFormFieldElements?: FormFieldElement[],
|
previouslyFoundFormFieldElements?: FormFieldElement[],
|
||||||
): FormFieldElement[] {
|
): FormFieldElement[] {
|
||||||
const formFieldElements =
|
let formFieldElements = previouslyFoundFormFieldElements;
|
||||||
previouslyFoundFormFieldElements ||
|
if (!formFieldElements) {
|
||||||
(this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) =>
|
formFieldElements = this.useTreeWalkerStrategyFlagSet
|
||||||
this.isNodeFormFieldElement(node),
|
? this.queryTreeWalkerForAutofillFormFieldElements()
|
||||||
) as FormFieldElement[]);
|
: this.deepQueryElements(document, this.formFieldQueryString, true);
|
||||||
|
}
|
||||||
|
|
||||||
if (!fieldsLimit || formFieldElements.length <= fieldsLimit) {
|
if (!fieldsLimit || formFieldElements.length <= fieldsLimit) {
|
||||||
return formFieldElements;
|
return formFieldElements;
|
||||||
@ -371,7 +447,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
|
|
||||||
if (!autofillFieldBase.viewable) {
|
if (!autofillFieldBase.viewable) {
|
||||||
this.elementInitializingIntersectionObserver.add(element);
|
this.elementInitializingIntersectionObserver.add(element);
|
||||||
this.intersectionObserver.observe(element);
|
this.intersectionObserver?.observe(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elementIsSpanElement(element)) {
|
if (elementIsSpanElement(element)) {
|
||||||
@ -864,28 +940,33 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
* Queries all potential form and field elements from the DOM and returns
|
* Queries all potential form and field elements from the DOM and returns
|
||||||
* a collection of form and field elements. Leverages the TreeWalker API
|
* a collection of form and field elements. Leverages the TreeWalker API
|
||||||
* to deep query Shadow DOM elements.
|
* to deep query Shadow DOM elements.
|
||||||
* @returns {{formElements: Node[], formFieldElements: Node[]}}
|
|
||||||
* @private
|
|
||||||
*/
|
*/
|
||||||
private queryAutofillFormAndFieldElements(): {
|
private queryAutofillFormAndFieldElements(): {
|
||||||
formElements: Node[];
|
formElements: HTMLFormElement[];
|
||||||
formFieldElements: Node[];
|
formFieldElements: FormFieldElement[];
|
||||||
} {
|
} {
|
||||||
const formElements: Node[] = [];
|
if (this.useTreeWalkerStrategyFlagSet) {
|
||||||
const formFieldElements: Node[] = [];
|
return this.queryTreeWalkerForAutofillFormAndFieldElements();
|
||||||
this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => {
|
|
||||||
if (nodeIsFormElement(node)) {
|
|
||||||
formElements.push(node);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isNodeFormFieldElement(node)) {
|
const queriedElements = this.deepQueryElements<HTMLElement>(
|
||||||
formFieldElements.push(node);
|
document,
|
||||||
return true;
|
`form, ${this.formFieldQueryString}`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const formElements: HTMLFormElement[] = [];
|
||||||
|
const formFieldElements: FormFieldElement[] = [];
|
||||||
|
for (let index = 0; index < queriedElements.length; index++) {
|
||||||
|
const element = queriedElements[index];
|
||||||
|
if (elementIsFormElement(element)) {
|
||||||
|
formElements.push(element);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (this.isNodeFormFieldElement(element)) {
|
||||||
});
|
formFieldElements.push(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { formElements, formFieldElements };
|
return { formElements, formFieldElements };
|
||||||
}
|
}
|
||||||
@ -916,7 +997,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ["textarea", "select"].includes(nodeTagName) && !nodeHasBwIgnoreAttribute;
|
return this.nonInputFormFieldTags.has(nodeTagName) && !nodeHasBwIgnoreAttribute;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -928,7 +1009,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
* @param {Node} node
|
* @param {Node} node
|
||||||
*/
|
*/
|
||||||
private getShadowRoot(node: Node): ShadowRoot | null {
|
private getShadowRoot(node: Node): ShadowRoot | null {
|
||||||
if (!nodeIsElement(node) || node.childNodes.length !== 0) {
|
if (!nodeIsElement(node)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -947,51 +1028,6 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
return (node as any).openOrClosedShadowRoot;
|
return (node as any).openOrClosedShadowRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively builds a collection of nodes that match the given filter callback.
|
|
||||||
* If a node has a ShadowRoot, it will be observed for mutations.
|
|
||||||
* @param {Node} rootNode
|
|
||||||
* @param {Node[]} treeWalkerQueryResults
|
|
||||||
* @param {Function} filterCallback
|
|
||||||
* @param {boolean} isObservingShadowRoot
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private buildTreeWalkerNodesQueryResults(
|
|
||||||
rootNode: Node,
|
|
||||||
treeWalkerQueryResults: Node[],
|
|
||||||
filterCallback: CallableFunction,
|
|
||||||
isObservingShadowRoot: boolean,
|
|
||||||
) {
|
|
||||||
const treeWalker = document?.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT);
|
|
||||||
let currentNode = treeWalker?.currentNode;
|
|
||||||
|
|
||||||
while (currentNode) {
|
|
||||||
if (filterCallback(currentNode)) {
|
|
||||||
treeWalkerQueryResults.push(currentNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeShadowRoot = this.getShadowRoot(currentNode);
|
|
||||||
if (nodeShadowRoot) {
|
|
||||||
if (isObservingShadowRoot) {
|
|
||||||
this.mutationObserver.observe(nodeShadowRoot, {
|
|
||||||
attributes: true,
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.buildTreeWalkerNodesQueryResults(
|
|
||||||
nodeShadowRoot,
|
|
||||||
treeWalkerQueryResults,
|
|
||||||
filterCallback,
|
|
||||||
isObservingShadowRoot,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentNode = treeWalker?.nextNode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up a mutation observer on the body of the document. Observes changes to
|
* Sets up a mutation observer on the body of the document. Observes changes to
|
||||||
* DOM elements to ensure we have an updated set of autofill field data.
|
* DOM elements to ensure we have an updated set of autofill field data.
|
||||||
@ -1020,29 +1056,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let mutationsIndex = 0; mutationsIndex < mutations.length; mutationsIndex++) {
|
if (!this.mutationsQueue.length) {
|
||||||
const mutation = mutations[mutationsIndex];
|
globalThis.requestIdleCallback(this.processMutations, { timeout: 500 });
|
||||||
if (
|
|
||||||
mutation.type === "childList" &&
|
|
||||||
(this.isAutofillElementNodeMutated(mutation.removedNodes, true) ||
|
|
||||||
this.isAutofillElementNodeMutated(mutation.addedNodes))
|
|
||||||
) {
|
|
||||||
this.domRecentlyMutated = true;
|
|
||||||
if (this.autofillOverlayContentService) {
|
|
||||||
this.autofillOverlayContentService.pageDetailsUpdateRequired = true;
|
|
||||||
}
|
|
||||||
this.noFieldsFound = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mutation.type === "attributes") {
|
|
||||||
this.handleAutofillElementAttributeMutation(mutation);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.domRecentlyMutated) {
|
|
||||||
this.updateAutofillElementsAfterMutation();
|
|
||||||
}
|
}
|
||||||
|
this.mutationsQueue.push(mutations);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1065,12 +1082,54 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
this.updateAutofillElementsAfterMutation();
|
this.updateAutofillElementsAfterMutation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the processing of all mutations in the mutations queue. Will trigger
|
||||||
|
* within an idle callback to help with performance and prevent excessive updates.
|
||||||
|
*/
|
||||||
|
private processMutations = () => {
|
||||||
|
for (let queueIndex = 0; queueIndex < this.mutationsQueue.length; queueIndex++) {
|
||||||
|
this.processMutationRecord(this.mutationsQueue[queueIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.domRecentlyMutated) {
|
||||||
|
this.updateAutofillElementsAfterMutation();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mutationsQueue = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a mutation record and updates the autofill elements if necessary.
|
||||||
|
*
|
||||||
|
* @param mutations - The mutation record to process
|
||||||
|
*/
|
||||||
|
private processMutationRecord(mutations: MutationRecord[]) {
|
||||||
|
for (let mutationIndex = 0; mutationIndex < mutations.length; mutationIndex++) {
|
||||||
|
const mutation = mutations[mutationIndex];
|
||||||
|
if (
|
||||||
|
mutation.type === "childList" &&
|
||||||
|
(this.isAutofillElementNodeMutated(mutation.removedNodes, true) ||
|
||||||
|
this.isAutofillElementNodeMutated(mutation.addedNodes))
|
||||||
|
) {
|
||||||
|
this.domRecentlyMutated = true;
|
||||||
|
if (this.autofillOverlayContentService) {
|
||||||
|
this.autofillOverlayContentService.pageDetailsUpdateRequired = true;
|
||||||
|
}
|
||||||
|
this.noFieldsFound = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutation.type === "attributes") {
|
||||||
|
this.handleAutofillElementAttributeMutation(mutation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the passed nodes either contain or are autofill elements.
|
* Checks if the passed nodes either contain or are autofill elements.
|
||||||
* @param {NodeList} nodes
|
*
|
||||||
* @param {boolean} isRemovingNodes
|
* @param nodes - The nodes to check
|
||||||
* @returns {boolean}
|
* @param isRemovingNodes - Whether the nodes are being removed
|
||||||
* @private
|
|
||||||
*/
|
*/
|
||||||
private isAutofillElementNodeMutated(nodes: NodeList, isRemovingNodes = false): boolean {
|
private isAutofillElementNodeMutated(nodes: NodeList, isRemovingNodes = false): boolean {
|
||||||
if (!nodes.length) {
|
if (!nodes.length) {
|
||||||
@ -1078,34 +1137,41 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
let isElementMutated = false;
|
let isElementMutated = false;
|
||||||
const mutatedElements: Node[] = [];
|
let mutatedElements: HTMLElement[] = [];
|
||||||
for (let index = 0; index < nodes.length; index++) {
|
for (let index = 0; index < nodes.length; index++) {
|
||||||
const node = nodes[index];
|
const node = nodes[index];
|
||||||
if (!nodeIsElement(node)) {
|
if (!nodeIsElement(node)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const autofillElementNodes = this.queryAllTreeWalkerNodes(
|
if (
|
||||||
node,
|
!this.useTreeWalkerStrategyFlagSet &&
|
||||||
(walkerNode: Node) =>
|
(nodeIsFormElement(node) || this.isNodeFormFieldElement(node))
|
||||||
nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode),
|
) {
|
||||||
) as HTMLElement[];
|
mutatedElements.push(node as HTMLElement);
|
||||||
|
}
|
||||||
|
|
||||||
if (autofillElementNodes.length) {
|
const autofillElements = this.useTreeWalkerStrategyFlagSet
|
||||||
|
? this.queryTreeWalkerForMutatedElements(node)
|
||||||
|
: this.deepQueryElements<HTMLElement>(node, `form, ${this.formFieldQueryString}`, true);
|
||||||
|
if (autofillElements.length) {
|
||||||
|
mutatedElements = mutatedElements.concat(autofillElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutatedElements.length) {
|
||||||
isElementMutated = true;
|
isElementMutated = true;
|
||||||
mutatedElements.push(...autofillElementNodes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRemovingNodes) {
|
if (isRemovingNodes) {
|
||||||
for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) {
|
for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) {
|
||||||
const node = mutatedElements[elementIndex];
|
const element = mutatedElements[elementIndex];
|
||||||
this.deleteCachedAutofillElement(
|
this.deleteCachedAutofillElement(
|
||||||
node as ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>,
|
element as ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (this.autofillOverlayContentService) {
|
} else if (this.autofillOverlayContentService) {
|
||||||
setTimeout(() => this.setupOverlayListenersOnMutatedElements(mutatedElements), 1000);
|
this.setupOverlayListenersOnMutatedElements(mutatedElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
return isElementMutated;
|
return isElementMutated;
|
||||||
@ -1122,15 +1188,18 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) {
|
for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) {
|
||||||
const node = mutatedElements[elementIndex];
|
const node = mutatedElements[elementIndex];
|
||||||
if (
|
if (
|
||||||
this.isNodeFormFieldElement(node) &&
|
!this.isNodeFormFieldElement(node) ||
|
||||||
!this.autofillFieldElements.get(node as ElementWithOpId<FormFieldElement>)
|
this.autofillFieldElements.get(node as ElementWithOpId<FormFieldElement>)
|
||||||
) {
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.requestIdleCallback(
|
||||||
// We are setting this item to a -1 index because we do not know its position in the DOM.
|
// We are setting this item to a -1 index because we do not know its position in the DOM.
|
||||||
// This value should be updated with the next call to collect page details.
|
// This value should be updated with the next call to collect page details.
|
||||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
() => void this.buildAutofillFieldItem(node as ElementWithOpId<FormFieldElement>, -1),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
{ timeout: 1000 },
|
||||||
this.buildAutofillFieldItem(node as ElementWithOpId<FormFieldElement>, -1);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1360,7 +1429,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
cachedAutofillFieldElement,
|
cachedAutofillFieldElement,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.intersectionObserver.unobserve(entry.target);
|
this.intersectionObserver?.unobserve(entry.target);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1375,6 +1444,150 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
|
|||||||
this.mutationObserver?.disconnect();
|
this.mutationObserver?.disconnect();
|
||||||
this.intersectionObserver?.disconnect();
|
this.intersectionObserver?.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries the DOM for all the nodes that match the given filter callback
|
||||||
|
* and returns a collection of nodes.
|
||||||
|
* @param rootNode
|
||||||
|
* @param filterCallback
|
||||||
|
* @param isObservingShadowRoot
|
||||||
|
*
|
||||||
|
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||||
|
*/
|
||||||
|
private queryAllTreeWalkerNodes(
|
||||||
|
rootNode: Node,
|
||||||
|
filterCallback: CallableFunction,
|
||||||
|
isObservingShadowRoot = true,
|
||||||
|
): Node[] {
|
||||||
|
const treeWalkerQueryResults: Node[] = [];
|
||||||
|
|
||||||
|
this.buildTreeWalkerNodesQueryResults(
|
||||||
|
rootNode,
|
||||||
|
treeWalkerQueryResults,
|
||||||
|
filterCallback,
|
||||||
|
isObservingShadowRoot,
|
||||||
|
);
|
||||||
|
|
||||||
|
return treeWalkerQueryResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively builds a collection of nodes that match the given filter callback.
|
||||||
|
* If a node has a ShadowRoot, it will be observed for mutations.
|
||||||
|
*
|
||||||
|
* @param rootNode
|
||||||
|
* @param treeWalkerQueryResults
|
||||||
|
* @param filterCallback
|
||||||
|
*
|
||||||
|
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||||
|
*/
|
||||||
|
private buildTreeWalkerNodesQueryResults(
|
||||||
|
rootNode: Node,
|
||||||
|
treeWalkerQueryResults: Node[],
|
||||||
|
filterCallback: CallableFunction,
|
||||||
|
isObservingShadowRoot: boolean,
|
||||||
|
) {
|
||||||
|
const treeWalker = document?.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT);
|
||||||
|
let currentNode = treeWalker?.currentNode;
|
||||||
|
|
||||||
|
while (currentNode) {
|
||||||
|
if (filterCallback(currentNode)) {
|
||||||
|
treeWalkerQueryResults.push(currentNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeShadowRoot = this.getShadowRoot(currentNode);
|
||||||
|
if (nodeShadowRoot) {
|
||||||
|
if (isObservingShadowRoot) {
|
||||||
|
this.mutationObserver.observe(nodeShadowRoot, {
|
||||||
|
attributes: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buildTreeWalkerNodesQueryResults(
|
||||||
|
nodeShadowRoot,
|
||||||
|
treeWalkerQueryResults,
|
||||||
|
filterCallback,
|
||||||
|
isObservingShadowRoot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = treeWalker?.nextNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||||
|
*/
|
||||||
|
private queryTreeWalkerForAutofillFormAndFieldElements(): {
|
||||||
|
formElements: HTMLFormElement[];
|
||||||
|
formFieldElements: FormFieldElement[];
|
||||||
|
} {
|
||||||
|
const formElements: HTMLFormElement[] = [];
|
||||||
|
const formFieldElements: FormFieldElement[] = [];
|
||||||
|
this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => {
|
||||||
|
if (nodeIsFormElement(node)) {
|
||||||
|
formElements.push(node);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isNodeFormFieldElement(node)) {
|
||||||
|
formFieldElements.push(node as FormFieldElement);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { formElements, formFieldElements };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||||
|
*/
|
||||||
|
private queryTreeWalkerForAutofillFormFieldElements(): FormFieldElement[] {
|
||||||
|
return this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) =>
|
||||||
|
this.isNodeFormFieldElement(node),
|
||||||
|
) as FormFieldElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||||
|
*
|
||||||
|
* @param node - The node to query
|
||||||
|
*/
|
||||||
|
private queryTreeWalkerForMutatedElements(node: Node): HTMLElement[] {
|
||||||
|
return this.queryAllTreeWalkerNodes(
|
||||||
|
node,
|
||||||
|
(walkerNode: Node) =>
|
||||||
|
nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode),
|
||||||
|
) as HTMLElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||||
|
*/
|
||||||
|
private queryTreeWalkerForPasswordElements(): HTMLElement[] {
|
||||||
|
return this.queryAllTreeWalkerNodes(
|
||||||
|
document.documentElement,
|
||||||
|
(node: Node) => nodeIsInputElement(node) && node.type === "password",
|
||||||
|
false,
|
||||||
|
) as HTMLElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a temporary method to maintain a fallback strategy for the tree walker API
|
||||||
|
*
|
||||||
|
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
||||||
|
*/
|
||||||
|
isPasswordFieldWithinDocument(): boolean {
|
||||||
|
if (this.useTreeWalkerStrategyFlagSet) {
|
||||||
|
return Boolean(this.queryTreeWalkerForPasswordElements()?.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(this.deepQueryElements(document, `input[type="password"]`)?.length);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CollectAutofillContentService;
|
export default CollectAutofillContentService;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
import { EVENTS } from "@bitwarden/common/autofill/constants";
|
||||||
|
|
||||||
import AutofillScript, { FillScript, FillScriptActions } from "../models/autofill-script";
|
import AutofillScript, { FillScript, FillScriptActions } from "../models/autofill-script";
|
||||||
|
import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils";
|
||||||
import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types";
|
import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types";
|
||||||
|
|
||||||
import AutofillOverlayContentService from "./autofill-overlay-content.service";
|
import AutofillOverlayContentService from "./autofill-overlay-content.service";
|
||||||
@ -71,6 +72,7 @@ describe("InsertAutofillContentService", () => {
|
|||||||
);
|
);
|
||||||
let insertAutofillContentService: InsertAutofillContentService;
|
let insertAutofillContentService: InsertAutofillContentService;
|
||||||
let fillScript: AutofillScript;
|
let fillScript: AutofillScript;
|
||||||
|
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
document.body.innerHTML = mockLoginForm;
|
document.body.innerHTML = mockLoginForm;
|
||||||
@ -99,11 +101,16 @@ describe("InsertAutofillContentService", () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
jest.clearAllTimers();
|
||||||
windowLocationSpy.mockRestore();
|
windowLocationSpy.mockRestore();
|
||||||
confirmSpy.mockRestore();
|
confirmSpy.mockRestore();
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mockQuerySelectorAll.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
describe("fillForm", () => {
|
describe("fillForm", () => {
|
||||||
it("returns early if the passed fill script does not have a script property", async () => {
|
it("returns early if the passed fill script does not have a script property", async () => {
|
||||||
fillScript.script = [];
|
fillScript.script = [];
|
||||||
|
@ -7,7 +7,6 @@ import {
|
|||||||
elementIsInputElement,
|
elementIsInputElement,
|
||||||
elementIsSelectElement,
|
elementIsSelectElement,
|
||||||
elementIsTextAreaElement,
|
elementIsTextAreaElement,
|
||||||
nodeIsInputElement,
|
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service";
|
import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service";
|
||||||
@ -101,13 +100,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private isPasswordFieldWithinDocument(): boolean {
|
private isPasswordFieldWithinDocument(): boolean {
|
||||||
return Boolean(
|
return this.collectAutofillContentService.isPasswordFieldWithinDocument();
|
||||||
this.collectAutofillContentService.queryAllTreeWalkerNodes(
|
|
||||||
document.documentElement,
|
|
||||||
(node: Node) => nodeIsInputElement(node) && node.type === "password",
|
|
||||||
false,
|
|
||||||
)?.length,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -98,6 +98,34 @@ function triggerTabOnRemovedEvent(tabId: number, removeInfo: chrome.tabs.TabRemo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockQuerySelectorAllDefinedCall() {
|
||||||
|
const originalDocumentQuerySelectorAll = document.querySelectorAll;
|
||||||
|
document.querySelectorAll = function (selector: string) {
|
||||||
|
return originalDocumentQuerySelectorAll.call(
|
||||||
|
document,
|
||||||
|
selector === ":defined" ? "*" : selector,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalShadowRootQuerySelectorAll = ShadowRoot.prototype.querySelectorAll;
|
||||||
|
ShadowRoot.prototype.querySelectorAll = function (selector: string) {
|
||||||
|
return originalShadowRootQuerySelectorAll.call(this, selector === ":defined" ? "*" : selector);
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalElementQuerySelectorAll = Element.prototype.querySelectorAll;
|
||||||
|
Element.prototype.querySelectorAll = function (selector: string) {
|
||||||
|
return originalElementQuerySelectorAll.call(this, selector === ":defined" ? "*" : selector);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
mockRestore: () => {
|
||||||
|
document.querySelectorAll = originalDocumentQuerySelectorAll;
|
||||||
|
ShadowRoot.prototype.querySelectorAll = originalShadowRootQuerySelectorAll;
|
||||||
|
Element.prototype.querySelectorAll = originalElementQuerySelectorAll;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
triggerTestFailure,
|
triggerTestFailure,
|
||||||
flushPromises,
|
flushPromises,
|
||||||
@ -111,4 +139,5 @@ export {
|
|||||||
triggerTabOnReplacedEvent,
|
triggerTabOnReplacedEvent,
|
||||||
triggerTabOnUpdatedEvent,
|
triggerTabOnUpdatedEvent,
|
||||||
triggerTabOnRemovedEvent,
|
triggerTabOnRemovedEvent,
|
||||||
|
mockQuerySelectorAllDefinedCall,
|
||||||
};
|
};
|
||||||
|
@ -240,7 +240,11 @@ function elementIsDescriptionTermElement(element: Element): element is HTMLEleme
|
|||||||
* @param node - The node to check.
|
* @param node - The node to check.
|
||||||
*/
|
*/
|
||||||
function nodeIsElement(node: Node): node is Element {
|
function nodeIsElement(node: Node): node is Element {
|
||||||
return node?.nodeType === Node.ELEMENT_NODE;
|
if (!node) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.nodeType === Node.ELEMENT_NODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -4,6 +4,7 @@ import { NotificationsService } from "@bitwarden/common/abstractions/notificatio
|
|||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
|
||||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
@ -65,7 +66,10 @@ export default class RuntimeBackground {
|
|||||||
sender: chrome.runtime.MessageSender,
|
sender: chrome.runtime.MessageSender,
|
||||||
sendResponse: (response: any) => void,
|
sendResponse: (response: any) => void,
|
||||||
) => {
|
) => {
|
||||||
const messagesWithResponse = ["biometricUnlock"];
|
const messagesWithResponse = [
|
||||||
|
"biometricUnlock",
|
||||||
|
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
|
||||||
|
];
|
||||||
|
|
||||||
if (messagesWithResponse.includes(msg.command)) {
|
if (messagesWithResponse.includes(msg.command)) {
|
||||||
this.processMessageWithSender(msg, sender).then(
|
this.processMessageWithSender(msg, sender).then(
|
||||||
@ -177,6 +181,11 @@ export default class RuntimeBackground {
|
|||||||
const result = await this.main.biometricUnlock();
|
const result = await this.main.biometricUnlock();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": {
|
||||||
|
return await this.configService.getFeatureFlag(
|
||||||
|
FeatureFlag.UseTreeWalkerApiForPageDetailsCollection,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ export enum FeatureFlag {
|
|||||||
EnableDeleteProvider = "AC-1218-delete-provider",
|
EnableDeleteProvider = "AC-1218-delete-provider",
|
||||||
ExtensionRefresh = "extension-refresh",
|
ExtensionRefresh = "extension-refresh",
|
||||||
RestrictProviderAccess = "restrict-provider-access",
|
RestrictProviderAccess = "restrict-provider-access",
|
||||||
|
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||||
@ -44,6 +45,7 @@ export const DefaultFeatureFlagValue = {
|
|||||||
[FeatureFlag.EnableDeleteProvider]: FALSE,
|
[FeatureFlag.EnableDeleteProvider]: FALSE,
|
||||||
[FeatureFlag.ExtensionRefresh]: FALSE,
|
[FeatureFlag.ExtensionRefresh]: FALSE,
|
||||||
[FeatureFlag.RestrictProviderAccess]: FALSE,
|
[FeatureFlag.RestrictProviderAccess]: FALSE,
|
||||||
|
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
||||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||||
|
|
||||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||||
|
Loading…
Reference in New Issue
Block a user