From e62ccfe00dd4e35105cc1812fca4d14d7e0e9456 Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Sat, 6 Apr 2024 22:22:25 +0300 Subject: [PATCH 1/3] Add element filter arg --- source/LoginTarget.ts | 2 +- source/inputs.ts | 34 +++++++++++++++++++++------------- source/loginTargets.ts | 18 ++++++++++++++---- source/types.ts | 2 ++ test/inputs.spec.js | 6 +++--- 5 files changed, 41 insertions(+), 21 deletions(-) diff --git a/source/LoginTarget.ts b/source/LoginTarget.ts index ccad567..d0ee17e 100644 --- a/source/LoginTarget.ts +++ b/source/LoginTarget.ts @@ -1,9 +1,9 @@ import isVisible from "is-visible"; import EventEmitter from "eventemitter3"; import { getSharedObserver as getUnloadObserver } from "./UnloadObserver.js"; -import { LoginTargetFeature } from "./types.js"; import { LocustInputEvent } from "./LocustInputEvent.js"; import { typeIntoInput } from "./typing.js"; +import { LoginTargetFeature } from "./types.js"; interface ChangeListener { input: HTMLElement; diff --git a/source/inputs.ts b/source/inputs.ts index 026d1de..5526ab9 100644 --- a/source/inputs.ts +++ b/source/inputs.ts @@ -6,6 +6,7 @@ import { SUBMIT_BUTTON_QUERIES, USERNAME_QUERIES } from "./inputPatterns.js"; +import { ElementValidatorCallback, LoginTargetFeature } from "./types.js"; export interface FetchedForm { form: HTMLFormElement | HTMLDivElement; @@ -71,11 +72,14 @@ function fetchForms(queryEl: Document | HTMLElement = document): Array { +export function fetchFormsWithInputs( + validator: ElementValidatorCallback, + queryEl: Document | HTMLElement = document +): Array { return fetchForms(queryEl).reduce((output: Array, formEl: HTMLFormElement | HTMLDivElement) => { - let usernameFields = fetchUsernameInputs(formEl); - const passwordFields = fetchPasswordInputs(formEl); - const otpFields = fetchOTPInputs(formEl); + let usernameFields = fetchUsernameInputs(validator, formEl); + const passwordFields = fetchPasswordInputs(validator, formEl); + const otpFields = fetchOTPInputs(validator, formEl); if (otpFields.length > 0 && passwordFields.length === 0) { // No password fields, so filter out any OTP fields from the potential username fields usernameFields = usernameFields.filter(field => otpFields.includes(field) === false); @@ -85,7 +89,7 @@ export function fetchFormsWithInputs(queryEl: Document | HTMLElement = document) usernameFields, passwordFields, otpFields, - submitButtons: fetchSubmitButtons(formEl) + submitButtons: fetchSubmitButtons(validator, formEl) }; if (form.usernameFields.length <= 0 && otpFields.length <= 0 && passwordFields.length <= 0) { return output; @@ -94,27 +98,31 @@ export function fetchFormsWithInputs(queryEl: Document | HTMLElement = document) }, []); } -function fetchOTPInputs(queryEl: Document | HTMLElement = document): Array { +function fetchOTPInputs(validator: ElementValidatorCallback, queryEl: Document | HTMLElement = document): Array { const megaQuery = OTP_QUERIES.join(", "); - const inputs = Array.prototype.slice.call(queryEl.querySelectorAll(megaQuery)).filter((el: Element) => isInput(el)) as Array; + let inputs = Array.prototype.slice.call(queryEl.querySelectorAll(megaQuery)).filter((el: Element) => isInput(el)) as Array; + inputs = inputs.filter(input => validator(LoginTargetFeature.OTP, input)); return sortFormElements(inputs, "otp"); } -function fetchPasswordInputs(queryEl: Document | HTMLElement = document): Array { +function fetchPasswordInputs(validator: ElementValidatorCallback, queryEl: Document | HTMLElement = document): Array { const megaQuery = PASSWORD_QUERIES.join(", "); - const inputs = Array.prototype.slice.call(queryEl.querySelectorAll(megaQuery)).filter((el: Element) => isInput(el)) as Array; + let inputs = Array.prototype.slice.call(queryEl.querySelectorAll(megaQuery)).filter((el: Element) => isInput(el)) as Array; + inputs = inputs.filter(input => validator(LoginTargetFeature.Password, input)); return sortFormElements(inputs, "password"); } -function fetchSubmitButtons(queryEl: Document | HTMLElement = document) { +function fetchSubmitButtons(validator: ElementValidatorCallback, queryEl: Document | HTMLElement = document) { const megaQuery = SUBMIT_BUTTON_QUERIES.join(", "); - const inputs = Array.prototype.slice.call(queryEl.querySelectorAll(megaQuery)); + let inputs = Array.prototype.slice.call(queryEl.querySelectorAll(megaQuery)) as Array; + inputs = inputs.filter(input => validator(LoginTargetFeature.Submit, input)); return sortFormElements(inputs, "submit"); } -function fetchUsernameInputs(queryEl: Document | HTMLElement = document): Array { +function fetchUsernameInputs(validator: ElementValidatorCallback, queryEl: Document | HTMLElement = document): Array { const megaQuery = USERNAME_QUERIES.join(", "); - const inputs = Array.prototype.slice.call(queryEl.querySelectorAll(megaQuery)).filter((el: Element) => isInput(el)) as Array; + let inputs = Array.prototype.slice.call(queryEl.querySelectorAll(megaQuery)).filter((el: Element) => isInput(el)) as Array; + inputs = inputs.filter(input => validator(LoginTargetFeature.Username, input)); return sortFormElements(inputs, "username"); } diff --git a/source/loginTargets.ts b/source/loginTargets.ts index 4b32a6d..f02eef5 100644 --- a/source/loginTargets.ts +++ b/source/loginTargets.ts @@ -1,6 +1,9 @@ import { fetchFormsWithInputs } from "./inputs.js"; import { LoginTarget } from "./LoginTarget.js"; import { revealShySubmitButtons } from "./prepare.js"; +import { ElementValidatorCallback } from "./types.js"; + +const DEFAULT_VALIDATOR: ElementValidatorCallback = () => true; /** * Get the best login target on the current page @@ -8,9 +11,12 @@ import { revealShySubmitButtons } from "./prepare.js"; * @returns A login target or null of none found * @see getLoginTargets */ -export function getLoginTarget(queryEl: Document | HTMLElement = document): LoginTarget | null { +export function getLoginTarget( + queryEl: Document | HTMLElement = document, + validator: ElementValidatorCallback = DEFAULT_VALIDATOR +): LoginTarget | null { revealShySubmitButtons(queryEl); - const targets = getLoginTargets(queryEl); + const targets = getLoginTargets(queryEl, validator); let bestScore = -9999, bestTarget = null; targets.forEach((target) => { @@ -31,11 +37,15 @@ export function getLoginTarget(queryEl: Document | HTMLElement = document): Logi * @param queryEl The element to query within * @returns An array of login targets */ -export function getLoginTargets(queryEl: Document | HTMLElement = document): Array { +export function getLoginTargets( + queryEl: Document | HTMLElement = document, + validator: ElementValidatorCallback = DEFAULT_VALIDATOR +): Array { revealShySubmitButtons(queryEl); - return fetchFormsWithInputs(queryEl).map((info) => { + return fetchFormsWithInputs(validator, queryEl).map((info) => { const { form, otpFields, usernameFields, passwordFields, submitButtons } = info; const target = new LoginTarget(); + // Set inputs to target - this attaches listeners target.usernameField = usernameFields[0]; target.passwordField = passwordFields[0]; target.otpField = otpFields[0]; diff --git a/source/types.ts b/source/types.ts index 12b31c1..7e9b77a 100644 --- a/source/types.ts +++ b/source/types.ts @@ -1,3 +1,5 @@ +export type ElementValidatorCallback = (feature: LoginTargetFeature, element: HTMLElement) => boolean; + export enum LoginTargetFeature { Form = "form", OTP = "otp", diff --git a/test/inputs.spec.js b/test/inputs.spec.js index c7276e5..fb6dc16 100644 --- a/test/inputs.spec.js +++ b/test/inputs.spec.js @@ -15,7 +15,7 @@ describe("inputs", function () { }); it("fetches forms by name", function () { - fetchFormsWithInputs(this.queryEl); + fetchFormsWithInputs(() => true, this.queryEl); expect(this.queryEl.querySelectorAll.calledWithExactly(FORM_QUERIES.join(","))).to.be .true; expect(this.queryEl.querySelectorAll.calledOnce).to.be.true; @@ -28,7 +28,7 @@ describe("inputs", function () { tagName: "form" }; this.forms.push(fakeForm); - fetchFormsWithInputs(this.queryEl); + fetchFormsWithInputs(() => true, this.queryEl); expect(fakeForm.querySelectorAll.callCount).to.be.at.least(3); }); @@ -44,7 +44,7 @@ describe("inputs", function () { tagName: "form" }; this.forms.push(fakeForm); - const forms = fetchFormsWithInputs(this.queryEl); + const forms = fetchFormsWithInputs(() => true, this.queryEl); expect(forms).to.have.lengthOf(0); }); }); From f02e902a1fe509c85d2978d431d98a62ec968b33 Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Sat, 6 Apr 2024 22:36:08 +0300 Subject: [PATCH 2/3] Simplify events, support react setters --- source/LocustInputEvent.ts | 13 +++++++------ source/typing.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/source/LocustInputEvent.ts b/source/LocustInputEvent.ts index 499ece9..5831b8b 100644 --- a/source/LocustInputEvent.ts +++ b/source/LocustInputEvent.ts @@ -1,22 +1,23 @@ export type InputEventTrigger = "keypress" | "fill"; export class LocustInputEvent extends InputEvent { - private _data: string; private _source: InputEventTrigger; constructor( source: InputEventTrigger, type: string, - data: string, - eventInitDict?: EventInit + eventInitDict?: InputEventInit ) { super(type, eventInitDict); this._source = source; - this._data = data; } - get data(): string { - return this._data; + /** + * React 15 compat + * @see https://chuckconway.com/changing-a-react-input-value-from-vanilla-javascript/ + */ + get simulated(): boolean { + return true; } get source(): InputEventTrigger { diff --git a/source/typing.ts b/source/typing.ts index 9e650c9..1895056 100644 --- a/source/typing.ts +++ b/source/typing.ts @@ -28,8 +28,13 @@ export async function typeIntoInput(input: HTMLInputElement, value: string): Pro newValue = `${newValue}${char}`; input.setAttribute("value", newValue); nativeInputValueSetter.call(input, newValue); + // Try react set (React 16) + const tracker = (input as any)._valueTracker; + if (tracker) { + tracker.setValue(newValue); + } // Input event data takes the single new character - const inputEvent = new LocustInputEvent("fill", "input", char, { bubbles: true }); + const inputEvent = new LocustInputEvent("fill", "input", { bubbles: true, data: char }); input.dispatchEvent(inputEvent); // Wait const waitTime = Math.floor(Math.random() * (MAX_TYPE_WAIT - MIN_TYPE_WAIT)) + MIN_TYPE_WAIT; @@ -39,6 +44,6 @@ export async function typeIntoInput(input: HTMLInputElement, value: string): Pro const blurEvent = new FocusEvent("blur", { bubbles: true }); input.dispatchEvent(blurEvent); // The change event gets all of the new data - const changeEvent = new LocustInputEvent("fill", "change", value, { bubbles: true }); + const changeEvent = new LocustInputEvent("fill", "change", { bubbles: true, data: value }); input.dispatchEvent(changeEvent); } From 1fa60c77c6e619cf50dd576091fd0b201b0a1072 Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Sat, 6 Apr 2024 22:46:47 +0300 Subject: [PATCH 3/3] Support prototype setter --- source/typing.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/source/typing.ts b/source/typing.ts index 1895056..d4fe5db 100644 --- a/source/typing.ts +++ b/source/typing.ts @@ -26,8 +26,15 @@ export async function typeIntoInput(input: HTMLInputElement, value: string): Pro while (characters.length > 0) { const char = characters.shift(); newValue = `${newValue}${char}`; + // Set using attribute input.setAttribute("value", newValue); + // Set using native methods + const proto = Object.getPrototypeOf(input); + const protoSetter = Object.getOwnPropertyDescriptor(proto, "value").set; nativeInputValueSetter.call(input, newValue); + if (protoSetter && nativeInputValueSetter !== protoSetter) { + protoSetter.call(input, newValue); + } // Try react set (React 16) const tracker = (input as any)._valueTracker; if (tracker) {