mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-22 11:45:59 +01:00
[PM-11517] Improve autofill collection of page details performance (#10816)
* Testing out a rework of the performance improvements introduced into extension * Working through improvements * Implementing max_depth methodology for the deepQuery approach used when querying elements * Refactoring implementation * Refactoring implementation * Fixing jest tests * Incorporating documenation within domQueryService * [PM-11519] `browser` global reference triggering an error when sending an extension message * [PM-11517] Working through refactoring and jest testing of the domQueryService * [PM-11517] Working through refactoring and jest testing of the domQueryService * [PM-11517] Incorporating tests for the debounce util method * [PM-11517] Incorporating tests for the debounce util method * [PM-11517] Removing unnecessary property * [PM-11517] Starting to work through an idea regarding querying without the shadowDom on pages that definitively do not contain a ShadowDOM element * [PM-11419] Adjusting implementation to ensure we clear any active requests when the passkeys setting is modified * [PM-11517] Removing unnecessary comments
This commit is contained in:
parent
62ee447c36
commit
b0e0e71974
@ -5,23 +5,17 @@ import {
|
|||||||
createAutofillPageDetailsMock,
|
createAutofillPageDetailsMock,
|
||||||
createAutofillScriptMock,
|
createAutofillScriptMock,
|
||||||
} from "../spec/autofill-mocks";
|
} from "../spec/autofill-mocks";
|
||||||
import { flushPromises, sendMockExtensionMessage } from "../spec/testing-utils";
|
import {
|
||||||
|
flushPromises,
|
||||||
|
mockQuerySelectorAllDefinedCall,
|
||||||
|
sendMockExtensionMessage,
|
||||||
|
} from "../spec/testing-utils";
|
||||||
import { FormFieldElement } from "../types";
|
import { FormFieldElement } from "../types";
|
||||||
|
|
||||||
let pageDetailsMock: AutofillPageDetails;
|
let pageDetailsMock: AutofillPageDetails;
|
||||||
let fillScriptMock: AutofillScript;
|
let fillScriptMock: AutofillScript;
|
||||||
let autofillFieldElementByOpidMock: FormFieldElement;
|
let autofillFieldElementByOpidMock: FormFieldElement;
|
||||||
|
|
||||||
jest.mock("../services/dom-query.service", () => {
|
|
||||||
const module = jest.requireActual("../services/dom-query.service");
|
|
||||||
return {
|
|
||||||
DomQueryService: class extends module.DomQueryService {
|
|
||||||
deepQueryElements<T>(element: HTMLElement, queryString: string): T[] {
|
|
||||||
return Array.from(element.querySelectorAll(queryString)) as T[];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
jest.mock("../services/collect-autofill-content.service", () => {
|
jest.mock("../services/collect-autofill-content.service", () => {
|
||||||
const module = jest.requireActual("../services/collect-autofill-content.service");
|
const module = jest.requireActual("../services/collect-autofill-content.service");
|
||||||
return {
|
return {
|
||||||
@ -47,6 +41,8 @@ jest.mock("../services/collect-autofill-content.service", () => {
|
|||||||
jest.mock("../services/insert-autofill-content.service");
|
jest.mock("../services/insert-autofill-content.service");
|
||||||
|
|
||||||
describe("AutoSubmitLogin content script", () => {
|
describe("AutoSubmitLogin content script", () => {
|
||||||
|
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
setupEnvironmentDefaults();
|
setupEnvironmentDefaults();
|
||||||
@ -60,6 +56,7 @@ describe("AutoSubmitLogin content script", () => {
|
|||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
mockQuerySelectorAll.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ends the auto-submit login workflow if the page does not contain any fields", async () => {
|
it("ends the auto-submit login workflow if the page does not contain any fields", async () => {
|
||||||
|
@ -10,7 +10,9 @@ import InsertAutofillContentService from "../services/insert-autofill-content.se
|
|||||||
import {
|
import {
|
||||||
elementIsInputElement,
|
elementIsInputElement,
|
||||||
getSubmitButtonKeywordsSet,
|
getSubmitButtonKeywordsSet,
|
||||||
|
nodeIsButtonElement,
|
||||||
nodeIsFormElement,
|
nodeIsFormElement,
|
||||||
|
nodeIsTypeSubmitElement,
|
||||||
sendExtensionMessage,
|
sendExtensionMessage,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
@ -189,13 +191,21 @@ import {
|
|||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
lastFieldIsPasswordInput = false,
|
lastFieldIsPasswordInput = false,
|
||||||
): boolean {
|
): boolean {
|
||||||
const genericSubmitElement = querySubmitButtonElement(element, "[type='submit']");
|
const genericSubmitElement = querySubmitButtonElement(
|
||||||
|
element,
|
||||||
|
"[type='submit']",
|
||||||
|
(node: Node) => nodeIsTypeSubmitElement(node),
|
||||||
|
);
|
||||||
if (genericSubmitElement) {
|
if (genericSubmitElement) {
|
||||||
clickSubmitElement(genericSubmitElement, lastFieldIsPasswordInput);
|
clickSubmitElement(genericSubmitElement, lastFieldIsPasswordInput);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonElement = querySubmitButtonElement(element, "button, [type='button']");
|
const buttonElement = querySubmitButtonElement(
|
||||||
|
element,
|
||||||
|
"button, [type='button']",
|
||||||
|
(node: Node) => nodeIsButtonElement(node),
|
||||||
|
);
|
||||||
if (buttonElement) {
|
if (buttonElement) {
|
||||||
clickSubmitElement(buttonElement, lastFieldIsPasswordInput);
|
clickSubmitElement(buttonElement, lastFieldIsPasswordInput);
|
||||||
return true;
|
return true;
|
||||||
@ -210,11 +220,17 @@ import {
|
|||||||
*
|
*
|
||||||
* @param element - The element to query for submit buttons
|
* @param element - The element to query for submit buttons
|
||||||
* @param selector - The selector to query for submit buttons
|
* @param selector - The selector to query for submit buttons
|
||||||
|
* @param treeWalkerFilter - The callback used to filter treeWalker results
|
||||||
*/
|
*/
|
||||||
function querySubmitButtonElement(element: HTMLElement, selector: string) {
|
function querySubmitButtonElement(
|
||||||
const submitButtonElements = domQueryService.deepQueryElements<HTMLButtonElement>(
|
element: HTMLElement,
|
||||||
|
selector: string,
|
||||||
|
treeWalkerFilter: CallableFunction,
|
||||||
|
) {
|
||||||
|
const submitButtonElements = domQueryService.query<HTMLButtonElement>(
|
||||||
element,
|
element,
|
||||||
selector,
|
selector,
|
||||||
|
treeWalkerFilter,
|
||||||
);
|
);
|
||||||
for (let index = 0; index < submitButtonElements.length; index++) {
|
for (let index = 0; index < submitButtonElements.length; index++) {
|
||||||
const submitElement = submitButtonElements[index];
|
const submitElement = submitButtonElements[index];
|
||||||
@ -272,20 +288,11 @@ import {
|
|||||||
* Gets all form elements on the page.
|
* Gets all form elements on the page.
|
||||||
*/
|
*/
|
||||||
function getAutofillFormElements(): HTMLFormElement[] {
|
function getAutofillFormElements(): HTMLFormElement[] {
|
||||||
const formElements: HTMLFormElement[] = [];
|
return domQueryService.query<HTMLFormElement>(
|
||||||
domQueryService.queryAllTreeWalkerNodes(
|
|
||||||
globalContext.document.documentElement,
|
globalContext.document.documentElement,
|
||||||
(node: Node) => {
|
"form",
|
||||||
if (nodeIsFormElement(node)) {
|
(node: Node) => nodeIsFormElement(node),
|
||||||
formElements.push(node);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return formElements;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -38,7 +38,7 @@ class AutofillInit implements AutofillInitInterface {
|
|||||||
* @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined.
|
* @param overlayNotificationsContentService - The overlay notifications content service, potentially undefined.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private domQueryService: DomQueryService,
|
domQueryService: DomQueryService,
|
||||||
private autofillOverlayContentService?: AutofillOverlayContentService,
|
private autofillOverlayContentService?: AutofillOverlayContentService,
|
||||||
private autofillInlineMenuContentService?: AutofillInlineMenuContentService,
|
private autofillInlineMenuContentService?: AutofillInlineMenuContentService,
|
||||||
private overlayNotificationsContentService?: OverlayNotificationsContentService,
|
private overlayNotificationsContentService?: OverlayNotificationsContentService,
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
export interface DomQueryService {
|
export interface DomQueryService {
|
||||||
deepQueryElements<T>(
|
query<T>(
|
||||||
root: Document | ShadowRoot | Element,
|
root: Document | ShadowRoot | Element,
|
||||||
queryString: string,
|
queryString: string,
|
||||||
|
treeWalkerFilter: CallableFunction,
|
||||||
mutationObserver?: MutationObserver,
|
mutationObserver?: MutationObserver,
|
||||||
|
forceDeepQueryAttempt?: boolean,
|
||||||
): T[];
|
): T[];
|
||||||
queryAllTreeWalkerNodes(
|
checkPageContainsShadowDom(): void;
|
||||||
rootNode: Node,
|
pageContainsShadowDomElements(): boolean;
|
||||||
filterCallback: CallableFunction,
|
|
||||||
mutationObserver?: MutationObserver,
|
|
||||||
): Node[];
|
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,8 @@ import {
|
|||||||
elementIsFillableFormField,
|
elementIsFillableFormField,
|
||||||
elementIsSelectElement,
|
elementIsSelectElement,
|
||||||
getAttributeBoolean,
|
getAttributeBoolean,
|
||||||
|
nodeIsButtonElement,
|
||||||
|
nodeIsTypeSubmitElement,
|
||||||
sendExtensionMessage,
|
sendExtensionMessage,
|
||||||
throttle,
|
throttle,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
@ -508,12 +510,20 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
|||||||
* @param element - The element to find the submit button within.
|
* @param element - The element to find the submit button within.
|
||||||
*/
|
*/
|
||||||
private findSubmitButton(element: HTMLElement): HTMLElement | null {
|
private findSubmitButton(element: HTMLElement): HTMLElement | null {
|
||||||
const genericSubmitElement = this.querySubmitButtonElement(element, "[type='submit']");
|
const genericSubmitElement = this.querySubmitButtonElement(
|
||||||
|
element,
|
||||||
|
"[type='submit']",
|
||||||
|
(node: Node) => nodeIsTypeSubmitElement(node),
|
||||||
|
);
|
||||||
if (genericSubmitElement) {
|
if (genericSubmitElement) {
|
||||||
return genericSubmitElement;
|
return genericSubmitElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitButtonElement = this.querySubmitButtonElement(element, "button, [type='button']");
|
const submitButtonElement = this.querySubmitButtonElement(
|
||||||
|
element,
|
||||||
|
"button, [type='button']",
|
||||||
|
(node: Node) => nodeIsButtonElement(node),
|
||||||
|
);
|
||||||
if (submitButtonElement) {
|
if (submitButtonElement) {
|
||||||
return submitButtonElement;
|
return submitButtonElement;
|
||||||
}
|
}
|
||||||
@ -524,11 +534,17 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
|
|||||||
*
|
*
|
||||||
* @param element - The element to query for a submit button.
|
* @param element - The element to query for a submit button.
|
||||||
* @param selector - The selector to use to query the element for a submit button.
|
* @param selector - The selector to use to query the element for a submit button.
|
||||||
|
* @param treeWalkerFilter - The tree walker filter to use when querying the element.
|
||||||
*/
|
*/
|
||||||
private querySubmitButtonElement(element: HTMLElement, selector: string) {
|
private querySubmitButtonElement(
|
||||||
const submitButtonElements = this.domQueryService.deepQueryElements<HTMLButtonElement>(
|
element: HTMLElement,
|
||||||
|
selector: string,
|
||||||
|
treeWalkerFilter: CallableFunction,
|
||||||
|
) {
|
||||||
|
const submitButtonElements = this.domQueryService.query<HTMLButtonElement>(
|
||||||
element,
|
element,
|
||||||
selector,
|
selector,
|
||||||
|
treeWalkerFilter,
|
||||||
);
|
);
|
||||||
for (let index = 0; index < submitButtonElements.length; index++) {
|
for (let index = 0; index < submitButtonElements.length; index++) {
|
||||||
const submitElement = submitButtonElements[index];
|
const submitElement = submitButtonElements[index];
|
||||||
|
@ -17,6 +17,14 @@ import { CollectAutofillContentService } from "./collect-autofill-content.servic
|
|||||||
import DomElementVisibilityService from "./dom-element-visibility.service";
|
import DomElementVisibilityService from "./dom-element-visibility.service";
|
||||||
import { DomQueryService } from "./dom-query.service";
|
import { DomQueryService } from "./dom-query.service";
|
||||||
|
|
||||||
|
jest.mock("../utils", () => {
|
||||||
|
const utils = jest.requireActual("../utils");
|
||||||
|
return {
|
||||||
|
...utils,
|
||||||
|
debounce: jest.fn((fn) => fn),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const mockLoginForm = `
|
const mockLoginForm = `
|
||||||
<div id="root">
|
<div id="root">
|
||||||
<form>
|
<form>
|
||||||
@ -29,6 +37,7 @@ const mockLoginForm = `
|
|||||||
const waitForIdleCallback = () => new Promise((resolve) => globalThis.requestIdleCallback(resolve));
|
const waitForIdleCallback = () => new Promise((resolve) => globalThis.requestIdleCallback(resolve));
|
||||||
|
|
||||||
describe("CollectAutofillContentService", () => {
|
describe("CollectAutofillContentService", () => {
|
||||||
|
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||||
const domElementVisibilityService = new DomElementVisibilityService();
|
const domElementVisibilityService = new DomElementVisibilityService();
|
||||||
const inlineMenuFieldQualificationService = mock<InlineMenuFieldQualificationService>();
|
const inlineMenuFieldQualificationService = mock<InlineMenuFieldQualificationService>();
|
||||||
const domQueryService = new DomQueryService();
|
const domQueryService = new DomQueryService();
|
||||||
@ -38,7 +47,6 @@ describe("CollectAutofillContentService", () => {
|
|||||||
);
|
);
|
||||||
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));
|
globalThis.requestIdleCallback = jest.fn((cb, options) => setTimeout(cb, 100));
|
||||||
@ -55,6 +63,7 @@ describe("CollectAutofillContentService", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
jest.restoreAllMocks();
|
jest.restoreAllMocks();
|
||||||
|
jest.clearAllTimers();
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2001,41 +2010,6 @@ describe("CollectAutofillContentService", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getShadowRoot", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// eslint-disable-next-line
|
|
||||||
// @ts-ignore
|
|
||||||
globalThis.chrome.dom = {
|
|
||||||
openOrClosedShadowRoot: jest.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null if the passed node is not an HTMLElement instance", () => {
|
|
||||||
const textNode = document.createTextNode("Hello, world!");
|
|
||||||
const shadowRoot = collectAutofillContentService["getShadowRoot"](textNode);
|
|
||||||
|
|
||||||
expect(shadowRoot).toEqual(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an open shadow root if the passed node has a shadowDOM element", () => {
|
|
||||||
const element = document.createElement("div");
|
|
||||||
element.attachShadow({ mode: "open" });
|
|
||||||
|
|
||||||
const shadowRoot = collectAutofillContentService["getShadowRoot"](element);
|
|
||||||
|
|
||||||
expect(shadowRoot).toBeInstanceOf(ShadowRoot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a value provided by Chrome's openOrClosedShadowRoot API", () => {
|
|
||||||
const element = document.createElement("div");
|
|
||||||
collectAutofillContentService["getShadowRoot"](element);
|
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
// @ts-ignore
|
|
||||||
expect(chrome.dom.openOrClosedShadowRoot).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");
|
||||||
@ -2048,6 +2022,12 @@ describe("CollectAutofillContentService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleMutationObserverMutation", () => {
|
describe("handleMutationObserverMutation", () => {
|
||||||
|
const waitForAllMutationsToComplete = async () => {
|
||||||
|
await waitForIdleCallback();
|
||||||
|
await waitForIdleCallback();
|
||||||
|
await waitForIdleCallback();
|
||||||
|
};
|
||||||
|
|
||||||
it("will set the domRecentlyMutated value to true and the noFieldsFound value to false if a form or field node has been added ", async () => {
|
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);
|
||||||
@ -2071,7 +2051,7 @@ describe("CollectAutofillContentService", () => {
|
|||||||
jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated");
|
jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated");
|
||||||
|
|
||||||
collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]);
|
collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]);
|
||||||
await waitForIdleCallback();
|
await waitForAllMutationsToComplete();
|
||||||
|
|
||||||
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true);
|
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true);
|
||||||
expect(collectAutofillContentService["noFieldsFound"]).toEqual(false);
|
expect(collectAutofillContentService["noFieldsFound"]).toEqual(false);
|
||||||
@ -2115,7 +2095,7 @@ describe("CollectAutofillContentService", () => {
|
|||||||
target: document.body,
|
target: document.body,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await waitForIdleCallback();
|
await waitForAllMutationsToComplete();
|
||||||
|
|
||||||
expect(collectAutofillContentService["_autofillFormElements"].size).toEqual(0);
|
expect(collectAutofillContentService["_autofillFormElements"].size).toEqual(0);
|
||||||
expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0);
|
expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0);
|
||||||
@ -2140,7 +2120,7 @@ describe("CollectAutofillContentService", () => {
|
|||||||
jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation");
|
jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation");
|
||||||
|
|
||||||
collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]);
|
collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]);
|
||||||
await waitForIdleCallback();
|
await waitForAllMutationsToComplete();
|
||||||
|
|
||||||
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(false);
|
expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(false);
|
||||||
expect(collectAutofillContentService["noFieldsFound"]).toEqual(true);
|
expect(collectAutofillContentService["noFieldsFound"]).toEqual(true);
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
getPropertyOrAttribute,
|
getPropertyOrAttribute,
|
||||||
requestIdleCallbackPolyfill,
|
requestIdleCallbackPolyfill,
|
||||||
cancelIdleCallbackPolyfill,
|
cancelIdleCallbackPolyfill,
|
||||||
|
debounce,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
|
|
||||||
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
|
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
|
||||||
@ -57,7 +58,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
"image",
|
"image",
|
||||||
"file",
|
"file",
|
||||||
]);
|
]);
|
||||||
private useTreeWalkerStrategyFlagSet = true;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private domElementVisibilityService: DomElementVisibilityService,
|
private domElementVisibilityService: DomElementVisibilityService,
|
||||||
@ -69,11 +69,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
inputQuery += `:not([type="${type}"])`;
|
inputQuery += `:not([type="${type}"])`;
|
||||||
}
|
}
|
||||||
this.formFieldQueryString = `${inputQuery}, textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]`;
|
this.formFieldQueryString = `${inputQuery}, textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]`;
|
||||||
|
|
||||||
// void sendExtensionMessage("getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag").then(
|
|
||||||
// (useTreeWalkerStrategyFlag) =>
|
|
||||||
// (this.useTreeWalkerStrategyFlagSet = !!useTreeWalkerStrategyFlag?.result),
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get autofillFormElements(): AutofillFormElements {
|
get autofillFormElements(): AutofillFormElements {
|
||||||
@ -297,13 +292,12 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
): FormFieldElement[] {
|
): FormFieldElement[] {
|
||||||
let formFieldElements = previouslyFoundFormFieldElements;
|
let formFieldElements = previouslyFoundFormFieldElements;
|
||||||
if (!formFieldElements) {
|
if (!formFieldElements) {
|
||||||
formFieldElements = this.useTreeWalkerStrategyFlagSet
|
formFieldElements = this.domQueryService.query<FormFieldElement>(
|
||||||
? this.queryTreeWalkerForAutofillFormFieldElements()
|
globalThis.document.documentElement,
|
||||||
: this.domQueryService.deepQueryElements(
|
this.formFieldQueryString,
|
||||||
document,
|
(node: Node) => this.isNodeFormFieldElement(node),
|
||||||
this.formFieldQueryString,
|
this.mutationObserver,
|
||||||
this.mutationObserver,
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fieldsLimit || formFieldElements.length <= fieldsLimit) {
|
if (!fieldsLimit || formFieldElements.length <= fieldsLimit) {
|
||||||
@ -836,17 +830,32 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
formElements: HTMLFormElement[];
|
formElements: HTMLFormElement[];
|
||||||
formFieldElements: FormFieldElement[];
|
formFieldElements: FormFieldElement[];
|
||||||
} {
|
} {
|
||||||
if (this.useTreeWalkerStrategyFlagSet) {
|
|
||||||
return this.queryTreeWalkerForAutofillFormAndFieldElements();
|
|
||||||
}
|
|
||||||
|
|
||||||
const queriedElements = this.domQueryService.deepQueryElements<HTMLElement>(
|
|
||||||
document,
|
|
||||||
`form, ${this.formFieldQueryString}`,
|
|
||||||
this.mutationObserver,
|
|
||||||
);
|
|
||||||
const formElements: HTMLFormElement[] = [];
|
const formElements: HTMLFormElement[] = [];
|
||||||
const formFieldElements: FormFieldElement[] = [];
|
const formFieldElements: FormFieldElement[] = [];
|
||||||
|
|
||||||
|
const queriedElements = this.domQueryService.query<HTMLElement>(
|
||||||
|
globalThis.document.documentElement,
|
||||||
|
`form, ${this.formFieldQueryString}`,
|
||||||
|
(node: Node) => {
|
||||||
|
if (nodeIsFormElement(node)) {
|
||||||
|
formElements.push(node);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isNodeFormFieldElement(node)) {
|
||||||
|
formFieldElements.push(node as FormFieldElement);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
this.mutationObserver,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (formElements.length || formFieldElements.length) {
|
||||||
|
return { formElements, formFieldElements };
|
||||||
|
}
|
||||||
|
|
||||||
for (let index = 0; index < queriedElements.length; index++) {
|
for (let index = 0; index < queriedElements.length; index++) {
|
||||||
const element = queriedElements[index];
|
const element = queriedElements[index];
|
||||||
if (elementIsFormElement(element)) {
|
if (elementIsFormElement(element)) {
|
||||||
@ -891,34 +900,6 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
return this.nonInputFormFieldTags.has(nodeTagName) && !nodeHasBwIgnoreAttribute;
|
return this.nonInputFormFieldTags.has(nodeTagName) && !nodeHasBwIgnoreAttribute;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempts to get the ShadowRoot of the passed node. If support for the
|
|
||||||
* extension based openOrClosedShadowRoot API is available, it will be used.
|
|
||||||
* Will return null if the node is not an HTMLElement or if the node has
|
|
||||||
* child nodes.
|
|
||||||
*
|
|
||||||
* @param {Node} node
|
|
||||||
*/
|
|
||||||
private getShadowRoot(node: Node): ShadowRoot | null {
|
|
||||||
if (!nodeIsElement(node)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.shadowRoot) {
|
|
||||||
return node.shadowRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((chrome as any).dom?.openOrClosedShadowRoot) {
|
|
||||||
try {
|
|
||||||
return (chrome as any).dom.openOrClosedShadowRoot(node);
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (node as any).openOrClosedShadowRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@ -948,7 +929,7 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.mutationsQueue.length) {
|
if (!this.mutationsQueue.length) {
|
||||||
requestIdleCallbackPolyfill(this.processMutations, { timeout: 500 });
|
requestIdleCallbackPolyfill(debounce(this.processMutations, 100), { timeout: 500 });
|
||||||
}
|
}
|
||||||
this.mutationsQueue.push(mutations);
|
this.mutationsQueue.push(mutations);
|
||||||
};
|
};
|
||||||
@ -979,41 +960,62 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
* within an idle callback to help with performance and prevent excessive updates.
|
* within an idle callback to help with performance and prevent excessive updates.
|
||||||
*/
|
*/
|
||||||
private processMutations = () => {
|
private processMutations = () => {
|
||||||
for (let queueIndex = 0; queueIndex < this.mutationsQueue.length; queueIndex++) {
|
const queueLength = this.mutationsQueue.length;
|
||||||
this.processMutationRecord(this.mutationsQueue[queueIndex]);
|
|
||||||
|
if (!this.domQueryService.pageContainsShadowDomElements()) {
|
||||||
|
this.domQueryService.checkPageContainsShadowDom();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.domRecentlyMutated) {
|
for (let queueIndex = 0; queueIndex < queueLength; queueIndex++) {
|
||||||
this.updateAutofillElementsAfterMutation();
|
const mutations = this.mutationsQueue[queueIndex];
|
||||||
|
const processMutationRecords = () => {
|
||||||
|
this.processMutationRecords(mutations);
|
||||||
|
|
||||||
|
if (queueIndex === queueLength - 1 && this.domRecentlyMutated) {
|
||||||
|
this.updateAutofillElementsAfterMutation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestIdleCallbackPolyfill(processMutationRecords, { timeout: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.mutationsQueue = [];
|
this.mutationsQueue = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a mutation record and updates the autofill elements if necessary.
|
* Processes all mutation records encountered by the mutation observer.
|
||||||
*
|
*
|
||||||
* @param mutations - The mutation record to process
|
* @param mutations - The mutation record to process
|
||||||
*/
|
*/
|
||||||
private processMutationRecord(mutations: MutationRecord[]) {
|
private processMutationRecords(mutations: MutationRecord[]) {
|
||||||
for (let mutationIndex = 0; mutationIndex < mutations.length; mutationIndex++) {
|
for (let mutationIndex = 0; mutationIndex < mutations.length; mutationIndex++) {
|
||||||
const mutation = mutations[mutationIndex];
|
const mutation: MutationRecord = mutations[mutationIndex];
|
||||||
if (
|
const processMutationRecord = () => this.processMutationRecord(mutation);
|
||||||
mutation.type === "childList" &&
|
requestIdleCallbackPolyfill(processMutationRecord, { timeout: 500 });
|
||||||
(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);
|
* Processes a single mutation record and updates the autofill elements if necessary.
|
||||||
|
* @param mutation
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private processMutationRecord(mutation: MutationRecord) {
|
||||||
|
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;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mutation.type === "attributes") {
|
||||||
|
this.handleAutofillElementAttributeMutation(mutation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1036,20 +1038,19 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (nodeIsFormElement(node) || this.isNodeFormFieldElement(node)) {
|
||||||
!this.useTreeWalkerStrategyFlagSet &&
|
|
||||||
(nodeIsFormElement(node) || this.isNodeFormFieldElement(node))
|
|
||||||
) {
|
|
||||||
mutatedElements.push(node as HTMLElement);
|
mutatedElements.push(node as HTMLElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
const autofillElements = this.useTreeWalkerStrategyFlagSet
|
const autofillElements = this.domQueryService.query<HTMLElement>(
|
||||||
? this.queryTreeWalkerForMutatedElements(node)
|
node,
|
||||||
: this.domQueryService.deepQueryElements<HTMLElement>(
|
`form, ${this.formFieldQueryString}`,
|
||||||
node,
|
(walkerNode: Node) =>
|
||||||
`form, ${this.formFieldQueryString}`,
|
nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode),
|
||||||
this.mutationObserver,
|
this.mutationObserver,
|
||||||
);
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
if (autofillElements.length) {
|
if (autofillElements.length) {
|
||||||
mutatedElements = mutatedElements.concat(autofillElements);
|
mutatedElements = mutatedElements.concat(autofillElements);
|
||||||
}
|
}
|
||||||
@ -1083,19 +1084,20 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
private setupOverlayListenersOnMutatedElements(mutatedElements: Node[]) {
|
private setupOverlayListenersOnMutatedElements(mutatedElements: Node[]) {
|
||||||
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 (
|
const buildAutofillFieldItem = () => {
|
||||||
!this.isNodeFormFieldElement(node) ||
|
if (
|
||||||
this.autofillFieldElements.get(node as ElementWithOpId<FormFieldElement>)
|
!this.isNodeFormFieldElement(node) ||
|
||||||
) {
|
this.autofillFieldElements.get(node as ElementWithOpId<FormFieldElement>)
|
||||||
continue;
|
) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
requestIdleCallbackPolyfill(
|
|
||||||
// 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.
|
||||||
() => void this.buildAutofillFieldItem(node as ElementWithOpId<FormFieldElement>, -1),
|
void this.buildAutofillFieldItem(node as ElementWithOpId<FormFieldElement>, -1);
|
||||||
{ timeout: 1000 },
|
};
|
||||||
);
|
|
||||||
|
requestIdleCallbackPolyfill(buildAutofillFieldItem, { timeout: 1000 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1367,6 +1369,19 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates whether a password field is within the document.
|
||||||
|
*/
|
||||||
|
isPasswordFieldWithinDocument(): boolean {
|
||||||
|
return (
|
||||||
|
this.domQueryService.query<HTMLInputElement>(
|
||||||
|
globalThis.document.documentElement,
|
||||||
|
`input[type="password"]`,
|
||||||
|
(node: Node) => nodeIsInputElement(node) && node.type === "password",
|
||||||
|
)?.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destroys the CollectAutofillContentService. Clears all
|
* Destroys the CollectAutofillContentService. Clears all
|
||||||
* timeouts and disconnects the mutation observer.
|
* timeouts and disconnects the mutation observer.
|
||||||
@ -1378,84 +1393,4 @@ export class CollectAutofillContentService implements CollectAutofillContentServ
|
|||||||
this.mutationObserver?.disconnect();
|
this.mutationObserver?.disconnect();
|
||||||
this.intersectionObserver?.disconnect();
|
this.intersectionObserver?.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @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.domQueryService.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;
|
|
||||||
},
|
|
||||||
this.mutationObserver,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { formElements, formFieldElements };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
|
||||||
*/
|
|
||||||
private queryTreeWalkerForAutofillFormFieldElements(): FormFieldElement[] {
|
|
||||||
return this.domQueryService.queryAllTreeWalkerNodes(
|
|
||||||
document.documentElement,
|
|
||||||
(node: Node) => this.isNodeFormFieldElement(node),
|
|
||||||
this.mutationObserver,
|
|
||||||
) 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.domQueryService.queryAllTreeWalkerNodes(
|
|
||||||
node,
|
|
||||||
(walkerNode: Node) =>
|
|
||||||
nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode),
|
|
||||||
this.mutationObserver,
|
|
||||||
) as HTMLElement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated - This method remains as a fallback in the case that the deepQuery implementation fails.
|
|
||||||
*/
|
|
||||||
private queryTreeWalkerForPasswordElements(): HTMLElement[] {
|
|
||||||
return this.domQueryService.queryAllTreeWalkerNodes(
|
|
||||||
document.documentElement,
|
|
||||||
(node: Node) => nodeIsInputElement(node) && node.type === "password",
|
|
||||||
) 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.domQueryService.deepQueryElements(document, `input[type="password"]`)?.length,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,60 @@
|
|||||||
import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils";
|
import { flushPromises, mockQuerySelectorAllDefinedCall } from "../spec/testing-utils";
|
||||||
|
|
||||||
import { DomQueryService } from "./dom-query.service";
|
import { DomQueryService } from "./dom-query.service";
|
||||||
|
|
||||||
|
jest.mock("../utils", () => {
|
||||||
|
const actualUtils = jest.requireActual("../utils");
|
||||||
|
return {
|
||||||
|
...actualUtils,
|
||||||
|
sendExtensionMessage: jest.fn((command, options) => {
|
||||||
|
if (command === "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag") {
|
||||||
|
return Promise.resolve({ result: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return chrome.runtime.sendMessage(Object.assign({ command }, options));
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe("DomQueryService", () => {
|
describe("DomQueryService", () => {
|
||||||
|
const originalDocumentReadyState = document.readyState;
|
||||||
let domQueryService: DomQueryService;
|
let domQueryService: DomQueryService;
|
||||||
let mutationObserver: MutationObserver;
|
let mutationObserver: MutationObserver;
|
||||||
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
domQueryService = new DomQueryService();
|
|
||||||
mutationObserver = new MutationObserver(() => {});
|
mutationObserver = new MutationObserver(() => {});
|
||||||
|
domQueryService = new DomQueryService();
|
||||||
|
await flushPromises();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(document, "readyState", {
|
||||||
|
value: originalDocumentReadyState,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
mockQuerySelectorAll.mockRestore();
|
mockQuerySelectorAll.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("checks the page content for shadow DOM elements after the page has completed loading", async () => {
|
||||||
|
Object.defineProperty(document, "readyState", {
|
||||||
|
value: "loading",
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
jest.spyOn(globalThis, "addEventListener");
|
||||||
|
|
||||||
|
const domQueryService = new DomQueryService();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(globalThis.addEventListener).toHaveBeenCalledWith(
|
||||||
|
"load",
|
||||||
|
domQueryService["checkPageContainsShadowDom"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe("deepQueryElements", () => {
|
describe("deepQueryElements", () => {
|
||||||
it("queries form field elements that are nested within a ShadowDOM", () => {
|
it("queries form field elements that are nested within a ShadowDOM", () => {
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
@ -26,9 +65,10 @@ describe("DomQueryService", () => {
|
|||||||
form.appendChild(input);
|
form.appendChild(input);
|
||||||
shadowRoot.appendChild(form);
|
shadowRoot.appendChild(form);
|
||||||
|
|
||||||
const formFieldElements = domQueryService.deepQueryElements(
|
const formFieldElements = domQueryService.query(
|
||||||
shadowRoot,
|
shadowRoot,
|
||||||
"input",
|
"input",
|
||||||
|
(element: Element) => element.tagName === "INPUT",
|
||||||
mutationObserver,
|
mutationObserver,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -36,6 +76,7 @@ describe("DomQueryService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
|
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
|
||||||
|
domQueryService["pageContainsShadowDom"] = true;
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
||||||
const root2 = document.createElement("div");
|
const root2 = document.createElement("div");
|
||||||
@ -47,18 +88,50 @@ describe("DomQueryService", () => {
|
|||||||
shadowRoot2.appendChild(form);
|
shadowRoot2.appendChild(form);
|
||||||
shadowRoot1.appendChild(root2);
|
shadowRoot1.appendChild(root2);
|
||||||
|
|
||||||
const formFieldElements = domQueryService.deepQueryElements(
|
const formFieldElements = domQueryService.query(
|
||||||
shadowRoot1,
|
shadowRoot1,
|
||||||
"input",
|
"input",
|
||||||
|
(element: Element) => element.tagName === "INPUT",
|
||||||
mutationObserver,
|
mutationObserver,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(formFieldElements).toStrictEqual([input]);
|
expect(formFieldElements).toStrictEqual([input]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("will fallback to using the TreeWalker API if a depth larger than 4 ShadowDOM elements is encountered", () => {
|
||||||
|
domQueryService["pageContainsShadowDom"] = true;
|
||||||
|
const root = document.createElement("div");
|
||||||
|
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
||||||
|
const root2 = document.createElement("div");
|
||||||
|
const shadowRoot2 = root2.attachShadow({ mode: "open" });
|
||||||
|
const root3 = document.createElement("div");
|
||||||
|
const shadowRoot3 = root3.attachShadow({ mode: "open" });
|
||||||
|
const root4 = document.createElement("div");
|
||||||
|
const shadowRoot4 = root4.attachShadow({ mode: "open" });
|
||||||
|
const root5 = document.createElement("div");
|
||||||
|
const shadowRoot5 = root5.attachShadow({ mode: "open" });
|
||||||
|
const form = document.createElement("form");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
form.appendChild(input);
|
||||||
|
shadowRoot5.appendChild(form);
|
||||||
|
shadowRoot4.appendChild(root5);
|
||||||
|
shadowRoot3.appendChild(root4);
|
||||||
|
shadowRoot2.appendChild(root3);
|
||||||
|
shadowRoot1.appendChild(root2);
|
||||||
|
const treeWalkerCallback = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => (element: Element) => element.tagName === "INPUT");
|
||||||
|
|
||||||
|
domQueryService.query(shadowRoot1, "input", treeWalkerCallback, mutationObserver);
|
||||||
|
|
||||||
|
expect(treeWalkerCallback).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("queryAllTreeWalkerNodes", () => {
|
describe("queryAllTreeWalkerNodes", () => {
|
||||||
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
|
it("queries form field elements that are nested within multiple ShadowDOM elements", () => {
|
||||||
|
domQueryService["pageContainsShadowDom"] = true;
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
const shadowRoot1 = root.attachShadow({ mode: "open" });
|
||||||
const root2 = document.createElement("div");
|
const root2 = document.createElement("div");
|
||||||
@ -70,8 +143,9 @@ describe("DomQueryService", () => {
|
|||||||
shadowRoot2.appendChild(form);
|
shadowRoot2.appendChild(form);
|
||||||
shadowRoot1.appendChild(root2);
|
shadowRoot1.appendChild(root2);
|
||||||
|
|
||||||
const formFieldElements = domQueryService.queryAllTreeWalkerNodes(
|
const formFieldElements = domQueryService.query(
|
||||||
shadowRoot1,
|
shadowRoot1,
|
||||||
|
"input",
|
||||||
(element: Element) => element.tagName === "INPUT",
|
(element: Element) => element.tagName === "INPUT",
|
||||||
mutationObserver,
|
mutationObserver,
|
||||||
);
|
);
|
||||||
|
@ -1,8 +1,77 @@
|
|||||||
import { nodeIsElement } from "../utils";
|
import { EVENTS, MAX_DEEP_QUERY_RECURSION_DEPTH } from "@bitwarden/common/autofill/constants";
|
||||||
|
|
||||||
|
import { nodeIsElement, sendExtensionMessage } from "../utils";
|
||||||
|
|
||||||
import { DomQueryService as DomQueryServiceInterface } from "./abstractions/dom-query.service";
|
import { DomQueryService as DomQueryServiceInterface } from "./abstractions/dom-query.service";
|
||||||
|
|
||||||
export class DomQueryService implements DomQueryServiceInterface {
|
export class DomQueryService implements DomQueryServiceInterface {
|
||||||
|
private pageContainsShadowDom: boolean;
|
||||||
|
private useTreeWalkerStrategyFlagSet = true;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
void this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a query that will trigger a deepQuery of the DOM, querying all elements that match the given query string.
|
||||||
|
* If the deepQuery fails or reaches a max recursion depth, it will fall back to a treeWalker query.
|
||||||
|
*
|
||||||
|
* @param root - The root element to start the query from
|
||||||
|
* @param queryString - The query string to match elements against
|
||||||
|
* @param treeWalkerFilter - The filter callback to use for the treeWalker query
|
||||||
|
* @param mutationObserver - The MutationObserver to use for observing shadow roots
|
||||||
|
* @param forceDeepQueryAttempt - Whether to force a deep query attempt
|
||||||
|
*/
|
||||||
|
query<T>(
|
||||||
|
root: Document | ShadowRoot | Element,
|
||||||
|
queryString: string,
|
||||||
|
treeWalkerFilter: CallableFunction,
|
||||||
|
mutationObserver?: MutationObserver,
|
||||||
|
forceDeepQueryAttempt?: boolean,
|
||||||
|
): T[] {
|
||||||
|
if (!forceDeepQueryAttempt && this.pageContainsShadowDomElements()) {
|
||||||
|
return this.queryAllTreeWalkerNodes<T>(root, treeWalkerFilter, mutationObserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return this.deepQueryElements<T>(root, queryString, mutationObserver);
|
||||||
|
} catch {
|
||||||
|
return this.queryAllTreeWalkerNodes<T>(root, treeWalkerFilter, mutationObserver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the page contains any shadow DOM elements.
|
||||||
|
*/
|
||||||
|
checkPageContainsShadowDom = (): void => {
|
||||||
|
this.pageContainsShadowDom = this.queryShadowRoots(globalThis.document.body, true).length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether to use the treeWalker strategy for querying the DOM.
|
||||||
|
*/
|
||||||
|
pageContainsShadowDomElements(): boolean {
|
||||||
|
return this.useTreeWalkerStrategyFlagSet || this.pageContainsShadowDom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the DomQueryService, checking for the presence of shadow DOM elements on the page.
|
||||||
|
*/
|
||||||
|
private async init() {
|
||||||
|
const useTreeWalkerStrategyFlag = await sendExtensionMessage(
|
||||||
|
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
|
||||||
|
);
|
||||||
|
if (useTreeWalkerStrategyFlag && typeof useTreeWalkerStrategyFlag.result === "boolean") {
|
||||||
|
this.useTreeWalkerStrategyFlagSet = useTreeWalkerStrategyFlag.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalThis.document.readyState === "complete") {
|
||||||
|
this.checkPageContainsShadowDom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
globalThis.addEventListener(EVENTS.LOAD, this.checkPageContainsShadowDom);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queries all elements in the DOM that match the given query string.
|
* Queries all elements in the DOM that match the given query string.
|
||||||
* Also, recursively queries all shadow roots for the element.
|
* Also, recursively queries all shadow roots for the element.
|
||||||
@ -11,16 +80,25 @@ export class DomQueryService implements DomQueryServiceInterface {
|
|||||||
* @param queryString - The query string to match elements against
|
* @param queryString - The query string to match elements against
|
||||||
* @param mutationObserver - The MutationObserver to use for observing shadow roots
|
* @param mutationObserver - The MutationObserver to use for observing shadow roots
|
||||||
*/
|
*/
|
||||||
deepQueryElements<T>(
|
private deepQueryElements<T>(
|
||||||
root: Document | ShadowRoot | Element,
|
root: Document | ShadowRoot | Element,
|
||||||
queryString: string,
|
queryString: string,
|
||||||
mutationObserver?: MutationObserver,
|
mutationObserver?: MutationObserver,
|
||||||
): T[] {
|
): T[] {
|
||||||
let elements = this.queryElements<T>(root, queryString);
|
let elements = this.queryElements<T>(root, queryString);
|
||||||
const shadowRoots = this.recursivelyQueryShadowRoots(root, mutationObserver);
|
|
||||||
|
const shadowRoots = this.recursivelyQueryShadowRoots(root);
|
||||||
for (let index = 0; index < shadowRoots.length; index++) {
|
for (let index = 0; index < shadowRoots.length; index++) {
|
||||||
const shadowRoot = shadowRoots[index];
|
const shadowRoot = shadowRoots[index];
|
||||||
elements = elements.concat(this.queryElements<T>(shadowRoot, queryString));
|
elements = elements.concat(this.queryElements<T>(shadowRoot, queryString));
|
||||||
|
|
||||||
|
if (mutationObserver) {
|
||||||
|
mutationObserver.observe(shadowRoot, {
|
||||||
|
attributes: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements;
|
return elements;
|
||||||
@ -46,23 +124,24 @@ export class DomQueryService implements DomQueryServiceInterface {
|
|||||||
* `isObservingShadowRoot` parameter is set to true.
|
* `isObservingShadowRoot` parameter is set to true.
|
||||||
*
|
*
|
||||||
* @param root - The root element to start the query from
|
* @param root - The root element to start the query from
|
||||||
* @param mutationObserver - The MutationObserver to use for observing shadow roots
|
* @param depth - The depth of the recursion
|
||||||
*/
|
*/
|
||||||
private recursivelyQueryShadowRoots(
|
private recursivelyQueryShadowRoots(
|
||||||
root: Document | ShadowRoot | Element,
|
root: Document | ShadowRoot | Element,
|
||||||
mutationObserver?: MutationObserver,
|
depth: number = 0,
|
||||||
): ShadowRoot[] {
|
): ShadowRoot[] {
|
||||||
|
if (!this.pageContainsShadowDom) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth >= MAX_DEEP_QUERY_RECURSION_DEPTH) {
|
||||||
|
throw new Error("Max recursion depth reached");
|
||||||
|
}
|
||||||
|
|
||||||
let shadowRoots = this.queryShadowRoots(root);
|
let shadowRoots = this.queryShadowRoots(root);
|
||||||
for (let index = 0; index < shadowRoots.length; index++) {
|
for (let index = 0; index < shadowRoots.length; index++) {
|
||||||
const shadowRoot = shadowRoots[index];
|
const shadowRoot = shadowRoots[index];
|
||||||
shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot));
|
shadowRoots = shadowRoots.concat(this.recursivelyQueryShadowRoots(shadowRoot, depth + 1));
|
||||||
if (mutationObserver) {
|
|
||||||
mutationObserver.observe(shadowRoot, {
|
|
||||||
attributes: true,
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return shadowRoots;
|
return shadowRoots;
|
||||||
@ -72,14 +151,23 @@ export class DomQueryService implements DomQueryServiceInterface {
|
|||||||
* Queries any immediate shadow roots found within the given root element.
|
* Queries any immediate shadow roots found within the given root element.
|
||||||
*
|
*
|
||||||
* @param root - The root element to start the query from
|
* @param root - The root element to start the query from
|
||||||
|
* @param returnSingleShadowRoot - Whether to return a single shadow root or an array of shadow roots
|
||||||
*/
|
*/
|
||||||
private queryShadowRoots(root: Document | ShadowRoot | Element): ShadowRoot[] {
|
private queryShadowRoots(
|
||||||
|
root: Document | ShadowRoot | Element,
|
||||||
|
returnSingleShadowRoot = false,
|
||||||
|
): ShadowRoot[] {
|
||||||
const shadowRoots: ShadowRoot[] = [];
|
const shadowRoots: ShadowRoot[] = [];
|
||||||
const potentialShadowRoots = root.querySelectorAll(":defined");
|
const potentialShadowRoots = root.querySelectorAll(":defined");
|
||||||
for (let index = 0; index < potentialShadowRoots.length; index++) {
|
for (let index = 0; index < potentialShadowRoots.length; index++) {
|
||||||
const shadowRoot = this.getShadowRoot(potentialShadowRoots[index]);
|
const shadowRoot = this.getShadowRoot(potentialShadowRoots[index]);
|
||||||
if (shadowRoot) {
|
if (!shadowRoot) {
|
||||||
shadowRoots.push(shadowRoot);
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
shadowRoots.push(shadowRoot);
|
||||||
|
if (returnSingleShadowRoot) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,12 +209,12 @@ export class DomQueryService implements DomQueryServiceInterface {
|
|||||||
* @param filterCallback
|
* @param filterCallback
|
||||||
* @param mutationObserver
|
* @param mutationObserver
|
||||||
*/
|
*/
|
||||||
queryAllTreeWalkerNodes(
|
private queryAllTreeWalkerNodes<T>(
|
||||||
rootNode: Node,
|
rootNode: Node,
|
||||||
filterCallback: CallableFunction,
|
filterCallback: CallableFunction,
|
||||||
mutationObserver?: MutationObserver,
|
mutationObserver?: MutationObserver,
|
||||||
): Node[] {
|
): T[] {
|
||||||
const treeWalkerQueryResults: Node[] = [];
|
const treeWalkerQueryResults: T[] = [];
|
||||||
|
|
||||||
this.buildTreeWalkerNodesQueryResults(
|
this.buildTreeWalkerNodesQueryResults(
|
||||||
rootNode,
|
rootNode,
|
||||||
@ -147,9 +235,9 @@ export class DomQueryService implements DomQueryServiceInterface {
|
|||||||
* @param filterCallback
|
* @param filterCallback
|
||||||
* @param mutationObserver
|
* @param mutationObserver
|
||||||
*/
|
*/
|
||||||
private buildTreeWalkerNodesQueryResults(
|
private buildTreeWalkerNodesQueryResults<T>(
|
||||||
rootNode: Node,
|
rootNode: Node,
|
||||||
treeWalkerQueryResults: Node[],
|
treeWalkerQueryResults: T[],
|
||||||
filterCallback: CallableFunction,
|
filterCallback: CallableFunction,
|
||||||
mutationObserver?: MutationObserver,
|
mutationObserver?: MutationObserver,
|
||||||
) {
|
) {
|
||||||
@ -158,7 +246,7 @@ export class DomQueryService implements DomQueryServiceInterface {
|
|||||||
|
|
||||||
while (currentNode) {
|
while (currentNode) {
|
||||||
if (filterCallback(currentNode)) {
|
if (filterCallback(currentNode)) {
|
||||||
treeWalkerQueryResults.push(currentNode);
|
treeWalkerQueryResults.push(currentNode as T);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeShadowRoot = this.getShadowRoot(currentNode);
|
const nodeShadowRoot = this.getShadowRoot(currentNode);
|
||||||
|
@ -68,6 +68,7 @@ function setMockWindowLocation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("InsertAutofillContentService", () => {
|
describe("InsertAutofillContentService", () => {
|
||||||
|
const mockQuerySelectorAll = mockQuerySelectorAllDefinedCall();
|
||||||
const inlineMenuFieldQualificationService = mock<InlineMenuFieldQualificationService>();
|
const inlineMenuFieldQualificationService = mock<InlineMenuFieldQualificationService>();
|
||||||
const domQueryService = new DomQueryService();
|
const domQueryService = new DomQueryService();
|
||||||
const domElementVisibilityService = new DomElementVisibilityService();
|
const domElementVisibilityService = new DomElementVisibilityService();
|
||||||
@ -82,7 +83,6 @@ 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;
|
||||||
|
@ -176,7 +176,7 @@ export function triggerWebRequestOnCompletedEvent(details: chrome.webRequest.Web
|
|||||||
|
|
||||||
export function mockQuerySelectorAllDefinedCall() {
|
export function mockQuerySelectorAllDefinedCall() {
|
||||||
const originalDocumentQuerySelectorAll = document.querySelectorAll;
|
const originalDocumentQuerySelectorAll = document.querySelectorAll;
|
||||||
document.querySelectorAll = function (selector: string) {
|
globalThis.document.querySelectorAll = function (selector: string) {
|
||||||
return originalDocumentQuerySelectorAll.call(
|
return originalDocumentQuerySelectorAll.call(
|
||||||
document,
|
document,
|
||||||
selector === ":defined" ? "*" : selector,
|
selector === ":defined" ? "*" : selector,
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
setElementStyles,
|
setElementStyles,
|
||||||
setupExtensionDisconnectAction,
|
setupExtensionDisconnectAction,
|
||||||
setupAutofillInitDisconnectAction,
|
setupAutofillInitDisconnectAction,
|
||||||
|
debounce,
|
||||||
} from "./index";
|
} from "./index";
|
||||||
|
|
||||||
describe("buildSvgDomElement", () => {
|
describe("buildSvgDomElement", () => {
|
||||||
@ -211,3 +212,35 @@ describe("setupAutofillInitDisconnectAction", () => {
|
|||||||
expect(window.bitwardenAutofillInit).toBeUndefined();
|
expect(window.bitwardenAutofillInit).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("debounce", () => {
|
||||||
|
const debouncedFunction = jest.fn();
|
||||||
|
const debounced = debounce(debouncedFunction, 100);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call the method until the delay is complete", () => {
|
||||||
|
debounced();
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
expect(debouncedFunction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the method a single time when the debounce is triggered multiple times", () => {
|
||||||
|
debounced();
|
||||||
|
debounced();
|
||||||
|
debounced();
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
|
||||||
|
expect(debouncedFunction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -311,6 +311,18 @@ export function nodeIsFormElement(node: Node): node is HTMLFormElement {
|
|||||||
return nodeIsElement(node) && elementIsFormElement(node);
|
return nodeIsElement(node) && elementIsFormElement(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function nodeIsTypeSubmitElement(node: Node): node is HTMLElement {
|
||||||
|
return nodeIsElement(node) && getPropertyOrAttribute(node as HTMLElement, "type") === "submit";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeIsButtonElement(node: Node): node is HTMLButtonElement {
|
||||||
|
return (
|
||||||
|
nodeIsElement(node) &&
|
||||||
|
(elementIsInstanceOf<HTMLButtonElement>(node, "button") ||
|
||||||
|
getPropertyOrAttribute(node as HTMLElement, "type") === "button")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a boolean representing the attribute value of an element.
|
* Returns a boolean representing the attribute value of an element.
|
||||||
*
|
*
|
||||||
@ -361,6 +373,20 @@ export function throttle(callback: (_args: any) => any, limit: number) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounces a callback function to run after a delay of `delay` milliseconds.
|
||||||
|
*
|
||||||
|
* @param callback - The callback function to debounce.
|
||||||
|
* @param delay - The time in milliseconds to debounce the callback.
|
||||||
|
*/
|
||||||
|
export function debounce(callback: (_args: any) => any, delay: number) {
|
||||||
|
let timeout: NodeJS.Timeout;
|
||||||
|
return function (...args: unknown[]) {
|
||||||
|
globalThis.clearTimeout(timeout);
|
||||||
|
timeout = globalThis.setTimeout(() => callback.apply(this, args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gathers and normalizes keywords from a potential submit button element. Used
|
* Gathers and normalizes keywords from a potential submit button element. Used
|
||||||
* to verify if the element submits a login or change password form.
|
* to verify if the element submits a login or change password form.
|
||||||
|
@ -107,3 +107,5 @@ export const ExtensionCommand = {
|
|||||||
export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof ExtensionCommand];
|
export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof ExtensionCommand];
|
||||||
|
|
||||||
export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute
|
export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute
|
||||||
|
|
||||||
|
export const MAX_DEEP_QUERY_RECURSION_DEPTH = 4;
|
||||||
|
Loading…
Reference in New Issue
Block a user