mirror of
https://github.com/bitwarden/browser.git
synced 2024-12-22 16:29:09 +01:00
[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:
parent
3e6ba798ca
commit
b2c3ecda0f
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user