diff --git a/README.md b/README.md index 3df2d3e..338ba2a 100644 --- a/README.md +++ b/README.md @@ -16,48 +16,28 @@ Locust helps find **login forms** by searching the DOM for common login form ele ## Usage ```ts -import { getLoginTarget } from "@withuno/locust"; -const { getLoginTarget } = Locust; +import { LoginTarget } from "@withuno/locust"; -getLoginTarget().login("myUsername", "myPassword"); -``` - -The example above enters the username and password in the **best** form found on the page and then proceeds to submit that form (logging the user in). - -_To find all forms on a page, use the `getLoginTargets` method instead, which returns an array of login targets. You can then sort through these to find all the different login forms that may exist._ +const target = LoginTarget.find(); -In the case that you don't want to automatically log in, but still enter the details, you can use the following example: +if (target) { + const form = target.get("form"); + const usernameInput = target.get("username"); + const passwordInput = target.get("password"); + const submitButton = target.get("submit"); -> **Note** -> `getLoginTarget` may return `null` if no form is found. - -```ts -getLoginTarget().enterDetails("myUsername", "myPassword"); + usernameInput.value = "myUsername"; + passwordInput.value = "myPassword"; + submitButton.click(); +} ``` +The example above enters the username and password in the **best** form found on the page and then proceeds to submit that form (logging the user in). -### Events - -Locust login targets will emit events when certain things happen. To listen for changes to the values of usernames and passwords on forms simply attach event listeners: - -```ts -const target = getLoginTarget(); -target.events.on("valueChanged", ({ type, value }) => { - if (type === "username") { - console.log("New username:", value); - } -}); -// `type` can be "username" or "password" -``` - -You can also listen to form submissions: +_To find all forms on a page, use the `LoginTarget.findAll()` method instead, which returns an array of login targets. You can then sort through these to find all the different login forms that may exist._ -```ts -const target = getLoginTarget(); -target.events.once("formSubmitted", ({ source }) => { - // `source` will either be "submitButton" or "form" -}); -``` +> [!NOTE] +> `LoginTarget.find()` may return `null` if no form is found. --- diff --git a/package-lock.json b/package-lock.json index ac6486f..300348f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "fastify": "~4.14.1", "flik": "0.6.1", "husky": "8.0.3", - "is-visible": "^2.2.0", "karma": "^6.4.0", "karma-chai": "^0.1.0", "karma-chrome-launcher": "^3.1.1", @@ -200,9 +199,9 @@ } }, "node_modules/@auto-it/core/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -293,9 +292,9 @@ } }, "node_modules/@auto-it/npm/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -378,9 +377,9 @@ } }, "node_modules/@auto-it/version-file/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -456,9 +455,9 @@ } }, "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -513,9 +512,9 @@ } }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -2146,9 +2145,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -2290,9 +2289,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -2349,9 +2348,9 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -4512,9 +4511,9 @@ } }, "node_modules/engine.io": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.4.1.tgz", - "integrity": "sha512-JFYQurD/nbsA5BSPmbaOSLa3tSVj8L6o4srSwXXY3NqE+gGUNmmPTbhn8tjzcCtSqhFgIeqef81ngny8JM25hw==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.4.2.tgz", + "integrity": "sha512-FKn/3oMiJjrOEOeUub2WCox6JhxBXq/Zn3fZOMCBxKnNYtsdKjxhl7yR3fZhM9PV+rdE75SU5SYMc+2PGzo+Tg==", "dev": true, "dependencies": { "@types/cookie": "^0.4.1", @@ -4971,9 +4970,9 @@ } }, "node_modules/eslint-config-airbnb-base/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -5108,9 +5107,9 @@ } }, "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -5147,9 +5146,9 @@ } }, "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -5247,9 +5246,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -5766,9 +5765,9 @@ } }, "node_modules/fastify/node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -7337,16 +7336,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-visible": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-visible/-/is-visible-2.2.0.tgz", - "integrity": "sha512-xv9AexiLVUJXQ3hC6lYsAfZZxvggqrMIjIhpvSXdiqgfUsSF8JghojXzstv3sx0xONEhiPQuykOAKPigC1EXeA==", - "dev": true, - "dependencies": { - "iselement": "^1.1.3", - "style-properties": "^1.3.1" - } - }, "node_modules/is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -7399,12 +7388,6 @@ "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/iselement": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/iselement/-/iselement-1.1.4.tgz", - "integrity": "sha512-4Q519eWmbHO1pbimiz7H1iJRUHVmAmfh0viSsUD+oAwVO4ntZt7gpf8i8AShVBTyOvRTZNYNBpUxOIvwZR+ffw==", - "dev": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7445,9 +7428,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -8229,9 +8212,9 @@ } }, "node_modules/lint-staged/node_modules/yaml": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.1.tgz", - "integrity": "sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", + "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==", "dev": true, "engines": { "node": ">= 14" @@ -8622,9 +8605,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -11057,9 +11040,9 @@ "dev": true }, "node_modules/semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, "bin": { "semver": "bin/semver" @@ -11394,9 +11377,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.2.tgz", - "integrity": "sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", "dev": true, "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -11739,12 +11722,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/style-properties": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/style-properties/-/style-properties-1.3.1.tgz", - "integrity": "sha512-n+uc4rCIzXUZ6197c98Lq63f8G0SpRj9kzIZT4QNQ0PYUR+D0kdXyZiBUyjE1KO2RtAc0ETRDPUxTImRFjvHig==", - "dev": true - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -12524,9 +12501,9 @@ } }, "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -13038,9 +13015,9 @@ } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -13123,9 +13100,9 @@ } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -13200,9 +13177,9 @@ } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -13261,9 +13238,9 @@ }, "dependencies": { "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -13307,9 +13284,9 @@ }, "dependencies": { "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -14511,9 +14488,9 @@ } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -14592,9 +14569,9 @@ } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -14634,9 +14611,9 @@ } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -16345,9 +16322,9 @@ } }, "engine.io": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.4.1.tgz", - "integrity": "sha512-JFYQurD/nbsA5BSPmbaOSLa3tSVj8L6o4srSwXXY3NqE+gGUNmmPTbhn8tjzcCtSqhFgIeqef81ngny8JM25hw==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.4.2.tgz", + "integrity": "sha512-FKn/3oMiJjrOEOeUub2WCox6JhxBXq/Zn3fZOMCBxKnNYtsdKjxhl7yR3fZhM9PV+rdE75SU5SYMc+2PGzo+Tg==", "dev": true, "requires": { "@types/cookie": "^0.4.1", @@ -16788,9 +16765,9 @@ }, "dependencies": { "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -16899,9 +16876,9 @@ } }, "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -16931,9 +16908,9 @@ }, "dependencies": { "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -16991,9 +16968,9 @@ } }, "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -17304,9 +17281,9 @@ } }, "semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dev": true, "requires": { "lru-cache": "^6.0.0" @@ -18432,16 +18409,6 @@ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true }, - "is-visible": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-visible/-/is-visible-2.2.0.tgz", - "integrity": "sha512-xv9AexiLVUJXQ3hC6lYsAfZZxvggqrMIjIhpvSXdiqgfUsSF8JghojXzstv3sx0xONEhiPQuykOAKPigC1EXeA==", - "dev": true, - "requires": { - "iselement": "^1.1.3", - "style-properties": "^1.3.1" - } - }, "is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -18479,12 +18446,6 @@ "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", "dev": true }, - "iselement": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/iselement/-/iselement-1.1.4.tgz", - "integrity": "sha512-4Q519eWmbHO1pbimiz7H1iJRUHVmAmfh0viSsUD+oAwVO4ntZt7gpf8i8AShVBTyOvRTZNYNBpUxOIvwZR+ffw==", - "dev": true - }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -18516,9 +18477,9 @@ }, "dependencies": { "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -19074,9 +19035,9 @@ }, "dependencies": { "yaml": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.1.tgz", - "integrity": "sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", + "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==", "dev": true } } @@ -19379,9 +19340,9 @@ }, "dependencies": { "semver": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", - "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true } } @@ -21161,9 +21122,9 @@ "dev": true }, "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true }, "serialize-javascript": { @@ -21432,9 +21393,9 @@ } }, "socket.io-parser": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.2.tgz", - "integrity": "sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", "dev": true, "requires": { "@socket.io/component-emitter": "~3.1.0", @@ -21689,12 +21650,6 @@ "peek-readable": "^5.0.0" } }, - "style-properties": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/style-properties/-/style-properties-1.3.1.tgz", - "integrity": "sha512-n+uc4rCIzXUZ6197c98Lq63f8G0SpRj9kzIZT4QNQ0PYUR+D0kdXyZiBUyjE1KO2RtAc0ETRDPUxTImRFjvHig==", - "dev": true - }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -22292,9 +22247,9 @@ } }, "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true }, "wordwrapjs": { diff --git a/package.json b/package.json index 1ee636c..5d285c6 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "fastify": "~4.14.1", "flik": "0.6.1", "husky": "8.0.3", - "is-visible": "^2.2.0", "karma": "^6.4.0", "karma-chai": "^0.1.0", "karma-chrome-launcher": "^3.1.1", diff --git a/src/index.ts b/src/index.ts index 39693ef..85a109c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1 @@ -import { getSharedObserver } from './utils/unload-observer'; - -// Initialise the DOM unload observer -getSharedObserver(); - -export { LoginTarget, LoginTargetFieldType } from './login/login-target'; -export { getVisibleLoginTarget, getLoginTarget, getLoginTargets } from './login'; +export { LoginTarget } from './targets/login'; diff --git a/src/login/index.ts b/src/login/index.ts deleted file mode 100644 index 6c8b9a4..0000000 --- a/src/login/index.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* eslint-disable prefer-destructuring */ - -import { FORM_QUERIES, PASSWORD_FIELD_SPEC, SUBMIT_BUTTON_SPEC, USERNAME_FIELD_SPEC } from './fields'; -import { LoginTarget } from './login-target'; -import { isFormElement } from '../utils/dom'; -import { isVisible } from '../utils/isVisible'; -import { resolveElements } from '../utils/resolve-elements'; - -/** - * Get the best login (visible) target on the current page. - * - * @param queryEl The element to query within - * @returns A login target or `null` if none found - * @see getLoginTarget - */ -export function getVisibleLoginTarget(queryEl: Document | HTMLElement = document) { - const bestTarget = getLoginTarget(queryEl); - const isTargetVisible = [ - bestTarget?.form, - bestTarget?.usernameField, - bestTarget?.passwordField, - bestTarget?.submitButton, - ].some((el) => { - return !!el && isVisible(el); - }); - return isTargetVisible ? bestTarget : null; -} - -/** - * Get the best login target on the current page. - * - * @param queryEl The element to query within - * @returns A login target or `null` if none found - * @see getLoginTargets - */ -export function getLoginTarget(queryEl: Document | HTMLElement = document) { - const targets = getLoginTargets(queryEl); - let bestScore = -1; - let bestTarget = null; - targets.forEach((target) => { - const score = target.calculateScore(); - if (score > bestScore) { - bestScore = score; - bestTarget = target; - } - }); - return bestTarget as LoginTarget | null; -} - -/** - * Fetch all login targets. - * - * Fetches all detected login targets within some element (defaults to the - * current document). Returned targets are not sorted or processed in any way - * that would indicate how likely they are to be the 'correct' login form for - * the page. - * - * @param queryEl The element to query within. - * @returns An array of login targets. - */ -export function getLoginTargets(queryEl: Document | HTMLElement = document) { - // Gather a list of all potential submit buttons throughout the document - // We use these as a fallback when a form is found without submit button(s) - // NOTE: this array will be mutated as we process forms into `LoginTargets` - let orphanedSubmitButtons = resolveElements(SUBMIT_BUTTON_SPEC, document); - - const forms = Array.prototype.slice.call(queryEl.querySelectorAll(FORM_QUERIES.join(','))); - - const targets = forms - .map((formEl) => { - const form = isFormElement(formEl) ? formEl : null; - const usernameFields = resolveElements(USERNAME_FIELD_SPEC, formEl); - const passwordFields = resolveElements(PASSWORD_FIELD_SPEC, formEl); - const submitButtons = resolveElements(SUBMIT_BUTTON_SPEC, formEl); - - // De-dupe submit buttons found associated to this login - // target that are present in `orphanedSubmitButtons`. - if (submitButtons.length) { - orphanedSubmitButtons = orphanedSubmitButtons.filter((orphan) => !submitButtons.includes(orphan)); - } - - return { form, usernameFields, passwordFields, submitButtons }; - }) - .filter(({ usernameFields, passwordFields }) => { - return usernameFields.length + passwordFields.length > 0; - }) - .map(({ form, usernameFields, passwordFields, submitButtons }) => { - const target = new LoginTarget(); - - target.form = form; - target.usernameField = usernameFields[0] ?? null; - target.passwordField = passwordFields[0] ?? null; - target.submitButton = (submitButtons.length ? submitButtons[0] : orphanedSubmitButtons[0]) ?? null; - - if (submitButtons.length > 1) { - target.baseScore -= 2; - } - if (submitButtons.length === 0) { - target.baseScore -= 1; - } - if (usernameFields.length > 1) { - target.baseScore -= 1; - } - if (passwordFields.length > 1) { - target.baseScore -= 2; - } - - return target; - }); - - return targets; -} diff --git a/src/login/login-target.ts b/src/login/login-target.ts deleted file mode 100644 index eb4cb2c..0000000 --- a/src/login/login-target.ts +++ /dev/null @@ -1,277 +0,0 @@ -import EventEmitter from 'eventemitter3'; - -import { setInputValue } from '../utils/dom'; -import { isVisible } from '../utils/isVisible'; - -const FORCE_SUBMIT_DELAY = 7500; - -export type LoginTargetFieldType = 'form' | 'submit' | 'username' | 'password'; - -function getChangeEventNameForFieldType(type: LoginTargetFieldType) { - switch (type) { - case 'form': - return 'submit'; - case 'submit': - return 'click'; - case 'username': - case 'password': - default: - return 'input'; - } -} - -interface ChangeListener { - input: HTMLElement; - listener: () => void; -} - -type LoginTargetEventEmitter = EventEmitter<{ - formSubmitted: (ctx: { source: 'form' | 'submitButton' }) => void; - valueChanged: (ctx: { type: 'username' | 'password'; value: string }) => void; -}>; - -/** - * The LoginTarget class which represents a 'target' for logging in - * with some credentials. - */ -export class LoginTarget { - public baseScore = 0; - public events = new EventEmitter() as LoginTargetEventEmitter; - - private _form: HTMLFormElement | null = null; - private _usernameField: HTMLInputElement | null = null; - private _passwordField: HTMLInputElement | null = null; - private _submitButton: HTMLElement | null = null; - private _forceSubmitDelay: number = FORCE_SUBMIT_DELAY; - private _changeListeners: Record = { - username: null, - password: null, - submit: null, - form: null, - }; - - /** - * Delay in milliseconds that the library should wait before force - * submitting the form. - */ - get forceSubmitDelay() { - return this._forceSubmitDelay; - } - set forceSubmitDelay(delay) { - this._forceSubmitDelay = delay; - } - - /** - * The target login form. - */ - get form() { - return this._form; - } - set form(form) { - if (form) { - this._form = form; - this._listenForUpdates('form', form); - } - } - - /** - * The password input element. - */ - get passwordField() { - return this._passwordField; - } - set passwordField(field) { - if (field) { - this._passwordField = field; - this._listenForUpdates('password', field); - } - } - - /** - * The submit button element. - */ - get submitButton() { - return this._submitButton; - } - set submitButton(button) { - if (button) { - this._submitButton = button; - this._listenForUpdates('submit', button); - } - } - - /** - * The username input element. - */ - get usernameField() { - return this._usernameField; - } - set usernameField(field) { - if (field) { - this._usernameField = field; - this._listenForUpdates('username', field); - } - } - - /** - * Calculate the score of the login target. - * This can be used to compare LoginTargets by their likelihood of being - * the correct login form. Higher number is better. - * - * @returns The calculated score. - */ - calculateScore() { - let score = this.baseScore; - score += this.form ? 10 : 0; - score += this.usernameField ? 10 : 0; - score += this.passwordField ? 10 : 0; - score += this.submitButton ? 10 : 0; - if (isVisible(this.form)) { - score += 10; - } - return score; - } - - /** - * Fill username into the username field. - * - * @param username The username to enter. - * - * @returns A promise that resolves once the data has been entered. - */ - fillUsername(username: string): Promise { - if (this.usernameField) { - setInputValue(this.usernameField, username); - } - return Promise.resolve(); - } - - /** - * Fill password into the password field. - * - * @param password The password to enter. - */ - fillPassword(password: string) { - if (this.passwordField) { - setInputValue(this.passwordField, password); - } - } - - /** - * Enter credentials into the form without logging in. - * - * @param username The username to enter - * @param password The password to enter - * - * @example - * ```ts - * loginTarget.enterDetails("myUsername", "myPassword"); - * ``` - */ - enterDetails(username: string, password: string) { - this.fillUsername(username); - this.fillPassword(password); - } - - /** - * Login using the form. - * - * Enters the credentials into the form and logs in by either pressing the - * login button or by submitting the form. The `force` option allows for - * trying both methods: first by clicking the button and second by calling - * `form.submit()`. When using `force=true`, if clicking the button doesn't - * unload the page in `target.forceSubmitDelay` milliseconds, - * `form.submit()` is called. If no form submit button is present, `force` - * does nothing as `form.submit()` is called immediately. - * - * @param username The username to login with. - * @param password The password to login with. - * - * @example - * ```ts - * loginTarget.login("myUsername", "myPassword"); - * ``` - */ - login(username: string, password: string) { - this.enterDetails(username, password); - this.submit(); - } - - /** - * Submit the associated form. - * - * You probably don't want this function. `login` or `enterDetails` are way - * better. - */ - submit() { - if (!this.submitButton) { - console.log('submit', this.form); - // No button, just try submitting - this.form?.submit?.(); - } - // Click button - this.submitButton?.click?.(); - } - - /** - * Attach an event listener to listen for input changes - * Attaches listeners for username/password input changes and emits an event - * when a change is detected. - * - * @param type The type of field (username/password/form/submit). - * @param input The target element. - * - * @fires valueChanged - * @fires formSubmitted - */ - private _listenForUpdates(type: LoginTargetFieldType, input: El) { - // Detect the proper event name - const eventListenerName = getChangeEventNameForFieldType(type); - - // Check if a listener exists already, and clear it if it does - if (this._changeListeners[type]) { - // eslint-disable-next-line @typescript-eslint/no-shadow - const { input, listener } = this._changeListeners[type]!; - input.removeEventListener(eventListenerName, listener, false); - } - - // Emit a value change event - let handleEvent; - - switch (type) { - case 'form': - case 'submit': { - // Listener function for the submission of the form - const source = type === 'form' ? 'form' : 'submitButton'; - handleEvent = () => this.events.emit('formSubmitted', { source }); - break; - } - - case 'username': - case 'password': - default: - { - const emit = (value: any) => { - this.events.emit('valueChanged', { - type, - value, - }); - }; - // Listener function for the input element - handleEvent = function (this: HTMLInputElement) { - emit(this.value); - }; - } - break; - } - - // Store the listener information - this._changeListeners[type] = { - input, - listener: handleEvent, - }; - - // Attach the listener - input.addEventListener(eventListenerName, handleEvent, false); - } -} diff --git a/src/targets/base-target.ts b/src/targets/base-target.ts new file mode 100644 index 0000000..d5b37c5 --- /dev/null +++ b/src/targets/base-target.ts @@ -0,0 +1,30 @@ +export abstract class BaseTarget { + private _fields = new Map(); + + constructor(protected baseScore: number = 0) {} + + public get score() { + return this.baseScore + this.calculateScore(); + } + + public get(key: Key): Fields[Key] { + return this._fields.get(key as any) ?? null; + } + + public set(key: Key, value?: Fields[Key]): Fields[Key] { + return this._fields.set(key as any, value ?? null).get(key as any); + } + + public has(key: Key): boolean { + return this._fields.has(key as any); + } + + /** + * Calculate the score of the login target. + * This can be used to compare LoginTargets by their likelihood of being + * the correct login form. Higher number is better. + * + * @returns The calculated score. + */ + protected abstract calculateScore(): number; +} diff --git a/src/login/fields.ts b/src/targets/login/fields.ts similarity index 99% rename from src/login/fields.ts rename to src/targets/login/fields.ts index 10a21fd..a5e69c2 100644 --- a/src/login/fields.ts +++ b/src/targets/login/fields.ts @@ -1,4 +1,4 @@ -import { FieldSpec } from '../utils/resolve-elements'; +import { FieldSpec } from '../../utils/resolve-elements'; // These queries are all modified to that they match inputs of type=text as well // as inputs with no type attribute at all. Each line (query) is turned into 2 diff --git a/src/targets/login/index.ts b/src/targets/login/index.ts new file mode 100644 index 0000000..db5358f --- /dev/null +++ b/src/targets/login/index.ts @@ -0,0 +1,135 @@ +import { SUBMIT_BUTTON_SPEC, FORM_QUERIES, USERNAME_FIELD_SPEC, PASSWORD_FIELD_SPEC } from './fields'; +import { isFormElement, isElementVisible } from '../../utils/dom'; +import { resolveElements } from '../../utils/resolve-elements'; +import { BaseTarget } from '../base-target'; + +/** + * The LoginTarget class which represents a 'target' for logging in + * with some credentials. + */ +export class LoginTarget extends BaseTarget<{ + form: HTMLFormElement | null; + username: HTMLInputElement | null; + password: HTMLInputElement | null; + submit: HTMLElement | null; +}> { + /** + * Get the best login target on the current page. + * + * @param queryEl The element to query within + * @returns A login target or `null` if none found + * @see getLoginTargets + */ + public static find( + queryEl: Document | ShadowRoot | HTMLElement = document, + options: { visible?: boolean } = {}, + ): LoginTarget | null { + const { visible = false } = options; + + const targets = LoginTarget.findAll(queryEl); + let bestScore = -1; + let bestTarget = null as LoginTarget | null; + targets.forEach((target) => { + const { score } = target; + if (score > bestScore) { + bestScore = score; + bestTarget = target; + } + }); + + if (visible) { + const isTargetVisible = [ + bestTarget?.get('form'), + bestTarget?.get('username'), + bestTarget?.get('password'), + bestTarget?.get('submit'), + ].some((el) => { + return !!el && isElementVisible(el); + }); + return isTargetVisible ? bestTarget : null; + } + + return bestTarget; + } + + /** + * Fetch all login targets. + * + * Fetches all detected login targets within some element (defaults to the + * current document). Returned targets are not sorted or processed in any way + * that would indicate how likely they are to be the 'correct' login form for + * the page. + * + * @param queryEl The element to query within. + * @returns An array of login targets. + */ + public static findAll(queryEl: Document | ShadowRoot | HTMLElement = document): LoginTarget[] { + // Gather a list of all potential submit buttons throughout the document + // We use these as a fallback when a form is found without submit button(s) + // NOTE: this array will be mutated as we process forms into `LoginTargets` + let orphanedSubmitButtons = resolveElements(SUBMIT_BUTTON_SPEC, document); + + const forms = Array.prototype.slice.call(queryEl.querySelectorAll(FORM_QUERIES.join(','))); + + const targets = forms + .map((formEl) => { + const form = isFormElement(formEl) ? formEl : null; + const usernameFields = resolveElements(USERNAME_FIELD_SPEC, formEl); + const passwordFields = resolveElements(PASSWORD_FIELD_SPEC, formEl); + const submitButtons = resolveElements(SUBMIT_BUTTON_SPEC, formEl); + + // De-dupe submit buttons found associated to this login + // target that are present in `orphanedSubmitButtons`. + if (submitButtons.length) { + orphanedSubmitButtons = orphanedSubmitButtons.filter((orphan) => !submitButtons.includes(orphan)); + } + + return { form, usernameFields, passwordFields, submitButtons }; + }) + .filter(({ usernameFields, passwordFields }) => { + return usernameFields.length + passwordFields.length > 0; + }) + .map(({ form, usernameFields, passwordFields, submitButtons }) => { + let baseScore = 0; + + if (submitButtons.length > 1) { + baseScore -= 2; + } + if (submitButtons.length === 0) { + baseScore -= 1; + } + if (usernameFields.length > 1) { + baseScore -= 1; + } + if (passwordFields.length > 1) { + baseScore -= 2; + } + + const target = new LoginTarget(baseScore); + + target.set('form', form); + target.set('username', usernameFields[0]); + target.set('password', passwordFields[0]); + target.set('submit', submitButtons.length ? submitButtons[0] : orphanedSubmitButtons[0]); + + return target; + }); + + return targets; + } + + protected calculateScore() { + let score = 0; + + score += this.get('form') ? 10 : 0; + score += this.get('username') ? 10 : 0; + score += this.get('password') ? 10 : 0; + score += this.get('submit') ? 10 : 0; + + if (isElementVisible(this.get('form'))) { + score += 10; + } + + return score; + } +} diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 5bf5698..bd0ee17 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -1,21 +1,3 @@ -const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!; - -/** - * Sets `value` on the given `input` element, - * then emits the relevant native event types. - * - * @param input The input element to modify. - * @param value A value to apply. - */ -export function setInputValue(input: HTMLInputElement, value: string) { - nativeInputValueSetter.call(input, value); - input.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true })); - input.dispatchEvent(new KeyboardEvent('keypress', { bubbles: true, cancelable: true })); - input.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, cancelable: true })); - input.dispatchEvent(new Event('input', { bubbles: true, cancelable: true })); - input.dispatchEvent(new Event('change', { bubbles: true, cancelable: true })); -} - export function isFormElement(el: Element): el is HTMLFormElement { return /^form$/i.test(el.tagName); } @@ -23,3 +5,28 @@ export function isFormElement(el: Element): el is HTMLFormElement { export function isInputElement(el: Element): el is HTMLInputElement { return /^input$/i.test(el.tagName); } + +export function isElementVisible(element: HTMLElement | null) { + if (!element) { + return false; + } + + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + + const xOverlap = Math.max(0, Math.min(rect.x + rect.width, window.innerWidth) - Math.max(rect.x, 0)); + const yOverlap = Math.max(0, Math.min(rect.y + rect.height, window.innerHeight) - Math.max(rect.y, 0)); + const elementArea = rect.width * rect.height; + const overlapArea = xOverlap * yOverlap; + const percentInView = overlapArea / elementArea; + const isInView = percentInView > 0; + + return ( + isInView && + style.opacity !== '' && + style.opacity !== '0' && + style.display !== 'none' && + style.visibility !== 'hidden' && + element.getAttribute('aria-hidden') !== 'true' + ); +} diff --git a/src/utils/isVisible.ts b/src/utils/isVisible.ts deleted file mode 100644 index febbe32..0000000 --- a/src/utils/isVisible.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function isVisible(el: HTMLElement | null): boolean { - if (el === null) return false; - - // https://github.com/jquery/jquery/blob/a684e6ba836f7c553968d7d026ed7941e1a612d8/src/css/hiddenVisibleSelectors.js - return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); -} diff --git a/src/utils/resolve-elements.ts b/src/utils/resolve-elements.ts index 8d55684..7724f14 100644 --- a/src/utils/resolve-elements.ts +++ b/src/utils/resolve-elements.ts @@ -1,8 +1,8 @@ -import { isVisible } from './isVisible'; +import { isElementVisible } from './dom'; -export interface FieldSpec { - root?: Document | HTMLElement; - prepare?: (root: Document | HTMLElement) => void; +export interface FieldSpec { + root?: Document | ShadowRoot | HTMLElement; + prepare?: (root: Document | ShadowRoot | HTMLElement) => void; find: { selectors: readonly string[]; sort?: Array<{ @@ -12,7 +12,9 @@ export interface FieldSpec { }; } -export type ElementTypeFromFieldSpec> = Spec extends FieldSpec ? Type : never; +export type ExtractElementTypeFromFieldSpec> = Spec extends FieldSpec + ? El + : never; const VISIBILE_SCORE_INCREMENT = 8; @@ -25,8 +27,8 @@ const VISIBILE_SCORE_INCREMENT = 8; */ export function resolveElements>( spec: Spec, - root: Document | HTMLElement = document, -): ElementTypeFromFieldSpec[] { + root: Document | ShadowRoot | HTMLElement = document, +): HTMLElement[] { const { prepare, find } = spec; const { selectors, sort: sortTests = [] } = find; @@ -41,7 +43,7 @@ export function resolveElements>( const value = check.test.test(html) ? check.weight : 0; return current + value; }, 0); - if (isVisible(el)) { + if (isElementVisible(el)) { score += VISIBILE_SCORE_INCREMENT; } el.setAttribute('data-uno-score', String(score)); @@ -58,5 +60,5 @@ export function resolveElements>( return 1; } return 0; - }) as ElementTypeFromFieldSpec[]; + }) as ExtractElementTypeFromFieldSpec[]; } diff --git a/src/utils/unload-observer.ts b/src/utils/unload-observer.ts deleted file mode 100644 index 0cb1a63..0000000 --- a/src/utils/unload-observer.ts +++ /dev/null @@ -1,26 +0,0 @@ -import EventEmitter from 'eventemitter3'; - -let __sharedInstance: UnloadObserver | undefined; - -export default class UnloadObserver extends EventEmitter { - private _willUnload = false; - - constructor() { - super(); - window.addEventListener('beforeunload', () => { - this._willUnload = true; - this.emit('unloading'); - }); - } - - get willUnload() { - return this._willUnload; - } -} - -export function getSharedObserver() { - if (!__sharedInstance) { - __sharedInstance = new UnloadObserver(); - } - return __sharedInstance; -} diff --git a/test/integration/login/index.ts b/test/integration/login/index.ts index 581c299..d1aa7f9 100644 --- a/test/integration/login/index.ts +++ b/test/integration/login/index.ts @@ -40,7 +40,7 @@ async function executeTestCase(config: TestCase, page: Page) { throw new Error('No global Locust variable found'); } - const target: LoginTarget = (window as any).Locust.getLoginTarget(); + const target: LoginTarget = (window as any).Locust.LoginTarget.find(); if (!target) { throw new Error('No login targets found'); @@ -48,19 +48,19 @@ async function executeTestCase(config: TestCase, page: Page) { if (expectedFields && expectedFields.username) { const usernameField = document.querySelector(expectedFields.username); - if (target.usernameField !== usernameField) { + if (target.get('username') !== usernameField) { throw new Error(`No username field found matching query: ${expectedFields.username}`); } } if (expectedFields && expectedFields.password) { const passwordField = document.querySelector(expectedFields.password); - if (target.passwordField !== passwordField) { + if (target.get('password') !== passwordField) { throw new Error(`No password field found matching query: ${expectedFields.password}`); } } if (expectedFields && expectedFields.submit) { const submitButton = document.querySelector(expectedFields.submit); - if (target.submitButton !== submitButton) { + if (target.get('submit') !== submitButton) { throw new Error(`No submit button found matching query: ${expectedFields.submit}`); } } diff --git a/test/unit/login/index.spec.ts b/test/unit/login/index.spec.ts deleted file mode 100644 index c6f49dd..0000000 --- a/test/unit/login/index.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { expect } from 'chai'; -import sinon from 'sinon'; - -import { getLoginTargets } from '@src/login'; -import { FORM_QUERIES } from '@src/login/fields'; -import { setInputValue } from '@src/utils/dom'; - -describe('inputs', function () { - describe('fetchFormsWithInputs', function () { - interface FetchFormsWithInputsContext extends Mocha.Context { - forms: Partial[]; - queryEl: { querySelectorAll: sinon.SinonStub }; - } - - beforeEach(function (this: FetchFormsWithInputsContext) { - this.forms = []; - const qsaStub = sinon.stub(); - qsaStub.returns([]).onFirstCall().returns(this.forms); - this.queryEl = { - querySelectorAll: qsaStub, - }; - }); - - it('fetches forms by name', function (this: FetchFormsWithInputsContext) { - getLoginTargets(this.queryEl as any); - expect(this.queryEl.querySelectorAll.calledWithExactly(FORM_QUERIES.join(','))).to.be.true; - expect(this.queryEl.querySelectorAll.calledOnce).to.be.true; - }); - - it('fetches elements under form', function (this: FetchFormsWithInputsContext) { - const fakeForm = { - elements: [] as any, - querySelectorAll: sinon.stub().returns([]), - tagName: 'form', - }; - this.forms.push(fakeForm); - getLoginTargets(this.queryEl as any); - expect(fakeForm.querySelectorAll.callCount).to.equal(5); - }); - - it('filters forms without password fields', function (this: FetchFormsWithInputsContext) { - const fakeForm = { - elements: [] as any, - querySelectorAll: sinon.stub().callsFake(function (query) { - if (/username/.test(query)) { - return {}; - } - return []; - }), - tagName: 'form', - }; - this.forms.push(fakeForm); - const forms = getLoginTargets(this.queryEl as any); - expect(forms).to.have.lengthOf(0); - }); - }); - - describe('setInputValue', function () { - interface SetInputValueContext extends Mocha.Context { - input: HTMLInputElement; - } - - beforeEach(function (this: SetInputValueContext) { - this.input = document.createElement('input'); - document.body.appendChild(this.input); - }); - - afterEach(function (this: SetInputValueContext) { - document.body.removeChild(this.input); - }); - - it("sets the input's value", function (this: SetInputValueContext) { - expect(this.input.value).to.equal(''); - setInputValue(this.input, 'new value'); - expect(this.input.value).to.equal('new value'); - }); - - it("fires the input's 'input' event", function (this: SetInputValueContext) { - return new Promise((resolve) => { - this.input.addEventListener( - 'input', - (event) => { - expect((event.target as HTMLInputElement).value).to.equal('123'); - resolve(); - }, - false, - ); - setInputValue(this.input, '123'); - }); - }); - - it("fires the input's 'change' event", function (this: SetInputValueContext) { - return new Promise((resolve) => { - this.input.addEventListener( - 'change', - (event) => { - expect((event.target as HTMLInputElement).value).to.equal('456'); - resolve(); - }, - false, - ); - setInputValue(this.input, '456'); - }); - }); - }); -}); diff --git a/test/unit/login/login-target.spec.ts b/test/unit/login/login-target.spec.ts deleted file mode 100644 index b0b77f1..0000000 --- a/test/unit/login/login-target.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { expect } from 'chai'; -import { EventEmitter } from 'eventemitter3'; -import sinon from 'sinon'; - -import { LoginTarget } from '@src/login/login-target'; -import { setInputValue } from '@src/utils/dom'; - -describe('LoginTarget', function () { - beforeEach(function () { - this.target = new LoginTarget(); - }); - - it('implements event emitter methods', function () { - expect(this.target.events).to.be.instanceOf(EventEmitter); - }); - - it('fires events when username inputs are updated', function () { - let currentValue = ''; - this.target.usernameField = document.createElement('input'); - this.target.events.on('valueChanged', (info) => { - if (info.type === 'username') { - currentValue = info.value; - } - }); - setInputValue(this.target.usernameField, 'user5644'); - expect(currentValue).to.equal('user5644'); - }); - - it('fires events when the submit button is clicked', function () { - let formSubmitted = 0; - this.target.events.on('formSubmitted', (info) => { - if (info.source === 'submitButton') { - formSubmitted += 1; - } - }); - const button = (this.target.submitButton = document.createElement('button')); - button.type = 'button'; - button.click(); - expect(formSubmitted).to.equal(1); - }); - - it('fires events when the form is submitted', function () { - let formSubmitted = 0; - this.target.events.on('formSubmitted', (info) => { - if (info.source === 'form') { - formSubmitted += 1; - } - }); - const fakeForm = { - tagName: 'form', - addEventListener: sinon.spy(), - }; - this.target.form = fakeForm; - expect(fakeForm.addEventListener.calledWith('submit')).to.be.true; - expect(fakeForm.addEventListener.calledOnce).to.be.true; - const eventListener = fakeForm.addEventListener.firstCall.args[1]; - // Simulate submit - eventListener(); - expect(formSubmitted).to.equal(1); - }); - - it('fires events when password inputs are updated', function () { - let currentValue = ''; - this.target.passwordField = document.createElement('input'); - this.target.events.on('valueChanged', (info) => { - if (info.type === 'password') { - currentValue = info.value; - } - }); - setInputValue(this.target.passwordField, 'pass!3233 5'); - expect(currentValue).to.equal('pass!3233 5'); - }); - - describe('calculateScore', function () { - it('returns 0 if no items are set', function () { - expect(this.target.calculateScore()).to.equal(0); - }); - - it('returns a higher score if a username field exists', function () { - this.target.usernameField = document.createElement('input'); - expect(this.target.calculateScore()).to.be.above(0); - }); - - it('returns a higher score if a password field exists', function () { - this.target.passwordField = document.createElement('input'); - expect(this.target.calculateScore()).to.be.above(0); - }); - - it('returns a higher score if both inputs exist', function () { - this.target.passwordField = document.createElement('input'); - const singleFieldScore = this.target.calculateScore(); - this.target.usernameField = document.createElement('input'); - expect(this.target.calculateScore()).to.be.above(singleFieldScore); - }); - }); - - describe('enterDetails', function () { - beforeEach(function () { - this.target.usernameField = this.username = document.createElement('input'); - this.target.passwordField = this.password = document.createElement('input'); - }); - - it('sets the values of the inputs', function () { - this.target.enterDetails('myUsername', 'myPassword'); - expect(this.username.value).to.equal('myUsername'); - expect(this.password.value).to.equal('myPassword'); - }); - }); - - describe('login', function () { - beforeEach(function () { - this.target.usernameField = this.username = document.createElement('input'); - this.target.passwordField = this.password = document.createElement('input'); - sinon.stub(this.target, 'submit'); - }); - - it('sets the values of the inputs', function () { - this.target.login('myUsername', 'myPassword'); - expect(this.username.value).to.equal('myUsername'); - expect(this.password.value).to.equal('myPassword'); - }); - - it('submits the form', function () { - this.target.login('myUsername', 'myPassword'); - expect(this.target.submit.calledOnce).to.be.true; - }); - }); - - describe('submit', function () { - beforeEach(function () { - this.target.submitButton = this.submitButton = document.createElement('input'); - sinon.stub(this.submitButton, 'click'); - }); - - it('clicks the submit button', function () { - this.target.submit(); - expect(this.submitButton.click.calledOnce).to.be.true; - }); - }); -}); diff --git a/test/unit/targets/login/index.spec.ts b/test/unit/targets/login/index.spec.ts new file mode 100644 index 0000000..2bdc37a --- /dev/null +++ b/test/unit/targets/login/index.spec.ts @@ -0,0 +1,56 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; + +import { LoginTarget } from '@src/targets/login'; +import { FORM_QUERIES } from '@src/targets/login/fields'; + +describe('inputs', function () { + describe('fetchFormsWithInputs', function () { + interface FetchFormsWithInputsContext extends Mocha.Context { + forms: Partial[]; + queryEl: { querySelectorAll: sinon.SinonStub }; + } + + beforeEach(function (this: FetchFormsWithInputsContext) { + this.forms = []; + const qsaStub = sinon.stub(); + qsaStub.returns([]).onFirstCall().returns(this.forms); + this.queryEl = { + querySelectorAll: qsaStub, + }; + }); + + it('fetches forms by name', function (this: FetchFormsWithInputsContext) { + LoginTarget.findAll(this.queryEl as any); + expect(this.queryEl.querySelectorAll.calledWithExactly(FORM_QUERIES.join(','))).to.be.true; + expect(this.queryEl.querySelectorAll.calledOnce).to.be.true; + }); + + it('fetches elements under form', function (this: FetchFormsWithInputsContext) { + const fakeForm = { + elements: [] as any, + querySelectorAll: sinon.stub().returns([]), + tagName: 'form', + }; + this.forms.push(fakeForm); + LoginTarget.findAll(this.queryEl as any); + expect(fakeForm.querySelectorAll.callCount).to.equal(5); + }); + + it('filters forms without password fields', function (this: FetchFormsWithInputsContext) { + const fakeForm = { + elements: [] as any, + querySelectorAll: sinon.stub().callsFake(function (query) { + if (/username/.test(query)) { + return {}; + } + return []; + }), + tagName: 'form', + }; + this.forms.push(fakeForm); + const forms = LoginTarget.findAll(this.queryEl as any); + expect(forms).to.have.lengthOf(0); + }); + }); +}); diff --git a/test/unit/utils/resolve-elements.spec.ts b/test/unit/utils/resolve-elements.spec.ts index ebb258e..136231e 100644 --- a/test/unit/utils/resolve-elements.spec.ts +++ b/test/unit/utils/resolve-elements.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; -import { USERNAME_FIELD_SPEC } from '@src/login/fields'; +import { USERNAME_FIELD_SPEC } from '@src/targets/login/fields'; import { resolveElements } from '@src/utils/resolve-elements'; describe('resolveElements', function () { diff --git a/types/is-visible.d.ts b/types/is-visible.d.ts deleted file mode 100644 index 3fa8451..0000000 --- a/types/is-visible.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "is-visible" { - export function isVisible(element?: El | null): boolean; - export default isVisible; -}