[PM-6352] Autofill functionality not working with re-hydrated DOM elements (#8033)

* [PM-6352] Autofill functionality not working with re-hydtrated DOM elements

* [PM-6352] Autofill functionality not working with re-hydtrated DOM elements

* [PM-6352] Fixing issue found with chrome.dom.openOrClosedShadowRoot call

* [PM-6352] Implementing jest tests and adding utils methods where appropriate

* [PM-6352] Implementing jest tests and adding utils methods where appropriate
This commit is contained in:
Cesar Gonzalez 2024-02-23 13:41:26 -06:00 committed by GitHub
parent 3e6ba798ca
commit b2c3ecda0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 203 additions and 43 deletions

View File

@ -10,7 +10,12 @@ import AutofillField from "../models/autofill-field";
import AutofillOverlayButtonIframe from "../overlay/iframe-content/autofill-overlay-button-iframe";
import AutofillOverlayListIframe from "../overlay/iframe-content/autofill-overlay-list-iframe";
import { ElementWithOpId, FillableFormFieldElement, FormFieldElement } from "../types";
import { generateRandomCustomElementName, sendExtensionMessage, setElementStyles } from "../utils";
import {
elementIsFillableFormField,
generateRandomCustomElementName,
sendExtensionMessage,
setElementStyles,
} from "../utils";
import {
AutofillOverlayElement,
RedirectFocusDirection,
@ -408,7 +413,7 @@ class AutofillOverlayContentService implements AutofillOverlayContentServiceInte
* @param formFieldElement - The form field element that triggered the input event.
*/
private triggerFormFieldInput(formFieldElement: ElementWithOpId<FormFieldElement>) {
if (formFieldElement instanceof HTMLSpanElement) {
if (!elementIsFillableFormField(formFieldElement)) {
return;
}

View File

@ -7,6 +7,19 @@ import {
FormFieldElement,
FormElementWithAttribute,
} from "../types";
import {
elementIsDescriptionDetailsElement,
elementIsDescriptionTermElement,
elementIsFillableFormField,
elementIsFormElement,
elementIsLabelElement,
elementIsSelectElement,
elementIsSpanElement,
nodeIsFormElement,
nodeIsElement,
elementIsInputElement,
elementIsTextAreaElement,
} from "../utils";
import { AutofillOverlayContentService } from "./abstractions/autofill-overlay-content.service";
import {
@ -347,7 +360,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
tagName: this.getAttributeLowerCase(element, "tagName"),
};
if (element.tagName.toLowerCase() === "span") {
if (elementIsSpanElement(element)) {
this.cacheAutofillFieldElement(index, element, autofillFieldBase);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@ -383,10 +396,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
autoCompleteType: this.getAutoCompleteAttribute(element),
disabled: this.getAttributeBoolean(element, "disabled"),
readonly: this.getAttributeBoolean(element, "readonly"),
selectInfo:
element.tagName.toLowerCase() === "select"
? this.getSelectElementOptions(element as HTMLSelectElement)
: null,
selectInfo: elementIsSelectElement(element)
? this.getSelectElementOptions(element as HTMLSelectElement)
: null,
form: fieldFormElement ? this.getPropertyOrAttribute(fieldFormElement, "opid") : null,
"aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true),
"aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true),
@ -502,7 +514,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
let currentElement: HTMLElement | null = element;
while (currentElement && currentElement !== document.documentElement) {
if (currentElement.tagName.toLowerCase() === "label") {
if (elementIsLabelElement(currentElement)) {
labelElementsSet.add(currentElement);
}
@ -511,10 +523,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
if (
!labelElementsSet.size &&
element.parentElement?.tagName.toLowerCase() === "dd" &&
element.parentElement.previousElementSibling?.tagName.toLowerCase() === "dt"
elementIsDescriptionDetailsElement(element.parentElement) &&
elementIsDescriptionTermElement(element.parentElement.previousElementSibling)
) {
labelElementsSet.add(element.parentElement.previousElementSibling as HTMLElement);
labelElementsSet.add(element.parentElement.previousElementSibling);
}
return this.createLabelElementsTag(labelElementsSet);
@ -577,13 +589,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
* @private
*/
private getAutofillFieldMaxLength(element: FormFieldElement): number | null {
const elementTagName = element.tagName.toLowerCase();
const elementHasMaxLengthProperty = elementTagName === "input" || elementTagName === "textarea";
const elementHasMaxLengthProperty =
elementIsInputElement(element) || elementIsTextAreaElement(element);
const elementMaxLength =
elementHasMaxLengthProperty &&
(element as HTMLInputElement | HTMLTextAreaElement).maxLength > -1
? (element as HTMLInputElement | HTMLTextAreaElement).maxLength
: 999;
elementHasMaxLengthProperty && element.maxLength > -1 ? element.maxLength : 999;
return elementHasMaxLengthProperty ? Math.min(elementMaxLength, 999) : null;
}
@ -747,10 +756,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
// Prioritize capturing text content from elements rather than nodes.
currentElement = currentElement.parentElement || currentElement.parentNode;
let siblingElement =
currentElement instanceof HTMLElement
? currentElement.previousElementSibling
: currentElement.previousSibling;
let siblingElement = nodeIsElement(currentElement)
? currentElement.previousElementSibling
: currentElement.previousSibling;
while (siblingElement?.lastChild && !this.isNewSectionElement(siblingElement)) {
siblingElement = siblingElement.lastChild;
}
@ -793,13 +801,13 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
* @private
*/
private getElementValue(element: FormFieldElement): string {
if (element.tagName.toLowerCase() === "span") {
if (!elementIsFillableFormField(element)) {
const spanTextContent = element.textContent || element.innerText;
return spanTextContent || "";
}
const elementValue = (element as FillableFormFieldElement).value || "";
const elementType = String((element as FillableFormFieldElement).type).toLowerCase();
const elementValue = element.value || "";
const elementType = String(element.type).toLowerCase();
if ("checked" in element && elementType === "checkbox") {
return element.checked ? "✓" : "";
}
@ -850,7 +858,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
const formElements: Node[] = [];
const formFieldElements: Node[] = [];
this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => {
if ((node as HTMLFormElement).tagName?.toLowerCase() === "form") {
if (nodeIsFormElement(node)) {
formElements.push(node);
return true;
}
@ -873,7 +881,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
* @private
*/
private isNodeFormFieldElement(node: Node): boolean {
if (!(node instanceof HTMLElement)) {
if (!nodeIsElement(node)) {
return false;
}
@ -904,15 +912,20 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
* @param {Node} node
*/
private getShadowRoot(node: Node): ShadowRoot | null {
if (!(node instanceof HTMLElement) || node.childNodes.length !== 0) {
if (!nodeIsElement(node) || node.childNodes.length !== 0) {
return null;
}
if (node.shadowRoot) {
return node.shadowRoot;
}
if ((chrome as any).dom?.openOrClosedShadowRoot) {
return (chrome as any).dom.openOrClosedShadowRoot(node);
try {
return (chrome as any).dom.openOrClosedShadowRoot(node);
} catch (error) {
return null;
}
}
return (node as any).openOrClosedShadowRoot;
@ -1052,15 +1065,14 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
const mutatedElements: Node[] = [];
for (let index = 0; index < nodes.length; index++) {
const node = nodes[index];
if (!(node instanceof HTMLElement)) {
if (!nodeIsElement(node)) {
continue;
}
const autofillElementNodes = this.queryAllTreeWalkerNodes(
node,
(walkerNode: Node) =>
(walkerNode as HTMLElement).tagName?.toLowerCase() === "form" ||
this.isNodeFormFieldElement(walkerNode),
nodeIsFormElement(walkerNode) || this.isNodeFormFieldElement(walkerNode),
) as HTMLElement[];
if (autofillElementNodes.length) {
@ -1115,11 +1127,8 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
private deleteCachedAutofillElement(
element: ElementWithOpId<HTMLFormElement> | ElementWithOpId<FormFieldElement>,
) {
if (
element.tagName.toLowerCase() === "form" &&
this.autofillFormElements.has(element as ElementWithOpId<HTMLFormElement>)
) {
this.autofillFormElements.delete(element as ElementWithOpId<HTMLFormElement>);
if (elementIsFormElement(element) && this.autofillFormElements.has(element)) {
this.autofillFormElements.delete(element);
return;
}
@ -1151,7 +1160,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte
*/
private handleAutofillElementAttributeMutation(mutation: MutationRecord) {
const targetElement = mutation.target;
if (!(targetElement instanceof HTMLElement)) {
if (!nodeIsElement(targetElement)) {
return;
}

View File

@ -1,6 +1,13 @@
import { EVENTS, TYPE_CHECK } from "../constants";
import AutofillScript, { AutofillInsertActions, FillScript } from "../models/autofill-script";
import { FormFieldElement } from "../types";
import {
elementIsFillableFormField,
elementIsInputElement,
elementIsSelectElement,
elementIsTextAreaElement,
nodeIsInputElement,
} from "../utils";
import { InsertAutofillContentService as InsertAutofillContentServiceInterface } from "./abstractions/insert-autofill-content.service";
import CollectAutofillContentService from "./collect-autofill-content.service";
@ -96,7 +103,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
return Boolean(
this.collectAutofillContentService.queryAllTreeWalkerNodes(
document.documentElement,
(node: Node) => node instanceof HTMLInputElement && node.type === "password",
(node: Node) => nodeIsInputElement(node) && node.type === "password",
false,
)?.length,
);
@ -195,8 +202,8 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
*/
private insertValueIntoField(element: FormFieldElement | null, value: string) {
const elementCanBeReadonly =
element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement;
const elementCanBeFilled = elementCanBeReadonly || element instanceof HTMLSelectElement;
elementIsInputElement(element) || elementIsTextAreaElement(element);
const elementCanBeFilled = elementCanBeReadonly || elementIsSelectElement(element);
if (
!element ||
@ -207,13 +214,13 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
return;
}
if (element instanceof HTMLSpanElement) {
if (!elementIsFillableFormField(element)) {
this.handleInsertValueAndTriggerSimulatedEvents(element, () => (element.innerText = value));
return;
}
const isFillableCheckboxOrRadioElement =
element instanceof HTMLInputElement &&
elementIsInputElement(element) &&
new Set(["checkbox", "radio"]).has(element.type) &&
new Set(["true", "y", "1", "yes", "✓"]).has(String(value).toLowerCase());
if (isFillableCheckboxOrRadioElement) {
@ -285,7 +292,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
*/
private triggerFillAnimationOnElement(element: FormFieldElement): void {
const skipAnimatingElement =
!(element instanceof HTMLSpanElement) &&
elementIsFillableFormField(element) &&
!new Set(["email", "text", "password", "number", "tel", "url"]).has(element?.type);
if (this.domElementVisibilityService.isElementHiddenByCss(element) || skipAnimatingElement) {
@ -371,6 +378,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
element.dispatchEvent(new Event(simulatedInputEvents[index], { bubbles: true }));
}
}
private nodeIsElement(node: Node): node is HTMLElement {
return node.nodeType === Node.ELEMENT_NODE;
}
}
export default InsertAutofillContentService;

View File

@ -1,4 +1,5 @@
import { AutofillPort } from "../enums/autofill-port.enums";
import { FillableFormFieldElement, FormFieldElement } from "../types";
/**
* Generates a random string of characters that formatted as a custom element name.
@ -151,6 +152,127 @@ function setupAutofillInitDisconnectAction(windowContext: Window) {
setupExtensionDisconnectAction(onDisconnectCallback);
}
/**
* Identifies whether an element is a fillable form field.
* This is determined by whether the element is a form field and not a span.
*
* @param formFieldElement - The form field element to check.
*/
function elementIsFillableFormField(
formFieldElement: FormFieldElement,
): formFieldElement is FillableFormFieldElement {
return formFieldElement?.tagName.toLowerCase() !== "span";
}
/**
* Identifies whether an element is an instance of a specific tag name.
*
* @param element - The element to check.
* @param tagName - The tag name to check against.
*/
function elementIsInstanceOf<T extends Element>(element: Element, tagName: string): element is T {
return element?.tagName.toLowerCase() === tagName;
}
/**
* Identifies whether an element is a span element.
*
* @param element - The element to check.
*/
function elementIsSpanElement(element: Element): element is HTMLSpanElement {
return elementIsInstanceOf<HTMLSpanElement>(element, "span");
}
/**
* Identifies whether an element is an input field.
*
* @param element - The element to check.
*/
function elementIsInputElement(element: Element): element is HTMLInputElement {
return elementIsInstanceOf<HTMLInputElement>(element, "input");
}
/**
* Identifies whether an element is a select field.
*
* @param element - The element to check.
*/
function elementIsSelectElement(element: Element): element is HTMLSelectElement {
return elementIsInstanceOf<HTMLSelectElement>(element, "select");
}
/**
* Identifies whether an element is a textarea field.
*
* @param element - The element to check.
*/
function elementIsTextAreaElement(element: Element): element is HTMLTextAreaElement {
return elementIsInstanceOf<HTMLTextAreaElement>(element, "textarea");
}
/**
* Identifies whether an element is a form element.
*
* @param element - The element to check.
*/
function elementIsFormElement(element: Element): element is HTMLFormElement {
return elementIsInstanceOf<HTMLFormElement>(element, "form");
}
/**
* Identifies whether an element is a label element.
*
* @param element - The element to check.
*/
function elementIsLabelElement(element: Element): element is HTMLLabelElement {
return elementIsInstanceOf<HTMLLabelElement>(element, "label");
}
/**
* Identifies whether an element is a description details `dd` element.
*
* @param element - The element to check.
*/
function elementIsDescriptionDetailsElement(element: Element): element is HTMLElement {
return elementIsInstanceOf<HTMLElement>(element, "dd");
}
/**
* Identifies whether an element is a description term `dt` element.
*
* @param element - The element to check.
*/
function elementIsDescriptionTermElement(element: Element): element is HTMLElement {
return elementIsInstanceOf<HTMLElement>(element, "dt");
}
/**
* Identifies whether a node is an HTML element.
*
* @param node - The node to check.
*/
function nodeIsElement(node: Node): node is Element {
return node?.nodeType === Node.ELEMENT_NODE;
}
/**
* Identifies whether a node is an input element.
*
* @param node - The node to check.
*/
function nodeIsInputElement(node: Node): node is HTMLInputElement {
return nodeIsElement(node) && elementIsInputElement(node);
}
/**
* Identifies whether a node is a form element.
*
* @param node - The node to check.
*/
function nodeIsFormElement(node: Node): node is HTMLFormElement {
return nodeIsElement(node) && elementIsFormElement(node);
}
export {
generateRandomCustomElementName,
buildSvgDomElement,
@ -159,4 +281,17 @@ export {
getFromLocalStorage,
setupExtensionDisconnectAction,
setupAutofillInitDisconnectAction,
elementIsFillableFormField,
elementIsInstanceOf,
elementIsSpanElement,
elementIsInputElement,
elementIsSelectElement,
elementIsTextAreaElement,
elementIsFormElement,
elementIsLabelElement,
elementIsDescriptionDetailsElement,
elementIsDescriptionTermElement,
nodeIsElement,
nodeIsInputElement,
nodeIsFormElement,
};