1
0
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:
Cesar Gonzalez 2024-09-16 08:35:56 -05:00 committed by GitHub
parent 62ee447c36
commit b0e0e71974
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 437 additions and 280 deletions

View File

@ -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 () => {

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

@ -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.

View File

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